Compare commits

..
211 changed files with 513 additions and 9324 deletions

No files matched your search

Generated
-5
View File
@@ -4086,7 +4086,6 @@ dependencies = [
"specta",
"tempfile",
"tracing",
"zip",
]
[[package]]
@@ -4242,7 +4241,6 @@ dependencies = [
"ide-vfs",
"ide-workspace",
"parking_lot",
"rustix",
"serde",
"serde_json",
"specta",
@@ -4252,7 +4250,6 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
"windows-sys 0.61.2",
]
[[package]]
@@ -4686,7 +4683,6 @@ dependencies = [
"ide-text",
"ide-vfs",
"parking_lot",
"semver",
"serde",
"serde_json",
"specta",
@@ -6211,7 +6207,6 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
"windows-sys 0.61.2",
]
[[package]]
-1
View File
@@ -8,7 +8,6 @@ license.workspace = true
[dependencies]
dunce.workspace = true
ide-core.workspace = true
zip.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
-13
View File
@@ -185,10 +185,8 @@ 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 {
@@ -816,17 +814,6 @@ 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());
+13 -248
View File
@@ -11,9 +11,7 @@ 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] =
@@ -287,14 +285,6 @@ 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));
@@ -318,7 +308,7 @@ fn read_raw(project: &Element) -> RawPom {
name: project.text_of("name"),
parent,
properties: read_properties(project.child("properties")),
modules: module_names(project),
modules: texts(project, "modules", "module"),
deps: project.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(),
managed: project
.list("dependencyManagement/dependencies", "dependency")
@@ -339,7 +329,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: module_names(p),
modules: texts(p, "modules", "module"),
properties: read_properties(p.child("properties")),
deps: p.list("dependencies", "dependency").into_iter().filter_map(read_dep).collect(),
..RawProfile::default()
@@ -458,20 +448,13 @@ 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");
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;
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)));
}
let home = dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
Some(PathBuf::from(repo.replace("${user.home}", &home)))
Some(m2.join("repository"))
}
impl Resolver {
@@ -697,76 +680,7 @@ pub fn goal_prefix(artifact_id: &str) -> String {
artifact_id.strip_suffix("-plugin").unwrap_or(artifact_id).to_owned()
}
/// 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.
/// Goals of well-known plugins (their plugin descriptors are not read).
fn known_goals(artifact_id: &str) -> &'static [&'static str] {
match artifact_id {
"maven-clean-plugin" => &["clean", "help"],
@@ -918,27 +832,9 @@ fn lifecycle_plugins(packaging: &str) -> &'static [&'static str] {
}
}
/// 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 {
/// 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 {
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());
@@ -1103,7 +999,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, resolver.local_repo.as_deref())
plugin(&i(&p.group_id), &i(&p.artifact_id), version, &p.goals)
})
.collect();
@@ -1215,16 +1111,6 @@ 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] };
@@ -1236,7 +1122,7 @@ pub fn parse_effective_pom_in(
let plugins = raw
.plugins
.iter()
.map(|pl| plugin(&pl.group_id, &pl.artifact_id, pl.version.clone(), &pl.goals, local_repo))
.map(|pl| plugin(&pl.group_id, &pl.artifact_id, pl.version.clone(), &pl.goals))
.collect();
let deps = raw
.deps
@@ -1367,107 +1253,6 @@ 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();
@@ -1491,26 +1276,6 @@ 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();
+4 -74
View File
@@ -48,11 +48,6 @@ 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>,
@@ -71,7 +66,6 @@ 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.
@@ -127,9 +121,6 @@ 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));
@@ -220,31 +211,8 @@ 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 {
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 cache =
MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() };
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"));
@@ -273,9 +241,8 @@ pub fn sync_maven(
});
}
})?;
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 effective_projects =
std::fs::read_to_string(&effective).map(|t| maven::parse_effective_pom(&t)).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);
@@ -365,7 +332,6 @@ 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,
@@ -377,7 +343,6 @@ mod tests {
"maven-clean-plugin",
Some("3.2.0".into()),
&[],
None,
)],
dependencies: vec![MavenDependency { artifact_id: "dep".into(), ..MavenDependency::default() }],
}],
@@ -403,41 +368,6 @@ mod tests {
assert_eq!(model.projects[0].dependencies[0].artifact_id, "fresh");
}
#[test]
fn a_cache_from_an_older_ide_is_ignored() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("p");
let cache_dir = tmp.path().join("cache");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("pom.xml"), "<project><groupId>g</groupId><artifactId>a</artifactId></project>")
.unwrap();
let mut model = maven::load_with_repo(&root, None);
let cache = MavenCache {
// What an older build wrote: no plugin descriptors were read back then, so
// its goal lists would shadow the ones the model now knows.
version: 0,
hash: hash_files(&maven::build_files(&root, &model)),
at_ms: 5.0,
error: None,
verbose: true,
projects: vec![CachedMavenProject {
key: "g:a".into(),
plugins: vec![maven::plugin(
"org.apache.maven.plugins",
"maven-clean-plugin",
Some("3.2.0".into()),
&[],
None,
)],
dependencies: vec![MavenDependency { artifact_id: "cached".into(), ..MavenDependency::default() }],
}],
};
write_cache(&cache_dir, MAVEN_CACHE, &cache).unwrap();
apply_maven_cache(&root, &cache_dir, &mut model);
assert!(model.synced_at.is_none(), "the cache is dropped, the model stays unsynced");
assert!(model.projects[0].dependencies.iter().all(|d| d.artifact_id != "cached"));
}
#[test]
fn gradle_cache_replaces_tasks_per_project() {
let tmp = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -19,4 +19,4 @@ tokio-util.workspace = true
tempfile.workspace = true
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation"] }
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_System_Registry", "Win32_Foundation"] }
-6
View File
@@ -82,12 +82,6 @@ pub enum IdeEvent {
status: ServerStatus,
message: Option<String>,
},
/// A running server lacks a capability; the UI offers the managed tool as a replacement.
LanguageServerWarning {
name: String,
message: String,
install_tool: String,
},
/// A stopped language server left the project's list (disabled in settings, or a
/// fallback that a preferred server took over from again).
LanguageServerRemoved {
-25
View File
@@ -1,25 +0,0 @@
//! Capabilities needed by the JVM hosting Java language-server bundles.
use std::path::Path;
/// jdtls' javac bundle, java-debug / hot code replace, and Lombok / Gradle tooling.
const BUNDLE_MODULES: &[&str] = &["jdk.compiler", "jdk.jdi", "jdk.unsupported"];
/// Require a full JDK, including the modules used by jdtls and its bundles.
/// Temurin JREs can list the build JDK's modules in `release` even when the runtime
/// image omits them, so module metadata alone does not establish suitability.
pub fn supports_java_bundles(home: &Path) -> bool {
let Ok(release) = std::fs::read_to_string(home.join("release")) else { return false };
let property =
|key: &str| release.lines().find_map(|line| line.trim().strip_prefix(key)).map(|v| v.trim().trim_matches('"'));
let javac = if cfg!(windows) { "javac.exe" } else { "javac" };
if property("IMAGE_TYPE=").is_some_and(|image| image.eq_ignore_ascii_case("jre"))
|| !home.join("bin").join(javac).is_file()
{
return false;
}
match property("MODULES=") {
Some(modules) => BUNDLE_MODULES.iter().all(|required| modules.split_whitespace().any(|m| m == *required)),
None => BUNDLE_MODULES.iter().all(|m| home.join("jmods").join(format!("{m}.jmod")).is_file()),
}
}
-1
View File
@@ -11,7 +11,6 @@ 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;
+38 -133
View File
@@ -245,7 +245,10 @@ 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, RegCloseKey, RegOpenKeyExW};
use windows_sys::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_READ, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey, RegGetValueW,
RegOpenKeyExW,
};
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
@@ -256,53 +259,45 @@ 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 value = read_registry_string(key, &wide("Path"));
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();
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> {
@@ -339,96 +334,6 @@ mod tests {
}
}
#[cfg(windows)]
#[test]
fn registry_strings_expand_and_stay_terminator_free() {
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_EXPAND_SZ, REG_SZ, RegCloseKey, RegCreateKeyExW,
RegDeleteTreeW, RegSetValueExW,
};
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
struct TestState {
key: HKEY,
name: String,
variable: String,
previous: Option<OsString>,
}
impl Drop for TestState {
fn drop(&mut self) {
// SAFETY: the key belongs to this test; Windows environment mutation is safe.
unsafe {
RegCloseKey(self.key);
RegDeleteTreeW(HKEY_CURRENT_USER, wide(&self.name).as_ptr());
match &self.previous {
Some(value) => std::env::set_var(&self.variable, value),
None => std::env::remove_var(&self.variable),
}
}
}
}
let name = format!("Software\\ai.l0ng.openidea.registry-read-test-{}", std::process::id());
let variable = format!("OPENIDEA_TEST_REGVAR_{}", std::process::id());
let long = "x".repeat(600);
let mut key: HKEY = std::ptr::null_mut();
let opened = unsafe {
RegCreateKeyExW(
HKEY_CURRENT_USER,
wide(&name).as_ptr(),
0,
std::ptr::null(),
0,
KEY_READ | KEY_WRITE,
std::ptr::null(),
&mut key,
std::ptr::null_mut(),
)
};
assert_eq!(opened, ERROR_SUCCESS, "the test key must be creatable under HKCU\\Software");
let _cleanup = TestState { key, name, previous: std::env::var_os(&variable), variable: variable.clone() };
// SAFETY: Windows permits environment mutation. No other test uses this process-specific name.
unsafe { std::env::set_var(&variable, &long) };
let set = |value: &str, kind: u32, data: &[u16]| {
// SAFETY: `key` is open for writing and `data` holds `data.len() * 2` bytes.
assert_eq!(
unsafe {
RegSetValueExW(
key,
wide(value).as_ptr(),
0,
kind,
data.as_ptr() as *const _,
(data.len() * 2) as u32,
)
},
ERROR_SUCCESS
);
};
let read = |value: &str| read_registry_string(key, &wide(value));
set("Plain", REG_SZ, &wide("plain"));
set("Expand", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin")));
set("ExpandLong", REG_EXPAND_SZ, &wide(&format!("%{variable}%")));
// Reproduce the reported interior terminator followed by a stale UTF-16 unit.
set("TrailingGarbage", REG_SZ, &wide("C:\\Zed\\bin\0n"));
set("ExpandTrailingGarbage", REG_EXPAND_SZ, &wide(&format!("%{variable}%\\bin\0n")));
set("Empty", REG_SZ, &wide(""));
let plain = read("Plain");
let expand = read("Expand");
let expand_long = read("ExpandLong");
assert_eq!(plain.as_deref(), Some("plain"));
assert_eq!(expand.as_deref(), Some(format!("{long}\\bin")).as_deref());
// Longer than its unexpanded form: needs the re-read at the expanded size.
assert_eq!(expand_long.as_deref(), Some(long.as_str()));
assert_eq!(read("TrailingGarbage").as_deref(), Some("C:\\Zed\\bin"));
assert_eq!(read("ExpandTrailingGarbage").as_deref(), Some(format!("{long}\\bin")).as_deref());
assert_eq!(read("Empty").as_deref(), Some(""));
assert_eq!(read("Missing"), None);
}
#[test]
fn append_path_entries_keeps_the_order_and_adds_what_is_missing() {
let join = |dirs: &[&str]| std::env::join_paths(dirs).unwrap();
-80
View File
@@ -207,58 +207,6 @@ 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.
@@ -344,34 +292,6 @@ 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);
+1 -57
View File
@@ -58,43 +58,6 @@ 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)]
@@ -191,7 +154,6 @@ impl Default for Settings {
ssh: SshSettings::default(),
deployment: DeploymentSettings::default(),
run_targets: Vec::new(),
cmake: CMakeSettings::default(),
}
}
}
@@ -235,7 +197,7 @@ impl Default for GeneralSettings {
fn default() -> Self {
Self {
reopen_last_project: true,
confirm_exit: true,
confirm_exit: false,
open_project_in: "ask".into(),
default_project_directory: String::new(),
save_on_idle: false,
@@ -803,24 +765,6 @@ impl Settings {
mod tests {
use super::*;
#[test]
fn cmake_profile_argument_arrays_round_trip() {
let mut settings = Settings::default();
assert!(settings.cmake.profiles.is_none());
assert!(CMakeProfile::default().cmake_args.is_empty());
assert!(CMakeProfile::default().build_args.is_empty());
let profile = CMakeProfile {
cmake_args: ["-DMESSAGE=two words", "-DQUOTE=\"hello\"", ""].map(String::from).into(),
build_args: ["-j", "8", "--", "LABEL=it's a build"].map(String::from).into(),
..Default::default()
};
settings.cmake.profiles = Some(vec![profile.clone()]);
let value = serde_json::to_value(&settings).unwrap();
assert_eq!(value["cmake"]["profiles"][0]["cmakeArgs"], serde_json::json!(profile.cmake_args));
assert_eq!(value["cmake"]["profiles"][0]["buildArgs"], serde_json::json!(profile.build_args));
assert_eq!(serde_json::from_value::<Settings>(value).unwrap(), settings);
}
#[test]
fn ui_font_fallback_defaults_empty_and_round_trips() {
let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap();
-3
View File
@@ -41,8 +41,6 @@ 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`.
@@ -73,7 +71,6 @@ pub struct GitSettings {
impl Default for GitSettings {
fn default() -> Self {
Self {
inline_blame: false,
executable: String::new(),
update_method: "merge".into(),
auto_update_on_push_rejected: false,
-10
View File
@@ -1389,16 +1389,6 @@ impl DebugManager {
})
}
/// Stops every session `key` started: the window that opened the project is closing.
pub async fn stop_key(&self, key: &str) {
let ids: Vec<DebugSessionId> = self.sessions(Some(key)).into_iter().map(|s| s.id).collect();
for id in ids {
if let Err(e) = self.stop(id).await {
tracing::debug!("debug session {id:?} did not stop: {e}");
}
}
}
/// Stops a session and its child sessions.
pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> {
let session = self.session(s)?;
-7
View File
@@ -51,11 +51,4 @@ uuid.workspace = true
tempfile.workspace = true
[dev-dependencies]
ide-ssh = { workspace = true, features = ["testing"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
[target.'cfg(unix)'.dependencies]
rustix = { version = "1.1", features = ["fs", "process"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
-1
View File
@@ -55,7 +55,6 @@ async fn run(base: PathBuf) -> i32 {
let sink_peer = peer.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir,
local_data_dir: data_dir.clone(),
data_dir,
languages,
syntax: Arc::default(),
-474
View File
@@ -1,474 +0,0 @@
//! Storage policy and best-effort migration of rebuildable desktop data.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Unknown entries and recovery/user content deliberately stay in roaming data.
pub fn is_local(entry: &Path) -> bool {
["tools", "language-server-runtimes", "index", "cache", "lsp", "notebook-runtime", "server", "logs"]
.iter()
.any(|root| entry.starts_with(root))
|| entry.starts_with("agents/tools")
|| entry == Path::new("agents/registry.json")
|| (entry.components().count() == 1
&& entry.file_name().is_some_and(|name| name.to_string_lossy().starts_with("java-usages-"))
&& entry.extension().is_some_and(|ext| ext == "jar" || ext == "tmp"))
}
pub fn data_path(data: &Path, local: &Path, entry: &Path) -> PathBuf {
if is_local(entry) { local.join(entry) } else { data.join(entry) }
}
#[derive(Debug, PartialEq, Eq)]
enum Action {
Skip,
Rename,
}
fn action(same_root: bool, source_exists: bool, target_exists: bool) -> Action {
if same_root || !source_exists || target_exists { Action::Skip } else { Action::Rename }
}
#[derive(Debug, PartialEq, Eq)]
enum RenameFailure {
Copy,
KeepSource,
}
fn rename_failure(error: &io::Error) -> RenameFailure {
if error.kind() == io::ErrorKind::CrossesDevices { RenameFailure::Copy } else { RenameFailure::KeepSource }
}
/// Only Windows splits these paths. Renames are cheap; cross-volume copies run in
/// the background. Consumers always use Local, even when migration fails.
pub fn migrate(data: &Path, local: &Path) {
if cfg!(target_os = "windows") && data != local {
migrate_entries(data, local);
}
}
fn migrate_entries(data: &Path, local: &Path) {
let mut entries: Vec<PathBuf> = [
"language-server-runtimes",
"index",
"cache",
"lsp",
"notebook-runtime",
"server",
"java-usages-1.1.1.jar",
"agents/registry.json",
]
.into_iter()
.map(PathBuf::from)
.collect();
// Virtual environments contain absolute interpreter paths. Keep the old copy
// intact; these tools are installed afresh in Local when requested. Registry
// agents also have absolute commands in user config, so leave legacy installs.
match fs::read_dir(data.join("tools")) {
Ok(tools) => {
for entry in tools {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
tracing::warn!(%error, "cannot enumerate legacy tool; keeping old data, using Local");
continue;
}
};
let name = entry.file_name();
let venv = ide_tools::registry::TOOLS.iter().any(|tool| {
name == tool.name && matches!(tool.method, ide_tools::registry::Method::PipVenv { .. })
});
if venv {
tracing::info!(?name, "leaving non-relocatable legacy tool; reinstall into Local when needed");
} else {
entries.push(Path::new("tools").join(name));
}
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => tracing::warn!(%error, "cannot enumerate legacy tools; keeping old data, using Local"),
}
let mut copies = Vec::new();
for entry in entries {
let source = data.join(&entry);
let target = data_path(data, local, &entry);
match try_rename(&source, &target) {
Ok(true) => copies.push((source, target)),
Ok(false) => {}
Err(error) => {
tracing::warn!(?source, ?target, %error, "data migration failed; keeping old data, using Local")
}
}
}
if !copies.is_empty() {
let result = std::thread::Builder::new().name("openidea-data-migration".into()).spawn(move || {
for (source, target) in copies {
tracing::info!(?source, ?target, "copying legacy data in background");
if let Err(error) = copy_then_remove(&source, &target) {
tracing::warn!(?source, ?target, %error, "data copy failed; keeping old data, using Local");
}
}
});
if let Err(error) = result {
tracing::warn!(%error, "cannot start data migration worker; keeping old data, using Local");
}
}
}
/// Returns true when a cross-volume copy is needed.
fn try_rename(source: &Path, target: &Path) -> io::Result<bool> {
if action(source == target, source.try_exists()?, target.try_exists()?) == Action::Skip {
return Ok(false);
}
if fs::symlink_metadata(source)?.file_type().is_symlink() {
return Err(io::Error::other("refusing to migrate a linked data entry"));
}
fs::create_dir_all(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?;
// Relative wrappers work at the old location too, so even a failed rename
// leaves the old installation usable and needs no rollback.
relocate_shims(source, source)?;
match rename_missing(source, target) {
Ok(()) => {
tracing::info!(?source, ?target, "moved legacy data to Local");
Ok(false)
}
Err(error) if rename_failure(&error) == RenameFailure::Copy => Ok(true),
Err(error) => Err(error),
}
}
fn copy_then_remove(source: &Path, target: &Path) -> io::Result<()> {
if target.try_exists()? {
return Ok(());
}
let parent = target.parent().ok_or_else(|| io::Error::other("missing data parent"))?;
fs::create_dir_all(parent)?;
let staging = parent.join(format!(".migration-{}", uuid::Uuid::new_v4()));
let result = (|| {
copy_tree(source, &staging)?;
relocate_shims(&staging, source)?;
// A tool may have been installed or a cache rebuilt while copying. Never
// merge into or overwrite it, and retain the original for recovery.
if target.try_exists()? {
tracing::info!(?source, ?target, "Local data created during migration; retaining legacy copy");
return Ok(());
}
rename_missing(&staging, target)?;
remove_entry(source)?;
tracing::info!(?source, ?target, "copied legacy data to Local");
Ok(())
})();
if staging.exists() {
let _ = remove_entry(&staging);
}
result
}
// std::fs::rename may replace even an empty directory on modern Windows.
#[cfg(windows)]
fn rename_missing(source: &Path, target: &Path) -> io::Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::MoveFileExW;
// canonicalize supplies extended-length paths, including redirected UNC roots.
let source = fs::canonicalize(source)?;
let target = fs::canonicalize(target.parent().ok_or_else(|| io::Error::other("missing data parent"))?)?
.join(target.file_name().ok_or_else(|| io::Error::other("missing data name"))?);
let wide = |path: &Path| -> io::Result<Vec<u16>> {
let mut value: Vec<u16> = path.as_os_str().encode_wide().collect();
if value.contains(&0) {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "NUL in data path"));
}
value.push(0);
Ok(value)
};
let source = wide(&source)?;
let target = wide(&target)?;
// SAFETY: both paths are valid NUL-terminated UTF-16 buffers. No flags means
// neither replacement nor implicit cross-volume copying is permitted.
if unsafe { MoveFileExW(source.as_ptr(), target.as_ptr(), 0) } == 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
// Filesystem tests run on Unix too; production migration is Windows-only.
#[cfg(not(windows))]
fn rename_missing(source: &Path, target: &Path) -> io::Result<()> {
if target.try_exists()? {
return Err(io::Error::from(io::ErrorKind::AlreadyExists));
}
fs::rename(source, target)
}
fn remove_entry(path: &Path) -> io::Result<()> {
if fs::symlink_metadata(path)?.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) }
}
fn copy_tree(source: &Path, target: &Path) -> io::Result<()> {
let metadata = fs::symlink_metadata(source)?;
if metadata.file_type().is_symlink() {
return Err(io::Error::other("refusing to follow a link during data migration"));
}
if metadata.is_dir() {
fs::create_dir(target)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
copy_tree(&entry.path(), &target.join(entry.file_name()))?;
}
} else if metadata.is_file() {
fs::copy(source, target)?;
} else {
return Err(io::Error::other("unsupported data entry"));
}
Ok(())
}
/// Make our absolute .cmd wrappers relative to their bin directory. They remain
/// valid in both locations if the move must be rolled back. npm shims already use
/// relative paths; payloads and user configuration are never rewritten.
fn relocate_shims(root: &Path, source: &Path) -> io::Result<()> {
if source.parent().and_then(Path::file_name).is_none_or(|name| name != "tools") {
return Ok(());
}
let bin = root.join("bin");
if !bin.is_dir() {
return Ok(());
}
for entry in fs::read_dir(bin)? {
let path = entry?.path();
if path.extension().is_none_or(|ext| ext != "cmd") {
continue;
}
let text = fs::read_to_string(&path)?;
if text.starts_with("@echo off\r\n") {
let updated = relative_wrapper(&text, source);
if updated != text {
fs::write(path, updated)?;
}
}
}
Ok(())
}
fn relative_wrapper(text: &str, source: &Path) -> String {
// PathBuf can contain both separators (notably roots joined with "tools/demo").
// Compare normalized paths, but retain every other byte of the managed wrapper.
let normalized = text.replace('\\', "/");
let prefix = source.to_string_lossy().replace('\\', "/");
#[cfg(windows)]
let (normalized, prefix) = (normalized.to_ascii_lowercase(), prefix.to_ascii_lowercase());
let mut updated = text.to_owned();
let offsets: Vec<_> = normalized.match_indices(&prefix).map(|(offset, _)| offset).collect();
for offset in offsets.into_iter().rev() {
let end = offset + prefix.len();
if normalized.as_bytes().get(end) == Some(&b'/') {
updated.replace_range(offset..end, "%~dp0..");
}
}
updated
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrappers_accept_mixed_separators_without_rewriting_neighbor_paths() {
let text = "@echo off\r\n\"C:\\old\\tools/demo\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n";
assert_eq!(
relative_wrapper(text, Path::new("C:/old/tools/demo")),
"@echo off\r\n\"%~dp0..\\payload.exe\" \"C:\\old\\tools/demo-other\\x\" %*\r\n"
);
}
#[test]
fn storage_policy_is_conservative_and_preserves_unix_layout() {
for entry in [
"tools/jdks",
"language-server-runtimes",
"lsp/jdtls",
"index/x",
"cache/x",
"notebook-runtime",
"server",
"logs",
"agents/tools",
"agents/registry.json",
"java-usages-1.1.1.jar",
] {
assert!(is_local(Path::new(entry)), "{entry}");
assert_eq!(
data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)),
Path::new("local").join(entry)
);
assert_eq!(
data_path(Path::new("data"), Path::new("data"), Path::new(entry)),
Path::new("data").join(entry)
);
}
for entry in [
"git-credentials.json",
"history",
"remote",
"scratches",
"consoles",
"plugins",
"workspaces",
"agents/models.json",
"agents/project",
"unknown",
] {
assert!(!is_local(Path::new(entry)), "{entry}");
assert_eq!(
data_path(Path::new("roaming"), Path::new("local"), Path::new(entry)),
Path::new("roaming").join(entry)
);
}
}
#[cfg(not(target_os = "windows"))]
#[test]
fn unix_startup_does_not_migrate_even_if_given_distinct_roots() {
assert_eq!(dirs::data_dir(), dirs::data_local_dir());
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("index")).unwrap();
migrate(&old, &new);
assert!(old.join("index").exists());
assert!(!new.exists());
}
#[test]
fn decisions_cover_missing_existing_and_failed_renames() {
assert_eq!(action(true, true, false), Action::Skip);
assert_eq!(action(false, false, false), Action::Skip);
assert_eq!(action(false, true, true), Action::Skip);
assert_eq!(action(false, true, false), Action::Rename);
assert_eq!(rename_failure(&io::Error::from(io::ErrorKind::CrossesDevices)), RenameFailure::Copy);
for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::AlreadyExists, io::ErrorKind::NotFound] {
assert_eq!(rename_failure(&io::Error::from(kind)), RenameFailure::KeepSource);
}
}
#[test]
fn same_volume_moves_and_second_start_does_not_overwrite() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("tools/demo/bin")).unwrap();
let wrapper = format!("@echo off\r\n\"{}/payload.exe\" %*\r\n", old.join("tools/demo").display());
fs::write(old.join("tools/demo/bin/demo.cmd"), wrapper).unwrap();
fs::create_dir_all(old.join("tools/ty/venv")).unwrap();
fs::write(old.join("git-credentials.json"), "secret").unwrap();
fs::create_dir_all(old.join("index")).unwrap();
migrate_entries(&old, &new);
assert!(!old.join("tools/demo").exists());
assert!(old.join("tools/ty/venv").exists());
assert!(old.join("git-credentials.json").exists());
assert!(fs::read_to_string(new.join("tools/demo/bin/demo.cmd")).unwrap().contains("%~dp0.."));
fs::create_dir_all(old.join("index")).unwrap();
fs::write(old.join("index/legacy"), "old").unwrap();
migrate_entries(&old, &new);
assert!(old.join("index/legacy").exists());
assert!(!new.join("index/legacy").exists());
}
#[cfg(unix)]
#[test]
fn copy_failure_does_not_publish_or_remove_source() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir(&old).unwrap();
fs::write(old.join("payload"), "old").unwrap();
std::os::unix::fs::symlink(old.join("payload"), old.join("link")).unwrap();
assert!(copy_then_remove(&old, &new).is_err());
assert!(old.join("payload").exists());
assert!(!new.exists());
assert_eq!(fs::read_dir(tmp.path()).unwrap().count(), 1);
}
#[test]
fn staged_copy_publishes_complete_data_and_preserves_existing_target() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let new = tmp.path().join("new");
fs::create_dir_all(old.join("nested")).unwrap();
fs::write(old.join("nested/tool.exe"), "payload").unwrap();
copy_then_remove(&old, &new).unwrap();
assert!(!old.exists());
assert_eq!(fs::read_to_string(new.join("nested/tool.exe")).unwrap(), "payload");
fs::create_dir(&old).unwrap();
fs::write(old.join("legacy"), "old").unwrap();
copy_then_remove(&old, &new).unwrap();
assert!(old.join("legacy").exists());
assert!(!new.join("legacy").exists());
let old_file = tmp.path().join("registry-old.json");
let new_file = tmp.path().join("registry-new.json");
fs::write(&old_file, "cached registry").unwrap();
copy_then_remove(&old_file, &new_file).unwrap();
assert!(!old_file.exists());
assert_eq!(fs::read_to_string(new_file).unwrap(), "cached registry");
}
#[test]
fn publication_refuses_existing_files_and_empty_directories() {
let tmp = tempfile::tempdir().unwrap();
for directory in [false, true] {
let old = tmp.path().join(if directory { "old-dir" } else { "old-file" });
let new = tmp.path().join(if directory { "new-dir" } else { "new-file" });
if directory {
fs::create_dir(&old).unwrap();
fs::create_dir(&new).unwrap();
fs::write(old.join("payload"), "old").unwrap();
} else {
fs::write(&old, "old").unwrap();
fs::write(&new, "new").unwrap();
}
assert!(rename_missing(&old, &new).is_err());
assert!(old.exists() && new.exists());
if directory {
assert!(!new.join("payload").exists());
} else {
assert_eq!(fs::read_to_string(new).unwrap(), "new");
}
}
}
#[test]
fn wrapper_failure_keeps_the_old_installation() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old/tools/demo");
let new = tmp.path().join("new/tools/demo");
fs::create_dir_all(old.join("bin")).unwrap();
fs::write(old.join("bin/demo.cmd"), [0xff, 0xfe]).unwrap();
assert!(try_rename(&old, &new).is_err());
assert!(old.join("bin/demo.cmd").exists());
assert!(!new.exists());
}
#[test]
fn failures_keep_source_without_publishing_partial_data() {
let tmp = tempfile::tempdir().unwrap();
let old = tmp.path().join("old");
let blocker = tmp.path().join("blocker");
fs::create_dir(&old).unwrap();
fs::write(old.join("payload"), "old").unwrap();
fs::write(&blocker, "file").unwrap();
assert!(try_rename(&old, &blocker.join("target")).is_err());
assert!(copy_then_remove(&old, &blocker.join("target")).is_err());
assert!(old.join("payload").exists());
assert!(copy_then_remove(&tmp.path().join("missing"), &tmp.path().join("new")).is_err());
assert!(!tmp.path().join("new").exists());
assert!(
!fs::read_dir(tmp.path())
.unwrap()
.flatten()
.any(|e| e.file_name().to_string_lossy().starts_with(".migration-"))
);
}
}
+12 -40
View File
@@ -7,16 +7,6 @@ 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")
@@ -54,19 +44,16 @@ pub async fn format(
return Err(IdeError::invalid("The file is read-only"));
}
let config = config.unwrap().clone();
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 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 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(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())?;
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())?;
Ok(text
.diff(&formatted)
.changes
@@ -82,13 +69,13 @@ pub async fn format(
if edits.is_ok() && whole_selection {
project.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Info,
title: name.into(),
body: Some(format!(
"{name} does not support selection formatting for this language; formatted the whole file"
)),
title: "Prettier".into(),
body: Some(
"Prettier does not support selection formatting for this language; formatted the whole file".into(),
),
});
}
edits.map_err(|e| e.with_tool(tool))
edits.map_err(|e| e.with_tool("prettier"))
}
#[cfg(test)]
@@ -117,19 +104,4 @@ 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();
}
}
}
+5 -45
View File
@@ -3,7 +3,6 @@
//! 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;
@@ -34,10 +33,7 @@ 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>>>,
@@ -53,14 +49,10 @@ 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).
@@ -84,17 +76,8 @@ pub struct Host {
impl Host {
pub fn new(config: HostConfig) -> Self {
let HostConfig {
config_dir,
data_dir,
local_data_dir,
languages,
syntax,
sink_for,
owner,
runtime,
project_environment,
} = config;
let HostConfig { config_dir, 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);
@@ -103,11 +86,8 @@ 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, local_data_dir.clone()),
environment: environment::Environment::new(project_environment, 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(),
@@ -116,7 +96,6 @@ impl Host {
syntax,
config_dir,
data_dir,
local_data_dir,
sink_for,
owner,
runtime,
@@ -176,21 +155,12 @@ impl Host {
}
project.agents.shutdown();
project.notebooks.stop_admission();
// What the closing window started goes with it — the builds and searches it
// asked for, its runs, its shells and its debug sessions — and the language
// servers finish shutting down before the window is gone, as IDEA closes a
// project's processes.
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
self.cancel_request(request);
}
self.processes.kill_project(&project.root);
self.terminals.kill_key(self.owner_window(window));
self.shutdown_project_workers_blocking(
vec![project.notebooks.clone()],
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned())),
);
let lsp = project.lsp.clone();
self.runtime.spawn(async move { lsp.shutdown_all().await });
}
}
@@ -200,8 +170,6 @@ 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();
@@ -219,12 +187,6 @@ 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() {
@@ -246,8 +208,6 @@ impl Host {
self.shutdown_project_workers_blocking(
projects.iter().map(|project| project.notebooks.clone()).collect(),
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
None,
None,
);
}
+1 -1
View File
@@ -25,7 +25,7 @@ pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
/// Where ACP Registry agents are saved (`<config>/agents`, like the user's own) and installed.
pub(crate) fn registry_paths(host: &Host) -> RegistryPaths {
RegistryPaths::new(&host.config_dir, &host.local_data_dir)
RegistryPaths::new(&host.config_dir, &host.data_dir)
}
fn join_err(e: impl std::fmt::Display) -> IdeError {
+14 -21
View File
@@ -133,29 +133,22 @@ pub async fn build_sync(
host.end_request(request);
let model = result??;
if tool == BuildTool::Maven
&& model.maven.is_some()
&& let Some(bridge) = &project.kotlin_bridge
&& let Some(maven) = &model.maven
{
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).
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}");
}
});
}
// 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)
}
+1 -1
View File
@@ -39,7 +39,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
.await?;
}
if config.adapter == ide_dap::kotlin::ADAPTER {
let data_dir = host.local_data_dir.clone();
let data_dir = host.data_dir.clone();
let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
let runtime_settings = settings.clone();
let runtime = host
-124
View File
@@ -1,124 +0,0 @@
//! Repository version lookups for manifest inspections and completion.
use crate::{Host, rpc::Dispatcher};
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, RequestId};
use ide_workspace::dependencies::{DependencyVersionSource, DependencyVersions, cargo};
struct CratesIo;
impl DependencyVersionSource for CratesIo {
fn id(&self) -> &'static str {
"crates-io"
}
fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>> {
let path = cargo::index_path(package).ok_or_else(|| IdeError::internal("invalid crate name"))?;
let search_path = std::env::var_os("PATH").unwrap_or_default();
let text = ide_tools::Http::new(&search_path)?.https_only().get_text_limited(
&format!("https://index.crates.io/{path}"),
Some(16 * 1024 * 1024),
cancel,
)?;
cargo::parse_index(&text).ok_or_else(|| IdeError::internal("invalid sparse index"))
}
}
/// `offset` limits a completion request to its version string; inspections omit it.
pub async fn dependency_versions(
host: &Host,
window: &str,
doc: DocId,
offset: Option<u32>,
request: RequestId,
) -> IdeResult<Vec<DependencyVersions>> {
let project = host.project(window)?;
let cache = host.dependency_versions.clone();
let cancel = host.begin_request(request);
let result = host
.blocking(move || {
let snap = project.documents.snapshot(doc)?;
if snap.large || snap.read_only || snap.path.file_name().is_none_or(|n| n != "Cargo.toml") {
return Ok(Vec::new());
}
let mut out = Vec::new();
for dependency in cargo::dependencies(&snap.text) {
if offset.is_some_and(|pos| pos < dependency.from || pos > dependency.to) {
continue;
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let versions = cache.versions(&CratesIo, &dependency.package, &cancel)?;
let update = cargo::update(&dependency.requirement, &versions);
out.push(DependencyVersions { dependency, versions, update });
}
Ok(out)
})
.await;
host.end_request(request);
result
}
pub(crate) fn register(d: &mut Dispatcher) {
d.add("dependency_versions", |c| async move {
dependency_versions(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?, c.arg("request")?).await
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::HostConfig;
use std::sync::Arc;
struct Source;
impl DependencyVersionSource for Source {
fn id(&self) -> &'static str {
"crates-io"
}
fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult<Vec<String>> {
Ok(vec!["1.10.0".into(), "0.2.1".into()])
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn host_uses_synced_document_cache_and_cursor_filter_without_network() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
let text = "# 中文 😀\n[dependencies]\ntokio = { version = \"0.2\", features = [\"full\"] }\nserde = \"1.0.100\"\nlocal = { path = \"../x\", version = \"0.2\" }\n";
let path = root.join("Cargo.toml");
std::fs::write(&path, text).unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(|_| {})),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let project = host.open_project("main", &root).unwrap();
let info = project.documents.open(&path).unwrap();
for package in ["tokio", "serde"] {
host.dependency_versions.versions(&Source, package, &CancellationToken::new()).unwrap();
}
let all = dependency_versions(&host, "main", info.id, None, RequestId(410)).await.unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all[0].update.as_ref().unwrap().replacement, "1.10");
assert!(all[1].update.is_none());
let offset = all[1].dependency.from;
let at_cursor = dependency_versions(&host, "main", info.id, Some(offset), RequestId(411)).await.unwrap();
assert_eq!(at_cursor.len(), 1);
assert_eq!(at_cursor[0].dependency.package, "serde");
assert!(dependency_versions(&host, "main", info.id, Some(0), RequestId(412)).await.unwrap().is_empty());
host.cancel_request(RequestId(413));
assert!(dependency_versions(&host, "main", info.id, None, RequestId(413)).await.is_err());
let other = root.join("other.toml");
std::fs::write(&other, text).unwrap();
let info = project.documents.open(&other).unwrap();
assert!(dependency_versions(&host, "main", info.id, None, RequestId(414)).await.unwrap().is_empty());
host.close_project("main");
}
}
-1
View File
@@ -30,7 +30,6 @@ impl Fixture {
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| {
+1 -1
View File
@@ -61,7 +61,7 @@ async fn resolve_launch(
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
let mut info = project.lsp.java_launch(&module, test, build && !delegated, cancel).await?;
let lsp_ms = started.elapsed().as_millis();
let data_dir = host.local_data_dir.clone();
let data_dir = host.data_dir.clone();
let cancel = cancel.clone();
tokio::task::spawn_blocking(move || {
let _external_build = external_build;
-2
View File
@@ -8,7 +8,6 @@ pub mod build;
pub mod bytes;
pub mod coverage;
pub mod debug;
pub mod dependencies;
pub mod deploy;
pub mod doc;
pub mod env;
@@ -70,7 +69,6 @@ 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);
+4 -5
View File
@@ -50,11 +50,10 @@ 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_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();
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();
all.extend(saved.iter().cloned());
for config in all.iter_mut().filter(|c| c.kind != "gradle") {
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(config), root, settings);
-20
View File
@@ -5,23 +5,6 @@ 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();
@@ -223,9 +206,6 @@ 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
});
+1 -48
View File
@@ -3,21 +3,6 @@ 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,
@@ -40,8 +25,7 @@ pub fn spawn(
.ok()
.filter(|p| p.is_trusted())
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), &shell));
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)))?;
let info = host.terminals.spawn(options, 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"));
}
@@ -63,34 +47,3 @@ pub fn kill(host: &Host, id: TerminalId) -> IdeResult<()> {
pub fn is_busy(host: &Host, id: TerminalId) -> IdeResult<bool> {
host.terminals.is_busy(id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::HostConfig;
use ide_core::{NullSink, error::ErrorKind};
use std::sync::Arc;
#[tokio::test]
async fn image_size_limit_is_checked_before_storage() {
let root = tempfile::tempdir().unwrap();
let host = Host::new(HostConfig {
config_dir: root.path().join("config"),
data_dir: root.path().join("data"),
local_data_dir: root.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
let id = TerminalId::next();
let error = store_image(&host, id, vec![0; MAX_IMAGE_BYTES + 1], "image/png".into()).await.unwrap_err();
assert_eq!(error.kind, ErrorKind::InvalidInput);
assert_eq!(error.message, "image is too large to paste (limit 20 MB)");
let error = store_image(&host, id, vec![0; MAX_IMAGE_BYTES], "image/png".into()).await.unwrap_err();
assert_eq!(error.message, "terminal session not found");
assert!(!host.data_dir.join("terminal-images").exists());
}
}
-220
View File
@@ -1,220 +0,0 @@
use ide_core::{IdeError, IdeResult};
use std::path::Path;
#[cfg(unix)]
mod unix {
use super::*;
use rustix::fs::{self, AtFlags, Dir, Mode, OFlags};
use std::fs::File;
use std::io::{self, Write};
use std::os::unix::fs::MetadataExt;
use std::time::{Duration, SystemTime};
const LIFETIME: Duration = Duration::from_secs(24 * 60 * 60);
const DIRECTORY: &str = "terminal-images";
fn extension(mime: &str) -> IdeResult<&str> {
match mime {
"image/png" => Ok("png"),
"image/jpeg" => Ok("jpg"),
"image/gif" => Ok("gif"),
"image/webp" => Ok("webp"),
"image/bmp" => Ok("bmp"),
"image/svg+xml" => Ok("svg"),
"image/tiff" => Ok("tiff"),
"image/x-icon" => Ok("ico"),
_ => Err(IdeError::invalid("unsupported image type")),
}
}
fn open_directory(data_dir: &Path) -> io::Result<File> {
if !data_dir.is_absolute() {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "data directory must be absolute"));
}
// The configured data directory and its ancestors are the host's trust boundary.
let parent =
fs::open(data_dir, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty())?;
match fs::mkdirat(&parent, DIRECTORY, Mode::from_bits_truncate(0o700)) {
Ok(()) | Err(rustix::io::Errno::EXIST) => (),
Err(e) => return Err(e.into()),
}
let dir: File = fs::openat(
&parent,
DIRECTORY,
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::empty(),
)?
.into();
let meta = dir.metadata()?;
if meta.uid() != rustix::process::geteuid().as_raw() || meta.mode() & 0o7777 != 0o700 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"terminal image directory is not private or owned by the current user",
));
}
Ok(dir)
}
fn owned_name(name: &str) -> bool {
let name = name.strip_prefix(".partial-").unwrap_or(name);
let Some((id, ext)) = name.strip_prefix("image-").and_then(|s| s.split_once('.')) else { return false };
id.len() == 32
&& id.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
&& matches!(ext, "png" | "jpg" | "gif" | "webp" | "bmp" | "svg" | "tiff" | "ico")
}
fn cleanup(dir: &File, now: SystemTime) -> io::Result<()> {
for entry in Dir::read_from(dir)? {
let entry = entry?;
let name = entry.file_name();
if !name.to_str().is_ok_and(owned_name) {
continue;
}
// Never follow symlinks; opening nonblocking avoids hanging on an unexpected FIFO.
let fd = match fs::openat(
dir,
name,
OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
Mode::empty(),
) {
Ok(fd) => fd,
Err(_) => continue,
};
let file = File::from(fd);
let meta = file.metadata()?;
if meta.is_file()
&& meta.uid() == rustix::process::geteuid().as_raw()
&& meta
.modified()
.ok()
.and_then(|time| now.duration_since(time).ok())
.is_some_and(|age| age >= LIFETIME)
{
match fs::unlinkat(dir, name, AtFlags::empty()) {
Ok(()) | Err(rustix::io::Errno::NOENT) => (),
Err(error) => return Err(error.into()),
}
}
}
Ok(())
}
fn store_with(
data_dir: &Path,
data: &[u8],
mime: &str,
now: SystemTime,
write: impl FnOnce(&mut File, &[u8]) -> io::Result<()>,
) -> IdeResult<String> {
let ext = extension(mime)?;
let dir = open_directory(data_dir)?;
cleanup(&dir, now)?;
let name = format!("image-{}.{}", uuid::Uuid::new_v4().simple(), ext);
let temporary = format!(".partial-{name}");
let fd = fs::openat(
&dir,
temporary.as_str(),
OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
Mode::from_bits_truncate(0o600),
)
.map_err(io::Error::from)?;
let mut file = File::from(fd);
if let Err(error) = write(&mut file, data) {
let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty());
return Err(error.into());
}
if let Err(error) = fs::linkat(&dir, temporary.as_str(), &dir, name.as_str(), AtFlags::empty()) {
let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty());
return Err(io::Error::from(error).into());
}
let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty());
Ok(data_dir.join(DIRECTORY).join(name).to_string_lossy().into_owned())
}
pub(in crate::ops::term) fn store(data_dir: &Path, data: &[u8], mime: &str) -> IdeResult<String> {
store_with(data_dir, data, mime, SystemTime::now(), |file, bytes| file.write_all(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::os::unix::fs::{PermissionsExt, symlink};
#[test]
fn stores_bytes_with_private_modes_and_unpredictable_names() {
let root = tempfile::tempdir().unwrap();
let first = store(root.path(), b"image bytes", "image/png").unwrap();
let second = store(root.path(), b"other", "image/jpeg").unwrap();
assert_ne!(first, second);
assert_eq!(fs::read(&first).unwrap(), b"image bytes");
assert_eq!(fs::metadata(&first).unwrap().permissions().mode() & 0o777, 0o600);
assert_eq!(fs::metadata(root.path().join(DIRECTORY)).unwrap().permissions().mode() & 0o777, 0o700);
assert!(owned_name(Path::new(&first).file_name().unwrap().to_str().unwrap()));
}
#[test]
fn expiry_only_removes_owned_regular_files_at_boundary() {
let root = tempfile::tempdir().unwrap();
let dir = open_directory(root.path()).unwrap();
let path = root.path().join(DIRECTORY);
let now = SystemTime::now();
let names = [
"image-00000000000000000000000000000001.png",
"image-00000000000000000000000000000002.png",
"image-00000000000000000000000000000003.png",
"unrelated.png",
];
for (name, time) in names.iter().zip([
now - LIFETIME,
now - LIFETIME + Duration::from_secs(1),
now + Duration::from_secs(1),
now - LIFETIME,
]) {
let file = File::create(path.join(name)).unwrap();
file.set_times(fs::FileTimes::new().set_modified(time)).unwrap();
}
let link = path.join("image-00000000000000000000000000000004.png");
symlink(path.join(names[0]), &link).unwrap();
cleanup(&dir, now).unwrap();
assert!(!path.join(names[0]).exists());
for name in &names[1..] {
assert!(path.join(name).exists());
}
assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink());
}
#[test]
fn rejects_symlink_and_insecure_directory() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let target = root.path().join(DIRECTORY);
symlink(outside.path(), &target).unwrap();
assert!(store(root.path(), b"x", "image/png").is_err());
fs::remove_file(&target).unwrap();
fs::create_dir(&target).unwrap();
fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap();
assert!(store(root.path(), b"x", "image/png").is_err());
assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0);
}
#[test]
fn failed_write_removes_partial_file() {
let root = tempfile::tempdir().unwrap();
let result = store_with(root.path(), b"partial", "image/png", SystemTime::now(), |file, bytes| {
file.write_all(bytes)?;
Err(io::Error::other("simulated failure"))
});
assert!(result.is_err());
assert_eq!(fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), 0);
}
}
}
#[cfg(unix)]
pub(super) use unix::store;
#[cfg(not(unix))]
pub(super) fn store(_data_dir: &Path, _data: &[u8], _mime: &str) -> IdeResult<String> {
Err(IdeError::internal("private terminal image storage is unsupported on this platform"))
}
+4 -4
View File
@@ -12,7 +12,7 @@ use std::path::PathBuf;
/// The installer, reporting the executables chosen in the window's settings.
fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
host.require_trust(window)?;
let data = host.local_data_dir.clone();
let data = host.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.local_data_dir);
let tools = Tools::new(&host.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.local_data_dir).into())
Ok(ide_tools::jdk::jdks_dir(&host.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.local_data_dir);
let tools = Tools::new(&host.data_dir);
let location = location.filter(|p| !p.as_os_str().is_empty());
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
+12 -12
View File
@@ -112,7 +112,7 @@ impl Host {
let project_key = project_key(&root);
let index = ProjectIndex::open(
&root,
&self.local_data_dir.join("index").join(&project_key),
&self.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.local_data_dir.clone();
let data_dir = self.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.local_data_dir.clone();
let data_dir = self.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.local_data_dir.join("cache").join(&project_key).join("build"),
&self.local_data_dir,
self.data_dir.join("cache").join(&project_key).join("build"),
&self.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.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
self.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.local_data_dir.join("cache").join(&project_key),
cache_dir: self.data_dir.join("cache").join(&project_key),
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
self.local_data_dir.join("cache").join(&project_key).join("build"),
self.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.local_data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
sink.clone(),
);
let project = Arc::new(Project {
trusted: Arc::new(AtomicBool::new(false)),
trusted: Arc::new(AtomicBool::new(true)),
sink,
languages,
settings,
@@ -526,9 +526,9 @@ impl Host {
structure: Arc::new(StructureService::new(syntax.clone())),
syntax,
detected_roots: Arc::default(),
cache_dir: self.local_data_dir.join("cache").join(&project_key),
cache_dir: self.data_dir.join("cache").join(&project_key),
java_launch_snapshots: Mutex::new(crate::ops::java::cache::Session::new(
self.local_data_dir.join("cache").join(&project_key).join("build"),
self.data_dir.join("cache").join(&project_key).join("build"),
)),
db,
http_history,
-3
View File
@@ -231,9 +231,6 @@ impl Dispatcher {
let on_output = c.channel("onOutput")?;
ops::term::spawn(&c.host, &c.window, c.arg("options")?, on_output)
})
.add("term_store_image", |c| async move {
ops::term::store_image(&c.host, c.arg("id")?, c.arg("data")?, c.arg("mime")?).await
})
.add("term_write", |c| async move { ops::term::write(&c.host, c.arg("id")?, &c.arg::<String>("data")?) })
.add("term_resize", |c| async move { ops::term::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
.add("term_kill", |c| async move { ops::term::kill(&c.host, c.arg("id")?) })
+3 -3
View File
@@ -135,7 +135,9 @@ impl crate::Host {
for request in requests {
self.cancel_request(request);
}
self.debugger.stop_key(&window).await;
for session in self.debugger.sessions(Some(&window)) {
let _ = self.debugger.stop(session.id).await;
}
}
}
project.lsp.reconfigure().await;
@@ -220,7 +222,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "index_status"
| "jvm_file_presentations"
| "deploy_coverage"
| "deploy_test_connection"
| "find_files"
| "find_symbols"
| "mark_recent"
@@ -242,7 +243,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "remote_current"
| "remote_disconnect"
| "remote_connect"
| "remote_bootstrap_list"
| "remote_list"
| "remote_open"
| "remote_of"
-54
View File
@@ -1,54 +0,0 @@
//! Closing a project takes the background processes of its window with it, as IDEA does,
//! and leaves the other windows' processes alone.
#![cfg(unix)]
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
fn options(root: &std::path::Path) -> ide_terminal::TerminalOptions {
ide_terminal::TerminalOptions {
cwd: root.to_path_buf().into(),
// A fixed shell: the test must not depend on the login shell of this machine.
shell: Some("/bin/sh".into()),
args: vec![],
env: Default::default(),
cols: 80,
rows: 24,
shell_integration: false,
}
}
#[test]
fn closing_a_project_closes_its_terminals_and_keeps_other_windows() {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
std::fs::create_dir_all(&root).unwrap();
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
runtime.block_on(async {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let project_shell =
ops::term::spawn(&host, "main", options(&root), Arc::new(|_| {})).expect("project terminal");
let other_shell = ops::term::spawn(&host, "other", options(&root), Arc::new(|_| {})).expect("other terminal");
assert_eq!(host.terminals.list().len(), 2);
host.close_project("main");
let left: Vec<_> = host.terminals.list().into_iter().map(|t| t.id).collect();
assert!(!left.contains(&project_shell.id), "the closing window's shell is gone");
assert!(left.contains(&other_shell.id), "another window keeps its shell");
host.terminals.kill_key("other");
});
}
-39
View File
@@ -1,39 +0,0 @@
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use std::sync::Arc;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn split_storage_keeps_user_history_and_uses_local_tools_and_caches() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let data = base.join("roaming");
let local = base.join("local");
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("notes.txt"), "hello\n").unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: data.clone(),
local_data_dir: local.clone(),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
assert_eq!(
std::path::PathBuf::from(ops::tools::jdk_download_location(&host, "main").unwrap()),
local.join("tools/jdks")
);
let light = host.open_light("main", &root.join("notes.txt")).unwrap();
assert!(light.cache_dir.starts_with(&local));
assert!(data.join("history").exists());
assert!(!local.join("history").exists());
let project = host.open_project("main", &root).unwrap();
assert!(project.cache_dir.starts_with(&local));
assert!(local.join("index").exists());
assert!(!data.join("index").exists());
assert!(!data.join("tools").exists());
host.shutdown().await;
}
-102
View File
@@ -9,7 +9,6 @@ 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)),
@@ -78,104 +77,3 @@ async fn light_windows_ignore_project_scoped_settings() {
assert!(!applied.project_scope);
assert!(!dir.join(".openidea").exists(), "no project settings next to the file");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn light_windows_stay_restricted_when_folder_trust_changes() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("loose");
let other = tmp.path().join("other");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&other).unwrap();
std::fs::write(root.join("one.py"), "print(1)\n").unwrap();
std::fs::write(other.join("two.js"), "const value = 1;\n").unwrap();
let host = host(&tmp.path().join("app"));
host.global_settings
.set(ide_workspace::SettingsScope::Global, "languages.prettier.prefer", serde_json::json!(true))
.unwrap();
let light = host.open_light("main", &root.join("one.py")).unwrap();
assert!(!light.is_trusted());
assert!(light.require_trust().is_err());
host.set_project_trust(&root, true, false).await.unwrap();
assert!(!light.is_trusted());
let trusted_folder = host.open_light("second", &root.join("one.py")).unwrap();
assert!(!trusted_folder.is_trusted());
let again = host.open_light("main", &other.join("two.js")).unwrap();
assert!(Arc::ptr_eq(&light, &again));
assert!(!again.is_trusted());
host.set_project_trust(&root, true, true).await.unwrap();
assert!(!light.is_trusted());
assert!(!trusted_folder.is_trusted());
for command in ["sdk_detect", "sdk_locate_tools", "notebook_execute", "notebook_restart", "run_start"] {
assert!(host.authorize_command("main", command).is_err(), "{command}");
}
let doc = ops::doc::open(&host, "main", other.join("two.js")).await.unwrap();
let error = ops::lsp::lsp_format(
&host,
"main",
doc.id,
None,
ide_lsp::types::FormatOptions { tab_size: 2, insert_spaces: true, on_save: false },
)
.await
.unwrap_err();
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
assert!(error.message.contains("Restricted Mode"));
host.set_project_trust(&root, false, false).await.unwrap();
assert!(!light.is_trusted());
assert!(!trusted_folder.is_trusted());
host.set_project_trust(&root, true, false).await.unwrap();
let project = host.open_project("main", &root).unwrap();
assert!(!project.light);
assert!(project.is_trusted());
assert!(project.require_trust().is_ok());
host.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn light_notebooks_open_without_interpreter_detection_or_execution() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("loose");
let bin = root.join(".venv").join(if cfg!(windows) { "Scripts" } else { "bin" });
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(root.join(".venv/pyvenv.cfg"), "home = python\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let python = bin.join("python");
std::fs::write(&python, "#!/bin/sh\nprintf probed > \"$0.probed\"\nprintf 'Python 3.12.0\\n'\n").unwrap();
std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let file = root.join("analysis.ipynb");
std::fs::write(
&file,
r#"{"cells":[{"id":"cell","cell_type":"code","source":["print(1)"],"metadata":{},"outputs":[],"execution_count":null}],"metadata":{},"nbformat":4,"nbformat_minor":5}"#,
)
.unwrap();
let host = host(&tmp.path().join("app"));
host.open_light("main", &file).unwrap();
let notebook = ops::notebook::open(&host, "main", file.to_string_lossy().into()).await.unwrap();
assert!(ops::notebook::save(&host, "main", notebook.id.clone()).await.is_ok());
let error = ops::sdk::sdk_detect(&host, "main", ide_sdk::SdkKind::Python).await.unwrap_err();
assert!(error.message.contains("Restricted Mode"));
assert!(ops::sdk::sdk_python_effective(&host, "main").is_err());
assert!(ops::sdk::sdk_locate_tools(&host, "main", vec!["prettier".into()], Some(root)).await.is_err());
let error = ops::notebook::restart(&host, "main", notebook.id.clone()).await.unwrap_err();
assert!(error.message.contains("Restricted Mode"));
let error = ops::notebook::execute(
&host,
"main",
notebook.id,
vec![notebook.cells[0].id.clone()],
None,
ide_core::RequestId(849),
ops::Channel::new(|_| {}),
)
.await
.unwrap_err();
assert!(error.message.contains("Restricted Mode"));
assert!(!bin.join("python.probed").exists());
host.shutdown().await;
}
-1
View File
@@ -20,7 +20,6 @@ fn host(base: &Path) -> Host {
Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
-1
View File
@@ -10,7 +10,6 @@ fn host(base: &std::path::Path) -> Arc<Host> {
Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
-1
View File
@@ -79,7 +79,6 @@ fn server_environment_from_login_shell_and_devcontainer() {
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |_| sink_events.clone() as SharedSink),
-3
View File
@@ -56,7 +56,6 @@ 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()))),
@@ -262,7 +261,6 @@ 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()))),
@@ -330,7 +328,6 @@ async fn properties_conversion_through_remote_dispatch_without_a_remote_host() {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
-1
View File
@@ -6,7 +6,6 @@ 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,7 +24,6 @@ fn terminal_activation_uses_the_resolved_default_shell() {
let host = Arc::new(Host::new(HostConfig {
config_dir: tmp.path().join("config"),
data_dir: tmp.path().join("data"),
local_data_dir: tmp.path().join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
-48
View File
@@ -1,48 +0,0 @@
#![cfg(unix)]
use ide_core::{NullSink, TerminalId};
use ide_host::{Host, HostConfig, ops};
use ide_terminal::TerminalOptions;
use std::sync::Arc;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn storage_requires_a_registered_terminal() {
let root = tempfile::tempdir().unwrap();
let data_dir = root.path().join("data");
std::fs::create_dir(&data_dir).unwrap();
let host = Host::new(HostConfig {
config_dir: root.path().join("config"),
data_dir: data_dir.clone(),
local_data_dir: data_dir.clone(),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |window| window,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
assert!(ops::term::store_image(&host, TerminalId::next(), vec![1], "image/png".into()).await.is_err());
assert!(!data_dir.join("terminal-images").exists());
let terminal = host
.terminals
.spawn(
TerminalOptions {
cwd: root.path().into(),
shell: Some("/bin/sh".into()),
args: vec![],
env: Default::default(),
cols: 80,
rows: 24,
shell_integration: false,
},
"main".into(),
Arc::new(NullSink),
Box::new(|_| {}),
)
.unwrap();
let path = ops::term::store_image(&host, terminal.id, vec![1, 2, 3], "image/png".into()).await.unwrap();
assert_eq!(std::fs::read(path).unwrap(), [1, 2, 3]);
host.terminals.kill(terminal.id).unwrap();
assert!(ops::term::store_image(&host, terminal.id, vec![4], "image/png".into()).await.is_err());
assert_eq!(std::fs::read_dir(data_dir.join("terminal-images")).unwrap().count(), 1);
}
-61
View File
@@ -11,7 +11,6 @@ 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)),
@@ -56,8 +55,6 @@ 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"] {
@@ -77,15 +74,6 @@ 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}");
@@ -120,55 +108,6 @@ 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,7 +12,6 @@ async fn restricted_status_does_not_select_the_project_git_for_other_windows() {
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
-1
View File
@@ -28,7 +28,6 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
-1
View File
@@ -31,7 +31,6 @@ async fn patches_read_and_write_the_requested_repository() {
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
-1
View File
@@ -22,7 +22,6 @@ 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)),
+1 -28
View File
@@ -8,7 +8,6 @@ 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")),
@@ -16,7 +15,6 @@ 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")),
@@ -375,7 +373,6 @@ 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++",
@@ -535,12 +532,6 @@ mod tests {
let reg = reg();
assert_eq!(detect(&reg, "src/main.rs"), "rust");
assert_eq!(detect(&reg, "a/b.TS"), "typescript");
for path in ["guide.adoc", "guide.asciidoc", "GUIDE.ADOC"] {
assert_eq!(detect(&reg, path), "asciidoc");
}
let asciidoc = reg.get(&LanguageId::new("asciidoc")).unwrap();
assert_eq!(asciidoc.ui_mode.as_deref(), Some("AsciiDoc"));
assert_eq!(asciidoc.line_comment.as_deref(), Some("//"));
assert_eq!(reg.detect(Path::new("script"), Some("#!/usr/bin/env python3")).as_str(), "python");
assert_eq!(detect(&reg, "README"), "plaintext");
assert_eq!(detect(&reg, "unknown.zzz"), "plaintext");
@@ -576,7 +567,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", "oxlint"]);
assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server"]);
assert_eq!(names("html"), ["angular-language-server", "vscode-html-language-server"]);
for lang in ["typescript", "html"] {
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
@@ -586,24 +577,6 @@ mod tests {
}
}
#[test]
fn oxlint_serves_js_files_only_below_its_config() {
let reg = reg();
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("apps/web/src")).unwrap();
std::fs::write(root.join("apps/web/.oxlintrc.json"), "{}").unwrap();
for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte"] {
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
let oxlint = cfg.language_servers.iter().find(|s| s.name == "oxlint").unwrap();
assert_eq!((oxlint.command.as_str(), oxlint.args.as_slice()), ("oxlint", ["--lsp".to_string()].as_slice()));
assert!(oxlint.require_root_marker && !oxlint.fallback, "{lang}");
let served = |file: &str| crate::marked_root(Some(root), &root.join(file), &oxlint.root_markers);
assert_eq!(served("apps/web/src/main.ts"), Some(root.join("apps/web")), "{lang}");
assert_eq!(served("apps/api/main.ts"), None, "{lang}");
}
}
#[test]
fn html_of_angular_projects_is_an_angular_template() {
let reg = reg();
-126
View File
@@ -1,126 +0,0 @@
//! 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));
}
}
+1 -26
View File
@@ -43,18 +43,6 @@ 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
@@ -84,17 +72,6 @@ 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));
@@ -110,12 +87,10 @@ 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())))
}
+2 -13
View File
@@ -8,7 +8,6 @@
mod angular;
mod caps;
mod clang_tidy;
mod convert;
mod external_project;
mod file_ops;
@@ -473,16 +472,13 @@ 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_resolved(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel)
me.launcher.launch_cancellable(&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, binary)) => {
*server.resolved_binary.write() = binary;
peer
}
Ok(peer) => peer,
Err(LaunchError::Missing) => {
let cfg = server.effective_config();
let message =
@@ -591,7 +587,6 @@ impl LspManager {
}
}
}
self.check_clang_tidy(&server).await;
server.refresh_busy_status(&self.sink);
server.announce_ready(generation);
self.spawn_supervisor(server, generation, peer);
@@ -815,12 +810,6 @@ impl LspManager {
}
for s in &servers {
self.ensure_started(s);
if s.running().is_some()
&& let Some(me) = self.me.upgrade()
{
let server = s.clone();
self.runtime.spawn(async move { me.check_clang_tidy(&server).await });
}
}
}
-2
View File
@@ -79,7 +79,6 @@ 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).
@@ -115,7 +114,6 @@ impl Server {
root,
config,
effective: Mutex::new(None),
resolved_binary: RwLock::new(None),
languages: Mutex::new(vec![language]),
state: Mutex::new(State { generation: 0, conn: Conn::Idle }),
start_cancel: Mutex::new(CancellationToken::new()),
-78
View File
@@ -21,7 +21,6 @@ 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)>>,
@@ -149,18 +148,6 @@ 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,
@@ -2849,68 +2836,3 @@ 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;
}
+1 -11
View File
@@ -4,7 +4,6 @@
//! 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;
@@ -17,14 +16,6 @@ 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);
@@ -33,8 +24,7 @@ pub(crate) fn detect_with_profiles(
detect_maven(root, excluded_poms, &mut out);
detect_go(root, &mut out);
detect_python(root, &mut out);
cmake::detect(root, profiles, &mut out);
dotnet::detect(root, &mut out);
cmake::detect(root, &mut out);
detect_make(root, &mut out);
detect_just(root, &mut out);
detect_godot(root, &mut out);
+5 -172
View File
@@ -2,7 +2,6 @@
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};
@@ -20,16 +19,10 @@ 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, profiles: Option<&[CMakeProfile]>, out: &mut Vec<RunConfig>) {
pub(super) fn detect(root: &Path, 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| {
@@ -86,69 +79,11 @@ pub(super) fn detect(root: &Path, profiles: Option<&[CMakeProfile]>, out: &mut V
);
before(&mut test, &build_id);
out.push(test);
targets(root, &build, &dir, &prefix, None, out);
targets(root, &build, &dir, &prefix, out);
}
}
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>,
) {
fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, 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
@@ -178,9 +113,6 @@ fn targets(
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 };
@@ -198,17 +130,8 @@ fn targets(
if !name.is_empty() {
args.extend(["--config", name]);
}
let mut build_config =
let 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 =
@@ -242,99 +165,9 @@ 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 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");
let query = cwd.join(dir).join(".cmake/api/v1/query/client-openidea");
fs::create_dir_all(&query)?;
fs::write(query.join("codemodel-v2"), "")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profiles_select_file_api_configuration_and_apply_target_options() {
let root = tempfile::tempdir().unwrap();
fs::write(root.path().join("CMakeLists.txt"), "project(app)").unwrap();
let reply = root.path().join("output/.cmake/api/v1/reply");
fs::create_dir_all(&reply).unwrap();
let write = |name: &str, value: Value| fs::write(reply.join(name), value.to_string()).unwrap();
write(
"index-1.json",
serde_json::json!({"objects":[{"kind":"codemodel","version":{"major":2},"jsonFile":"model.json"}]}),
);
write(
"model.json",
serde_json::json!({"paths":{"source":root.path()},"configurations":[
{"name":"Debug","targets":[{"jsonFile":"debug.json"}]},
{"name":"Release","targets":[{"jsonFile":"release.json"}]}
]}),
);
for name in ["debug", "release"] {
write(
&format!("{name}.json"),
serde_json::json!({"name":"app","type":"EXECUTABLE","artifacts":[{"path":format!("{name}/app")}]}),
);
}
let profile = CMakeProfile {
name: "Production".into(),
build_type: "Release".into(),
build_dir: "output".into(),
build_args: ["-j", "8", "--", "verbose=1"].map(String::from).into(),
env: [("FOO".into(), "bar".into())].into(),
..Default::default()
};
let mut configs = Vec::new();
detect(root.path(), Some(std::slice::from_ref(&profile)), &mut configs);
let run = configs.iter().find(|c| c.kind == "native").unwrap();
assert!(run.command.ends_with("release/app"));
assert_eq!(run.env, profile.env);
assert_eq!(configs.iter().filter(|c| c.kind == "native").count(), 1);
let build = configs.iter().find(|c| c.id == run.extras.before_launch[0].target).unwrap();
assert_eq!(
build.args,
["--build", "output", "--target", "app", "--config", "Release", "-j", "8", "--", "verbose=1"]
);
assert_eq!(build.env, profile.env);
assert_eq!(build.extras.before_launch[0].target, "cmake:profile:0:configure");
fs::write(root.path().join("output/CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Ninja\n").unwrap();
let mut configured = Vec::new();
detect(root.path(), Some(std::slice::from_ref(&profile)), &mut configured);
for id in ["cmake:profile:0:build", build.id.as_str()] {
let build = configured.iter().find(|c| c.id == id).unwrap();
assert!(build.extras.before_launch.is_empty(), "{id} should use CMake's automatic regeneration");
}
}
#[test]
fn generator_change_clears_only_cmake_state_at_launch() {
let root = tempfile::tempdir().unwrap();
let build = root.path().join("cmake-build-debug");
fs::create_dir_all(build.join("CMakeFiles")).unwrap();
fs::write(build.join("CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Unix Makefiles\n").unwrap();
fs::write(build.join("keep.txt"), "artifact").unwrap();
fs::write(root.path().join("CMakeLists.txt"), "project(app)").unwrap();
let mut configs = Vec::new();
detect(root.path(), Some(&[CMakeProfile { generator: "Ninja".into(), ..Default::default() }]), &mut configs);
assert!(build.join("CMakeCache.txt").exists());
prepare(&configs[0], root.path()).unwrap();
assert!(!build.join("CMakeCache.txt").exists());
assert!(!build.join("CMakeFiles").exists());
assert!(build.join("keep.txt").exists());
assert!(build.join(".cmake/api/v1/query/client-openidea/codemodel-v2").exists());
}
}
-121
View File
@@ -1,121 +0,0 @@
//! Runnable .NET projects and launch profiles, without a solution model.
use crate::{
RunConfig,
dotnet::{parse_project, profiles, solution_projects},
};
use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::Path,
};
pub(crate) fn detect(root: &Path, out: &mut Vec<RunConfig>) {
let mut dirs = vec![root.to_path_buf()];
let mut projects = BTreeSet::new();
while let Some(dir) = dirs.pop() {
let Ok(entries) = fs::read_dir(&dir) else { continue };
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if entry.file_type().is_ok_and(|t| t.is_dir()) {
if !name.starts_with('.')
&& !matches!(
name.as_str(),
"bin" | "obj" | "node_modules" | "target" | "build" | "dist" | "vendor" | "packages"
)
{
dirs.push(path);
}
} else if entry.file_type().is_ok_and(|t| t.is_file()) {
match path.extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase().as_str() {
"csproj" => {
projects.insert(path);
}
"sln" | "slnx" => {
if let Ok(src) = fs::read_to_string(&path) {
for project in solution_projects(
&src,
path.extension().is_some_and(|e| e.eq_ignore_ascii_case("slnx")),
) {
projects.insert(dir.join(project));
}
}
}
_ => {}
}
}
}
}
let root = dunce::canonicalize(root).unwrap_or_else(|_| root.into());
let projects: BTreeSet<_> = projects.into_iter().filter_map(|p| dunce::canonicalize(p).ok()).collect();
for path in projects {
let Some(project) = fs::read_to_string(&path).ok().and_then(|s| parse_project(&s)) else { continue };
let rel = path.strip_prefix(&root).unwrap_or(&path).to_string_lossy().replace('\\', "/");
let name = path.file_stem().unwrap_or_default().to_string_lossy();
let profiles = profiles(&path);
let mut params = BTreeMap::from([
("project".into(), rel.clone()),
("framework".into(), project.frameworks.first().cloned().unwrap_or_default()),
("frameworks".into(), project.frameworks.join(";")),
("profiles".into(), serde_json::to_string(&profiles).unwrap_or_default()),
("launchProfile".into(), String::new()),
("programArgs".into(), String::new()),
]);
if project.executable && !project.test {
out.push(detected(&rel, &name, "", params.clone(), false));
for profile in profiles.keys() {
params.insert("launchProfile".into(), profile.clone());
out.push(detected(&rel, &name, profile, params.clone(), false));
}
}
if project.test {
params.insert("launchProfile".into(), String::new());
out.push(detected(&rel, &name, "", params, true));
}
}
}
fn detected(project: &str, name: &str, profile: &str, params: BTreeMap<String, String>, test: bool) -> RunConfig {
let op = if test { "test" } else { "run" };
let mut config = super::config(
format!("dotnet:{project}:{op}:{profile}"),
if test {
format!("dotnet test ({name})")
} else if profile.is_empty() {
format!("{name} (.NET Project)")
} else {
format!("{name}: {profile}")
},
if test { "dotnet-test" } else { "dotnet" },
"dotnet",
&[],
);
config.args = command_args(&params, test);
config.is_test = test;
config.debug_adapter = (!test).then(|| "netcoredbg".into());
config.extras.params = params;
if !test {
config.extras.before_launch.push(crate::BeforeLaunchTask { kind: "dotnetBuild".into(), target: String::new() });
}
config
}
fn command_args(params: &BTreeMap<String, String>, test: bool) -> Vec<String> {
let mut args = if test {
vec!["test".into(), params.get("project").cloned().unwrap_or_default()]
} else {
vec!["run".into(), "--project".into(), params.get("project").cloned().unwrap_or_default()]
};
if let Some(framework) = params.get("framework").filter(|s| !s.is_empty()) {
args.extend(["--framework".into(), framework.clone()]);
}
if !test {
if let Some(profile) = params.get("launchProfile").filter(|s| !s.is_empty()) {
args.extend(["--launch-profile".into(), profile.clone()]);
} else {
args.push("--no-launch-profile".into());
}
}
args
}
-257
View File
@@ -1,257 +0,0 @@
//! Lightweight .NET project discovery; MSBuild remains the authority for build outputs.
use crate::RunConfig;
use ide_core::{IdeError, IdeResult};
use quick_xml::{Reader, XmlVersion, events::Event};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Default, PartialEq)]
pub struct Project {
pub executable: bool,
pub frameworks: Vec<String>,
pub test: bool,
}
#[derive(Default)]
struct Element {
name: String,
attrs: BTreeMap<String, String>,
text: String,
conditional: bool,
}
fn elements(xml: &str) -> Option<Vec<Element>> {
let mut reader = Reader::from_str(xml);
let mut out: Vec<Element> = Vec::new();
let mut stack: Vec<usize> = Vec::new();
loop {
let event = reader.read_event().ok()?;
let empty = matches!(&event, Event::Empty(_));
match event {
Event::Start(tag) | Event::Empty(tag) => {
let attrs: BTreeMap<_, _> = tag
.attributes()
.map(|a| {
let a = a.ok()?;
Some((
a.key.as_ref().to_owned(),
a.normalized_value(XmlVersion::Implicit1_0).ok()?.into_owned(),
))
})
.collect::<Option<_>>()?;
let conditional = attrs.contains_key("Condition") || stack.last().is_some_and(|i| out[*i].conditional);
out.push(Element {
name: tag.local_name().as_ref().to_owned(),
attrs,
conditional,
..Default::default()
});
// Empty elements have no corresponding End event.
if !empty {
stack.push(out.len() - 1);
}
}
Event::End(_) => {
stack.pop()?;
}
Event::Text(text) => {
if let Some(i) = stack.last() {
out[*i].text.push_str(&text.xml_content(XmlVersion::Implicit1_0));
}
}
Event::CData(text) => {
if let Some(i) = stack.last() {
out[*i].text.push_str(text.as_ref());
}
}
Event::GeneralRef(reference) => {
let name = reference.as_ref();
let escaped = format!("&{name};");
if let Some(i) = stack.last() {
out[*i].text.push_str(&quick_xml::escape::unescape(&escaped).ok()?);
}
}
Event::Eof => return stack.is_empty().then_some(out),
_ => {}
}
}
}
pub fn parse_project(xml: &str) -> Option<Project> {
let tags = elements(xml)?;
if tags.first()?.name != "Project" {
return None;
}
let property = |name: &str| tags.iter().rev().find(|t| t.name == name && !t.conditional).map(|t| t.text.trim());
let web = tags.iter().any(|t| {
t.attrs
.get("Sdk")
.or_else(|| (t.name == "Sdk").then(|| t.attrs.get("Name")).flatten())
.is_some_and(|s| s.split(';').any(|sdk| sdk.split('/').next() == Some("Microsoft.NET.Sdk.Web")))
});
let executable = match property("OutputType") {
Some(output) => output.eq_ignore_ascii_case("exe") || output.eq_ignore_ascii_case("winexe"),
None => web,
};
let frameworks = property("TargetFramework")
.filter(|v| !v.is_empty())
.or_else(|| property("TargetFrameworks"))
.unwrap_or_default()
.split(';')
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_owned)
.collect();
let test = tags.iter().any(|t| {
t.name == "PackageReference"
&& t.attrs
.get("Include")
.or_else(|| t.attrs.get("Update"))
.is_some_and(|v| v.eq_ignore_ascii_case("Microsoft.NET.Test.Sdk"))
});
Some(Project { executable, frameworks, test })
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase", default)]
pub struct Profile {
pub command_name: String,
pub command_line_args: String,
pub working_directory: String,
pub application_url: String,
pub environment_variables: BTreeMap<String, String>,
}
pub fn parse_profiles(json: &str) -> BTreeMap<String, Profile> {
let Ok(json) = serde_json::from_str::<serde_json::Value>(&ide_core::jsonc::strip(json)) else {
return BTreeMap::new();
};
json["profiles"]
.as_object()
.into_iter()
.flatten()
.filter_map(|(name, value)| {
let profile: Profile = serde_json::from_value(value.clone()).ok()?;
(profile.command_name == "Project").then(|| (name.clone(), profile))
})
.collect()
}
pub fn solution_projects(src: &str, xml: bool) -> Vec<PathBuf> {
let paths: Vec<String> = if xml {
elements(src)
.unwrap_or_default()
.into_iter()
.filter(|e| e.name == "Project")
.filter_map(|mut e| e.attrs.remove("Path"))
.collect()
} else {
src.lines()
.filter_map(|line| {
let line = line.trim();
if !line.starts_with("Project(") {
return None;
}
Some(line.split_once('=')?.1.split('"').nth(3)?.to_owned())
})
.collect()
};
paths
.into_iter()
.filter(|p| p.to_ascii_lowercase().ends_with(".csproj"))
.map(|p| PathBuf::from(p.replace('\\', "/")))
.collect()
}
pub(crate) fn profiles(project: &Path) -> BTreeMap<String, Profile> {
project
.parent()
.and_then(|p| fs::read_to_string(p.join("Properties/launchSettings.json")).ok())
.map(|s| parse_profiles(&s))
.unwrap_or_default()
}
/// Resolves the selected profile afresh so changing project/profile never reuses stale values.
pub fn resolve(root: &Path, mut config: RunConfig) -> IdeResult<RunConfig> {
let params = &config.extras.params;
let project = params
.get("project")
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| IdeError::invalid("Select a .NET project"))?;
let project = dunce::canonicalize(root.join(project))?;
let dir = project.parent().ok_or_else(|| IdeError::invalid("Invalid .NET project path"))?;
let profile_name = params.get("launchProfile").cloned().unwrap_or_default();
let profile = if profile_name.is_empty() {
Profile::default()
} else {
profiles(&project).remove(&profile_name).ok_or_else(|| {
IdeError::invalid(format!(".NET launch profile '{profile_name}' is missing or is not a Project profile"))
})?
};
let mut env = profile.environment_variables;
if !profile.application_url.is_empty() {
env.insert("ASPNETCORE_URLS".into(), profile.application_url);
}
if !profile_name.is_empty() {
env.insert("DOTNET_LAUNCH_PROFILE".into(), profile_name);
}
env.extend(config.env);
config.env = env;
config.cwd = Some(
config
.cwd
.map(|p| root.join(&*p))
.unwrap_or_else(|| {
if profile.working_directory.is_empty() { dir.into() } else { dir.join(&profile.working_directory) }
})
.into(),
);
config.extras.params.insert("project".into(), project.to_string_lossy().into_owned());
config.extras.params.insert("profileArgs".into(), profile.command_line_args);
Ok(config)
}
/// Queries the evaluated output path, including AssemblyName, OutputPath and imported properties.
pub async fn target_path(root: &Path, config: &RunConfig) -> IdeResult<String> {
let params = &config.extras.params;
let project = params.get("project").ok_or_else(|| IdeError::invalid("Select a .NET project"))?;
let mut args = vec![
"msbuild".to_owned(),
root.join(project).to_string_lossy().into_owned(),
"-nologo".into(),
"-getProperty:TargetPath".into(),
"-p:Configuration=Debug".into(),
];
if let Some(framework) = params.get("framework").filter(|s| !s.is_empty()) {
args.push(format!("-p:TargetFramework={framework}"));
}
let mut command = std::process::Command::new(&config.command);
ide_core::process::host_env(&mut command);
command.args(args).current_dir(root);
if !config.extras.include_system_env {
command.env_clear();
} else {
command.envs(ide_core::process::shell_env());
}
command.envs(crate::resolved_env(root, config)?);
let mut command = tokio::process::Command::from(command);
command.kill_on_drop(true);
let output = tokio::time::timeout(std::time::Duration::from_secs(30), command.output())
.await
.map_err(|_| IdeError::unavailable(".NET output resolution timed out"))??;
if !output.status.success() {
return Err(IdeError::invalid(format!(
".NET output resolution failed: {}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)));
}
let target = String::from_utf8_lossy(&output.stdout).trim().to_owned();
if target.is_empty() || !Path::new(&target).is_file() {
return Err(IdeError::invalid(".NET build output is missing; run dotnet build before debugging"));
}
Ok(target)
}
-10
View File
@@ -2,7 +2,6 @@
mod config;
mod detect;
pub mod dotnet;
pub mod envfile;
pub mod go;
pub mod gradle_jvm;
@@ -202,15 +201,6 @@ pub fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<RunConfig
detect::detect_excluding(root, excluded_poms)
}
/// Detects configurations with explicit CMake profiles, or legacy discovery when absent.
pub fn detect_with_profiles(
root: &Path,
excluded_poms: &[PathBuf],
profiles: Option<&[ide_core::settings::CMakeProfile]>,
) -> Vec<RunConfig> {
detect::detect_with_profiles(root, excluded_poms, profiles)
}
/// Reads `<root>/.openidea/run.toml`; a missing file yields no configs.
pub fn load_user_configs(root: &Path) -> IdeResult<Vec<RunConfig>> {
config::load(root)
-69
View File
@@ -652,72 +652,3 @@ fn go_default_build_output_is_ignored_by_version_control() {
let ignore = fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap();
assert!(ignore.lines().any(|l| l == "/bin/"), "{ignore}");
}
#[test]
fn cmake_profiles_control_commands_names_and_environment() {
use ide_core::settings::CMakeProfile;
let dir = project(&[("CMakeLists.txt", "project(app)")]);
for generator in ["", "Ninja", "Unix Makefiles", "Ninja Multi-Config", "Visual Studio 17 2022"] {
let profile = CMakeProfile {
name: "Production".into(),
build_type: "Release".into(),
generator: generator.into(),
cmake_args: ["-DFEATURE=ON", "-DMESSAGE=two words", "-DQUOTE=\"hello world\"", ""].map(String::from).into(),
build_dir: "output dir".into(),
build_args: ["-j", "8", "--", "VERBOSE=1"].map(String::from).into(),
env: [("MY_ENV".into(), "yes".into())].into(),
};
let configs = ide_run::detect_with_profiles(dir.path(), &[], Some(std::slice::from_ref(&profile)));
let configure = find(&configs, "cmake:profile:0:configure");
let mut expected = vec!["-S", ".", "-B", "output dir"];
if !generator.is_empty() {
expected.extend(["-G", generator]);
}
expected.extend([
"-DCMAKE_BUILD_TYPE=Release",
"-DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
"-DFEATURE=ON",
"-DMESSAGE=two words",
"-DQUOTE=\"hello world\"",
"",
]);
assert_eq!(configure.args, expected);
assert_eq!(configure.name, "CMake Configure (output dir)");
let build = find(&configs, "cmake:profile:0:build");
assert_eq!(build.args, ["--build", "output dir", "--config", "Release", "-j", "8", "--", "VERBOSE=1"]);
assert_eq!(build.extras.before_launch[0].target, configure.id);
assert!(configs.iter().all(|c| c.env == profile.env));
let configs = ide_run::detect_with_profiles(dir.path(), &[], Some(&[CMakeProfile::default(), profile]));
assert!(find(&configs, "cmake:profile:0:build").name.ends_with("[Debug]"));
assert!(find(&configs, "cmake:profile:1:build").name.ends_with("[Production]"));
assert_eq!(find(&configs, "cmake:profile:0:build").args[1], "cmake-build-debug");
}
assert_eq!(ide_run::detect_with_profiles(dir.path(), &[], None), detect(dir.path()));
assert!(ide_run::detect_with_profiles(dir.path(), &[], Some(&[])).is_empty());
}
#[test]
fn cmake_profile_build_configures_only_without_a_cache() {
use ide_core::settings::CMakeProfile;
let root = project(&[("CMakeLists.txt", "project(app)")]);
for dir in ["", "custom output"] {
let profile = CMakeProfile { build_dir: dir.into(), generator: "Ninja".into(), ..Default::default() };
let build_dir = root.path().join(if dir.is_empty() { "cmake-build-debug" } else { dir });
for cached in [false, true] {
if cached {
fs::create_dir_all(&build_dir).unwrap();
// Generator changes are handled by an explicit Configure, not every Build.
fs::write(build_dir.join("CMakeCache.txt"), "CMAKE_GENERATOR:INTERNAL=Unix Makefiles\n").unwrap();
}
let configs = ide_run::detect_with_profiles(root.path(), &[], Some(std::slice::from_ref(&profile)));
let build = find(&configs, "cmake:profile:0:build");
if cached {
assert!(build.extras.before_launch.is_empty());
} else {
assert_eq!(build.extras.before_launch.len(), 1);
assert_eq!(build.extras.before_launch[0].target, "cmake:profile:0:configure");
}
assert_eq!(find(&configs, "cmake:profile:0:test").extras.before_launch[0].target, build.id);
}
}
}
-138
View File
@@ -1,138 +0,0 @@
use ide_run::{
detect,
dotnet::{parse_profiles, parse_project, resolve, solution_projects},
};
use std::fs;
use std::path::PathBuf;
const CONSOLE: &str = r#"<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFrameworks>net10.0; net9.0</TargetFrameworks></PropertyGroup></Project>"#;
const PROFILES: &str = r#"{
// Only Project profiles can be run with dotnet.
"profiles": {
"http": {"commandName":"Project", "applicationUrl":"http://localhost:5165", "commandLineArgs":"--message \"hello world\"", "workingDirectory":"work", "environmentVariables":{"DEMO":"profile"}},
"https": {"commandName":"Project", "environmentVariables":{"DEMO":"https"}},
"IIS Express": {"commandName":"IISExpress"},
"external": {"commandName":"Executable"},
"broken": {"commandName":"Project", "environmentVariables":7}
}
}"#;
fn write(root: &std::path::Path, file: &str, src: &str) {
let path = root.join(file);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, src).unwrap();
}
#[test]
fn project_properties_and_sdk_defaults() {
let p = parse_project(CONSOLE).unwrap();
assert!(p.executable && !p.test);
assert_eq!(p.frameworks, ["net10.0", "net9.0"]);
for (sdk, output, executable) in [
("Microsoft.NET.Sdk", "", false),
("Microsoft.NET.Sdk", "<OutputType>WinExe</OutputType>", true),
("Microsoft.NET.Sdk.Web", "", true),
("Microsoft.NET.Sdk.Web", "<OutputType>Library</OutputType>", false),
] {
let xml = format!(
r#"<Project Sdk="{sdk}"><PropertyGroup>{output}<TargetFramework>net10.0</TargetFramework></PropertyGroup></Project>"#
);
let p = parse_project(&xml).unwrap();
assert_eq!(p.executable, executable, "{xml}");
assert_eq!(p.frameworks, ["net10.0"]);
}
let p = parse_project(r#"<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"><Sdk Name="Microsoft.NET.Sdk.Web"/><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup><PropertyGroup Condition="'$(Configuration)'=='Release'"><OutputType>Library</OutputType></PropertyGroup><ItemGroup><PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0" /></ItemGroup></Project>"#).unwrap();
assert!(p.executable && p.test);
assert!(parse_project("<Project><PropertyGroup>").is_none());
assert!(parse_project("garbage").is_none());
assert!(
parse_project(
r#"<Project><ItemGroup><PackageReference Update="microsoft.net.test.sdk" /></ItemGroup></Project>"#
)
.unwrap()
.test
);
assert!(
!parse_project(r#"<Project><!-- <PackageReference Include="Microsoft.NET.Test.Sdk"/> --></Project>"#)
.unwrap()
.test
);
}
#[test]
fn profiles_filter_and_preserve_launch_properties() {
let p = parse_profiles(PROFILES);
assert_eq!(p.keys().map(String::as_str).collect::<Vec<_>>(), ["http", "https"]);
assert_eq!(p["http"].application_url, "http://localhost:5165");
assert_eq!(p["http"].environment_variables["DEMO"], "profile");
assert_eq!(p["http"].command_line_args, "--message \"hello world\"");
assert_eq!(p["http"].working_directory, "work");
assert!(parse_profiles("invalid").is_empty());
}
#[test]
fn solution_paths_support_windows_separators_folders_and_entities() {
let sln = "Project(\"{guid}\") = \"App\", \"src\\App\\App.csproj\", \"{guid}\"\nEndProject\nProject(\"{folder}\") = \"Folder\", \"Folder\", \"{guid}\"\nProject(\"{guid}\") = \"External\", \"..\\other\\Other.csproj\", \"{guid}\"";
assert_eq!(
solution_projects(sln, false),
[PathBuf::from("src/App/App.csproj"), PathBuf::from("../other/Other.csproj")]
);
let slnx = r#"<Solution><Folder Name="/src/"><Project Path="src\A&amp;B\App.csproj" /></Folder><Project Path="other.vbproj" /></Solution>"#;
assert_eq!(solution_projects(slnx, true), [PathBuf::from("src/A&B/App.csproj")]);
assert!(solution_projects("<Solution><Project", true).is_empty());
}
#[test]
fn detection_deduplicates_projects_and_generates_commands() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
write(root, "src/App/App.csproj", CONSOLE);
write(root, "src/App/Properties/launchSettings.json", PROFILES);
write(
root,
"demo.slnx",
r#"<Solution><Project Path="src/App/App.csproj"/><Project Path="../external/Missing.csproj"/></Solution>"#,
);
write(
root,
"src/Test/Test.csproj",
r#"<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup><ItemGroup><PackageReference Include="Microsoft.NET.Test.Sdk"/></ItemGroup></Project>"#,
);
write(root, "bin/Ignored.csproj", CONSOLE);
write(root, "obj/Ignored.csproj", CONSOLE);
write(root, "node_modules/Ignored.csproj", CONSOLE);
write(root, "Lib.csproj", "<Project Sdk=\"Microsoft.NET.Sdk\" />");
let configs = detect(root);
assert_eq!(configs.len(), 4);
let app = configs.iter().find(|c| c.extras.params.get("launchProfile").is_some_and(|p| p == "http")).unwrap();
assert_eq!(
app.args,
["run", "--project", "src/App/App.csproj", "--framework", "net10.0", "--launch-profile", "http"]
);
assert_eq!(app.debug_adapter.as_deref(), Some("netcoredbg"));
assert_eq!(app.extras.before_launch[0].kind, "dotnetBuild");
let test = configs.iter().find(|c| c.is_test).unwrap();
assert_eq!(test.kind, "dotnet-test");
assert_eq!(test.args, ["test", "src/Test/Test.csproj", "--framework", "net10.0"]);
assert!(test.debug_adapter.is_none());
let mut edited = app.clone();
edited.env.insert("DEMO".into(), "override".into());
let resolved = resolve(root, edited.clone()).unwrap();
assert_eq!(resolved.env["DEMO"], "override");
assert_eq!(resolved.env["ASPNETCORE_URLS"], "http://localhost:5165");
assert!(resolved.cwd.unwrap().ends_with("src/App/work"));
edited.extras.params.insert("launchProfile".into(), "https".into());
let resolved = resolve(root, edited).unwrap();
assert!(!resolved.env.contains_key("ASPNETCORE_URLS"));
assert!(resolved.cwd.unwrap().ends_with("src/App"));
}
#[test]
fn solution_references_can_reach_projects_outside_the_open_directory() {
let temp = tempfile::tempdir().unwrap();
write(temp.path(), "external/App.csproj", CONSOLE);
write(temp.path(), "open/demo.sln", "Project(\"{guid}\") = \"App\", \"..\\external\\App.csproj\", \"{guid}\"");
let configs = detect(&temp.path().join("open"));
assert_eq!(configs.len(), 1);
assert!(configs[0].args[2].ends_with("external/App.csproj"));
}
+5 -17
View File
@@ -78,7 +78,7 @@ fn jdk_setting(root: &Path, value: &str) -> Option<Result<crate::java::Jdk, Path
/// The project JDK (`languages.java.jdk`), else the JDK the project pins for mise / asdf.
pub fn project_jdk(root: &Path, s: &Settings) -> Option<crate::java::Jdk> {
match jdk_setting(root, &s.languages.java.jdk) {
Some(jdk) => jdk.inspect_err(|p| tracing::warn!("project JDK {}", not_a_jdk(p))).ok(),
Some(jdk) => jdk.inspect_err(|p| tracing::warn!("project JDK {} is not a JDK home", p.display())).ok(),
None => pinned::pinned_install(root, Tool::Java).and_then(|(dir, _)| pinned::jdk_in_install(&dir)),
}
}
@@ -88,23 +88,11 @@ 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 {} (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())
Some(Err(p)) => Err(format!(
"The language server JDK {} is not a JDK home (Settings › Languages & Frameworks › Java).",
p.display()
)),
}
}
+33 -213
View File
@@ -16,13 +16,6 @@ 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="))?;
@@ -206,7 +199,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 && ide_core::java_runtime::supports_java_bundles(&j.home))
jdks.iter().find(|j| j.major >= min_java)
}
/// The Java version the jdtls that `cfg` launches needs: the highest any of its
@@ -320,36 +313,8 @@ 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
@@ -402,14 +367,6 @@ 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
@@ -428,9 +385,6 @@ 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)),
};
@@ -440,10 +394,6 @@ 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))) {
@@ -587,19 +537,6 @@ 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();
@@ -626,13 +563,8 @@ 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\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
std::fs::write(project_home.join("release"), "JAVA_VERSION=\"21.0.1\"\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();
@@ -640,13 +572,8 @@ 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\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
std::fs::write(home.join("release"), "JAVA_VERSION=\"25.0.1\"\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));
@@ -665,47 +592,11 @@ 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 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 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 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();
@@ -715,7 +606,7 @@ mod tests {
"name": "JavaSE-21", "path": pick_jdk(&installed, None).unwrap().home, "default": true
}])
);
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("custom-server-runtime").to_string_lossy());
assert_eq!(cfg.env["JAVA_HOME"], "/custom-server-runtime");
}
#[test]
@@ -728,16 +619,15 @@ 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 = [
test_jdk(fixtures.path(), 25, "/jdk25"),
test_jdk(fixtures.path(), 21, "/jdk21a"),
test_jdk(fixtures.path(), 21, "/jdk21b"),
test_jdk(fixtures.path(), 8, "/jdk8"),
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") },
];
let bundles = [PathBuf::from("/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar")];
configure_jdtls(
@@ -757,22 +647,13 @@ 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": 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!(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!(opts["bundles"], json!(["/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar"]));
assert_eq!(
cfg.env["JAVA_HOME"],
fixtures.path().join("jdk25").to_string_lossy(),
"first JDK new enough to run jdtls"
);
assert_eq!(cfg.env["JAVA_HOME"], "/jdk25", "first JDK new enough to run jdtls");
assert_eq!(cfg.launch_error, None);
// Deterministic (reconfigure compares configs) and idempotent.
@@ -791,7 +672,7 @@ mod tests {
assert_eq!(cfg, before);
// The project JDK is the default runtime, and runs the server when new enough.
let project = test_jdk(fixtures.path(), 21, "/jdk21b");
let project = Jdk { major: 21, home: PathBuf::from("/jdk21b") };
let mut cfg: LanguageServerConfig = toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap();
configure_jdtls(
&mut cfg,
@@ -806,8 +687,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": fixtures.path().join("jdk21b"), "default": true }));
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("jdk21b").to_string_lossy());
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": "/jdk21b", "default": true }));
assert_eq!(cfg.env["JAVA_HOME"], "/jdk21b");
let mut other: LanguageServerConfig = toml::from_str("name = \"pyright\"\ncommand = \"x\"").unwrap();
configure_jdtls(
@@ -824,54 +705,6 @@ 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() };
@@ -914,9 +747,8 @@ 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 = [test_jdk(fixtures.path(), 17, "/jdk17"), test_jdk(fixtures.path(), 8, "/jdk8")];
let old = [Jdk { major: 17, home: PathBuf::from("/jdk17") }, Jdk { major: 8, home: PathBuf::from("/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"));
@@ -935,7 +767,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(), test_jdk(fixtures.path(), 21, "/jdk21")];
let mixed = [old[0].clone(), Jdk { major: 21, home: PathBuf::from("/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.
@@ -948,12 +780,11 @@ 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 = [
test_jdk(fixtures.path(), 21, "/jdk21"),
test_jdk(fixtures.path(), 17, "/jdk17"),
test_jdk(fixtures.path(), 8, "/jdk8"),
Jdk { major: 21, home: PathBuf::from("/jdk21") },
Jdk { major: 17, home: PathBuf::from("/jdk17") },
Jdk { major: 8, home: PathBuf::from("/jdk8") },
];
let mut cfg = jdtls();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &installed, None, None, 25, &[], None);
@@ -962,11 +793,12 @@ 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(), &[test_jdk(fixtures.path(), 25, "/tools/jdks/temurin-25")]].concat();
let downloaded =
[installed.as_slice(), &[Jdk { major: 25, home: PathBuf::from("/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"], fixtures.path().join("tools/jdks/temurin-25").to_string_lossy());
assert_eq!(cfg.env["JAVA_HOME"], Path::new("/tools/jdks/temurin-25").to_string_lossy());
}
#[test]
@@ -1003,9 +835,8 @@ 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| test_jdk(fixtures.path(), major, home);
let jdk = |major: u32, home: &str| Jdk { major, home: PathBuf::from(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| {
@@ -1026,35 +857,24 @@ 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"].clone(), cfg.launch_error),
(fixtures.path().join("jdk21").to_string_lossy().into_owned(), None)
);
assert_eq!((cfg.env["JAVA_HOME"].as_str(), cfg.launch_error), ("/jdk21", 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": fixtures.path().join("jdk17"), "default": true }),
"still compiles on 17"
);
assert_eq!(rt[0], json!({ "name": "JavaSE-17", "path": "/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"], fixtures.path().join("jdk25").to_string_lossy());
assert_eq!(run(None, JDTLS_MIN_JAVA).env["JAVA_HOME"], "/jdk25");
// 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"], fixtures.path().join("opt/own-jdk").to_string_lossy());
assert_eq!(cfg.env["JAVA_HOME"], "/opt/own-jdk");
// Too old for this jdtls: the user's choice is not overridden, it is explained.
let old = jdk(21, "/jdk21");
let cfg = run(Some(&old), 25);
let cfg = run(Some(&jdk(21, "/jdk21")), 25);
assert!(!cfg.env.contains_key("JAVA_HOME"));
let err = cfg.launch_error.unwrap();
// 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("/jdk21") && err.contains("JDK 25 or newer"), "{err}");
assert!(err.contains(SERVER_JDK_SETTING), "both messages point at the same setting: {err}");
}
+1 -19
View File
@@ -249,10 +249,6 @@ 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();
}
@@ -469,12 +465,7 @@ 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\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
std::fs::write(jdk.join("bin/javac"), "").unwrap();
std::fs::write(jdk.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap();
std::fs::write(jdk.join("bin/java"), "").unwrap();
let cfg = |command: &str| -> LanguageServerConfig {
@@ -495,15 +486,6 @@ 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))
+2 -18
View File
@@ -166,12 +166,7 @@ fn run_configs_use_selected_interpreter_and_package_manager() {
fn fake_jdk(dir: &Path) {
std::fs::create_dir_all(dir.join("bin")).unwrap();
std::fs::write(dir.join("bin").join(exe("java")), "").unwrap();
std::fs::write(
dir.join("release"),
"JAVA_VERSION=\"17.0.9\"\nMODULES=\"java.base jdk.compiler jdk.jdi jdk.unsupported\"\n",
)
.unwrap();
std::fs::write(dir.join("bin").join(crate::exe("javac")), "").unwrap();
std::fs::write(dir.join("release"), "JAVA_VERSION=\"17.0.9\"\n").unwrap();
}
#[test]
@@ -312,20 +307,9 @@ 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.
std::fs::create_dir(tmp.path().join("not-a-jdk")).unwrap();
s.languages.java.jdtls_jdk = "not-a-jdk".into();
s.languages.java.jdtls_jdk = "missing".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]
+1 -19
View File
@@ -53,8 +53,6 @@ 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>>,
@@ -85,7 +83,6 @@ impl TerminalManager {
pub fn spawn(
&self,
options: TerminalOptions,
key: String,
sink: SharedSink,
on_output: Box<dyn Fn(String) + Send>,
) -> IdeResult<TerminalInfo> {
@@ -124,7 +121,6 @@ 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),
@@ -164,15 +160,6 @@ 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);
@@ -191,10 +178,6 @@ 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();
@@ -649,7 +632,6 @@ mod tests {
.manager
.spawn(
opts,
"test".into(),
h.sink.clone(),
Box::new(move |s| {
let _ = tx.send(s);
@@ -807,7 +789,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".into(), other_sink, Box::new(|_| {})).expect("spawn");
let theirs = h.manager.spawn(sh("exit 2"), 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:?}"),
+1 -3
View File
@@ -21,8 +21,7 @@ pub enum ArchiveKind {
impl ArchiveKind {
pub fn from_name(name: &str) -> Self {
let n = name.to_ascii_lowercase();
// JetBrains' macOS .sit distributions are zip archives.
if n.ends_with(".zip") || n.ends_with(".vsix") || n.ends_with(".sit") {
if n.ends_with(".zip") || n.ends_with(".vsix") {
Self::Zip
} else if n.ends_with(".tar.gz") || n.ends_with(".tgz") {
Self::TarGz
@@ -394,7 +393,6 @@ 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);
+38 -96
View File
@@ -13,30 +13,20 @@ pub fn find_up(file: &Path, relative: &Path) -> Option<PathBuf> {
file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file())
}
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);
fn package_bin(package: &Path) -> IdeResult<PathBuf> {
let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?)
.map_err(|e| IdeError::invalid(format!("{name}: {e}")))?;
.map_err(|e| IdeError::invalid(format!("Prettier: {e}")))?;
let bin = json["bin"]
.as_str()
.or_else(|| json["bin"][tool].as_str())
.ok_or_else(|| IdeError::invalid(format!("{name}: package has no CLI entry point")))?;
.or_else(|| json["bin"]["prettier"].as_str())
.ok_or_else(|| IdeError::invalid("Prettier: package has no CLI entry point"))?;
let path = package.join(bin);
if !path.is_file() {
return Err(IdeError::unavailable(format!("{name}: CLI does not exist: {}", path.display())));
return Err(IdeError::unavailable(format!("Prettier: 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> {
@@ -44,26 +34,22 @@ impl Tools {
}
fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
if crate::registry::find(tool).is_none_or(|t| t.kind != crate::ToolKind::Formatter) {
if tool != "prettier" {
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, tool)?, true)
(package_bin(custom)?, 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, &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 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 {
(self.which(tool).ok_or_else(|| IdeError::missing_tool(tool))?, false)
(self.which("prettier").ok_or_else(|| IdeError::missing_tool("prettier"))?, false)
};
if node {
let name = display_name(tool);
let node =
self.which("node").ok_or_else(|| IdeError::unavailable(format!("{name}: Node.js is required")))?;
let node = self.which("node").ok_or_else(|| IdeError::unavailable("Prettier: Node.js is required"))?;
let mut cmd = self.command(node);
cmd.arg(path);
Ok(cmd)
@@ -73,40 +59,34 @@ impl Tools {
}
}
/// 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.
/// Project configuration overrides these defaults; Prettier resolves config and plugins itself.
pub fn arguments(
tool: &str,
config: &FormatterConfig,
file: &Path,
tab_size: u32,
insert_spaces: bool,
range: Option<(u32, u32)>,
) -> Vec<OsString> {
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),
);
}
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();
args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]);
if supports_range(tool, config)
if config.range
&& let Some((start, end)) = range
{
args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]);
@@ -120,8 +100,8 @@ pub fn arguments(
args
}
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))
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"))
}
#[cfg(test)]
@@ -138,7 +118,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("prettier", custom, "text", &Default::default()).unwrap_err();
let failure = run(custom, "text", &Default::default()).unwrap_err();
assert_eq!(failure.data, Some(ErrorData::Tool { tool: "prettier".into(), missing: false }));
}
@@ -147,7 +127,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("prettier", command, "", &Default::default()).unwrap_err();
let failure = run(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 }));
}
@@ -181,8 +161,7 @@ mod tests {
("vue", true),
] {
let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range };
let args =
arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9)));
let args = arguments(&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"]));
@@ -192,41 +171,4 @@ 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);
}
}
+49 -57
View File
@@ -108,7 +108,26 @@ 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 {
let image = "jdk";
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();
format!(
"{ADOPTIUM_API}/assets/latest/{feature}/hotspot?architecture={}&image_type={image}&os={}&vendor=eclipse",
adoptium_arch(platform.arch),
@@ -171,12 +190,16 @@ 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 == "jdk")
a.binary.image_type.as_deref().is_none_or(|t| t == image.name())
&& 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)
@@ -226,10 +249,6 @@ 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 {
@@ -264,8 +283,8 @@ impl Tools {
self.download_java(vendor, feature, location, false, cancel, emit)
}
/// Downloads a full JDK for a language server and its bundles (java-debug
/// needs `jdk.jdi`). Kept outside project JDK discovery roots.
/// Downloads a runtime for a language server, preferring a JRE. Only a
/// platform without a JRE build falls back to a full JDK.
pub fn download_java_runtime(
&self,
feature: u32,
@@ -291,8 +310,19 @@ 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 =
adoptium_pick(&http.get_text(&adoptium_assets_url(feature, self.platform), cancel)?, self.platform)?;
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)?;
}
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 };
@@ -306,7 +336,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) = usable_java_home(&target, self.platform, runtime) {
if let Some(home) = find_jdk_home(&target, self.platform) {
emit(InstallEvent::Line { text: format!("{} is already installed", target.display()) });
return Ok(done(home));
}
@@ -321,7 +351,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 = usable_java_home(&pkg, self.platform, runtime)
let home = find_jdk_home(&pkg, self.platform)
.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() {
@@ -332,7 +362,7 @@ impl Tools {
}
std::fs::rename(unpacked_root(&pkg, &home), &target)?;
clear_quarantine(&target);
usable_java_home(&target, self.platform, runtime)
find_jdk_home(&target, self.platform)
.ok_or_else(|| IdeError::new(ErrorKind::Io, format!("no JDK found in {}", target.display())))
})();
let _ = std::fs::remove_dir_all(&staging);
@@ -407,56 +437,18 @@ mod tests {
}
#[test]
fn runtime_assets_require_jdk_images() {
fn runtime_assets_use_jre_without_changing_jdk_downloads() {
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!(adoptium_pick(&format!("[{jre},{jdk}]"), platform).unwrap().is_some());
assert!(pick_image(&format!("[{jre}]"), platform, ImageType::Jre).unwrap().is_some());
assert!(adoptium_pick(&format!("[{jre}]"), platform).unwrap().is_none());
}
}
#[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");
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());
}
}
+10 -85
View File
@@ -134,28 +134,6 @@ 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",
@@ -636,25 +614,15 @@ 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"],
},
@@ -987,8 +955,12 @@ mod tests {
known.insert((d.adapter.clone(), d.command.clone()));
}
}
// The host selects the formatter of each file (Prettier or oxfmt).
for t in TOOLS.iter().filter(|t| !matches!(t.kind, Plugin | Formatter)) {
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) {
assert!(
known.contains(&(t.name.to_string(), t.commands[0].to_string())),
"{} / {} is not referenced by languages/*.toml",
@@ -1016,51 +988,4 @@ mod tests {
}
}
}
/// The v263 kotlin-server-* release notes: the CLI distribution archives plus
/// the VS Code extension and checksums; each platform must pick its native
/// distribution and the aarch64 archive must never be picked on x64.
#[test]
fn kotlin_lsp_picks_the_native_archive_for_each_platform() {
use crate::platform::{Platform, select_asset};
let names = [
"kotlin-server-0.0.27-mac-amd64.vsix",
"kotlin-server-0.0.27-mac-amd64.vsix.sha256",
"kotlin-server-0.0.27-mac-aarch64.vsix",
"kotlin-server-0.0.27-mac-aarch64.vsix.sha256",
"kotlin-server-0.0.27-linux-amd64.vsix",
"kotlin-server-0.0.27-linux-amd64.vsix.sha256",
"kotlin-server-0.0.27-linux-aarch64.vsix",
"kotlin-server-0.0.27-linux-aarch64.vsix.sha256",
"kotlin-server-0.0.27-win-amd64.vsix",
"kotlin-server-0.0.27-win-amd64.vsix.sha256",
"kotlin-server-0.0.27-win-aarch64.vsix",
"kotlin-server-0.0.27-win-aarch64.vsix.sha256",
"kotlin-server-263.7084.0.sit",
"kotlin-server-263.7084.0.sit.sha256",
"kotlin-server-263.7084.0-aarch64.sit",
"kotlin-server-263.7084.0-aarch64.sit.sha256",
"kotlin-server-263.7084.0.tar.gz",
"kotlin-server-263.7084.0.tar.gz.sha256",
"kotlin-server-263.7084.0-aarch64.tar.gz",
"kotlin-server-263.7084.0-aarch64.tar.gz.sha256",
"kotlin-server-263.7084.0.win.zip",
"kotlin-server-263.7084.0.win.zip.sha256",
"kotlin-server-263.7084.0-aarch64.win.zip",
"kotlin-server-263.7084.0-aarch64.win.zip.sha256",
];
let rules = find("kotlin-lsp").unwrap().asset_rules();
let mac_arm = Platform { os: Mac, arch: Arm64 };
assert_eq!(select_asset(&names, rules, mac_arm), Some("kotlin-server-263.7084.0-aarch64.sit"));
let mac_x64 = Platform { os: Mac, arch: X64 };
assert_eq!(select_asset(&names, rules, mac_x64), Some("kotlin-server-263.7084.0.sit"));
let linux_x64 = Platform { os: Linux, arch: X64 };
assert_eq!(select_asset(&names, rules, linux_x64), Some("kotlin-server-263.7084.0.tar.gz"));
let linux_arm = Platform { os: Linux, arch: Arm64 };
assert_eq!(select_asset(&names, rules, linux_arm), Some("kotlin-server-263.7084.0-aarch64.tar.gz"));
let win_x64 = Platform { os: Windows, arch: X64 };
assert_eq!(select_asset(&names, rules, win_x64), Some("kotlin-server-263.7084.0.win.zip"));
let win_arm = Platform { os: Windows, arch: Arm64 };
assert_eq!(select_asset(&names, rules, win_arm), Some("kotlin-server-263.7084.0-aarch64.win.zip"));
}
}
+2 -2
View File
@@ -19,8 +19,8 @@ fn format(
};
let mut cmd = tools.formatter_command("prettier", path, None)?;
cmd.current_dir(path.parent().unwrap());
cmd.args(formatter::arguments("prettier", &config, path, 4, true, range));
formatter::run("prettier", cmd, text, &CancellationToken::new())
cmd.args(formatter::arguments(&config, path, 4, true, range));
formatter::run(cmd, text, &CancellationToken::new())
}
#[test]
+9 -22
View File
@@ -89,28 +89,15 @@ pub(crate) fn blame(
return Ok(uncommitted_lines(line_count));
}
// Restricted blame reads committed blobs only. Feeding working-tree bytes to
// Git's --contents path would run configured clean filters, so it blames the
// revision plain. Git learned to combine `--contents` with a revision in 2.41;
// older releases reject the pair (`fatal: cannot use --contents with final
// commit object name`) and take the same plain path — the edited lines are
// marked uncommitted below either way (issue #755).
let contents_args = ["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel];
let plain_args: &[&str] = if crate::execution_allowed() {
&["--literal-pathspecs", "blame", "--porcelain", revision, "--", &rel]
// Git's --contents path would run configured clean filters.
let out = if crate::execution_allowed() {
run(
r.root(),
&["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel],
Some(&contents),
)?
} else {
&["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel]
};
let (out, contents_blame) = if crate::execution_allowed() {
let out = cli::run_raw(r.root(), &contents_args, Some(&contents))?;
if out.code == Some(0) {
(out, true)
} else if String::from_utf8_lossy(&out.stderr).contains("cannot use --contents") {
(run(r.root(), plain_args, None)?, false)
} else {
return Err(cli::failure(&contents_args, &out.stderr, &out.stdout, out.code));
}
} else {
(run(r.root(), plain_args, None)?, false)
run(r.root(), &["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel], None)?
};
let mut lines = crate::blame::parse_porcelain(&out.stdout_str());
for line in &mut lines {
@@ -119,7 +106,7 @@ pub(crate) fn blame(
line.summary = String::new();
}
}
if !contents_blame {
if !crate::execution_allowed() {
let base = r.file_at(path, Some(revision), encoding)?.unwrap_or_default();
for hunk in crate::diff::line_hunks(&base, &text).into_iter().rev() {
let start = (hunk.old_start as usize).min(lines.len());
-1
View File
@@ -15,7 +15,6 @@ serde_json.workspace = true
specta.workspace = true
toml.workspace = true
toml_edit.workspace = true
semver = "1.0.28"
parking_lot.workspace = true
blake3.workspace = true
tracing.workspace = true
+9 -47
View File
@@ -23,26 +23,16 @@ pub struct InvalidateCachesOptions {
const MARKER: &str = "invalidate-caches.json";
/// Data-directory folders removed for `options`.
pub fn cache_dirs(data_dir: &Path, local_data_dir: &Path, options: &InvalidateCachesOptions) -> Vec<PathBuf> {
let mut dirs = vec![local_data_dir.join("index")];
pub fn cache_dirs(data_dir: &Path, options: &InvalidateCachesOptions) -> Vec<PathBuf> {
let mut dirs = vec![data_dir.join("index")];
if options.local_history {
dirs.push(data_dir.join("history"));
}
if options.language_servers {
dirs.push(local_data_dir.join("lsp"));
dirs.push(data_dir.join("lsp"));
}
if options.build_caches {
dirs.push(local_data_dir.join("cache"));
}
// Also invalidate pre-migration caches so a later start cannot revive them.
if data_dir != local_data_dir {
dirs.push(data_dir.join("index"));
if options.language_servers {
dirs.push(data_dir.join("lsp"));
}
if options.build_caches {
dirs.push(data_dir.join("cache"));
}
dirs.push(data_dir.join("cache"));
}
dirs
}
@@ -67,11 +57,11 @@ pub fn cancel(data_dir: &Path) {
/// Carries out a scheduled invalidation (call at start-up, before projects
/// open). Returns the folders removed.
pub fn apply_pending(data_dir: &Path, local_data_dir: &Path) -> Vec<PathBuf> {
pub fn apply_pending(data_dir: &Path) -> Vec<PathBuf> {
let Some(options) = pending(data_dir) else { return Vec::new() };
cancel(data_dir);
let mut removed = Vec::new();
for dir in cache_dirs(data_dir, local_data_dir, &options) {
for dir in cache_dirs(data_dir, &options) {
match fs::remove_dir_all(&dir) {
Ok(()) => removed.push(dir),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
@@ -85,34 +75,6 @@ pub fn apply_pending(data_dir: &Path, local_data_dir: &Path) -> Vec<PathBuf> {
mod tests {
use super::*;
#[test]
fn split_roots_invalidate_local_caches_and_roaming_history() {
let tmp = tempfile::tempdir().unwrap();
let data = tmp.path().join("roaming");
let local = tmp.path().join("local");
let options = InvalidateCachesOptions { local_history: true, language_servers: true, build_caches: true };
assert_eq!(
cache_dirs(&data, &local, &options),
vec![
local.join("index"),
data.join("history"),
local.join("lsp"),
local.join("cache"),
data.join("index"),
data.join("lsp"),
data.join("cache")
]
);
for path in cache_dirs(&data, &local, &options) {
fs::create_dir_all(path).unwrap();
}
fs::create_dir_all(local.join("tools")).unwrap();
schedule(&data, &options).unwrap();
assert_eq!(apply_pending(&data, &local).len(), 7);
assert!(local.join("tools").exists());
assert_eq!(pending(&data), None);
}
#[test]
fn scheduled_invalidation_runs_once() {
let tmp = tempfile::tempdir().unwrap();
@@ -120,16 +82,16 @@ mod tests {
for d in ["index/p1", "history/p1", "lsp/jdtls/x", "cache/p1/build", "tools/jdk"] {
fs::create_dir_all(data.join(d)).unwrap();
}
assert!(apply_pending(data, data).is_empty());
assert!(apply_pending(data).is_empty());
let opts = InvalidateCachesOptions { local_history: true, language_servers: false, build_caches: true };
schedule(data, &opts).unwrap();
assert_eq!(pending(data), Some(opts));
let removed = apply_pending(data, data);
let removed = apply_pending(data);
assert_eq!(removed, vec![data.join("index"), data.join("history"), data.join("cache")]);
assert!(data.join("lsp/jdtls/x").exists() && data.join("tools/jdk").exists());
assert_eq!(pending(data), None);
assert!(apply_pending(data, data).is_empty());
assert!(apply_pending(data).is_empty());
schedule(data, &InvalidateCachesOptions::default()).unwrap();
cancel(data);
@@ -1,102 +0,0 @@
use super::DependencyVersionSource;
use ide_core::{CancellationToken, IdeError, IdeResult};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const TTL: Duration = Duration::from_secs(3600);
const FAILURE_TTL: Duration = Duration::from_secs(30);
const MAX_DISK_BYTES: u64 = 2 * 1024 * 1024;
type Slot = Arc<Mutex<Option<Cached>>>;
#[derive(Clone, Serialize, Deserialize)]
struct Cached {
fetched: u64,
versions: Vec<String>,
#[serde(skip)]
failed: bool,
}
/// Same-package lookups share one lock and fetch. Different packages don't block
/// each other; waiting callers can cancel independently of the active fetch.
pub struct VersionCache {
directory: PathBuf,
entries: Mutex<HashMap<String, Slot>>,
ttl: Duration,
}
impl VersionCache {
pub fn new(directory: PathBuf) -> Self {
Self { directory, entries: Mutex::default(), ttl: TTL }
}
pub fn versions(
&self,
source: &dyn DependencyVersionSource,
package: &str,
cancel: &CancellationToken,
) -> IdeResult<Vec<String>> {
let safe = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b));
if !safe(source.id()) || !safe(package) || package.len() > 64 {
return Ok(Vec::new());
}
let key = format!("{}-{}", source.id(), package.to_ascii_lowercase());
let slot = self.entries.lock().entry(key.clone()).or_default().clone();
let mut cached = loop {
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if let Some(guard) = slot.try_lock_for(Duration::from_millis(50)) {
break guard;
}
};
let checked_at = now();
let fresh = |c: &Cached| {
c.fetched <= checked_at && checked_at - c.fetched < if c.failed { FAILURE_TTL } else { self.ttl }.as_secs()
};
if let Some(entry) = cached.as_ref().filter(|c| fresh(c)) {
return Ok(entry.versions.clone());
}
let path = self.directory.join(format!("{key}.json"));
let disk = std::fs::metadata(&path)
.ok()
.filter(|m| m.len() <= MAX_DISK_BYTES)
.and_then(|_| std::fs::read(&path).ok())
.and_then(|bytes| serde_json::from_slice::<Cached>(&bytes).ok())
.filter(fresh);
if let Some(entry) = disk {
let versions = entry.versions.clone();
*cached = Some(entry);
return Ok(versions);
}
let result = source.fetch(package, cancel);
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let failed = result.is_err();
let versions = result.unwrap_or_default();
let entry = Cached { fetched: now(), versions: versions.clone(), failed };
if !failed
&& let Ok(bytes) = serde_json::to_vec(&entry)
&& std::fs::create_dir_all(&self.directory).is_ok()
{
let temporary = path.with_extension("tmp");
if std::fs::write(&temporary, bytes).is_ok() {
let _ = std::fs::rename(&temporary, &path);
let _ = std::fs::remove_file(&temporary);
}
}
*cached = Some(entry);
Ok(versions)
}
}
fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
}
#[cfg(test)]
mod tests;
-97
View File
@@ -1,97 +0,0 @@
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
struct Source {
calls: AtomicUsize,
fail: bool,
}
impl DependencyVersionSource for Source {
fn id(&self) -> &'static str {
"test"
}
fn fetch(&self, _: &str, _: &CancellationToken) -> IdeResult<Vec<String>> {
self.calls.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(20));
if self.fail { Err(IdeError::unavailable("offline")) } else { Ok(vec!["2.0.0".into()]) }
}
}
fn source(fail: bool) -> Source {
Source { calls: AtomicUsize::new(0), fail }
}
#[test]
fn memory_disk_ttl_corruption_and_failed_cache_writes() {
let dir = tempfile::tempdir().unwrap();
let s = source(false);
let cancel = CancellationToken::new();
let cache = VersionCache::new(dir.path().into());
for _ in 0..2 {
assert_eq!(cache.versions(&s, "foo", &cancel).unwrap(), ["2.0.0"]);
}
assert_eq!(s.calls.load(Ordering::SeqCst), 1);
VersionCache::new(dir.path().into()).versions(&s, "foo", &cancel).unwrap();
assert_eq!(s.calls.load(Ordering::SeqCst), 1, "disk hit after service restart");
let mut expired = VersionCache::new(dir.path().into());
expired.ttl = Duration::ZERO;
expired.versions(&s, "foo", &cancel).unwrap();
assert_eq!(s.calls.load(Ordering::SeqCst), 2);
std::fs::write(dir.path().join("test-foo.json"), b"corrupt").unwrap();
VersionCache::new(dir.path().into()).versions(&s, "foo", &cancel).unwrap();
assert_eq!(s.calls.load(Ordering::SeqCst), 3);
let file = dir.path().join("not-a-directory");
std::fs::write(&file, "x").unwrap();
assert_eq!(VersionCache::new(file).versions(&s, "foo", &cancel).unwrap(), ["2.0.0"]);
}
#[test]
fn concurrent_fetches_coalesce_and_failures_are_silent_and_short_cached() {
let dir = tempfile::tempdir().unwrap();
let cache = VersionCache::new(dir.path().into());
let s = source(false);
std::thread::scope(|scope| {
for _ in 0..8 {
scope.spawn(|| assert_eq!(cache.versions(&s, "foo", &CancellationToken::new()).unwrap(), ["2.0.0"]));
}
});
assert_eq!(s.calls.load(Ordering::SeqCst), 1);
let s = source(true);
for _ in 0..2 {
assert!(cache.versions(&s, "offline", &CancellationToken::new()).unwrap().is_empty());
}
assert_eq!(s.calls.load(Ordering::SeqCst), 1);
assert!(!dir.path().join("test-offline.json").exists(), "failures aren't persisted");
let slot = cache.entries.lock().get("test-offline").unwrap().clone();
slot.lock().as_mut().unwrap().fetched -= 31;
cache.versions(&s, "offline", &CancellationToken::new()).unwrap();
assert_eq!(s.calls.load(Ordering::SeqCst), 2);
}
#[test]
fn cancellation_and_invalid_cache_keys_do_not_fetch() {
let dir = tempfile::tempdir().unwrap();
let cache = VersionCache::new(dir.path().into());
let s = source(false);
let token = CancellationToken::new();
token.cancel();
assert!(cache.versions(&s, "foo", &token).is_err());
assert!(cache.versions(&s, "../foo", &CancellationToken::new()).unwrap().is_empty());
assert_eq!(s.calls.load(Ordering::SeqCst), 0);
}
#[test]
fn cancelled_waiter_does_not_cancel_the_shared_fetch() {
let dir = tempfile::tempdir().unwrap();
let cache = VersionCache::new(dir.path().into());
let s = source(false);
let slot = Arc::new(Mutex::new(None));
cache.entries.lock().insert("test-foo".into(), slot.clone());
let guard = slot.lock();
let token = CancellationToken::new();
std::thread::scope(|scope| {
let waiter = scope.spawn(|| cache.versions(&s, "foo", &token));
token.cancel();
assert!(waiter.join().unwrap().is_err());
});
drop(guard);
assert_eq!(cache.versions(&s, "foo", &CancellationToken::new()).unwrap(), ["2.0.0"]);
}
@@ -1,150 +0,0 @@
use super::{DependencyUpdate, ManifestDependency};
use semver::{Op, Version, VersionReq};
use serde::Deserialize;
use toml_edit::{Document, Item, TableLike};
/// Cargo's sparse-index path, including the special cases for short names.
pub fn index_path(name: &str) -> Option<String> {
if name.is_empty()
|| name.len() > 64
|| !name.bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_'))
|| !name.as_bytes()[0].is_ascii_alphabetic()
{
return None;
}
let name = name.to_ascii_lowercase();
Some(match name.len() {
1 => format!("1/{name}"),
2 => format!("2/{name}"),
3 => format!("3/{}/{name}", &name[..1]),
_ => format!("{}/{}/{name}", &name[..2], &name[2..4]),
})
}
pub fn parse_index(text: &str) -> Option<Vec<String>> {
#[derive(Deserialize)]
struct Entry {
vers: String,
#[serde(default)]
yanked: bool,
}
let mut versions = Vec::new();
for line in text.lines().filter(|l| !l.trim().is_empty()) {
let entry: Entry = serde_json::from_str(line).ok()?;
if !entry.yanked {
versions.push(Version::parse(&entry.vers).ok()?);
}
}
versions.sort_unstable_by(|a, b| b.cmp(a));
versions.dedup();
Some(versions.into_iter().map(|v| v.to_string()).collect())
}
/// A complete TOML parser supplies spans, so strings/comments that resemble tables
/// never become dependencies. Broken documents are silent until they parse again.
pub fn dependencies(text: &str) -> Vec<ManifestDependency> {
let Ok(doc) = text.parse::<Document<String>>() else { return Vec::new() };
let mut out = Vec::new();
sections(doc.as_table(), text, &mut out);
if let Some(workspace) = doc.get("workspace").and_then(Item::as_table_like)
&& let Some(deps) = workspace.get("dependencies").and_then(Item::as_table_like)
{
collect(deps, text, &mut out);
}
if let Some(targets) = doc.get("target").and_then(Item::as_table_like) {
for (_, target) in targets.iter() {
if let Some(target) = target.as_table_like() {
sections(target, text, &mut out);
}
}
}
out.sort_unstable_by_key(|d| d.from);
out
}
fn sections(table: &dyn TableLike, text: &str, out: &mut Vec<ManifestDependency>) {
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
if let Some(deps) = table.get(section).and_then(Item::as_table_like) {
collect(deps, text, out);
}
}
}
fn collect(table: &dyn TableLike, text: &str, out: &mut Vec<ManifestDependency>) {
for (name, item) in table.iter() {
let (package, value) = if let Some(dep) = item.as_table_like() {
if ["path", "git", "registry", "registry-index"].iter().any(|k| dep.contains_key(k))
|| dep.get("workspace").and_then(Item::as_bool) == Some(true)
{
continue;
}
let Some(version) = dep.get("version") else { continue };
(dep.get("package").and_then(Item::as_str).unwrap_or(name), version)
} else {
(name, item)
};
if index_path(package).is_none() {
continue;
}
let Some(requirement) = value.as_str() else { continue };
let Some(span) = value.span() else { continue };
let Some(raw) = text.get(span.clone()) else { continue };
// Spans cover the encoded TOML string. Replace its entire contents,
// including escapes, while retaining the original quote delimiters.
let delimiter = if raw.starts_with("\"\"\"") || raw.starts_with("'''") { 3 } else { 1 };
out.push(ManifestDependency {
package: package.to_owned(),
requirement: requirement.to_owned(),
from: text[..span.start + delimiter].encode_utf16().count() as u32,
to: text[..span.end - delimiter].encode_utf16().count() as u32,
});
}
}
pub fn update(requirement: &str, versions: &[String]) -> Option<DependencyUpdate> {
let req = VersionReq::parse(requirement).ok()?;
let prerelease = req.comparators.iter().any(|c| !c.pre.is_empty());
let latest =
versions.iter().filter_map(|v| Version::parse(v).ok()).filter(|v| prerelease || v.pre.is_empty()).max()?;
if req.matches(&latest) {
return None;
}
// An unavailable future version isn't an invitation to downgrade.
if req.comparators.iter().any(|c| {
(c.op == Op::Greater
&& c.patch.is_none()
&& (latest.major < c.major
|| (latest.major == c.major && c.minor.is_none_or(|minor| latest.minor <= minor))))
|| !matches!(c.op, Op::Less | Op::LessEq)
&& latest
<= Version {
major: c.major,
minor: c.minor.unwrap_or(0),
patch: c.patch.unwrap_or(0),
pre: c.pre.clone(),
build: Default::default(),
}
}) {
return None;
}
let trimmed = requirement.trim();
let digits = trimmed.trim_start_matches(['^', '~', '=']).trim_start();
let simple = digits.split('.').all(|s| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()));
let mut replacement = latest.to_string();
if simple && latest.pre.is_empty() {
let precision = digits.split('.').count();
let version = match precision {
1 => latest.major.to_string(),
2 => format!("{}.{}", latest.major, latest.minor),
_ => latest.to_string(),
};
let candidate = format!("{}{version}", &trimmed[..trimmed.len() - digits.len()]);
if VersionReq::parse(&candidate).is_ok_and(|r| r.matches(&latest)) {
replacement = candidate;
}
}
Some(DependencyUpdate { latest: latest.to_string(), replacement })
}
#[cfg(test)]
mod tests;
@@ -1,143 +0,0 @@
use super::*;
#[test]
fn sparse_paths_and_input_validation() {
for (name, path) in
[("A", "1/a"), ("ab", "2/ab"), ("AbC", "3/a/abc"), ("serde", "se/rd/serde"), ("my_crate", "my/_c/my_crate")]
{
assert_eq!(index_path(name).as_deref(), Some(path));
}
for name in ["", "../serde", "a/b", "sé", "-foo", "a?b", "1abc"] {
assert!(index_path(name).is_none(), "{name}");
}
}
#[test]
fn sparse_index_filters_yanked_sorts_semver_and_keeps_prereleases_for_completion() {
let text = r#"{"vers":"1.9.0","yanked":false}
{"vers":"2.0.0-beta.1","yanked":false}
{"vers":"9.0.0","yanked":true}
{"vers":"1.10.0","yanked":false}
{"vers":"1.10.0"}"#;
assert_eq!(parse_index(text).unwrap(), ["2.0.0-beta.1", "1.10.0", "1.9.0"]);
assert!(parse_index("{broken").is_none());
assert!(parse_index(r#"{"vers":"bogus"}"#).is_none());
}
#[test]
fn cargo_requirements_updates_and_precision() {
for (req, latest, expected) in [
("1", "1.9.0", None),
("1.0.100", "1.0.228", None),
("^1.2", "1.9.0", None),
("0.7", "0.7.9", None),
("0.0.1", "0.0.2", Some("0.0.2")),
("1", "2.3.4", Some("2")),
("1.2", "2.3.4", Some("2.3")),
("1.2.3", "2.3.0", Some("2.3.0")),
("1.2.3", "1.3.0", None),
("0.2", "1.3.0", Some("1.3")),
("=1.2.3", "1.3.0", Some("=1.3.0")),
("~1.2", "1.3.0", Some("~1.3")),
("^0.2", "0.3.1", Some("^0.3")),
("1.*", "1.9.0", None),
("*", "2.3.4", None),
(">=1, <2", "2.3.4", Some("2.3.4")),
(">=1, <2", "2.0.0", Some("2.0.0")),
("<1", "1.0.0", Some("1.0.0")),
(">=1, <3", "2.3.4", None),
(">=9", "2.3.4", None),
(">1", "1.9.0", None),
(">1.2", "1.2.9", None),
(">1.2.3", "1.2.3", None),
("=9.1.2", "2.3.4", None),
("bad", "2.3.4", None),
("1.0", "2.0.0-beta.1", None),
("1.0.0-beta.1", "1.0.0-beta.2", None),
("=1.0.0-beta.1", "1.0.0-beta.2", Some("1.0.0-beta.2")),
("1.0.0-beta.1", "2.0.0-beta.1", Some("2.0.0-beta.1")),
] {
let actual = update(req, &[latest.to_owned()]);
assert_eq!(actual.as_ref().map(|u| u.replacement.as_str()), expected, "{req} -> {latest}");
if let Some(u) = actual {
assert!(VersionReq::parse(&u.replacement).unwrap().matches(&Version::parse(latest).unwrap()));
}
}
let versions = vec!["3.0.0-beta.1".into(), "2.0.0".into(), "1.0.0".into()];
assert_eq!(update("1", &versions).unwrap().latest, "2.0.0");
}
#[test]
fn locates_all_cargo_sections_and_only_replaces_version_contents() {
let text = r#"# 中文 😀
[package]
name = "fixture"
version = "1.0.0"
[dependencies]
serde = "1.0.100" # stays
rand = '0.7'
tokio = { version = "0.2", features = ["full"] }
renamed = { package = "real-crate", version = "1.2" }
local = { path = "../local", version = "1" }
git = { git = "https://example.com/repo", version = "1" }
inherited = { workspace = true }
other = { registry = "private", version = "1" }
[dev-dependencies]
check = "1"
[build-dependencies.build]
version = "1.2.3"
[target.'cfg(unix)'.dependencies]
native = "0.1"
[target.'cfg(windows)'.dev-dependencies]
win = { version = "1" }
[target.'cfg(unix)'.build-dependencies]
build-native = "2"
[workspace.dependencies]
shared = { version = "1", features = ["std"] }
[package.metadata.dependencies]
not-a-dependency = "1"
"#;
let deps = dependencies(text);
assert_eq!(
deps.iter().map(|d| d.package.as_str()).collect::<Vec<_>>(),
["serde", "rand", "tokio", "real-crate", "check", "build", "native", "win", "build-native", "shared"]
);
let utf16: Vec<_> = text.encode_utf16().collect();
for dep in &deps {
assert_eq!(String::from_utf16(&utf16[dep.from as usize..dep.to as usize]).unwrap(), dep.requirement);
}
let tokio = &deps[2];
let mut edited = utf16.clone();
edited.splice(tokio.from as usize..tokio.to as usize, "1.3".encode_utf16());
assert_eq!(String::from_utf16(&edited).unwrap(), text.replace("version = \"0.2\"", "version = \"1.3\""));
let rand = &deps[1];
let mut edited = utf16;
edited.splice(rand.from as usize..rand.to as usize, "0.9".encode_utf16());
assert_eq!(String::from_utf16(&edited).unwrap(), text.replace("'0.7'", "'0.9'"));
}
#[test]
fn invalid_toml_and_decoy_tables_are_silent() {
assert!(dependencies("[dependencies]\na = \"").is_empty());
assert!(dependencies("[package]\ndescription = '''\n[dependencies]\na = \"1\"\n'''\n").is_empty());
assert!(dependencies("[dependencies]\na = { version = 1 }\nb = { workspace = true, version = \"1\" }\nc = { version = \"1\", registry-index = \"x\" }").is_empty());
let deps = dependencies("[dependencies]\na = \"\"\n");
assert_eq!(deps.len(), 1, "empty version strings still offer completion");
assert_eq!(deps[0].from, deps[0].to);
}
#[test]
fn replaces_encoded_and_multiline_strings_without_damaging_quotes() {
for raw in [r#""0.\u0032""#, "\"\"\"\n0.2\"\"\"", "'''0.2'''"] {
let text = format!("[dependencies]\ntokio = {{ version = {raw}, features = [\"full\"] }}\n");
let dep = dependencies(&text).pop().unwrap();
assert_eq!(dep.requirement, "0.2");
let mut encoded: Vec<_> = text.encode_utf16().collect();
encoded.splice(dep.from as usize..dep.to as usize, "1.10".encode_utf16());
let edited = String::from_utf16(&encoded).unwrap();
assert!(edited.contains("features = [\"full\"]"));
assert_eq!(dependencies(&edited)[0].requirement, "1.10");
assert_eq!(edited.matches('"').count(), text.matches('"').count());
assert_eq!(edited.matches('\'').count(), text.matches('\'').count());
}
}
@@ -1,41 +0,0 @@
//! Manifest dependencies and repository versions, independent of editor and transport.
mod cache;
pub mod cargo;
pub use cache::VersionCache;
use ide_core::{CancellationToken, IdeResult};
use serde::{Deserialize, Serialize};
use specta::Type;
/// A repository adapter; Maven/npm can use the same cache and editor protocol.
pub trait DependencyVersionSource: Send + Sync {
/// A filesystem-safe, stable repository identifier.
fn id(&self) -> &'static str;
/// Non-yanked versions in descending semantic order.
fn fetch(&self, package: &str, cancel: &CancellationToken) -> IdeResult<Vec<String>>;
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DependencyUpdate {
pub latest: String,
pub replacement: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ManifestDependency {
pub package: String,
pub requirement: String,
/// UTF-16 offsets of the string's contents (quotes excluded).
pub from: u32,
pub to: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DependencyVersions {
pub dependency: ManifestDependency,
pub versions: Vec<String>,
pub update: Option<DependencyUpdate>,
}
-1
View File
@@ -1,7 +1,6 @@
//! The project model that ties documents, settings and history together.
pub mod caches;
pub mod dependencies;
pub mod devcontainer;
pub mod documents;
pub mod editorconfig;
-69
View File
@@ -2,29 +2,6 @@
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
/// Platform adapters supply the system's tab preference and active project state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TabbingPreference {
Always,
InFullScreen,
Manually,
}
pub fn should_group_new_project(
preference: TabbingPreference,
fullscreen: bool,
active_project: bool,
light_edit: bool,
) -> bool {
active_project
&& !light_edit
&& match preference {
TabbingPreference::Always => true,
TabbingPreference::InFullScreen => fullscreen,
TabbingPreference::Manually => false,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WindowGroup {
pub members: Vec<String>,
@@ -62,13 +39,6 @@ impl WindowGroups {
Ok(())
}
/// Adds a window to the anchor's group without merging unrelated groups.
pub fn join(&mut self, anchor: &str, member: &str) -> IdeResult<()> {
let mut members = self.group(anchor).map(|g| g.members.clone()).unwrap_or_else(|| vec![anchor.to_owned()]);
members.push(member.to_owned());
self.merge(members, anchor)
}
pub fn select(&mut self, caller: &str, target: &str) -> IdeResult<WindowGroup> {
let group = self
.groups
@@ -124,45 +94,6 @@ mod tests {
values.iter().map(|s| (*s).into()).collect()
}
#[test]
fn new_project_grouping_follows_system_preference_and_excludes_light_edit() {
for preference in [TabbingPreference::Always, TabbingPreference::InFullScreen, TabbingPreference::Manually] {
for fullscreen in [false, true] {
for active_project in [false, true] {
for light_edit in [false, true] {
let expected = matches!(
(preference, fullscreen, active_project, light_edit),
(TabbingPreference::Always, _, true, false)
| (TabbingPreference::InFullScreen, true, true, false)
);
assert_eq!(
should_group_new_project(preference, fullscreen, active_project, light_edit),
expected,
"{preference:?}, fullscreen={fullscreen}, active={active_project}, light={light_edit}"
);
}
}
}
}
}
#[test]
fn joining_new_windows_preserves_the_active_group_and_other_groups() {
let mut groups = WindowGroups::default();
groups.merge(members(&["other-a", "other-b"]), "other-b").unwrap();
groups.join("a", "b").unwrap();
assert_eq!(groups.group("a").unwrap().members, members(&["a", "b"]));
let before = groups.select("a", "b").unwrap();
assert_eq!(before.selected, "a");
groups.join("b", "c").unwrap();
let before = groups.select("b", "c").unwrap();
assert_eq!(before.selected, "b");
assert_eq!(groups.group("c").unwrap().members, members(&["a", "b", "c"]));
assert_eq!(groups.group("c").unwrap().selected, "c");
assert_eq!(groups.group("other-a").unwrap().members, members(&["other-a", "other-b"]));
assert_eq!(groups.group("other-a").unwrap().selected, "other-b");
}
#[test]
fn selecting_and_detaching_preserve_the_other_sessions() {
let mut groups = WindowGroups::default();
-84
View File
@@ -1,84 +0,0 @@
import { expect, test, type Page } from "@playwright/test";
/** Mount the real console with enough output to recycle rows while selecting. */
async function consoleOutput(page: Page, follow = false) {
await page.goto("/");
await page.evaluate(async (follow) => {
const reactPath = "/node_modules/.vite/deps/react.js";
const domPath = "/node_modules/.vite/deps/react-dom_client.js";
const viewPath = "/src/features/run/ConsoleView.tsx";
const modelPath = "/src/features/run/console.ts";
const react = await import(reactPath);
const dom = await import(domPath);
const { createElement, useState } = react.default ?? react;
const { createRoot } = dom.default ?? dom;
const { ConsoleView } = await import(viewPath);
const { ConsoleModel } = await import(modelPath);
const model = new ConsoleModel();
model.append({ kind: "stdout", text: Array.from({ length: 500 }, (_, i) => `output ${i}`).join("\n") });
const host = document.createElement("div");
host.style.cssText = "position:fixed;inset:0;z-index:99999;display:flex;height:300px;background:white";
document.body.append(host);
function Console() {
const [following, setFollowing] = useState(follow);
return createElement(ConsoleView, {
model,
version: 1,
softWrap: false,
follow: following,
onFollowChange: setFollowing,
cwd: "/",
});
}
createRoot(host).render(createElement(Console));
}, follow);
const log = page.getByRole("log", { name: "Console", exact: true });
await expect(log.locator("[data-index]").first()).toBeVisible();
return log;
}
test("keeps a console selection intact when scrolling beyond the virtual window", async ({ page }) => {
const log = await consoleOutput(page);
await log.evaluate((el) => {
el.scrollTop = 200 * 20;
});
const start = log.locator('[data-index="205"]');
await expect(start).toBeVisible();
await start.evaluate((el) => {
const selection = window.getSelection()!;
selection.setBaseAndExtent(el.firstChild!.firstChild!, 2, el.nextElementSibling!.firstChild!.firstChild!, 5);
});
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("tput 205\noutpu");
await log.evaluate((el) => {
el.scrollTop = 350 * 20;
});
await expect(log.locator('[data-index="350"]')).toBeVisible();
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("tput 205\noutpu");
await page.evaluate(() => window.getSelection()?.removeAllRanges());
await expect(start).toHaveCount(0);
});
test("preserves all lines in a drag selection across virtual windows", async ({ page }) => {
const log = await consoleOutput(page);
await log.evaluate((el) => {
el.scrollTop = 200 * 20;
});
const start = log.locator('[data-index="205"]');
await expect(start).toBeVisible();
const box = (await start.boundingBox())!;
await page.mouse.move(box.x + 20, box.y + 10);
await page.mouse.down();
await page.mouse.move(box.x + 60, box.y + 30, { steps: 5 });
await log.evaluate((el) => {
el.scrollTop = 280 * 20;
});
const end = log.locator('[data-index="285"]');
await expect(end).toBeVisible();
const endBox = (await end.boundingBox())!;
await page.mouse.move(endBox.x + 60, endBox.y + 10, { steps: 5 });
await page.mouse.up();
const text = await page.evaluate(() => window.getSelection()?.toString());
expect(text).toContain("output 206\noutput 207");
expect(text).toContain("output 250\noutput 251");
expect(text).toContain("output 284");
});
-30
View File
@@ -1,30 +0,0 @@
import { expect, openFromTree, test } from "./fixtures";
// Headless Chromium hides scrollbars by default; this test drags one.
test.use({ launchOptions: { ignoreDefaultArgs: ["--hide-scrollbars"] } });
test("the scrollbar thumb drags even where overview marks cover it (#752)", async ({ workbench: page }) => {
const editor = await openFromTree(page, "build.gradle.kts");
await editor.locator(".cm-content").click();
await page.keyboard.press("Meta+a");
await page.keyboard.insertText(Array.from({ length: 400 }, (_, i) => `val mark${i} = ${i}`).join("\n"));
// A match on every line covers the overview lane, as a new file's change mark does.
await page.keyboard.press("Meta+f");
await page.getByRole("textbox", { name: "Find" }).fill("mark");
await expect(editor.locator(".cm-openidea-overviewMark").first()).toBeVisible();
const scroller = editor.locator(".cm-scroller");
await scroller.evaluate((s) => (s.scrollTop = 0));
const box = (await scroller.boundingBox())!;
// Over a mark and inside the thumb, which is at the top of the track.
const x = box.x + box.width - 5;
const y = box.y + 12;
await page.mouse.move(x - 40, y);
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x, y + 150, { steps: 10 });
await page.mouse.up();
expect(await scroller.evaluate((s) => s.scrollTop)).toBeGreaterThan(0);
// Away from the thumb the marks take the pointer again.
await page.mouse.move(x, box.y + box.height - 20);
await expect(editor.locator(".cm-openidea-overview")).not.toHaveClass(/cm-openidea-overview-onThumb/);
});
-81
View File
@@ -1,81 +0,0 @@
import { expect, test } from "./fixtures";
const themes = [
{ theme: "light", scheme: "theme" },
{ theme: "dark", scheme: "theme" },
{ theme: "dark-hc", scheme: "theme" },
{ theme: "light", scheme: "dark" },
{ theme: "dark", scheme: "light" },
] as const;
for (const { theme, scheme } of themes) {
test(`usage highlights have readable text with ${theme} theme and ${scheme} scheme`, async ({
workbench: page,
}, info) => {
await page.evaluate(
async ({ theme, scheme }) => {
const themePath = "/src/workbench/theme.ts";
const schemePath = "/src/platform/colorScheme.ts";
const usagesPath = "/src/features/code-insight/usages.ts";
const { applyThemeAttributes } = await import(themePath);
const { schemeCss } = await import(schemePath);
const { useUsagesPopup } = await import(usagesPath);
applyThemeAttributes(theme);
const style = document.createElement("style");
style.textContent = schemeCss(scheme);
document.head.append(style);
const symbol = "trainingSessionFetch";
const texts = [
`export function ${symbol}() {}`,
`import { ${symbol} } from './api';`,
`// Call ${symbol} here`,
`const name = '${symbol}';`,
];
useUsagesPopup.setState({
open: true,
anchor: { x: 100, y: 100 },
symbol,
groups: texts.map((text, index) => ({
path: `/contrast/example-${index}.ts`,
usages: [
{
path: `/contrast/example-${index}.ts`,
line: 0,
col: text.indexOf(symbol),
endCol: text.indexOf(symbol) + symbol.length,
text,
},
],
})),
note: null,
});
},
{ theme, scheme },
);
const popup = page.getByRole("dialog", { name: "Usages", exact: true });
await expect(popup.locator(".tok-comment").first()).toBeVisible();
await expect(popup.locator("mark")).toHaveCount(4);
const ratios = await popup.locator("mark").evaluateAll((marks) => {
const luminance = (color: string) => {
const channels = color
.match(/[\d.]+/g)!
.slice(0, 3)
.map(Number)
.map((value) => {
const channel = value / 255;
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
});
return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722;
};
return marks.flatMap((mark) => {
const background = luminance(getComputedStyle(mark).backgroundColor);
return [mark, ...mark.querySelectorAll("*")].map((node) => {
const foreground = luminance(getComputedStyle(node).color);
return (Math.max(background, foreground) + 0.05) / (Math.min(background, foreground) + 0.05);
});
});
});
for (const ratio of ratios) expect(ratio).toBeGreaterThanOrEqual(4.5);
await popup.screenshot({ path: info.outputPath("usage-highlights.png") });
});
}
-6
View File
@@ -1,6 +0,0 @@
id = "asciidoc"
name = "AsciiDoc"
extensions = ["adoc", "asciidoc"]
line-comment = "//"
indent = { tab-size = 2, insert-spaces = true }
ui-mode = "AsciiDoc"
-13
View File
@@ -1,13 +0,0 @@
id = "d"
name = "D"
extensions = ["d", "di"]
line-comment = "//"
block-comment = ["/*", "*/"]
indent = { tab-size = 4, insert-spaces = true }
ui-mode = "D"
[[language-servers]]
name = "serve-d"
command = "serve-d"
root-markers = ["dub.json", "dub.sdl", "dub.selections.json"]
install-hint = "Install serve-d (https://github.com/symmetryinvestments/serve-d) and expose it as `serve-d`"
-8
View File
@@ -27,14 +27,6 @@ args = ["--stdio"]
root-markers = ["jsconfig.json", "tsconfig.json", "package.json"]
install-hint = "npm i -g typescript@6 typescript-language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
[debugger]
adapter = "js-debug"
command = "js-debug-adapter"
-8
View File
@@ -26,14 +26,6 @@ args = ["--stdio"]
root-markers = ["jsconfig.json", "tsconfig.json", "package.json"]
install-hint = "npm i -g typescript@6 typescript-language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
[debugger]
adapter = "js-debug"
command = "js-debug-adapter"
-8
View File
@@ -13,11 +13,3 @@ command = "svelteserver"
args = ["--stdio"]
root-markers = ["svelte.config.js", "svelte.config.mjs", "svelte.config.ts", "package.json"]
install-hint = "npm i -g svelte-language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
-8
View File
@@ -37,14 +37,6 @@ root-markers = ["angular.json", "node_modules/@angular/core"]
require-root-marker = true
install-hint = "npm i -g @angular/language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
[debugger]
adapter = "js-debug"
command = "js-debug-adapter"
-8
View File
@@ -26,14 +26,6 @@ args = ["--stdio"]
root-markers = ["tsconfig.json", "jsconfig.json", "package.json"]
install-hint = "npm i -g typescript@6 typescript-language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
[debugger]
adapter = "js-debug"
command = "js-debug-adapter"
-8
View File
@@ -22,11 +22,3 @@ command = "vtsls"
args = ["--stdio"]
root-markers = ["tsconfig.json", "jsconfig.json", "package.json"]
install-hint = "npm i -g @vtsls/language-server"
[[language-servers]]
name = "oxlint"
command = "oxlint"
args = ["--lsp"]
root-markers = [".oxlintrc.json", ".oxlintrc.jsonc", "oxlint.config.ts", "oxlint.config.mts"]
require-root-marker = true
install-hint = "npm i -D oxlint"
+1 -1
View File
@@ -22,7 +22,7 @@ install_dir = { windows = "%LOCALAPPDATA%/OpenIDEA", macos = "/Applications/Open
logs = { windows = "%LOCALAPPDATA%/ai.l0ng.openidea/logs/openidea*.log", macos = "~/Library/Logs/ai.l0ng.openidea/openidea*.log", linux = "~/.local/share/ai.l0ng.openidea/logs/openidea*.log" }
# The index and build caches. jdtls keeps its own workspace under lsp/jdtls,
# next to the jdtls install, so a cold Java run still reuses jdtls's build state.
cache_dirs = { windows = ["%LOCALAPPDATA%/ai.l0ng.openidea/index", "%LOCALAPPDATA%/ai.l0ng.openidea/cache"], macos = ["~/Library/Application Support/ai.l0ng.openidea/index", "~/Library/Application Support/ai.l0ng.openidea/cache"], linux = ["~/.local/share/ai.l0ng.openidea/index", "~/.local/share/ai.l0ng.openidea/cache"] }
cache_dirs = { windows = ["%APPDATA%/ai.l0ng.openidea/index", "%APPDATA%/ai.l0ng.openidea/cache"], macos = ["~/Library/Application Support/ai.l0ng.openidea/index", "~/Library/Application Support/ai.l0ng.openidea/cache"], linux = ["~/.local/share/ai.l0ng.openidea/index", "~/.local/share/ai.l0ng.openidea/cache"] }
[apps.openidea.markers]
project_opened = 'opened .* in window'
-8
View File
@@ -78,11 +78,3 @@ objc2-core-foundation = { version = "0.3", default-features = false, features =
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] }
# Used only by the debug font-restriction override; Cargo dependency cfg cannot test debug_assertions.
objc2-web-kit = { version = "0.3", default-features = false, features = ["std", "WKWebViewConfiguration", "WKPreferences"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
-17
View File
@@ -1,17 +0,0 @@
//! Typed IPC adapters for manifest dependency versions.
use crate::state::AppState;
use ide_core::{DocId, IdeResult, RequestId};
use ide_workspace::dependencies::DependencyVersions;
use tauri::{State, Window};
#[tauri::command]
#[specta::specta]
pub async fn dependency_versions(
window: Window,
state: State<'_, AppState>,
doc: DocId,
offset: Option<u32>,
request: RequestId,
) -> IdeResult<Vec<DependencyVersions>> {
ide_host::ops::dependencies::dependency_versions(&state.host, window.label(), doc, offset, request).await
}
-4
View File
@@ -16,7 +16,6 @@ pub mod clipboard;
pub mod coverage;
pub mod db;
pub mod debug;
pub mod dependencies;
pub mod deploy;
pub mod doc;
pub mod docker;
@@ -191,7 +190,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
nav::fs_resolve_reference,
nav::path_completions,
nav::web_unresolved_references,
dependencies::dependency_versions,
spell::spell_check,
spell::spell_suggest,
search::search_start,
@@ -298,7 +296,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
vcs::vcs_changelists_save,
vcs::vcs_write_patch,
term::term_spawn,
term::term_store_image,
term::term_write,
term::term_resize,
term::term_kill,
@@ -306,7 +303,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
term::term_default_shell,
term::term_available_shells,
run::run_configs,
run::run_dotnet_resolve,
run::run_save_configs,
run::run_templates,
run::run_save_templates,
-11
View File
@@ -8,17 +8,6 @@ use ide_run::{OutputChunk, RunConfig, RunInfo};
use tauri::ipc::Channel;
use tauri::{State, Window};
#[tauri::command]
#[specta::specta]
pub async fn run_dotnet_resolve(
window: Window,
state: State<'_, AppState>,
config: RunConfig,
debug: bool,
) -> IdeResult<RunConfig> {
ide_host::ops::run::run_dotnet_resolve(&state.host, window.label(), config, debug).await
}
#[tauri::command]
#[specta::specta]
pub async fn run_application_build(
-11
View File
@@ -24,17 +24,6 @@ pub async fn term_spawn(
)
}
#[tauri::command]
#[specta::specta]
pub async fn term_store_image(
state: State<'_, AppState>,
id: TerminalId,
data: Vec<u8>,
mime: String,
) -> IdeResult<String> {
ide_host::ops::term::store_image(&state.host, id, data, mime).await
}
#[tauri::command]
#[specta::specta]
pub fn term_write(state: State<'_, AppState>, id: TerminalId, data: String) -> IdeResult<()> {
+2 -12
View File
@@ -11,8 +11,6 @@ mod events;
#[cfg(any(target_os = "linux", test))]
mod linux_window_state;
mod logging;
#[cfg(target_os = "macos")]
mod macos;
mod menu;
mod remote_dev;
@@ -21,8 +19,6 @@ mod theme_frame;
#[cfg(target_os = "macos")]
mod titlebar;
mod window_groups;
#[cfg(any(windows, test))]
mod window_work_area;
mod windows;
use parking_lot::Mutex;
@@ -57,7 +53,7 @@ pub fn run() {
let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier);
// Managed LSP/DAP installs; must run before other threads start and before PATH is cached.
if let Some(data) = dirs::data_local_dir() {
if let Some(data) = dirs::data_dir() {
ide_tools::extend_process_path(&data.join(&context.config().identifier));
ide_dap::adapter::set_tools_root(ide_tools::tools_dir(&data.join(&context.config().identifier)));
ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(&data.join(&context.config().identifier)));
@@ -74,9 +70,6 @@ pub fn run() {
// Single instance first: a second launch focuses this one and opens its path argument.
// Debug builds have a separate identifier per worktree.
let app = app.plugin(tauri_plugin_single_instance::init(windows::on_second_instance));
// Install before window-state restores maximized geometry during window creation.
#[cfg(windows)]
let app = app.plugin(window_work_area::init());
let app = app.plugin(tauri_plugin_opener::init()).plugin(tauri_plugin_dialog::init());
#[cfg(target_os = "linux")]
let app = app.plugin(linux_window_state::plugin());
@@ -174,10 +167,7 @@ pub fn run() {
tauri::WindowEvent::Focused(false) => application_focus::changed(window.app_handle(), false),
tauri::WindowEvent::Destroyed => {
#[cfg(target_os = "macos")]
{
titlebar::forget(window);
macos::forget_fullscreen(window.label());
}
titlebar::forget(window);
window_groups::destroyed(window.app_handle(), window.label());
if let Some(settings) = window.app_handle().get_webview_window(&windows::settings_label(window.label())) {
let _ = settings.destroy();
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff. Show more