mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 17:22:55 -10:00
Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edfe6c7b65 | ||
|
|
d939a19747 | ||
|
|
d981603f3c | ||
|
|
7fdf1d62c3 | ||
|
|
4e9442b499 | ||
|
|
8ea8c98a39 | ||
|
|
a623e5d385 | ||
|
|
e584f044d7 | ||
|
|
df9a9e0be0 | ||
|
|
91c15ff31b | ||
|
|
f8105e5473 | ||
|
|
53b5052bbb | ||
|
|
4ea7d32074 | ||
|
|
502a9dc7dd | ||
|
|
78b23df7b5 | ||
|
|
362ae3535c | ||
|
|
f6fbc0e5db | ||
|
|
2a6cc686eb | ||
|
|
64b8b5cea9 | ||
|
|
e3f8932e21 | ||
|
|
3be40bd57c | ||
|
|
7375f30422 | ||
|
|
a8e12ff7c4 | ||
|
|
47f68862c8 | ||
|
|
193e6a4840 | ||
|
|
95188d9f40 | ||
|
|
6184330876 | ||
|
|
8e8a998abe | ||
|
|
743612d4ec | ||
|
|
44c603f7db | ||
|
|
e2f8e8d5bc | ||
|
|
ff97d7724c | ||
|
|
239753df9f | ||
|
|
c8cc69a56e | ||
|
|
c5ff0de1ee | ||
|
|
fb55847afa | ||
|
|
2653021389 | ||
|
|
e7f00ee7ce | ||
|
|
4015a5b71f | ||
|
|
88619505c3 | ||
|
|
0d0de01831 | ||
|
|
9ce3c4cd7e | ||
|
|
74062497bd | ||
|
|
8d7ce81249 | ||
|
|
b3abc8edd1 | ||
|
|
245fe3cdf4 | ||
|
|
b5dbd3c020 | ||
|
|
4034853f91 | ||
|
|
93328d7895 | ||
|
|
b9d3ce7723 | ||
|
|
be7e2ff710 | ||
|
|
210f9a78ac |
No files matched your search
Generated
+41
-38
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4061,7 +4061,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4075,22 +4075,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"ide-core",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -4099,10 +4099,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4115,7 +4116,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4129,7 +4130,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
@@ -4147,7 +4148,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"dirs 6.0.0",
|
||||
@@ -4169,10 +4170,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4183,7 +4183,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4195,11 +4195,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4209,7 +4208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4241,6 +4240,7 @@ dependencies = [
|
||||
"ide-vfs",
|
||||
"ide-workspace",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4250,11 +4250,12 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4272,7 +4273,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4294,7 +4295,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4310,7 +4311,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4365,7 +4366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4390,7 +4391,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4413,7 +4414,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4431,7 +4432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4444,7 +4445,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4461,7 +4462,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4484,7 +4485,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4503,7 +4504,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4523,7 +4524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4536,7 +4537,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4555,7 +4556,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4570,7 +4571,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4583,7 +4584,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4595,7 +4596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4616,7 +4617,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4635,7 +4636,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4657,7 +4658,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4673,7 +4674,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4683,6 +4684,7 @@ dependencies = [
|
||||
"ide-text",
|
||||
"ide-vfs",
|
||||
"parking_lot",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -6144,7 +6146,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"block2 0.6.2",
|
||||
@@ -6207,6 +6209,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.33"
|
||||
version = "0.1.34"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -169,7 +169,7 @@ impl Translator {
|
||||
Some("tool_use") => {
|
||||
// The input streams after this: shown by name until the message has it.
|
||||
let (id, name) = (str_of(&block, "id"), str_of(&block, "name"));
|
||||
if !id.is_empty() && !tools::is_plan(&name) && !(tools::is_agent(&name) && self.subagents) {
|
||||
if !(id.is_empty() || tools::is_plan(&name) || tools::is_agent(&name) && self.subagents) {
|
||||
out.extend(self.ensure_call(&id, &name, &json!({}), &session));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,12 @@ license.workspace = true
|
||||
[dependencies]
|
||||
dunce.workspace = true
|
||||
ide-core.workspace = true
|
||||
zip.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
tracing.workspace = true
|
||||
dirs.workspace = true
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
//! line to a callback, and cancellation kills the whole process group (the
|
||||
//! `mvnw` / `gradlew` wrapper scripts start the JVM as a child).
|
||||
|
||||
use ide_core::process::kill_tree;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::path::Path;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -22,18 +23,6 @@ pub(crate) struct Output {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
fn kill_tree(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
if let Ok(pid) = i32::try_from(child.id()) {
|
||||
// SAFETY: plain syscall; the group was created by `process_group(0)` at spawn.
|
||||
unsafe {
|
||||
libc::kill(-pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// `program` as spawnable: a bare name (`mvn`, `mvn.cmd`) resolves on the IDE's
|
||||
/// search path — the login shell's `PATH` plus version-manager shims — since the
|
||||
/// app's own `PATH` can be minimal and `Command` would not see `mvn` there.
|
||||
@@ -80,12 +69,8 @@ pub(crate) fn run_with_env(
|
||||
let mut command = Command::new(&program);
|
||||
command.args(args).current_dir(cwd).envs(env).envs(encoding.env.iter().map(|(k, v)| (k, v))).stdin(Stdio::null());
|
||||
command.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
command.process_group(0);
|
||||
}
|
||||
ide_core::process::prepare(&mut command);
|
||||
// Its own group: `mvnw` / `gradlew` start the JVM as a child, and cancelling kills both.
|
||||
ide_core::process::prepare(ide_core::process::own_group(&mut command));
|
||||
if clear_env {
|
||||
command.env_clear().envs(env).envs(encoding.env);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -18,5 +18,8 @@ tokio-util.workspace = true
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[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"] }
|
||||
@@ -36,12 +36,21 @@ pub enum IdeEvent {
|
||||
base_version: u32,
|
||||
version: u32,
|
||||
changes: Vec<TextChange>,
|
||||
/// Part of a grouped transaction; the editor must not record a file-local undo.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
workspace_edit: bool,
|
||||
},
|
||||
/// A document's dirty flag changed (saved / modified externally).
|
||||
DocDirty {
|
||||
doc: DocId,
|
||||
dirty: bool,
|
||||
},
|
||||
/// A complete workspace edit can be undone as one operation.
|
||||
WorkspaceEditApplied {
|
||||
id: u32,
|
||||
paths: Vec<crate::IpcPath>,
|
||||
},
|
||||
/// An open document's language changed (Settings › File Types, Override File
|
||||
/// Type), or its UI mode (a framework's root marker appeared or went).
|
||||
DocLanguage {
|
||||
@@ -82,6 +91,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 {
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+269
-39
@@ -1,8 +1,11 @@
|
||||
//! Child-process helpers shared by every crate that spawns tools.
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::process::Command;
|
||||
#[cfg(windows)]
|
||||
use std::process::Stdio;
|
||||
use std::process::{Child, Command};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Windows `CREATE_NO_WINDOW` creation flag, for call sites that combine it
|
||||
/// with other flags (and then call [`host_env`] themselves). Prefer [`prepare`] otherwise.
|
||||
@@ -25,6 +28,70 @@ pub fn prepare(cmd: &mut Command) -> &mut Command {
|
||||
host_env(cmd)
|
||||
}
|
||||
|
||||
/// Starts the child as the leader of its own process group on Unix, so [`kill_tree`] and
|
||||
/// [`terminate_tree`] reach what it starts in turn (`gh` → `git`, `npm` → `node`, `mvnw` →
|
||||
/// the JVM). A no-op elsewhere: on Windows the tree is found from the child's process id.
|
||||
pub fn own_group(cmd: &mut Command) -> &mut Command {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Kills the child and everything it started, then reaps it.
|
||||
///
|
||||
/// - Unix: `SIGKILL` to its process group, so spawn it with [`own_group`].
|
||||
/// - Windows: `taskkill /T /F`, which walks the tree from the child's process id.
|
||||
pub fn kill_tree(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
signal_group(child, libc::SIGKILL);
|
||||
#[cfg(windows)]
|
||||
taskkill_tree(child);
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// [`kill_tree`] that first asks the group to exit: `SIGTERM`, then `grace`, then `SIGKILL`.
|
||||
/// Git removes its `index.lock` on `SIGTERM`, where `SIGKILL` leaves it behind and the next
|
||||
/// git command fails. Windows has no such signal to send from outside the child's console,
|
||||
/// so there it is [`kill_tree`].
|
||||
pub fn terminate_tree(child: &mut Child, grace: Duration) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
signal_group(child, libc::SIGTERM);
|
||||
// Sleep rather than `try_wait`: the child stays unreaped, so its process id (and the
|
||||
// group id) cannot be reused before the `SIGKILL` below.
|
||||
std::thread::sleep(grace);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = grace;
|
||||
kill_tree(child);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn signal_group(child: &Child, signal: libc::c_int) {
|
||||
if let Ok(pid) = libc::pid_t::try_from(child.id()) {
|
||||
// SAFETY: plain syscall. The child is not reaped yet, so `-pid` is still its group (or
|
||||
// no group at all when it was not spawned with `own_group`, and the call fails).
|
||||
unsafe {
|
||||
libc::kill(-pid, signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn taskkill_tree(child: &Child) {
|
||||
let mut taskkill = Command::new("taskkill");
|
||||
prepare(&mut taskkill)
|
||||
.args(["/T", "/F", "/PID", &child.id().to_string()])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
let _ = taskkill.status();
|
||||
}
|
||||
|
||||
/// Gives `cmd` the host's environment back when the IDE runs from an AppImage. `AppRun` and
|
||||
/// its hooks point `LD_LIBRARY_PATH`, `PATH`, `XDG_DATA_DIRS`, `GIO_MODULE_DIR`, `GTK_PATH`,
|
||||
/// `GST_PLUGIN_*`, ... into the mounted image for the IDE itself; a host program that
|
||||
@@ -245,10 +312,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 +323,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 +406,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();
|
||||
@@ -502,3 +664,71 @@ mod tests {
|
||||
assert_eq!(find_executable("nope", &search), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod kill_tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Runs `script` in `sh` as its own group's leader and waits for it to write `ready`.
|
||||
fn start(script: &str, ready: &Path) -> Child {
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.arg("-c").arg(script);
|
||||
let child = own_group(&mut cmd).spawn().unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while !ready.exists() {
|
||||
assert!(Instant::now() < deadline, "the script never got ready");
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
child
|
||||
}
|
||||
|
||||
fn assert_gone(pid: libc::pid_t) {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
// SAFETY: signal 0 only checks that the process exists.
|
||||
while unsafe { libc::kill(pid, 0) } == 0 {
|
||||
assert!(Instant::now() < deadline, "process {pid} survived");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kill_tree_kills_what_the_child_started() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pid_file = dir.path().join("pid");
|
||||
let script = format!("sleep 30 & echo $! > '{0}.tmp' && mv '{0}.tmp' '{0}'; wait", pid_file.display());
|
||||
let mut child = start(&script, &pid_file);
|
||||
let grandchild: libc::pid_t = std::fs::read_to_string(&pid_file).unwrap().trim().parse().unwrap();
|
||||
kill_tree(&mut child);
|
||||
assert!(child.try_wait().unwrap().is_some(), "the child is reaped");
|
||||
assert_gone(grandchild);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminate_tree_lets_the_child_clean_up() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (ready, cleaned) = (dir.path().join("ready"), dir.path().join("cleaned"));
|
||||
let script = format!(
|
||||
"trap 'touch \"{}\"; exit 0' TERM; touch \"{}\"; sleep 30 & wait",
|
||||
cleaned.display(),
|
||||
ready.display()
|
||||
);
|
||||
let mut child = start(&script, &ready);
|
||||
terminate_tree(&mut child, Duration::from_millis(500));
|
||||
assert!(cleaned.exists(), "the SIGTERM handler ran before the SIGKILL");
|
||||
assert!(child.try_wait().unwrap().is_some(), "the child is reaped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminate_tree_still_kills_a_child_that_ignores_sigterm() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ready = dir.path().join("ready");
|
||||
let script = format!("trap '' TERM; touch \"{}\"; while :; do sleep 1; done", ready.display());
|
||||
let mut child = start(&script, &ready);
|
||||
let started = Instant::now();
|
||||
terminate_tree(&mut child, Duration::from_millis(50));
|
||||
assert!(child.try_wait().unwrap().is_some(), "the child is reaped");
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -97,6 +97,16 @@ pub struct TextEdit {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
pub struct WorkspaceEdit {
|
||||
pub files: Vec<FileEdit>,
|
||||
/// Content fingerprints captured before presenting an edit for confirmation.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub preconditions: Vec<WorkspaceEditPrecondition>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
pub struct WorkspaceEditPrecondition {
|
||||
pub path: crate::IpcPath,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
impl WorkspaceEdit {
|
||||
@@ -108,17 +118,26 @@ impl WorkspaceEdit {
|
||||
let mut resolve_aliases = true;
|
||||
for file in std::mem::take(&mut self.files) {
|
||||
match file {
|
||||
FileEdit::Edit { path, edits } => {
|
||||
FileEdit::Edit { path, edits, version } => {
|
||||
let path = if resolve_aliases {
|
||||
dunce::canonicalize(&path).unwrap_or_else(|_| path.to_path_buf())
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
let index = *batch.entry(path.clone()).or_insert_with(|| {
|
||||
files.push(FileEdit::Edit { path: path.into(), edits: Vec::new() });
|
||||
files.push(FileEdit::Edit { path: path.clone().into(), edits: Vec::new(), version });
|
||||
files.len() - 1
|
||||
});
|
||||
let FileEdit::Edit { edits: own, .. } = &mut files[index] else { unreachable!() };
|
||||
let FileEdit::Edit { edits: own, version: expected, .. } = &mut files[index] else {
|
||||
unreachable!()
|
||||
};
|
||||
if expected.is_some() && version.is_some() && *expected != version {
|
||||
// Keep incompatible constraints so application rejects them
|
||||
// before changing any file instead of silently losing one.
|
||||
files.push(FileEdit::Edit { path: path.into(), edits, version });
|
||||
continue;
|
||||
}
|
||||
*expected = expected.or(version);
|
||||
for edit in edits {
|
||||
if !own.contains(&edit) {
|
||||
own.push(edit);
|
||||
@@ -140,10 +159,26 @@ impl WorkspaceEdit {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum FileEdit {
|
||||
Edit { path: crate::IpcPath, edits: Vec<TextEdit> },
|
||||
Create { path: crate::IpcPath, overwrite: bool },
|
||||
Rename { from: crate::IpcPath, to: crate::IpcPath },
|
||||
Delete { path: crate::IpcPath, recursive: bool },
|
||||
Edit {
|
||||
path: crate::IpcPath,
|
||||
edits: Vec<TextEdit>,
|
||||
/// LSP TextDocumentEdit version; null/unversioned edits use content preconditions.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
version: Option<i32>,
|
||||
},
|
||||
Create {
|
||||
path: crate::IpcPath,
|
||||
overwrite: bool,
|
||||
},
|
||||
Rename {
|
||||
from: crate::IpcPath,
|
||||
to: crate::IpcPath,
|
||||
},
|
||||
Delete {
|
||||
path: crate::IpcPath,
|
||||
recursive: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Type)]
|
||||
@@ -254,7 +289,7 @@ mod workspace_edit_tests {
|
||||
std::fs::write(&real, "add()").unwrap();
|
||||
std::os::unix::fs::symlink(&real, &alias).unwrap();
|
||||
let change = TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() };
|
||||
let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()] };
|
||||
let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()], version: None };
|
||||
let mut workspace = WorkspaceEdit {
|
||||
files: vec![
|
||||
edit(real.clone()),
|
||||
@@ -262,6 +297,7 @@ mod workspace_edit_tests {
|
||||
FileEdit::Create { path: dir.path().join("other").into(), overwrite: false },
|
||||
edit(real),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
workspace.deduplicate();
|
||||
assert_eq!(workspace.files.len(), 3);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -13,8 +13,5 @@ specta.workspace = true
|
||||
tracing.workspace = true
|
||||
which = "8"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -1,11 +1,16 @@
|
||||
//! Child processes of the docker CLI: bounded runs and cancellable line streams.
|
||||
|
||||
use ide_core::process::terminate_tree;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::io::Read;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long a cancelled or timed-out child's process group (CLI plugins such as compose)
|
||||
/// gets to exit on `SIGTERM`, and clean up, before `SIGKILL`.
|
||||
const KILL_GRACE: Duration = Duration::from_millis(20);
|
||||
|
||||
pub(crate) struct Output {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
@@ -20,36 +25,13 @@ pub(crate) enum Stream {
|
||||
|
||||
fn prepare(cmd: &mut Command) {
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
ide_core::process::prepare(cmd);
|
||||
ide_core::process::prepare(ide_core::process::own_group(cmd));
|
||||
}
|
||||
|
||||
fn spawn(cmd: &mut Command) -> IdeResult<Child> {
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run docker: {e}")))
|
||||
}
|
||||
|
||||
/// Kills the child and, on Unix, its whole process group (CLI plugins such as compose).
|
||||
pub(crate) fn kill(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let pid = child.id() as libc::pid_t;
|
||||
// SAFETY: plain syscall; the child leads its own process group (`prepare`).
|
||||
unsafe {
|
||||
libc::kill(-pid, libc::SIGTERM);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
unsafe {
|
||||
libc::kill(-pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<String> {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
@@ -88,11 +70,11 @@ pub(crate) fn run_with_input(
|
||||
break status;
|
||||
}
|
||||
if cancel.is_some_and(CancellationToken::is_cancelled) {
|
||||
kill(&mut child);
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
kill(&mut child);
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::unavailable("docker did not respond in time"));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
@@ -166,7 +148,7 @@ pub(crate) fn stream(
|
||||
let mut stderr_tail: Vec<String> = Vec::new();
|
||||
while open > 0 {
|
||||
if cancel.is_cancelled() {
|
||||
kill(&mut child);
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(50)) {
|
||||
@@ -189,7 +171,7 @@ pub(crate) fn stream(
|
||||
break status;
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
kill(&mut child);
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
|
||||
@@ -13,6 +13,3 @@ serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
tracing.workspace = true
|
||||
which = "8"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
@@ -1,11 +1,16 @@
|
||||
//! Child processes of the gh CLI: bounded runs and cancellable line streams.
|
||||
|
||||
use ide_core::process::terminate_tree;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::io::Read;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long a cancelled or timed-out child's process group (gh spawns git and helpers)
|
||||
/// gets to exit on `SIGTERM` before `SIGKILL`: git removes its lock files on `SIGTERM`.
|
||||
const KILL_GRACE: Duration = Duration::from_millis(20);
|
||||
|
||||
pub(crate) struct Output {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
@@ -20,36 +25,13 @@ pub(crate) enum Stream {
|
||||
|
||||
fn prepare(cmd: &mut Command) {
|
||||
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
ide_core::process::prepare(cmd);
|
||||
ide_core::process::prepare(ide_core::process::own_group(cmd));
|
||||
}
|
||||
|
||||
fn spawn(cmd: &mut Command) -> IdeResult<Child> {
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("could not run gh: {e}")))
|
||||
}
|
||||
|
||||
/// Kills the child and, on Unix, its whole process group (gh spawns git and helpers).
|
||||
pub(crate) fn kill(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let pid = child.id() as libc::pid_t;
|
||||
// SAFETY: plain syscall; the child leads its own process group (`prepare`).
|
||||
unsafe {
|
||||
libc::kill(-pid, libc::SIGTERM);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
unsafe {
|
||||
libc::kill(-pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<String> {
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = Vec::new();
|
||||
@@ -92,11 +74,11 @@ pub(crate) fn run_with_input(
|
||||
break status;
|
||||
}
|
||||
if cancel.is_some_and(CancellationToken::is_cancelled) {
|
||||
kill(&mut child);
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
kill(&mut child);
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::unavailable("gh did not respond in time"));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
@@ -170,7 +152,7 @@ pub(crate) fn stream(
|
||||
let mut stderr_tail: Vec<String> = Vec::new();
|
||||
while open > 0 {
|
||||
if cancel.is_cancelled() {
|
||||
kill(&mut child);
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(50)) {
|
||||
@@ -193,7 +175,7 @@ pub(crate) fn stream(
|
||||
break status;
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
kill(&mut child);
|
||||
terminate_tree(&mut child, KILL_GRACE);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
//! 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",
|
||||
"java-usages-1.2.0.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",
|
||||
"java-usages-1.2.0.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-"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -153,14 +174,24 @@ impl Host {
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
project.documents.clear_workspace_edit_history();
|
||||
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 +201,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 +220,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,9 +247,18 @@ 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,
|
||||
);
|
||||
}
|
||||
|
||||
/// Called before desktop exit, which may bypass document destructors.
|
||||
pub fn clear_workspace_edit_history_on_exit(&self) {
|
||||
for project in self.projects() {
|
||||
project.documents.clear_workspace_edit_history();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops what this host started: notebook kernels, language servers, agents, runs and terminals.
|
||||
/// For a process that exits without dropping the host (the remote server).
|
||||
pub async fn shutdown(&self) {
|
||||
@@ -222,6 +270,7 @@ impl Host {
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.stop();
|
||||
}
|
||||
project.documents.clear_workspace_edit_history();
|
||||
project.notebooks.stop_admission();
|
||||
project.agents.shutdown();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -203,8 +210,10 @@ pub async fn maven_exclude_dependency(
|
||||
let (path, text, range) = declaring_pom(&project, &pom, &dependency)?;
|
||||
match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) {
|
||||
Some(Exclusion::Edit(edit)) => {
|
||||
let edit =
|
||||
WorkspaceEdit { files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit] }] };
|
||||
let edit = WorkspaceEdit {
|
||||
files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit], version: None }],
|
||||
..Default::default()
|
||||
};
|
||||
apply_workspace_edit(&project.documents, &edit)?;
|
||||
}
|
||||
Some(Exclusion::Present) => {}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?).await
|
||||
});
|
||||
d.add("doc_set_line_ending", |c| async move {
|
||||
doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)
|
||||
blocking(move || doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)).await
|
||||
});
|
||||
d.add("doc_outline", |c| async move { doc_outline(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_folding", |c| async move { doc_folding(&c.host, &c.window, c.arg("doc")?).await });
|
||||
|
||||
@@ -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 |_| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+107
-25
@@ -1,9 +1,33 @@
|
||||
use super::doc::blocking;
|
||||
use crate::Host;
|
||||
use crate::apply_workspace_edit as apply_edit;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
|
||||
use ide_lsp::types::*;
|
||||
|
||||
async fn prepare_actions(
|
||||
project: std::sync::Arc<crate::Project>,
|
||||
doc: DocId,
|
||||
version: u32,
|
||||
mut actions: Vec<CodeAction>,
|
||||
) -> IdeResult<Vec<CodeAction>> {
|
||||
blocking(move || {
|
||||
if project.documents.version(doc)? != version {
|
||||
return Err(ide_core::IdeError::new(
|
||||
ide_core::error::ErrorKind::OutOfSync,
|
||||
"document changed while computing code actions; request them again",
|
||||
));
|
||||
}
|
||||
for action in &mut actions {
|
||||
if let Some(edit) = &mut action.edit {
|
||||
project.documents.prepare_workspace_edit(edit)?;
|
||||
}
|
||||
}
|
||||
Ok(actions)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn lsp_completion(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -138,7 +162,11 @@ pub async fn lsp_code_actions(
|
||||
range: Range,
|
||||
only: Vec<String>,
|
||||
) -> IdeResult<Vec<CodeAction>> {
|
||||
host.project(window)?.lsp.code_actions(doc, range, only).await
|
||||
let project = host.project(window)?;
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let actions = project.lsp.code_actions(doc, range, only).await?;
|
||||
prepare_actions(project, doc, version, actions).await
|
||||
}
|
||||
|
||||
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
|
||||
@@ -150,11 +178,15 @@ pub async fn lsp_code_action_query(
|
||||
range: Range,
|
||||
only: Vec<String>,
|
||||
) -> IdeResult<CodeActionQuery> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let project = host.project(window)?;
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.code_action_query(doc, range, only, cancel).await;
|
||||
let result = project.lsp.code_action_query(doc, range, only, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
let mut result = result?;
|
||||
result.actions = prepare_actions(project, doc, version, result.actions).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Resolves a code action's edit without applying it (intention preview).
|
||||
@@ -167,19 +199,28 @@ pub async fn lsp_resolve_code_action(
|
||||
if action.edit.is_some() {
|
||||
return Ok(action);
|
||||
}
|
||||
host.project(window)?.lsp.resolve_code_action(doc, action).await
|
||||
let project = host.project(window)?;
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let action = project.lsp.resolve_code_action(doc, action).await?;
|
||||
Ok(prepare_actions(project, doc, version, vec![action]).await?.remove(0))
|
||||
}
|
||||
|
||||
/// Resolves (if needed), applies the edit, then runs the command of a code action.
|
||||
pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let action = if action.edit.is_none() && action.command.is_none() {
|
||||
project.lsp.resolve_code_action(doc, action).await?
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let action = project.lsp.resolve_code_action(doc, action).await?;
|
||||
prepare_actions(project.clone(), doc, version, vec![action]).await?.remove(0)
|
||||
} else {
|
||||
action
|
||||
};
|
||||
if let Some(edit) = &action.edit {
|
||||
apply_edit(&project.documents, edit)?;
|
||||
let documents = project.documents.clone();
|
||||
let edit = edit.clone();
|
||||
blocking(move || apply_edit(&documents, &edit)).await?;
|
||||
}
|
||||
if let Some(command) = action.command {
|
||||
project.lsp.execute_command(doc, command).await?;
|
||||
@@ -200,11 +241,24 @@ pub async fn lsp_rename(
|
||||
pos: Pos,
|
||||
new_name: String,
|
||||
) -> IdeResult<Renamed> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let project = host.project(window)?;
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.rename(doc, pos, new_name, cancel).await;
|
||||
let result = project.lsp.rename(doc, pos, new_name, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
let mut renamed = result?;
|
||||
blocking(move || {
|
||||
if project.documents.version(doc)? != version {
|
||||
return Err(ide_core::IdeError::new(
|
||||
ide_core::error::ErrorKind::OutOfSync,
|
||||
"document changed while computing rename; request it again",
|
||||
));
|
||||
}
|
||||
project.documents.prepare_workspace_edit(&mut renamed.edit)?;
|
||||
Ok(renamed)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn lsp_format(
|
||||
@@ -279,21 +333,43 @@ pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResu
|
||||
}
|
||||
|
||||
/// Applies a (previewed) workspace edit, e.g. after rename confirmation.
|
||||
pub fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
|
||||
pub async fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
// Multi-file refactorings leave a Local History point to go back to.
|
||||
let paths: Vec<&std::path::Path> = edit
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if edit.files.len() > 1 {
|
||||
project.record_before_refactoring(&paths, "Apply changes");
|
||||
}
|
||||
apply_edit(&project.documents, &edit)
|
||||
blocking(move || {
|
||||
// Multi-file refactorings leave a Local History point to go back to.
|
||||
let paths: Vec<&std::path::Path> = edit
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if edit.files.len() > 1 {
|
||||
project.record_before_refactoring(&paths, "Apply changes");
|
||||
}
|
||||
apply_edit(&project.documents, &edit)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn prepare_workspace_edit(host: &Host, window: &str, mut edit: WorkspaceEdit) -> IdeResult<WorkspaceEdit> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.documents.prepare_workspace_edit(&mut edit)?;
|
||||
Ok(edit)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn undo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.documents.undo_workspace_edit(id)).await
|
||||
}
|
||||
|
||||
pub async fn redo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.documents.redo_workspace_edit(id)).await
|
||||
}
|
||||
|
||||
/// Runs a server command (`workspace/executeCommand`), e.g. a completion item's
|
||||
@@ -441,7 +517,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("lsp_servers", |c| async move { lsp_servers(&c.host, &c.window) });
|
||||
d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) });
|
||||
d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await });
|
||||
d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?) });
|
||||
d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?).await });
|
||||
d.add("lsp_resolve_command", |c| async move {
|
||||
lsp_resolve_command(
|
||||
&c.host,
|
||||
@@ -465,6 +541,12 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add(
|
||||
"prepare_workspace_edit",
|
||||
|c| async move { prepare_workspace_edit(&c.host, &c.window, c.arg("edit")?).await },
|
||||
);
|
||||
d.add("redo_workspace_edit", |c| async move { redo_workspace_edit(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("undo_workspace_edit", |c| async move { undo_workspace_edit(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("lsp_execute_command", |c| async move {
|
||||
lsp_execute_command(&c.host, &c.window, c.arg("doc")?, c.arg("command")?).await
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -21,6 +22,7 @@ pub mod notebook;
|
||||
pub mod project;
|
||||
mod python;
|
||||
pub mod refactor;
|
||||
pub mod routes;
|
||||
pub mod run;
|
||||
pub mod sdk;
|
||||
pub mod search;
|
||||
@@ -69,6 +71,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);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! File move / rename refactoring: reference updates from the language
|
||||
//! servers, the move itself (one undoable step) and its undo.
|
||||
use crate::Host;
|
||||
use crate::apply_workspace_edit;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use ide_core::{IdeResult, RequestId, WorkspaceEdit};
|
||||
@@ -26,7 +25,12 @@ pub async fn refactor_file_move_edits(
|
||||
let token = host.begin_request(request);
|
||||
let result = project.lsp.will_rename_files(&pairs(&moves), token).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
let mut result = result?;
|
||||
blocking(move || {
|
||||
project.documents.prepare_workspace_edit(&mut result.edit)?;
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Applies `edit` (from [`refactor_file_move_edits`], possibly empty), moves the
|
||||
@@ -42,7 +46,7 @@ pub async fn refactor_move_files(
|
||||
let p = project.clone();
|
||||
let moved = moves.clone();
|
||||
let id = blocking(move || {
|
||||
let apply = |e: &WorkspaceEdit| apply_workspace_edit(&p.documents, e);
|
||||
let apply = |e: &WorkspaceEdit| p.documents.apply_file_move_edits(e);
|
||||
p.moves.perform(&p.documents, Some(&p.history), &moved, &edit, &label, &apply)
|
||||
})
|
||||
.await?;
|
||||
@@ -77,13 +81,17 @@ pub async fn refactor_file_move_text_edits(
|
||||
let cancel = token.clone();
|
||||
let result = blocking(move || {
|
||||
let replacements = ide_workspace::text_refs::move_replacements(&project.root, &project.roots(), &moves);
|
||||
let edit = ide_workspace::text_refs::find_text_occurrences(
|
||||
let mut edit = ide_workspace::text_refs::find_text_occurrences(
|
||||
&project.root,
|
||||
&project.exclude_globs(),
|
||||
&replacements,
|
||||
&|| cancel.is_cancelled(),
|
||||
);
|
||||
if cancel.is_cancelled() { Err(ide_core::IdeError::cancelled()) } else { Ok(edit) }
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
project.documents.prepare_workspace_edit(&mut edit)?;
|
||||
Ok(edit)
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Spring Web mappings, source side: the project's Java and Kotlin files that could
|
||||
//! declare a route, with their text. The UI owns the annotation parser and the index
|
||||
//! it feeds, so this side stays a cheap scan.
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::IdeResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// Files handed out at most, and bytes in total: a project's controllers fit well
|
||||
/// inside these, and the UI indexes them once.
|
||||
const MAX_FILES: usize = 1024;
|
||||
const MAX_BYTES: usize = 48 << 20;
|
||||
/// A single source bigger than this is not a controller.
|
||||
const MAX_FILE_BYTES: u64 = 4 << 20;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RouteSource {
|
||||
pub path: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// The files Spring mappings can live in: Java and Kotlin sources.
|
||||
fn declares_mappings(path: &Path) -> bool {
|
||||
matches!(path.extension().and_then(|e| e.to_str()), Some("java" | "kt" | "kts"))
|
||||
}
|
||||
|
||||
/// Files under `root` whose text names a mapping: `ignore` rules and `excludes`
|
||||
/// decide what counts, and the UI ranks the matches itself.
|
||||
pub(crate) fn sources_in(root: &Path, excludes: &[String]) -> Vec<RouteSource> {
|
||||
let found: Mutex<Vec<RouteSource>> = Mutex::new(Vec::new());
|
||||
let bytes = AtomicUsize::new(0);
|
||||
ide_vfs::walk_files(root, excludes, |path| {
|
||||
if !declares_mappings(path) || bytes.load(Ordering::Relaxed) >= MAX_BYTES {
|
||||
return;
|
||||
}
|
||||
if std::fs::metadata(path).map_or(true, |m| m.len() > MAX_FILE_BYTES) {
|
||||
return;
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(path) else { return };
|
||||
// Only a file that names a mapping annotation is worth parsing.
|
||||
if !text.contains("Mapping") {
|
||||
return;
|
||||
}
|
||||
let Ok(mut found) = found.lock() else { return };
|
||||
if found.len() >= MAX_FILES {
|
||||
return;
|
||||
}
|
||||
bytes.fetch_add(text.len(), Ordering::Relaxed);
|
||||
found.push(RouteSource { path: path.to_string_lossy().into_owned(), text });
|
||||
});
|
||||
let mut files = found.into_inner().unwrap_or_default();
|
||||
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
files
|
||||
}
|
||||
|
||||
/// The folders to scan: the project folder itself first, then content roots outside it
|
||||
/// (`content_roots` lists only the ones outside, so the project folder is never implied —
|
||||
/// forgetting it scans nothing).
|
||||
fn scan_roots(root: &Path, content_roots: Vec<PathBuf>) -> Vec<PathBuf> {
|
||||
let mut roots = vec![root.to_path_buf()];
|
||||
roots.extend(content_roots);
|
||||
roots
|
||||
}
|
||||
|
||||
/// Every mapping source of the project, for the request-path index.
|
||||
pub fn spring_route_sources(host: &Host, window: &str) -> IdeResult<Vec<RouteSource>> {
|
||||
let project = host.project(window)?;
|
||||
let excludes = project.search_excludes();
|
||||
let mut out = Vec::new();
|
||||
for root in scan_roots(&project.root, project.content_roots()) {
|
||||
out.extend(sources_in(&root, &excludes));
|
||||
}
|
||||
out.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
out.dedup_by(|a, b| a.path == b.path);
|
||||
match out.len() {
|
||||
0 => tracing::warn!("spring routes: no mapping source under {:?} (excludes: {})", project.root, excludes.len()),
|
||||
n => tracing::info!("spring routes: {n} mapping sources under {:?}", project.root),
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_project_folder_is_scanned_even_without_other_content_roots() {
|
||||
let root = Path::new("/p/demo");
|
||||
assert_eq!(scan_roots(root, Vec::new()), [PathBuf::from("/p/demo")]);
|
||||
assert_eq!(
|
||||
scan_roots(root, vec![PathBuf::from("/p/elsewhere")]),
|
||||
[PathBuf::from("/p/demo"), PathBuf::from("/p/elsewhere")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_java_and_kotlin_sources_are_read() {
|
||||
for (name, expected) in
|
||||
[("A.java", true), ("B.kt", true), ("C.kts", true), ("D.txt", false), ("pom.xml", false), ("noext", false)]
|
||||
{
|
||||
assert_eq!(declares_mappings(Path::new(name)), expected, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_that_name_no_mapping_are_left_out() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
|
||||
std::fs::write(tmp.path().join("src/Ctl.java"), "class Ctl { @GetMapping(\"/a\") void a() {} }").unwrap();
|
||||
std::fs::write(tmp.path().join("src/Plain.java"), "class Plain { void a() {} }").unwrap();
|
||||
std::fs::write(tmp.path().join("src/Notes.txt"), "@GetMapping(\"/a\")").unwrap();
|
||||
let files = sources_in(tmp.path(), &[]);
|
||||
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::<Vec<_>>());
|
||||
assert!(Path::new(&files[0].path).ends_with("src/Ctl.java"), "{}", files[0].path);
|
||||
assert!(files[0].text.contains("@GetMapping"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excluded_folders_are_skipped() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join("module/src")).unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join("build")).unwrap();
|
||||
std::fs::write(tmp.path().join("module/src/A.java"), "@RequestMapping(\"/a\")").unwrap();
|
||||
std::fs::write(tmp.path().join("build/B.java"), "@RequestMapping(\"/b\")").unwrap();
|
||||
let files = sources_in(tmp.path(), &["build/**".to_owned()]);
|
||||
assert_eq!(files.len(), 1, "{:?}", files.iter().map(|f| &f.path).collect::<Vec<_>>());
|
||||
assert!(Path::new(&files[0].path).ends_with("module/src/A.java"), "{}", files[0].path);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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 || {
|
||||
|
||||
@@ -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,
|
||||
@@ -881,37 +881,7 @@ impl Project {
|
||||
|
||||
/// Applies a multi-file edit through the document layer so open editors update live.
|
||||
pub fn apply_workspace_edit(documents: &Documents, edit: &ide_core::WorkspaceEdit) -> IdeResult<()> {
|
||||
use ide_core::FileEdit;
|
||||
let mut edit = edit.clone();
|
||||
edit.deduplicate();
|
||||
for file in &edit.files {
|
||||
match file {
|
||||
FileEdit::Edit { path, edits } => {
|
||||
// Files not open in an editor are opened transiently and closed again.
|
||||
let (doc, transient) = match documents.by_path(path) {
|
||||
Some(doc) => (doc, false),
|
||||
None => (documents.open(path)?.id, true),
|
||||
};
|
||||
let result = documents.apply_edits(doc, edits).and_then(|_| documents.save(doc));
|
||||
if transient {
|
||||
documents.close(doc);
|
||||
}
|
||||
result?;
|
||||
}
|
||||
FileEdit::Create { path, overwrite } => {
|
||||
if path.exists() {
|
||||
if *overwrite {
|
||||
ide_vfs::save_text(path, "", "utf-8", false)?;
|
||||
}
|
||||
} else {
|
||||
ide_vfs::create_file(path)?;
|
||||
}
|
||||
}
|
||||
FileEdit::Rename { from, to } => ide_vfs::rename(from, to)?,
|
||||
FileEdit::Delete { path, .. } => ide_vfs::delete(path)?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
documents.apply_workspace_edit(edit)
|
||||
}
|
||||
|
||||
/// Files the watcher reports per batch that Local History snapshots.
|
||||
@@ -1253,10 +1223,14 @@ mod workspace_edit_tests {
|
||||
let doc = documents.open(&alias).unwrap();
|
||||
let changes = |path: PathBuf| FileEdit::Edit {
|
||||
path: path.into(),
|
||||
version: None,
|
||||
edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() }],
|
||||
};
|
||||
apply_workspace_edit(&documents, &WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)] })
|
||||
.unwrap();
|
||||
apply_workspace_edit(
|
||||
&documents,
|
||||
&WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)], ..Default::default() },
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(real).unwrap(), "sum_values();\n");
|
||||
assert_eq!(documents.snapshot(doc.id).unwrap().text, "sum_values();\n");
|
||||
}
|
||||
|
||||
@@ -215,9 +215,14 @@ impl Dispatcher {
|
||||
})
|
||||
.add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("doc_close", |c| async move { ops::doc::close(&c.host, &c.window, c.arg("doc")?) })
|
||||
.add("doc_close", |c| async move {
|
||||
ops::doc::blocking(move || ops::doc::close(&c.host, &c.window, c.arg("doc")?)).await
|
||||
})
|
||||
.add("doc_edit", |c| async move {
|
||||
ops::doc::edit(&c.host, &c.window, c.arg("doc")?, c.arg("baseVersion")?, c.arg("changes")?)
|
||||
ops::doc::blocking(move || {
|
||||
ops::doc::edit(&c.host, &c.window, c.arg("doc")?, c.arg("baseVersion")?, c.arg("changes")?)
|
||||
})
|
||||
.await
|
||||
})
|
||||
.add("doc_save", |c| async move { ops::doc::save(&c.host, &c.window, c.arg("doc")?).await })
|
||||
.add("doc_save_all", |c| async move { ops::doc::save_all(&c.host, &c.window).await })
|
||||
@@ -231,6 +236,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")?) })
|
||||
|
||||
@@ -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;
|
||||
@@ -211,6 +209,10 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "menu_model"
|
||||
| "menu_configure"
|
||||
| "menu_set_disabled"
|
||||
| "prepare_workspace_edit"
|
||||
| "apply_workspace_edit"
|
||||
| "undo_workspace_edit"
|
||||
| "redo_workspace_edit"
|
||||
| "lsp_servers"
|
||||
| "lsp_resend_diagnostics"
|
||||
| "recent_projects"
|
||||
@@ -222,6 +224,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 +246,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "remote_current"
|
||||
| "remote_disconnect"
|
||||
| "remote_connect"
|
||||
| "remote_bootstrap_list"
|
||||
| "remote_list"
|
||||
| "remote_open"
|
||||
| "remote_of"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()))),
|
||||
@@ -87,6 +88,40 @@ async fn serves_projects_files_documents_search_and_runs() {
|
||||
}
|
||||
let call = Invoke { window: "unknown".into(), cmd: "coverage_collect".into(), args: json!({}) };
|
||||
assert!(peer.request::<_, Value>("invoke", call, None).await.is_err());
|
||||
// Manual workspace edits remain available in Restricted Mode, like doc/fs
|
||||
// editing. They do not ask a language server to execute project commands.
|
||||
let manual = project.join("src/manual.txt");
|
||||
std::fs::write(&manual, "old").unwrap();
|
||||
let edit = json!({ "files": [{ "kind": "edit", "path": manual, "edits": [{
|
||||
"range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } },
|
||||
"new_text": "new"
|
||||
}] }] });
|
||||
let edit = invoke(&peer, "prepare_workspace_edit", json!({ "edit": edit })).await.unwrap();
|
||||
assert_eq!(edit["preconditions"].as_array().unwrap().len(), 1);
|
||||
invoke(&peer, "apply_workspace_edit", json!({ "edit": edit })).await.unwrap();
|
||||
until("workspace edit history", || {
|
||||
client.events.lock().iter().any(|event| matches!(event.event, ide_core::IdeEvent::WorkspaceEditApplied { .. }))
|
||||
})
|
||||
.await;
|
||||
let undo_id = client
|
||||
.events
|
||||
.lock()
|
||||
.iter()
|
||||
.find_map(|event| match &event.event {
|
||||
ide_core::IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&manual).unwrap(), "new");
|
||||
invoke(&peer, "undo_workspace_edit", json!({ "id": undo_id })).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&manual).unwrap(), "old");
|
||||
invoke(&peer, "redo_workspace_edit", json!({ "id": undo_id })).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&manual).unwrap(), "new");
|
||||
assert_eq!(invoke(&peer, "project_current", json!({})).await.unwrap()["trusted"], false);
|
||||
for command in ["lsp_execute_command", "lsp_apply_code_action"] {
|
||||
let error = invoke(&peer, command, json!({})).await.unwrap_err();
|
||||
assert!(error.message.contains("Restricted Mode"), "{command}: {error:?}");
|
||||
}
|
||||
invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap();
|
||||
let current = invoke(&peer, "project_current", json!({})).await.unwrap();
|
||||
assert_eq!(current["name"], "demo");
|
||||
@@ -261,6 +296,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 +364,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)),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
use ide_core::{IdeEvent, SharedSink, WorkspaceEdit, error::ErrorKind};
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn preview_apply_and_grouped_undo_use_the_shared_host_commands() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let first = tmp.path().join("first.txt");
|
||||
let second = tmp.path().join("second.txt");
|
||||
for path in [&first, &second] {
|
||||
std::fs::write(path, "old\n").unwrap();
|
||||
}
|
||||
let events: Arc<Mutex<Vec<IdeEvent>>> = Arc::default();
|
||||
let sink: SharedSink = {
|
||||
let events = events.clone();
|
||||
Arc::new(move |event| events.lock().push(event))
|
||||
};
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("local-data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |_| sink.clone()),
|
||||
owner: |window| window,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.open_light("main", &first).unwrap();
|
||||
let info = ops::doc::open(&host, "main", first.clone()).await.unwrap();
|
||||
let edit: WorkspaceEdit = serde_json::from_value(json!({ "files": [
|
||||
{ "kind": "edit", "path": first, "version": info.version, "edits": [{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } }, "new_text": "new" }] },
|
||||
{ "kind": "edit", "path": second, "edits": [{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 } }, "new_text": "new" }] }
|
||||
] })).unwrap();
|
||||
let preview = ops::lsp::prepare_workspace_edit(&host, "main", edit.clone()).await.unwrap();
|
||||
assert_eq!(preview.preconditions.len(), 2);
|
||||
std::fs::write(&second, "own\n").unwrap();
|
||||
let error = ops::lsp::apply_workspace_edit(&host, "main", preview).await.unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::OutOfSync);
|
||||
assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n");
|
||||
|
||||
let preview = ops::lsp::prepare_workspace_edit(&host, "main", edit).await.unwrap();
|
||||
ops::lsp::apply_workspace_edit(&host, "main", preview).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&first).unwrap(), "new\n");
|
||||
assert_eq!(std::fs::read_to_string(&second).unwrap(), "new\n");
|
||||
let id = events
|
||||
.lock()
|
||||
.iter()
|
||||
.find_map(|event| match event {
|
||||
IdeEvent::WorkspaceEditApplied { id, .. } => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
ops::lsp::undo_workspace_edit(&host, "main", id).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&first).unwrap(), "old\n");
|
||||
assert_eq!(std::fs::read_to_string(&second).unwrap(), "own\n");
|
||||
assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "old\n");
|
||||
ops::lsp::redo_workspace_edit(&host, "main", id).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&first).unwrap(), "new\n");
|
||||
assert_eq!(std::fs::read_to_string(&second).unwrap(), "new\n");
|
||||
host.clear_workspace_edit_history_on_exit();
|
||||
assert_eq!(ops::lsp::undo_workspace_edit(&host, "main", id).await.unwrap_err().kind, ErrorKind::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn contended_version_reads_do_not_block_the_async_worker() {
|
||||
use ide_core::{LanguageId, Pos, Range, RequestId};
|
||||
use ide_workspace::documents::DocumentObserver;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
struct PausedWrite {
|
||||
entered: Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
|
||||
release: Mutex<mpsc::Receiver<()>>,
|
||||
}
|
||||
impl DocumentObserver for PausedWrite {
|
||||
fn changed(
|
||||
&self,
|
||||
_: ide_core::DocId,
|
||||
_: &std::path::Path,
|
||||
_: u32,
|
||||
_: &[ide_text::AppliedChange],
|
||||
_: &dyn Fn() -> String,
|
||||
) {
|
||||
if let Some(entered) = self.entered.lock().take() {
|
||||
let _ = entered.send(());
|
||||
self.release.lock().recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for request_kind in 0..5 {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("main.txt");
|
||||
std::fs::write(&file, "old").unwrap();
|
||||
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("local-data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |window| window,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
let project = host.open_light("main", &file).unwrap();
|
||||
let doc = project.documents.open(&file).unwrap().id;
|
||||
assert_eq!(project.documents.snapshot(doc).unwrap().language, LanguageId::plain_text());
|
||||
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = mpsc::channel();
|
||||
project.documents.add_observer(Arc::new(PausedWrite {
|
||||
entered: Mutex::new(Some(entered_tx)),
|
||||
release: Mutex::new(release_rx),
|
||||
}));
|
||||
let documents = project.documents.clone();
|
||||
let writer = std::thread::spawn(move || {
|
||||
documents
|
||||
.apply_workspace_edit(
|
||||
&serde_json::from_value(json!({ "files": [{
|
||||
"kind": "edit", "path": file, "edits": [{ "range": {
|
||||
"start": { "line": 0, "col": 0 }, "end": { "line": 0, "col": 3 }
|
||||
}, "new_text": "new" }]
|
||||
}] }))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
entered_rx.await.unwrap();
|
||||
let (heartbeat_tx, heartbeat_rx) = mpsc::channel();
|
||||
// Always release the writer, even on the old blocking implementation.
|
||||
let watchdog = std::thread::spawn(move || {
|
||||
let responsive = heartbeat_rx.recv_timeout(Duration::from_secs(2)).is_ok();
|
||||
release_tx.send(()).unwrap();
|
||||
responsive
|
||||
});
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
let _ = started_tx.send(());
|
||||
let action =
|
||||
|| serde_json::from_value(json!({ "title": "test", "isPreferred": false, "server": 1 })).unwrap();
|
||||
match request_kind {
|
||||
0 => {
|
||||
let _ = ops::lsp::lsp_code_actions(&host, "main", doc, Range::point(Pos::new(0, 0)), vec![]).await;
|
||||
}
|
||||
1 => {
|
||||
let _ = ops::lsp::lsp_code_action_query(
|
||||
&host,
|
||||
"main",
|
||||
RequestId(1),
|
||||
doc,
|
||||
Range::point(Pos::new(0, 0)),
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
2 => {
|
||||
let _ = ops::lsp::lsp_resolve_code_action(&host, "main", doc, action()).await;
|
||||
}
|
||||
3 => {
|
||||
let _ = ops::lsp::lsp_apply_code_action(&host, "main", doc, action()).await;
|
||||
}
|
||||
_ => {
|
||||
let _ =
|
||||
ops::lsp::lsp_rename(&host, "main", RequestId(1), doc, Pos::new(0, 0), "other".into()).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
started_rx.await.unwrap();
|
||||
let _ = heartbeat_tx.send(());
|
||||
task.await.unwrap();
|
||||
writer.join().unwrap();
|
||||
assert!(watchdog.join().unwrap(), "request {request_kind} blocked the only async worker");
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -15,9 +15,9 @@ mod syntax;
|
||||
pub use markers::marked_root;
|
||||
pub use registry::{FileTypes, LanguageRegistry};
|
||||
pub use syntax::{
|
||||
CSS_CLASS, CSS_ID, CSS_VARIABLE, FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES,
|
||||
ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind,
|
||||
SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility,
|
||||
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, FileReference, Literal, LiteralKind, MAX_CACHED_TREES,
|
||||
MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion, Syntax, SyntaxCache, SyntaxCacheStats,
|
||||
SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference, WebReferenceKind, merge_visibility,
|
||||
};
|
||||
|
||||
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
|
||||
@@ -96,7 +96,9 @@ pub struct LanguageServerConfig {
|
||||
pub args: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub env: BTreeMap<String, String>,
|
||||
/// Files whose presence marks a workspace root, e.g. `Cargo.toml`.
|
||||
/// Files whose presence marks a workspace root, e.g. `Cargo.toml`;
|
||||
/// `file#table` requires the TOML file to contain that dotted table, e.g.
|
||||
/// `pyproject.toml#tool.ruff`.
|
||||
#[serde(default)]
|
||||
pub root_markers: Vec<String>,
|
||||
/// Serve only files below a root marker, not the whole project: a framework's
|
||||
|
||||
@@ -19,9 +19,13 @@ pub fn marked_root(project_root: Option<&Path>, file: &Path, markers: &[String])
|
||||
best
|
||||
}
|
||||
|
||||
/// `dir` contains `marker`: a relative path, or a file name pattern with one
|
||||
/// `*` (`*.csproj`).
|
||||
/// `dir` contains `marker`: a relative path, a file name pattern with one
|
||||
/// `*` (`*.csproj`), or a TOML file name (no pattern) and a dotted table it
|
||||
/// must contain (`pyproject.toml#tool.ruff`).
|
||||
fn has_marker(dir: &Path, marker: &str) -> bool {
|
||||
if let Some((file, table)) = marker.split_once('#') {
|
||||
return has_toml_table(&dir.join(file), table);
|
||||
}
|
||||
let Some((prefix, suffix)) = marker.split_once('*') else {
|
||||
return dir.join(marker).exists();
|
||||
};
|
||||
@@ -30,9 +34,23 @@ fn has_marker(dir: &Path, marker: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// `file` parses as TOML and has the dotted `table` (`tool.ruff`).
|
||||
fn has_toml_table(file: &Path, table: &str) -> bool {
|
||||
let Ok(text) = std::fs::read_to_string(file) else { return false };
|
||||
let value = match toml::from_str::<toml::Value>(&text) {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
tracing::debug!("root marker {}: {e}", file.display());
|
||||
return false;
|
||||
}
|
||||
};
|
||||
table.split('.').try_fold(&value, |v, key| v.get(key)).is_some_and(toml::Value::is_table)
|
||||
}
|
||||
|
||||
/// `path` is (or, after its creation or removal, was) a root marker of the
|
||||
/// directory above it.
|
||||
pub(crate) fn is_marker_path(path: &Path, marker: &str) -> bool {
|
||||
let marker = marker.split_once('#').map_or(marker, |(file, _)| file);
|
||||
match marker.split_once('*') {
|
||||
None => path.ends_with(marker),
|
||||
Some((prefix, suffix)) => {
|
||||
@@ -74,6 +92,29 @@ mod tests {
|
||||
assert_eq!(marked_root(None, &file, &markers), Some(home.to_path_buf()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_markers_need_their_table() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let file = root.join("svc/app/main.py");
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
let markers = vec!["ruff.toml".to_string(), "pyproject.toml#tool.ruff".to_string()];
|
||||
std::fs::write(root.join("pyproject.toml"), "[project]\nname = \"x\"\n[tool.black]\n").unwrap();
|
||||
assert_eq!(marked_root(Some(root), &file, &markers), None);
|
||||
std::fs::write(root.join("svc/pyproject.toml"), "[tool.ruff.lint]\nselect = [\"E\"]\n").unwrap();
|
||||
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc")));
|
||||
// Outermost wins: an inline table counts; a value that is no table, or a
|
||||
// file that does not parse, does not.
|
||||
std::fs::write(root.join("pyproject.toml"), "tool = { ruff = { line-length = 100 } }\n").unwrap();
|
||||
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.to_path_buf()));
|
||||
std::fs::write(root.join("pyproject.toml"), "tool = { ruff = 1 }\n").unwrap();
|
||||
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc")));
|
||||
std::fs::write(root.join("pyproject.toml"), "[tool.ruff]\n[broken").unwrap();
|
||||
assert_eq!(marked_root(Some(root), &file, &markers), Some(root.join("svc")));
|
||||
assert!(is_marker_path(&root.join("svc/pyproject.toml"), "pyproject.toml#tool.ruff"));
|
||||
assert!(!is_marker_path(&root.join("svc/setup.cfg"), "pyproject.toml#tool.ruff"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patterns_match_file_names() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -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(®, "src/main.rs"), "rust");
|
||||
assert_eq!(detect(®, "a/b.TS"), "typescript");
|
||||
for path in ["guide.adoc", "guide.asciidoc", "GUIDE.ADOC"] {
|
||||
assert_eq!(detect(®, 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(®, "README"), "plaintext");
|
||||
assert_eq!(detect(®, "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,50 @@ 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 ruff_serves_python_files_only_below_its_config() {
|
||||
let reg = reg();
|
||||
let cfg = reg.get(&LanguageId::new("python")).unwrap();
|
||||
let names: Vec<&str> = cfg.language_servers.iter().map(|s| s.name.as_str()).collect();
|
||||
// After the fallbacks: hover, rename and symbols stay with the type checker.
|
||||
assert_eq!(names, ["basedpyright", "pyright", "ty", "ruff"]);
|
||||
let ruff = cfg.language_servers.iter().find(|s| s.name == "ruff").unwrap();
|
||||
assert_eq!((ruff.command.as_str(), ruff.args.as_slice()), ("ruff", ["server".to_string()].as_slice()));
|
||||
assert!(ruff.require_root_marker && !ruff.fallback);
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
for dir in ["lint", "dot", "pyproject", "plain"] {
|
||||
std::fs::create_dir_all(root.join(dir)).unwrap();
|
||||
}
|
||||
std::fs::write(root.join("lint/ruff.toml"), "").unwrap();
|
||||
std::fs::write(root.join("dot/.ruff.toml"), "").unwrap();
|
||||
std::fs::write(root.join("pyproject/pyproject.toml"), "[tool.ruff]\nline-length = 100\n").unwrap();
|
||||
std::fs::write(root.join("plain/pyproject.toml"), "[project]\nname = \"plain\"\n").unwrap();
|
||||
let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &ruff.root_markers);
|
||||
for dir in ["lint", "dot", "pyproject"] {
|
||||
assert_eq!(served(&format!("{dir}/pkg/main.py")), Some(root.join(dir)), "{dir}");
|
||||
}
|
||||
assert_eq!(served("plain/main.py"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_of_angular_projects_is_an_angular_template() {
|
||||
let reg = reg();
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Source ranges for Spring-style configuration keys. This is a syntax index,
|
||||
//! not a YAML evaluator: aliases, merge keys and runtime profile precedence are
|
||||
//! intentionally left to the YAML server / application.
|
||||
use super::lines::LineIndex;
|
||||
use super::{ParsedTree, Syntax, TreeError};
|
||||
use ide_core::{LanguageId, Range};
|
||||
use tree_sitter::Node;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ConfigurationKey {
|
||||
pub name: String,
|
||||
pub range: Range,
|
||||
}
|
||||
|
||||
impl Syntax {
|
||||
pub fn configuration_keys(
|
||||
&self,
|
||||
language: &LanguageId,
|
||||
text: &str,
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
) -> Result<Vec<ConfigurationKey>, TreeError> {
|
||||
if language.as_str() == "properties" {
|
||||
return Ok(properties(text));
|
||||
}
|
||||
self.with_parsed(language, text, cancelled, |tree| tree.configuration_keys(cancelled))
|
||||
}
|
||||
}
|
||||
|
||||
impl ParsedTree<'_> {
|
||||
pub fn configuration_keys(&self, cancelled: &dyn Fn() -> bool) -> Vec<ConfigurationKey> {
|
||||
let mut out = Vec::new();
|
||||
if self.grammar == "yaml" {
|
||||
yaml(self.tree.root_node(), "", self.text, &LineIndex::new(self.text), &mut out, cancelled, 0);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn yaml(
|
||||
node: Node<'_>,
|
||||
prefix: &str,
|
||||
text: &str,
|
||||
lines: &LineIndex<'_>,
|
||||
out: &mut Vec<ConfigurationKey>,
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
depth: usize,
|
||||
) {
|
||||
if depth > 64 || cancelled() || out.len() >= 20_000 {
|
||||
return;
|
||||
}
|
||||
match node.kind() {
|
||||
"block_mapping_pair" | "flow_pair" => {
|
||||
let Some(key) = node.child_by_field_name("key") else { return };
|
||||
let raw = &text[key.byte_range()];
|
||||
if raw.contains('\n') {
|
||||
return;
|
||||
}
|
||||
let name = if raw.starts_with('\'') && raw.ends_with('\'') {
|
||||
raw[1..raw.len() - 1].replace("''", "'")
|
||||
} else if raw.starts_with('"') {
|
||||
// JSON escapes cover ordinary quoted configuration keys. Unsupported
|
||||
// YAML-only escapes are skipped rather than indexed under a wrong key.
|
||||
let Ok(name) = serde_json::from_str::<String>(raw) else { return };
|
||||
name
|
||||
} else {
|
||||
if raw.contains(['\n', '{', '[', '*', '&', '!']) || raw == "<<" {
|
||||
return;
|
||||
}
|
||||
raw.to_owned()
|
||||
};
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
let name = if prefix.is_empty() {
|
||||
name
|
||||
} else if name.starts_with('[') {
|
||||
format!("{prefix}{name}")
|
||||
} else {
|
||||
format!("{prefix}.{name}")
|
||||
};
|
||||
if name.len() > 1024 {
|
||||
return;
|
||||
}
|
||||
out.push(ConfigurationKey { name: name.clone(), range: lines.range(key.start_byte(), key.end_byte()) });
|
||||
if let Some(value) = node.child_by_field_name("value") {
|
||||
yaml(value, &name, text, lines, out, cancelled, depth + 1);
|
||||
}
|
||||
}
|
||||
"block_sequence" | "flow_sequence" => {
|
||||
let mut cursor = node.walk();
|
||||
for (i, child) in node.named_children(&mut cursor).filter(|n| n.kind() != "comment").enumerate() {
|
||||
yaml(child, &format!("{prefix}[{i}]"), text, lines, out, cancelled, depth + 1);
|
||||
}
|
||||
}
|
||||
// Do not descend into scalars, anchors or aliases.
|
||||
"stream"
|
||||
| "document"
|
||||
| "block_node"
|
||||
| "flow_node"
|
||||
| "block_mapping"
|
||||
| "flow_mapping"
|
||||
| "block_sequence_item" => {
|
||||
let mut cursor = node.walk();
|
||||
for child in node.named_children(&mut cursor) {
|
||||
yaml(child, prefix, text, lines, out, cancelled, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn properties(text: &str) -> Vec<ConfigurationKey> {
|
||||
let lines = LineIndex::new(text);
|
||||
let mut out = Vec::new();
|
||||
let mut logical = String::new();
|
||||
let mut offsets = Vec::new();
|
||||
let mut base = 0;
|
||||
for line in text.split_inclusive('\n') {
|
||||
let physical = line.trim_end_matches(['\r', '\n']);
|
||||
let start = physical.len() - physical.trim_start_matches([' ', '\t', '\u{c}']).len();
|
||||
let slice = &physical[start..];
|
||||
if logical.is_empty() && (slice.is_empty() || slice.starts_with(['#', '!'])) {
|
||||
base += line.len();
|
||||
continue;
|
||||
}
|
||||
let continued = slice.bytes().rev().take_while(|b| *b == b'\\').count() % 2 == 1;
|
||||
let end = physical.len() - usize::from(continued);
|
||||
for (i, ch) in physical[start..end].char_indices() {
|
||||
offsets.extend((0..ch.len_utf8()).map(|byte| base + start + i + byte));
|
||||
logical.push(ch);
|
||||
}
|
||||
if !continued || base + line.len() == text.len() {
|
||||
let mut escaped = false;
|
||||
let key_end = logical
|
||||
.char_indices()
|
||||
.find_map(|(i, ch)| {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
return None;
|
||||
}
|
||||
if ch == '\\' {
|
||||
escaped = true;
|
||||
return None;
|
||||
}
|
||||
matches!(ch, '=' | ':' | ' ' | '\t' | '\u{c}').then_some(i)
|
||||
})
|
||||
.unwrap_or(logical.len());
|
||||
if key_end > 0
|
||||
&& key_end <= 1024
|
||||
&& let Some(name) = unescape_property(&logical[..key_end])
|
||||
{
|
||||
out.push(ConfigurationKey { name, range: lines.range(offsets[0], offsets[key_end - 1] + 1) });
|
||||
}
|
||||
logical.clear();
|
||||
offsets.clear();
|
||||
}
|
||||
base += line.len();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn unescape_property(raw: &str) -> Option<String> {
|
||||
let mut chars = raw.chars();
|
||||
let mut out = String::new();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch != '\\' {
|
||||
out.push(ch);
|
||||
continue;
|
||||
}
|
||||
match chars.next()? {
|
||||
'u' => {
|
||||
let hex: String = chars.by_ref().take(4).collect();
|
||||
if hex.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
out.push(char::from_u32(u32::from_str_radix(&hex, 16).ok()?)?);
|
||||
}
|
||||
't' => out.push('\t'),
|
||||
'n' => out.push('\n'),
|
||||
'r' => out.push('\r'),
|
||||
'f' => out.push('\u{c}'),
|
||||
ch => out.push(ch),
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn yaml_structures_and_utf16() {
|
||||
let text = "app:\n 'time-out': 1\n hosts:\n - url: a\n - {url: b}\n map: {east: {size: 2}}\n---\n\"app.name\": ok\n'😀': {port: 3}\n";
|
||||
let keys = Syntax::new().configuration_keys(&LanguageId::new("yaml"), text, &|| false).unwrap();
|
||||
let names: Vec<_> = keys.iter().map(|k| k.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"app",
|
||||
"app.time-out",
|
||||
"app.hosts",
|
||||
"app.hosts[0].url",
|
||||
"app.hosts[1].url",
|
||||
"app.map",
|
||||
"app.map.east",
|
||||
"app.map.east.size",
|
||||
"app.name",
|
||||
"😀",
|
||||
"😀.port"
|
||||
]
|
||||
);
|
||||
assert_eq!(keys.last().unwrap().range.start.col, 7);
|
||||
}
|
||||
#[test]
|
||||
fn aliases_and_values_are_not_configuration_keys() {
|
||||
let keys = Syntax::new()
|
||||
.configuration_keys(
|
||||
&LanguageId::new("yaml"),
|
||||
"base: &base {size: 1}\napp:\n <<: *base\n text: |\n fake: key\n",
|
||||
&|| false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(keys.iter().map(|k| k.name.as_str()).collect::<Vec<_>>(), ["base", "base.size", "app", "app.text"]);
|
||||
}
|
||||
#[test]
|
||||
fn properties_unicode_ranges_and_eof_continuation() {
|
||||
let keys = properties("app.中文=1\nlast\\\n");
|
||||
assert_eq!(keys[0].range.end.col, 6);
|
||||
assert_eq!(keys[1].name, "last");
|
||||
}
|
||||
#[test]
|
||||
fn properties_escapes_continuations_and_comments() {
|
||||
let keys = properties(
|
||||
"# comment\r\napp.time\\\n -out : 1\napp\\.name=ok\napp.\\u0061=2\nspace\\ key value\n!ignored=x\n",
|
||||
);
|
||||
assert_eq!(
|
||||
keys.iter().map(|k| k.name.as_str()).collect::<Vec<_>>(),
|
||||
["app.time-out", "app.name", "app.a", "space key"]
|
||||
);
|
||||
assert_eq!(keys[0].range.end.line, 2);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Tree-sitter driven structure: outline, folding, symbol tags, selection ranges, highlighting.
|
||||
|
||||
mod cache;
|
||||
mod configuration;
|
||||
mod folding;
|
||||
mod grammars;
|
||||
mod highlight;
|
||||
@@ -12,6 +13,7 @@ mod web;
|
||||
|
||||
use crate::{FoldRange, LanguageConfig, LanguageRegistry, SymbolTag};
|
||||
pub use cache::{MAX_CACHED_TREES, SyntaxCache, SyntaxCacheStats};
|
||||
pub use configuration::ConfigurationKey;
|
||||
use grammars::{SPECS, Spec};
|
||||
pub use highlight::{SyntaxHighlightKind, SyntaxHighlights};
|
||||
use ide_core::{LanguageId, OutlineItem, Pos, Range};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -621,7 +621,7 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit {
|
||||
})
|
||||
.map(|e| conv.text_edit(&path, e))
|
||||
.collect();
|
||||
files.push(FileEdit::Edit { path: path.into(), edits });
|
||||
files.push(FileEdit::Edit { path: path.into(), edits, version: e.text_document.version });
|
||||
};
|
||||
if let Some(changes) = e.document_changes {
|
||||
match changes {
|
||||
@@ -657,11 +657,11 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit {
|
||||
for (uri, edits) in changes {
|
||||
if let Some(path) = uri_path(&uri) {
|
||||
let edits = conv.text_edits(&path, edits);
|
||||
files.push(FileEdit::Edit { path: path.into(), edits });
|
||||
files.push(FileEdit::Edit { path: path.into(), edits, version: None });
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut edit = WorkspaceEdit { files };
|
||||
let mut edit = WorkspaceEdit { files, ..Default::default() };
|
||||
edit.deduplicate();
|
||||
edit
|
||||
}
|
||||
@@ -670,28 +670,36 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit {
|
||||
/// `edit` where they leave its own alone: what only the other server sees
|
||||
/// (Angular templates next to vtsls's TypeScript).
|
||||
pub fn merge_workspace_edit(edit: &mut WorkspaceEdit, extra: WorkspaceEdit) {
|
||||
edit.preconditions.extend(extra.preconditions);
|
||||
let overlap =
|
||||
|a: &TextEdit, b: &TextEdit| a.range == b.range || (a.range.start < b.range.end && b.range.start < a.range.end);
|
||||
for file in extra.files {
|
||||
let FileEdit::Edit { path, edits } = file else {
|
||||
let FileEdit::Edit { path, edits, version } = file else {
|
||||
if !edit.files.contains(&file) {
|
||||
edit.files.push(file);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let own = edit.files.iter_mut().find_map(|f| match f {
|
||||
FileEdit::Edit { path: p, edits } if *p == path => Some(edits),
|
||||
FileEdit::Edit { path: p, edits, version } if *p == path => Some((edits, version)),
|
||||
_ => None,
|
||||
});
|
||||
match own {
|
||||
Some(own) => {
|
||||
Some((own, own_version)) => {
|
||||
// Conflicting versions cannot describe one consistent edit. Keep both
|
||||
// operations so transaction preflight rejects them instead of dropping a guard.
|
||||
if own_version.is_some() && version.is_some() && *own_version != version {
|
||||
edit.files.push(FileEdit::Edit { path, edits, version });
|
||||
continue;
|
||||
}
|
||||
*own_version = own_version.or(version);
|
||||
let added: Vec<TextEdit> = edits.into_iter().filter(|e| !own.iter().any(|o| overlap(o, e))).collect();
|
||||
if !added.is_empty() {
|
||||
own.extend(added);
|
||||
own.sort_by_key(|e| e.range.start);
|
||||
}
|
||||
}
|
||||
None => edit.files.push(FileEdit::Edit { path, edits }),
|
||||
None => edit.files.push(FileEdit::Edit { path, edits, version }),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1036,16 +1044,20 @@ mod tests {
|
||||
range: Range::new(Pos::new(l, c), Pos::new(l, c + 5)),
|
||||
new_text: text.into(),
|
||||
};
|
||||
let file = |p: &str, edits: Vec<TextEdit>| FileEdit::Edit { path: PathBuf::from(p).into(), edits };
|
||||
let file =
|
||||
|p: &str, edits: Vec<TextEdit>| FileEdit::Edit { path: PathBuf::from(p).into(), edits, version: None };
|
||||
// vtsls renames a shorthand property with an alias; the Angular server
|
||||
// renames it plainly and adds the template.
|
||||
let mut merged =
|
||||
WorkspaceEdit { files: vec![file("/a.ts", vec![edit(3, 2, "items: entries"), edit(9, 4, "entries")])] };
|
||||
let mut merged = WorkspaceEdit {
|
||||
files: vec![file("/a.ts", vec![edit(3, 2, "items: entries"), edit(9, 4, "entries")])],
|
||||
..Default::default()
|
||||
};
|
||||
let angular = WorkspaceEdit {
|
||||
files: vec![
|
||||
file("/a.ts", vec![edit(9, 4, "entries"), edit(3, 2, "entries"), edit(1, 14, "entries")]),
|
||||
file("/a.html", vec![edit(0, 7, "entries")]),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
merge_workspace_edit(&mut merged, angular);
|
||||
assert_eq!(
|
||||
@@ -1306,7 +1318,7 @@ mod tests {
|
||||
assert_eq!(w.files.len(), 4);
|
||||
assert_eq!(w.files[0], FileEdit::Create { path: "/n.rs".into(), overwrite: true });
|
||||
match &w.files[1] {
|
||||
FileEdit::Edit { path, edits } => {
|
||||
FileEdit::Edit { path, edits, .. } => {
|
||||
assert_eq!(path, Path::new("/n.rs"));
|
||||
assert_eq!(edits.len(), 2);
|
||||
assert_eq!(edits[1].new_text, "y");
|
||||
@@ -1322,6 +1334,26 @@ mod tests {
|
||||
assert!(matches!(&workspace_edit(&conv, e).files[0], FileEdit::Edit { edits, .. } if edits[0].new_text == "q"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_edits_preserve_document_versions_and_merge_guards() {
|
||||
let conv = Conv::utf16();
|
||||
let incoming = |version| {
|
||||
serde_json::from_value(json!({
|
||||
"documentChanges": [{
|
||||
"textDocument": { "uri": "file:///a.rs", "version": version },
|
||||
"edits": [{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } }, "newText": "x" }]
|
||||
}]
|
||||
})).unwrap()
|
||||
};
|
||||
let mut edit = workspace_edit(&conv, incoming(Some(7)));
|
||||
assert!(matches!(edit.files[0], FileEdit::Edit { version: Some(7), .. }));
|
||||
merge_workspace_edit(&mut edit, workspace_edit(&conv, incoming(None::<i32>)));
|
||||
assert!(matches!(edit.files[0], FileEdit::Edit { version: Some(7), .. }));
|
||||
merge_workspace_edit(&mut edit, workspace_edit(&conv, incoming(Some(8))));
|
||||
assert_eq!(edit.files.len(), 2, "inconsistent version guards must not be discarded");
|
||||
assert!(matches!(edit.files[1], FileEdit::Edit { version: Some(8), .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_token_decoding() {
|
||||
let data = [1, 2, 3, 0, 1, 0, 5, 2, 1, 0, 2, 0, 4, 2, 3];
|
||||
|
||||
@@ -18,6 +18,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
const SERVER: &str = "jdtls";
|
||||
|
||||
pub(crate) mod configuration;
|
||||
mod formatter;
|
||||
pub use formatter::configure_formatter;
|
||||
/// First start imports the Maven / Gradle model, which can take minutes.
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
//! Bridges configuration syntax to the app-owned JDT extension. No additional
|
||||
//! language server, build, or network request is needed. Disk parsing runs on a
|
||||
//! blocking worker; open buffers always win over cached disk contents.
|
||||
use super::*;
|
||||
use crate::text::Encoding;
|
||||
use crate::types::{References, ReferencesIncomplete};
|
||||
use crate::{DocSnap, Target};
|
||||
use ide_core::{Location, Pos};
|
||||
use ide_language::{ConfigurationKey, Syntax};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::SystemTime;
|
||||
|
||||
const COMMAND: &str = "openidea.java.configuration";
|
||||
const MAX_BYTES: u64 = 2 * 1024 * 1024;
|
||||
const MAX_FILES: usize = 2000;
|
||||
const MAX_CACHED_KEYS: usize = 100_000;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ConfigurationCache {
|
||||
files: HashMap<PathBuf, CachedFile>,
|
||||
}
|
||||
struct CachedFile {
|
||||
stamp: (SystemTime, u64),
|
||||
keys: Vec<ConfigurationKey>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Pattern {
|
||||
name: String,
|
||||
relaxed: bool,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Response {
|
||||
#[serde(default)]
|
||||
locations: Vec<lsp_types::Location>,
|
||||
#[serde(default)]
|
||||
keys: Vec<Pattern>,
|
||||
#[serde(default)]
|
||||
roots: Vec<String>,
|
||||
#[serde(default)]
|
||||
incomplete: bool,
|
||||
}
|
||||
fn syntax() -> &'static Syntax {
|
||||
static SYNTAX: OnceLock<Syntax> = OnceLock::new();
|
||||
SYNTAX.get_or_init(Syntax::new)
|
||||
}
|
||||
fn language(path: &Path) -> Option<LanguageId> {
|
||||
match path.extension()?.to_str()? {
|
||||
"yaml" | "yml" => Some(LanguageId::new("yaml")),
|
||||
"properties" => Some(LanguageId::new("properties")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn parts(key: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut part = String::new();
|
||||
let mut chars = key.chars().peekable();
|
||||
let mut bracket = false;
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'[' => {
|
||||
if !part.is_empty() {
|
||||
out.push(std::mem::take(&mut part));
|
||||
}
|
||||
for ch in chars.by_ref() {
|
||||
if ch == ']' {
|
||||
break;
|
||||
}
|
||||
if !matches!(ch, '-' | '_') {
|
||||
part.extend(ch.to_lowercase());
|
||||
}
|
||||
}
|
||||
out.push(std::mem::take(&mut part));
|
||||
bracket = true;
|
||||
}
|
||||
'.' => {
|
||||
if !bracket {
|
||||
out.push(std::mem::take(&mut part));
|
||||
}
|
||||
bracket = false;
|
||||
}
|
||||
'-' | '_' => {}
|
||||
ch => {
|
||||
part.extend(ch.to_lowercase());
|
||||
bracket = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !part.is_empty() {
|
||||
out.push(part);
|
||||
}
|
||||
out
|
||||
}
|
||||
fn matches(pattern: &Pattern, key: &str) -> bool {
|
||||
if !pattern.relaxed {
|
||||
return pattern.name == key;
|
||||
}
|
||||
let (a, b) = (parts(&pattern.name), parts(key));
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| a == "*" || *a == b)
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
pub(crate) async fn configuration_navigation(
|
||||
&self,
|
||||
snap: &DocSnap,
|
||||
pos: Pos,
|
||||
usages: bool,
|
||||
include_declaration: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<References>> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let config = language(&snap.path).is_some();
|
||||
if !config && snap.language.as_str() != "java" {
|
||||
return Ok(None);
|
||||
}
|
||||
// Definition in ordinary Java code remains JDT's definition. Reverse
|
||||
// configuration navigation is offered on string expressions; field/config
|
||||
// accessor usages are merged into the ordinary Find Usages response.
|
||||
if !config && !usages {
|
||||
let text = snap.text.as_str();
|
||||
if !text.contains("Value") && !text.contains("getProperty") && !text.contains("getRequiredProperty") {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let key = if config {
|
||||
let text = snap.text.as_str().to_owned();
|
||||
if text.len() as u64 > MAX_BYTES {
|
||||
return Ok(None);
|
||||
}
|
||||
let lang = language(&snap.path).expect("configuration language");
|
||||
let cancelled = cancel.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
syntax()
|
||||
.configuration_keys(&lang, &text, &|| cancelled.is_cancelled())
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.find(|key| key.range.start <= pos && pos < key.range.end)
|
||||
.map(|key| key.name)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if config && key.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
if config {
|
||||
// Avoid starting a JVM for unrelated YAML (CI, Kubernetes, etc.).
|
||||
let path = snap.path.clone();
|
||||
let root = self.project_root.clone();
|
||||
let java_project = tokio::task::spawn_blocking(move || {
|
||||
path.ancestors().take_while(|p| p.starts_with(&root)).any(|p| {
|
||||
["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"]
|
||||
.iter()
|
||||
.any(|name| p.join(name).is_file())
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !java_project {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let result = self.configuration_query(snap, pos, key, usages, include_declaration, cancel).await;
|
||||
match result {
|
||||
Err(e) if cancel.is_cancelled() || e.kind == ErrorKind::Cancelled => Err(IdeError::cancelled()),
|
||||
Err(e) => {
|
||||
tracing::debug!("Configuration navigation unavailable; retaining native language navigation: {e}");
|
||||
Ok(None)
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
|
||||
async fn configuration_query(
|
||||
&self,
|
||||
snap: &DocSnap,
|
||||
pos: Pos,
|
||||
key: Option<String>,
|
||||
usages: bool,
|
||||
include_declaration: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<References>> {
|
||||
let (server, run) = self.jdtls(&snap.path, cancel).await?;
|
||||
if !run.caps.read().commands().iter().any(|command| command == COMMAND) {
|
||||
return Ok(None);
|
||||
}
|
||||
let target = Target { server: server.clone(), run };
|
||||
let position = snap.text.ide_to_lsp(Encoding::Utf16, pos);
|
||||
let response: Response = self
|
||||
.call(
|
||||
&target,
|
||||
"workspace/executeCommand",
|
||||
json!({
|
||||
"command": COMMAND, "arguments": [snap.uri, key, position.line, position.character, usages, include_declaration]
|
||||
}),
|
||||
Some(cancel),
|
||||
crate::LONG_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if response.keys.is_empty() {
|
||||
return Ok(response
|
||||
.incomplete
|
||||
.then(|| References { incomplete: Some(ReferencesIncomplete::FilesSkipped), ..Default::default() }));
|
||||
}
|
||||
let mut result = References {
|
||||
locations: response.locations.iter().filter_map(|l| self.conv(Encoding::Utf16).location(l)).collect(),
|
||||
incomplete: response.incomplete.then_some(ReferencesIncomplete::FilesSkipped),
|
||||
..Default::default()
|
||||
};
|
||||
self.note_virtual(&server, result.locations.iter().map(|l| l.uri.as_deref()));
|
||||
if key.is_none() || usages {
|
||||
let roots: Vec<PathBuf> = response.roots.iter().filter_map(|uri| super::project_path(uri)).collect();
|
||||
let overlays: HashMap<PathBuf, String> = self
|
||||
.docs
|
||||
.lock()
|
||||
.values()
|
||||
.filter(|d| language(&d.path).is_some() && d.text.as_str().len() as u64 <= MAX_BYTES)
|
||||
.map(|d| (d.canonical.clone(), d.text.as_str().to_owned()))
|
||||
.collect();
|
||||
let cache = self.configuration_cache.clone();
|
||||
let cancelled = cancel.clone();
|
||||
let keys = response.keys;
|
||||
let files =
|
||||
tokio::task::spawn_blocking(move || configuration_files(&roots, &keys, &overlays, &cache, &cancelled))
|
||||
.await
|
||||
.map_err(|_| IdeError::unavailable("configuration search worker stopped"))?;
|
||||
result.locations.extend(files.locations);
|
||||
result.incomplete = result.incomplete.or(files.incomplete);
|
||||
}
|
||||
if cancel.is_cancelled() || !self.docs.lock().values().any(|d| d.uri == snap.uri && d.version == snap.version) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if usages && key.is_some() && !include_declaration {
|
||||
let source_path = self
|
||||
.docs
|
||||
.lock()
|
||||
.values()
|
||||
.find(|d| d.uri == snap.uri)
|
||||
.map(|d| d.canonical.clone())
|
||||
.unwrap_or_else(|| snap.path.clone());
|
||||
result.locations.retain(|location| {
|
||||
location.path.to_path_buf() != source_path || !(location.range.start <= pos && pos < location.range.end)
|
||||
});
|
||||
}
|
||||
crate::convert::dedup_locations(&mut result.locations);
|
||||
Ok(Some(result))
|
||||
}
|
||||
}
|
||||
|
||||
fn in_module(root: &Path, path: &Path) -> bool {
|
||||
if !path.starts_with(root) {
|
||||
return false;
|
||||
}
|
||||
for parent in path.parent().into_iter().flat_map(Path::ancestors).take_while(|p| *p != root) {
|
||||
if matches!(
|
||||
parent.file_name().and_then(|n| n.to_str()),
|
||||
Some("target" | "build" | "node_modules" | ".git" | ".gradle")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if ["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"].iter().any(|name| parent.join(name).is_file())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn configuration_files(
|
||||
roots: &[PathBuf],
|
||||
patterns: &[Pattern],
|
||||
overlays: &HashMap<PathBuf, String>,
|
||||
cache: &Mutex<ConfigurationCache>,
|
||||
cancel: &CancellationToken,
|
||||
) -> References {
|
||||
let mut result = References::default();
|
||||
let mut paths = std::collections::BTreeSet::new();
|
||||
// Do not scan a source root twice when its enclosing module is also present.
|
||||
let roots: Vec<_> =
|
||||
roots.iter().filter(|root| !roots.iter().any(|other| other != *root && root.starts_with(other))).collect();
|
||||
for root in &roots {
|
||||
let module = (*root).clone();
|
||||
let walker = ignore::WalkBuilder::new(root)
|
||||
.follow_links(false)
|
||||
.filter_entry(move |entry| {
|
||||
if !entry.file_type().is_some_and(|t| t.is_dir()) {
|
||||
return true;
|
||||
}
|
||||
if entry.path() == module {
|
||||
return true;
|
||||
}
|
||||
if matches!(entry.file_name().to_str(), Some("target" | "build" | "node_modules" | ".git" | ".gradle"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// A sibling application's identical key must not leak into this module.
|
||||
!["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"]
|
||||
.iter()
|
||||
.any(|name| entry.path().join(name).is_file())
|
||||
})
|
||||
.build();
|
||||
for (visited, entry) in walker.enumerate() {
|
||||
if cancel.is_cancelled() {
|
||||
return result;
|
||||
}
|
||||
if visited >= 50_000 || paths.len() >= MAX_FILES {
|
||||
result.incomplete = Some(ReferencesIncomplete::TooManyFiles);
|
||||
break;
|
||||
}
|
||||
match entry {
|
||||
Ok(entry) if entry.file_type().is_some_and(|t| t.is_file()) && language(entry.path()).is_some() => {
|
||||
paths.insert(entry.into_path());
|
||||
}
|
||||
Err(_) => result.incomplete = Some(ReferencesIncomplete::FilesSkipped),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
paths.extend(overlays.keys().filter(|p| roots.iter().any(|r| in_module(r, p))).cloned());
|
||||
let mut cache = cache.lock();
|
||||
if cache.files.len() > MAX_FILES {
|
||||
cache.files.clear();
|
||||
}
|
||||
for path in paths {
|
||||
if cancel.is_cancelled() {
|
||||
return result;
|
||||
}
|
||||
let lang = language(&path).expect("configuration file");
|
||||
let keys = if let Some(text) = overlays.get(&path) {
|
||||
syntax().configuration_keys(&lang, text, &|| cancel.is_cancelled()).ok()
|
||||
} else {
|
||||
(|| {
|
||||
let metadata = std::fs::metadata(&path).ok()?;
|
||||
if metadata.len() > MAX_BYTES {
|
||||
return None;
|
||||
}
|
||||
let stamp = (metadata.modified().ok()?, metadata.len());
|
||||
if let Some(file) = cache.files.get(&path).filter(|f| f.stamp == stamp) {
|
||||
return Some(file.keys.clone());
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).ok()?;
|
||||
let keys = syntax().configuration_keys(&lang, &text, &|| cancel.is_cancelled()).ok()?;
|
||||
cache.files.remove(&path);
|
||||
if cache.files.len() >= MAX_FILES
|
||||
|| cache.files.values().map(|file| file.keys.len()).sum::<usize>() + keys.len() > MAX_CACHED_KEYS
|
||||
{
|
||||
cache.files.clear();
|
||||
}
|
||||
cache.files.insert(path.clone(), CachedFile { stamp, keys: keys.clone() });
|
||||
Some(keys)
|
||||
})()
|
||||
};
|
||||
match keys {
|
||||
Some(keys) => result.locations.extend(
|
||||
keys.into_iter()
|
||||
.filter(|key| patterns.iter().any(|p| matches(p, &key.name)))
|
||||
.map(|key| Location::file(path.clone(), key.range)),
|
||||
),
|
||||
None => result.incomplete = Some(ReferencesIncomplete::FilesSkipped),
|
||||
}
|
||||
if result.locations.len() >= MAX_FILES {
|
||||
result.locations.truncate(MAX_FILES);
|
||||
result.incomplete = Some(ReferencesIncomplete::TooManyFiles);
|
||||
break;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn file_cache_tracks_edits_overlays_removals_and_limits() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = directory.path().to_path_buf();
|
||||
let file = root.join("application.properties");
|
||||
std::fs::write(&file, "app.port=1\n").unwrap();
|
||||
let patterns = [Pattern { name: "app.port".into(), relaxed: true }];
|
||||
let cache = Mutex::new(ConfigurationCache::default());
|
||||
let cancel = CancellationToken::new();
|
||||
let query = |overlays: &HashMap<PathBuf, String>| {
|
||||
configuration_files(std::slice::from_ref(&root), &patterns, overlays, &cache, &cancel)
|
||||
};
|
||||
assert_eq!(query(&HashMap::new()).locations.len(), 1);
|
||||
std::fs::write(&file, "app.other=12\n").unwrap();
|
||||
assert!(query(&HashMap::new()).locations.is_empty());
|
||||
assert_eq!(query(&HashMap::from([(file.clone(), "app.port=unsaved\n".into())])).locations.len(), 1);
|
||||
std::fs::remove_file(&file).unwrap();
|
||||
assert!(query(&HashMap::new()).locations.is_empty());
|
||||
std::fs::write(&file, "x".repeat(MAX_BYTES as usize + 1)).unwrap();
|
||||
assert_eq!(query(&HashMap::new()).incomplete, Some(ReferencesIncomplete::FilesSkipped));
|
||||
cancel.cancel();
|
||||
assert!(query(&HashMap::new()).locations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relaxed_binding_keeps_boundaries() {
|
||||
let pattern = Pattern { name: "app.clients[*].timeOut".into(), relaxed: true };
|
||||
assert!(matches(&pattern, "app.clients[2].time-out"));
|
||||
assert!(!matches(&pattern, "app.clientsTimeOut"));
|
||||
let map = Pattern { name: "app.routes.*.url".into(), relaxed: true };
|
||||
assert!(matches(&map, "app.routes[us.east].url"));
|
||||
assert!(!matches(&map, "app.routes.us.east.url"));
|
||||
assert!(!matches(&Pattern { name: "app.time-out".into(), relaxed: false }, "app.timeOut"));
|
||||
}
|
||||
}
|
||||
@@ -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())))
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
mod angular;
|
||||
mod caps;
|
||||
mod clang_tidy;
|
||||
mod convert;
|
||||
mod external_project;
|
||||
mod file_ops;
|
||||
@@ -178,6 +179,7 @@ pub struct LspManager {
|
||||
shutting_down: AtomicBool,
|
||||
execution_allowed: AtomicBool,
|
||||
java_usages_warned: AtomicBool,
|
||||
configuration_cache: Arc<Mutex<jdtls::configuration::ConfigurationCache>>,
|
||||
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
|
||||
shown_messages: Mutex<HashSet<(String, String)>>,
|
||||
/// Virtual (non-`file:`) URIs a server returned, by the server that owns them.
|
||||
@@ -312,6 +314,7 @@ impl LspManager {
|
||||
shutting_down: AtomicBool::new(false),
|
||||
execution_allowed: AtomicBool::new(true),
|
||||
java_usages_warned: AtomicBool::new(false),
|
||||
configuration_cache: Arc::new(Mutex::new(Default::default())),
|
||||
shown_messages: Mutex::new(HashSet::new()),
|
||||
virtual_origins: Mutex::new(HashMap::new()),
|
||||
virtual_docs: Mutex::new(HashMap::new()),
|
||||
@@ -472,13 +475,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 +593,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 +817,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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1111,6 +1124,15 @@ impl LspManager {
|
||||
}
|
||||
|
||||
pub async fn goto(&self, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
|
||||
if matches!(kind, GotoKind::Definition) {
|
||||
let snap = self.snapshot(doc)?;
|
||||
if let Some(result) =
|
||||
self.configuration_navigation(&snap, pos, false, false, &CancellationToken::new()).await?
|
||||
&& !result.locations.is_empty()
|
||||
{
|
||||
return Ok(result.locations);
|
||||
}
|
||||
}
|
||||
let (method, cap, feature) = goto_method(kind);
|
||||
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
|
||||
self.goto_in(&snap, &targets, method, pos, None).await
|
||||
@@ -1189,6 +1211,36 @@ impl LspManager {
|
||||
include_declaration: bool,
|
||||
project_wide: bool,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<References> {
|
||||
let snap = self.snapshot(doc)?;
|
||||
// Scoped probes back code lenses and bulk analysis. Keep their native cost;
|
||||
// configuration indexing is reserved for explicit, project-wide usages.
|
||||
let configuration = if project_wide || snap.language.as_str() != "java" {
|
||||
self.configuration_navigation(&snap, pos, true, include_declaration, &cancel).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if snap.language.as_str() != "java"
|
||||
&& let Some(result) = configuration
|
||||
{
|
||||
return Ok(result);
|
||||
}
|
||||
let mut result = self.native_references(doc, pos, include_declaration, project_wide, cancel).await?;
|
||||
if let Some(configuration) = configuration {
|
||||
result.locations.extend(configuration.locations);
|
||||
result.incomplete = result.incomplete.or(configuration.incomplete);
|
||||
convert::dedup_locations(&mut result.locations);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn native_references(
|
||||
&self,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
include_declaration: bool,
|
||||
project_wide: bool,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<References> {
|
||||
let (snap, targets) = self.doc_targets(doc, "find references", |c| c.has("referencesProvider")).await?;
|
||||
// The extension preserves method identity before JDT collapses references to locations.
|
||||
@@ -1201,7 +1253,7 @@ impl LspManager {
|
||||
let t = &targets[0];
|
||||
let extension_error = Mutex::new(None);
|
||||
let query = || async {
|
||||
if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) {
|
||||
if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let params = snap.pos_params(t, pos);
|
||||
@@ -1229,7 +1281,7 @@ impl LspManager {
|
||||
*extension_error.lock() = Some(e.clone());
|
||||
}
|
||||
let rows = result?;
|
||||
if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) {
|
||||
if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let conv = self.conv(t.enc());
|
||||
@@ -1255,7 +1307,7 @@ impl LspManager {
|
||||
scope::Widened::only(first, empty, &t.server)
|
||||
};
|
||||
// A changed source also invalidates an earlier answer retained by scope widening.
|
||||
if cancel.is_cancelled() || !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version) {
|
||||
if cancel.is_cancelled() || self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
// Scope widening can retain the first answer after a failed second query.
|
||||
@@ -1281,7 +1333,7 @@ impl LspManager {
|
||||
Err(e) => {
|
||||
if e.kind == ErrorKind::Cancelled
|
||||
|| cancel.is_cancelled()
|
||||
|| !self.docs.lock().get(&doc).is_some_and(|d| d.version == snap.version)
|
||||
|| self.docs.lock().get(&doc).is_none_or(|d| d.version != snap.version)
|
||||
{
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
@@ -637,7 +650,7 @@ async fn diagnostics_become_events_and_feed_code_actions() {
|
||||
assert_eq!(s.requested("codeAction/resolve")[0]["data"], json!({ "id": 7 }));
|
||||
let edit = resolved.edit.unwrap();
|
||||
assert!(
|
||||
matches!(&edit.files[0], FileEdit::Edit { path: p, edits } if p == &path && edits[0].range == rng(0, 0, 1, 0))
|
||||
matches!(&edit.files[0], FileEdit::Edit { path: p, edits, .. } if p == &path && edits[0].range == rng(0, 0, 1, 0))
|
||||
);
|
||||
|
||||
h.mgr.execute_command(doc, actions[1].command.clone().unwrap()).await.unwrap();
|
||||
@@ -682,7 +695,9 @@ async fn apply_edit_hook_and_server_requests() {
|
||||
let edits = applied.lock().clone();
|
||||
assert_eq!(edits.len(), 1);
|
||||
assert_eq!(edits[0].files[0], FileEdit::Create { path: new_file.into(), overwrite: false });
|
||||
assert!(matches!(&edits[0].files[1], FileEdit::Edit { path: p, edits } if p == &path && edits[0].new_text == "X"));
|
||||
assert!(
|
||||
matches!(&edits[0].files[1], FileEdit::Edit { path: p, edits, .. } if p == &path && edits[0].new_text == "X")
|
||||
);
|
||||
|
||||
let peer = s.peer();
|
||||
let cfg: Value = peer
|
||||
@@ -1378,7 +1393,7 @@ fn edits_in(edit: &WorkspaceEdit, path: &Path) -> Vec<(Range, String)> {
|
||||
edit.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
FileEdit::Edit { path: p, edits } if p.to_path_buf() == path => {
|
||||
FileEdit::Edit { path: p, edits, .. } if p.to_path_buf() == path => {
|
||||
Some(edits.iter().map(|e| (e.range, e.new_text.clone())))
|
||||
}
|
||||
_ => None,
|
||||
@@ -1775,7 +1790,7 @@ async fn will_and_did_rename_files_honour_filters() {
|
||||
assert_eq!(result.servers, vec!["mock".to_owned()]);
|
||||
assert!(result.errors.is_empty());
|
||||
match &result.edit.files[..] {
|
||||
[FileEdit::Edit { path, edits }] => {
|
||||
[FileEdit::Edit { path, edits, .. }] => {
|
||||
assert_eq!(path, &main);
|
||||
assert_eq!(edits[0].range, rng(0, 4, 0, 7));
|
||||
assert_eq!(edits[0].new_text, "lib");
|
||||
@@ -2836,3 +2851,157 @@ 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;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_definition_uses_java_without_yaml_definition_capability() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
|
||||
let s = h.launcher.state("jdtls");
|
||||
let target = uri_of(&h.root.join("Settings.java"));
|
||||
s.respond("workspace/executeCommand", json!({
|
||||
"locations": [{"uri": target, "range": {"start": {"line": 2, "character": 4}, "end": {"line": 2, "character": 11}}}],
|
||||
"keys": [{"name": "app.timeOut", "relaxed": true}], "roots": []
|
||||
}));
|
||||
let path = h.root.join("application.yml");
|
||||
let doc = DocId::next();
|
||||
h.mgr.did_open(doc, &path, &LanguageId::new("yaml"), 1, "app:\n time-out: 5\n");
|
||||
let locations = h.mgr.goto(doc, Pos::new(1, 4), GotoKind::Definition).await.unwrap();
|
||||
assert_eq!(locations.len(), 1);
|
||||
assert_eq!(locations[0].range.start, Pos::new(2, 4));
|
||||
assert_eq!(s.requested("workspace/executeCommand")[0]["arguments"][1], "app.time-out");
|
||||
assert!(s.requested("textDocument/definition").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_usages_merge_native_and_unsaved_profiles_without_sibling_modules() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
let (doc, path) = h.open("Settings.java", "int timeOut;");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let root = uri_of(&h.root);
|
||||
s.respond(
|
||||
"workspace/executeCommand",
|
||||
json!({
|
||||
"locations": [], "keys": [{"name": "app.timeOut", "relaxed": true}], "roots": [root]
|
||||
}),
|
||||
);
|
||||
s.respond(
|
||||
"textDocument/references",
|
||||
json!([{"uri": uri_of(&path), "range": {
|
||||
"start": {"line": 3, "character": 0}, "end": {"line": 3, "character": 7}
|
||||
}}]),
|
||||
);
|
||||
std::fs::write(h.root.join("application.yml"), "app:\n time-out: 1\n").unwrap();
|
||||
std::fs::write(h.root.join("application-prod.properties"), "app.time-out=2\n").unwrap();
|
||||
let overlay = h.root.join("application-dev.yml");
|
||||
std::fs::write(&overlay, "app:\n old: 3\n").unwrap();
|
||||
h.mgr.did_open(DocId::next(), &overlay, &LanguageId::new("yaml"), 1, "app:\n time-out: 3\n");
|
||||
let sibling = h.root.join("other");
|
||||
std::fs::create_dir(&sibling).unwrap();
|
||||
std::fs::write(sibling.join("pom.xml"), "<project/>").unwrap();
|
||||
std::fs::write(sibling.join("application.yml"), "app:\n time-out: wrong\n").unwrap();
|
||||
h.mgr.did_open(
|
||||
DocId::next(),
|
||||
&sibling.join("application.yml"),
|
||||
&LanguageId::new("yaml"),
|
||||
1,
|
||||
"app:\n time-out: still-wrong\n",
|
||||
);
|
||||
let scoped = h.mgr.references(doc, Pos::new(0, 6), false, false, Default::default()).await.unwrap();
|
||||
assert_eq!(scoped.locations.len(), 1);
|
||||
assert!(s.requested("workspace/executeCommand").is_empty());
|
||||
let refs = h.mgr.references(doc, Pos::new(0, 6), false, true, Default::default()).await.unwrap();
|
||||
assert_eq!(refs.locations.len(), 4, "{refs:?}");
|
||||
assert!(refs.locations.iter().all(|l| !l.path.to_path_buf().starts_with(&sibling)));
|
||||
assert!(refs.locations.iter().any(|l| l.path.to_path_buf() == overlay));
|
||||
assert_eq!(refs.incomplete, None);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_extension_failure_preserves_native_java_navigation() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
let (doc, path) = h.open("Settings.java", "@Value(\"${app.port}\") int port;");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
s.errors.lock().insert("workspace/executeCommand".into(), (-32603, "old server".into()));
|
||||
s.respond(
|
||||
"textDocument/definition",
|
||||
json!({"uri": uri_of(&path), "range": {
|
||||
"start": {"line": 0, "character": 1}, "end": {"line": 0, "character": 6}
|
||||
}}),
|
||||
);
|
||||
let result = h.mgr.goto(doc, Pos::new(0, 2), GotoKind::Definition).await.unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(s.requested("textDocument/definition").len(), 1);
|
||||
}
|
||||
@@ -194,6 +194,62 @@ async fn ty_python_end_to_end() {
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Ruff next to basedpyright in a project configured for Ruff: its lint
|
||||
/// diagnostics, the only formatter and the only Organize Imports and Fix All.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires ruff and basedpyright on PATH"]
|
||||
async fn ruff_next_to_basedpyright_end_to_end() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().canonicalize().unwrap();
|
||||
std::fs::write(root.join("pyproject.toml"), "[tool.ruff.lint]\nselect = [\"F\", \"I\"]\n").unwrap();
|
||||
let path = root.join("example.py");
|
||||
let source = "import sys\nimport os\n\nx = os.getcwd( )\n";
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let mut python = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("python")).unwrap();
|
||||
python.language_servers.retain(|server| matches!(server.name.as_str(), "basedpyright" | "ruff"));
|
||||
assert_eq!(python.language_servers.len(), 2);
|
||||
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![python]));
|
||||
let doc = DocId::next();
|
||||
mgr.did_open(doc, &path, &LanguageId::new("python"), 1, source);
|
||||
for name in ["basedpyright", "ruff"] {
|
||||
retry(name, Duration::from_secs(60), || {
|
||||
let status = last_status(&events, name);
|
||||
if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status {
|
||||
panic!("{name} failed: {msg:?}");
|
||||
}
|
||||
async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) }
|
||||
})
|
||||
.await;
|
||||
}
|
||||
// F401 (`sys` unused) and I001 (unsorted imports).
|
||||
let codes = retry("ruff diagnostics", Duration::from_secs(30), || {
|
||||
let codes: Option<Vec<String>> = events.lock().iter().rev().find_map(|e| match e {
|
||||
IdeEvent::Diagnostics { source, items, .. } if source == "ruff" && !items.is_empty() => {
|
||||
Some(items.iter().filter_map(|d| d.code.clone()).collect())
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
async move { codes }
|
||||
})
|
||||
.await;
|
||||
assert!(codes.iter().any(|c| c == "F401") && codes.iter().any(|c| c == "I001"), "{codes:?}");
|
||||
|
||||
let options = ide_lsp::types::FormatOptions { tab_size: 4, insert_spaces: true, on_save: false };
|
||||
let edits = mgr.format(doc, None, options).await.unwrap();
|
||||
assert!(!edits.is_empty() && edits.iter().all(|e| e.range.start.line == 3), "{edits:?}");
|
||||
|
||||
let whole = Range::new(Pos::new(0, 0), Pos::new(4, 0));
|
||||
for kind in ["source.organizeImports", "source.fixAll"] {
|
||||
let actions = mgr.code_actions(doc, whole, vec![kind.into()]).await.unwrap();
|
||||
let kinds: Vec<&str> = actions.iter().filter_map(|a| a.kind.as_deref()).collect();
|
||||
assert_eq!(kinds, [format!("{kind}.ruff")], "{actions:?}");
|
||||
let action = mgr.resolve_code_action(doc, actions[0].clone()).await.unwrap();
|
||||
let edit = action.edit.expect("an edit");
|
||||
assert!(!edited_lines(&edit, &path).is_empty(), "{edit:?}");
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "requires rust-analyzer"]
|
||||
async fn rust_analyzer_end_to_end() {
|
||||
@@ -412,7 +468,7 @@ fn edited_lines(edit: &ide_core::WorkspaceEdit, path: &Path) -> Vec<u32> {
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path: p, edits } if p.to_path_buf() == path => {
|
||||
ide_core::FileEdit::Edit { path: p, edits, .. } if p.to_path_buf() == path => {
|
||||
Some(edits.iter().map(|e| e.range.start.line))
|
||||
}
|
||||
_ => None,
|
||||
|
||||
@@ -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);
|
||||
@@ -880,6 +890,19 @@ fn detect_python(root: &Path, out: &mut Vec<RunConfig>) {
|
||||
if uses_pytest(root) || env.has_tool(root, "pytest") {
|
||||
out.push(py_config("pytest".into(), "pytest".into(), "pytest", env.tool(root, "pytest"), &[]).test());
|
||||
}
|
||||
// After pytest: a project set up for pytest (pytest-django) keeps it as the default test runner.
|
||||
if root.join("manage.py").is_file() {
|
||||
out.push(
|
||||
py_config(
|
||||
"django:test".into(),
|
||||
"manage.py test".into(),
|
||||
"django-test",
|
||||
env.python(),
|
||||
&["manage.py", "test"],
|
||||
)
|
||||
.test(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- make / just
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms, 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -317,7 +317,7 @@ impl ProcessManager {
|
||||
return Err(IdeError::invalid("input cannot be redirected from a file when emulating a terminal"));
|
||||
}
|
||||
let mut env = launch_env(root, config)?;
|
||||
if matches!(config.kind.as_str(), "python" | "pytest") {
|
||||
if matches!(config.kind.as_str(), "python" | "pytest" | "django-test") {
|
||||
crate::python_path::extend(&mut env.vars, &options.python_paths, !env.clear)?;
|
||||
}
|
||||
let on_output: OutputFn = Arc::from(on_output);
|
||||
@@ -745,7 +745,7 @@ fn launch_env(root: &Path, config: &RunConfig) -> IdeResult<LaunchEnv> {
|
||||
let paths: Vec<PathBuf> = extras.env_files.iter().map(|f| resolve_cwd(root, Some(Path::new(f)))).collect();
|
||||
crate::envfile::load(&paths, &base).map_err(|e| IdeError::not_found(format!("cannot read .env file: {e}")))?
|
||||
};
|
||||
if matches!(config.kind.as_str(), "python" | "pytest")
|
||||
if matches!(config.kind.as_str(), "python" | "pytest" | "django-test")
|
||||
&& !config.env.contains_key("PYTHONUNBUFFERED")
|
||||
&& !vars.contains_key("PYTHONUNBUFFERED")
|
||||
{
|
||||
|
||||
@@ -372,13 +372,19 @@ fn python_projects() {
|
||||
("manage.py", ""),
|
||||
]);
|
||||
let c = detect(dir.path());
|
||||
assert_eq!(ids(&c), ["django:runserver", "python:module:mypkg", "pytest"]);
|
||||
// pytest stays ahead of `manage.py test`: the first test configuration is the default for context runs.
|
||||
assert_eq!(ids(&c), ["django:runserver", "python:module:mypkg", "pytest", "django:test"]);
|
||||
let pytest = find(&c, "pytest");
|
||||
assert_eq!(cmdline(pytest), "uv run pytest");
|
||||
assert!(pytest.is_test);
|
||||
assert_eq!(pytest.debug_adapter.as_deref(), Some("debugpy"));
|
||||
assert_eq!(cmdline(find(&c, "python:module:mypkg")), "uv run python -m mypkg");
|
||||
assert_eq!(cmdline(find(&c, "django:runserver")), "uv run python manage.py runserver");
|
||||
let django = find(&c, "django:test");
|
||||
assert_eq!(cmdline(django), "uv run python manage.py test");
|
||||
assert_eq!(django.kind, "django-test");
|
||||
assert!(django.is_test);
|
||||
assert_eq!(django.debug_adapter.as_deref(), Some("debugpy"));
|
||||
|
||||
let dir = project(&[("pytest.ini", "[pytest]\n"), ("poetry.lock", "")]);
|
||||
assert_eq!(cmdline(find(&detect(dir.path()), "pytest")), "poetry run pytest");
|
||||
@@ -398,6 +404,7 @@ fn python_virtualenv() {
|
||||
let c = detect(dir.path());
|
||||
assert_eq!(cmdline(find(&c, "pytest")), ".venv/bin/pytest");
|
||||
assert_eq!(cmdline(find(&c, "django:runserver")), ".venv/bin/python manage.py runserver");
|
||||
assert_eq!(cmdline(find(&c, "django:test")), ".venv/bin/python manage.py test");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -652,3 +659,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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&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"));
|
||||
}
|
||||
@@ -447,7 +447,7 @@ fn build_tools_get_the_console_encoding_for_every_jvm() {
|
||||
|
||||
#[test]
|
||||
fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() {
|
||||
for (kind, source_dir) in [("python", ""), ("pytest", "src")] {
|
||||
for (kind, source_dir) in [("python", ""), ("pytest", "src"), ("django-test", "")] {
|
||||
let h = harness();
|
||||
let root = h.root.path();
|
||||
let source = root.join(source_dir);
|
||||
@@ -465,6 +465,11 @@ fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() {
|
||||
let mut config = cfg("python-imports", "python3", &["app/main.py"]);
|
||||
config.kind = kind.into();
|
||||
config.extras.env_files = vec![".env".into()];
|
||||
// Unbuffered, so output printed by a test stays next to its result line.
|
||||
assert_eq!(
|
||||
ide_run::resolved_env(root, &config).unwrap().get("PYTHONUNBUFFERED").map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
let options = StartOptions { python_paths: vec![root.to_owned(), source], ..Default::default() };
|
||||
let chunks: Chunks = Arc::default();
|
||||
let sink = chunks.clone();
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,10 +629,10 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
|
||||
resolve_run_command(c, root, s);
|
||||
let mut path_dirs: Vec<PathBuf> = Vec::new();
|
||||
match c.kind.as_str() {
|
||||
"python" | "pytest" => {
|
||||
"python" | "pytest" | "django-test" => {
|
||||
if let Some(py) = &explicit_python {
|
||||
let runner = PY_RUNNERS.contains(&c.command.as_str());
|
||||
if c.kind == "python" && (runner || is_python_command(&c.command)) {
|
||||
if (c.kind == "python" || c.kind == "django-test") && (runner || is_python_command(&c.command)) {
|
||||
if runner && c.args.first().map(String::as_str) == Some("run") {
|
||||
// `uv run python …` → `<interpreter> …`
|
||||
let skip = if c.args.get(1).is_some_and(|a| is_python_command(a)) { 2 } else { 1 };
|
||||
|
||||
+214
-34
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -993,7 +1173,7 @@ mod kotlin_bridge_bundle_tests {
|
||||
|
||||
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure.
|
||||
pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
let extension = data_dir.join("java-usages-1.1.1.jar");
|
||||
let extension = data_dir.join("java-usages-1.2.0.jar");
|
||||
let bytes = include_bytes!("../../../plugins/java-usages/openidea-java-usages.jar");
|
||||
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
|
||||
clean_usages_bundles(data_dir, &extension);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -152,6 +152,8 @@ fn run_configs_use_selected_interpreter_and_package_manager() {
|
||||
run("pytest", "uv", &["run", "pytest", "-x"]),
|
||||
run("npm", "npm", &["run", "dev"]),
|
||||
run("shell", "python3", &["x.py"]),
|
||||
run("django-test", "uv", &["run", "python", "manage.py", "test"]),
|
||||
run("django-test", ".venv/bin/python", &["manage.py", "test", "polls"]),
|
||||
];
|
||||
apply_to_run_configs(&mut configs, tmp.path(), &s);
|
||||
assert_eq!(configs[0].command, "/opt/py/bin/python3");
|
||||
@@ -161,12 +163,22 @@ fn run_configs_use_selected_interpreter_and_package_manager() {
|
||||
assert_eq!(configs[2].args, ["-m", "pytest", "-x"]);
|
||||
assert_eq!(configs[3].command, "pnpm");
|
||||
assert_eq!(configs[4].command, "python3", "shell configs are left alone");
|
||||
// Django tests run on the selected interpreter, like `manage.py runserver`.
|
||||
assert_eq!(configs[5].command, "/opt/py/bin/python3");
|
||||
assert_eq!(configs[5].args, ["manage.py", "test"]);
|
||||
assert_eq!(configs[6].command, "/opt/py/bin/python3");
|
||||
assert_eq!(configs[6].args, ["manage.py", "test", "polls"]);
|
||||
}
|
||||
|
||||
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]
|
||||
@@ -249,10 +261,10 @@ fn language_server_settings_disable_add_and_override() {
|
||||
let mut s = Settings::default();
|
||||
s.language_servers.insert("pylsp".into(), LanguageServerSettings { enabled: false, ..Default::default() });
|
||||
s.language_servers.insert(
|
||||
"ruff".into(),
|
||||
"pylyzer".into(),
|
||||
LanguageServerSettings {
|
||||
command: "ruff".into(),
|
||||
args: Some(vec!["server".into()]),
|
||||
command: "pylyzer".into(),
|
||||
args: Some(vec!["--server".into()]),
|
||||
languages: vec!["python".into()],
|
||||
root_markers: vec!["pyproject.toml".into()],
|
||||
..Default::default()
|
||||
@@ -260,17 +272,42 @@ fn language_server_settings_disable_add_and_override() {
|
||||
);
|
||||
let defined = [server("basedpyright"), server("pylsp")];
|
||||
let names = |v: Vec<LanguageServerConfig>| v.into_iter().map(|c| c.name).collect::<Vec<_>>();
|
||||
assert_eq!(names(language_servers("python", &defined, &s)), ["basedpyright", "ruff"]);
|
||||
assert_eq!(names(language_servers("python", &defined, &s)), ["basedpyright", "pylyzer"]);
|
||||
assert_eq!(names(language_servers("rust", &[server("rust-analyzer")], &s)), ["rust-analyzer"]);
|
||||
|
||||
// Listed with the tools even while disabled.
|
||||
let mut langs = ide_language::LanguageRegistry::load(None).unwrap().all();
|
||||
with_custom_servers(&mut langs, &s);
|
||||
let python = langs.iter().find(|l| l.id.as_str() == "python").unwrap();
|
||||
let mut pylyzer = python.language_servers.iter().find(|c| c.name == "pylyzer").unwrap().clone();
|
||||
assert_eq!(pylyzer.root_markers, ["pyproject.toml"]);
|
||||
server_launch_overrides(&mut pylyzer, &s);
|
||||
assert_eq!((pylyzer.command.as_str(), pylyzer.args.as_slice()), ("pylyzer", &["--server".to_string()][..]));
|
||||
|
||||
// A hand-defined server named like a built-in one only overrides how it
|
||||
// launches: languages and root markers stay the built-in definition's.
|
||||
s.language_servers.insert(
|
||||
"ruff".into(),
|
||||
LanguageServerSettings {
|
||||
command: "/opt/ruff".into(),
|
||||
args: Some(vec!["server".into(), "--preview".into()]),
|
||||
languages: vec!["python".into()],
|
||||
root_markers: vec!["pyproject.toml".into()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let mut langs = ide_language::LanguageRegistry::load(None).unwrap().all();
|
||||
with_custom_servers(&mut langs, &s);
|
||||
let python = langs.iter().find(|l| l.id.as_str() == "python").unwrap();
|
||||
assert_eq!(python.language_servers.iter().filter(|c| c.name == "ruff").count(), 1);
|
||||
let mut ruff = python.language_servers.iter().find(|c| c.name == "ruff").unwrap().clone();
|
||||
assert_eq!(ruff.root_markers, ["pyproject.toml"]);
|
||||
assert_eq!(ruff.root_markers, ["ruff.toml", ".ruff.toml", "pyproject.toml#tool.ruff"]);
|
||||
assert!(ruff.require_root_marker);
|
||||
server_launch_overrides(&mut ruff, &s);
|
||||
assert_eq!((ruff.command.as_str(), ruff.args.as_slice()), ("ruff", &["server".to_string()][..]));
|
||||
assert_eq!(
|
||||
(ruff.command.as_str(), ruff.args.as_slice()),
|
||||
("/opt/ruff", &["server".to_string(), "--preview".to_string()][..])
|
||||
);
|
||||
|
||||
s.language_servers.insert(
|
||||
"rust-analyzer".into(),
|
||||
@@ -307,9 +344,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]
|
||||
|
||||
@@ -159,7 +159,7 @@ impl StructureService {
|
||||
let runnable = body.is_some_and(|b| {
|
||||
members(*b).into_iter().any(|n| {
|
||||
if !matches!(n.kind(), "method_declaration" | "function_declaration")
|
||||
|| !n.child_by_field_name("name").is_some_and(|id| text(id, snap.text) == "main")
|
||||
|| n.child_by_field_name("name").is_none_or(|id| text(id, snap.text) != "main")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -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:?}"),
|
||||
|
||||
@@ -20,8 +20,8 @@ zip.workspace = true
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
[target.'cfg(unix)'.dev-dependencies]
|
||||
libc = "0.2"
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,48 +4,16 @@
|
||||
use crate::InstallEvent;
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::output_encoding::decode_tool_output;
|
||||
use ide_core::process::{kill_tree, own_group};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::path::Path;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub type Emit<'a> = &'a dyn Fn(InstallEvent);
|
||||
|
||||
/// Starts the child as the leader of its own process group, so [`kill`] reaches what it
|
||||
/// starts in turn (`npm` → `node`, `go install` → the compiler, `pip` → build backends).
|
||||
fn own_group(cmd: &mut Command) -> &mut Command {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Kills the child and everything it started.
|
||||
fn kill(child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
if let Ok(pid) = i32::try_from(child.id()) {
|
||||
// SAFETY: plain syscall; the child leads its own process group (`own_group`) and is not reaped yet.
|
||||
unsafe {
|
||||
libc::kill(-pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut taskkill = Command::new("taskkill");
|
||||
ide_core::process::prepare(&mut taskkill)
|
||||
.args(["/T", "/F", "/PID", &child.id().to_string()])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
let _ = taskkill.status();
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
fn describe(cmd: &Command) -> String {
|
||||
let mut s = Path::new(cmd.get_program()).file_name().unwrap_or_default().to_string_lossy().into_owned();
|
||||
for a in cmd.get_args() {
|
||||
@@ -100,7 +68,7 @@ pub fn run_collect(mut cmd: Command, cancel: &CancellationToken, emit: Emit) ->
|
||||
let mut output = String::new();
|
||||
let status = loop {
|
||||
if cancel.is_cancelled() {
|
||||
kill(&mut child);
|
||||
kill_tree(&mut child);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||
@@ -144,7 +112,7 @@ pub fn run_quiet(mut cmd: Command, timeout: Duration) -> Option<(bool, String)>
|
||||
Ok(Some(s)) => break s,
|
||||
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(15)),
|
||||
_ => {
|
||||
kill(&mut child);
|
||||
kill_tree(&mut child);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -256,7 +224,7 @@ impl Http {
|
||||
});
|
||||
let status = loop {
|
||||
if cancel.is_cancelled() {
|
||||
kill(&mut child);
|
||||
kill_tree(&mut child);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
match child.try_wait()? {
|
||||
@@ -308,7 +276,7 @@ impl Http {
|
||||
let mut last_bytes = 0;
|
||||
let status = loop {
|
||||
if cancel.is_cancelled() {
|
||||
kill(&mut child);
|
||||
kill_tree(&mut child);
|
||||
let _ = std::fs::remove_file(dest);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
@@ -413,7 +381,7 @@ pub(crate) fn run_input(
|
||||
}
|
||||
};
|
||||
if result.is_err() {
|
||||
kill(&mut child);
|
||||
kill_tree(&mut child);
|
||||
}
|
||||
result?;
|
||||
let output = output.unwrap()?;
|
||||
|
||||
@@ -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",
|
||||
@@ -211,6 +233,17 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
homepage: "https://docs.astral.sh/ty/",
|
||||
needs: &[],
|
||||
},
|
||||
ToolSpec {
|
||||
name: "ruff",
|
||||
display: "Ruff",
|
||||
kind: LanguageServer,
|
||||
commands: &["ruff"],
|
||||
method: Method::PipVenv { package: "ruff" },
|
||||
fallback: None,
|
||||
version: ver("ruff"),
|
||||
homepage: "https://docs.astral.sh/ruff/",
|
||||
needs: &[],
|
||||
},
|
||||
ToolSpec {
|
||||
name: "bash-language-server",
|
||||
display: "Bash Language Server",
|
||||
@@ -614,15 +647,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 +998,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 +1027,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"));
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,7 @@ fn interchangeable_servers_list_their_alternatives() {
|
||||
assert_eq!(find(&list, "vtsls").alternatives, ["vtsls", "typescript-language-server"]);
|
||||
// A regular server after a fallback starts no group with it.
|
||||
assert!(find(&list, "angular-language-server").alternatives.is_empty());
|
||||
assert!(find(&list, "ruff").alternatives.is_empty());
|
||||
assert!(find(&list, "rust-analyzer").alternatives.is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Loaded 100 of 307 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user