fix(workspace): unify nested project discovery and run ownership (#1716)

* fix(build): scope Kotlin bridge to linked Maven modules

* fix(run): resolve nested Go modules for context runs

* fix(gradle): discover nested builds and select compatible JVMs

* fix(workspace): share project discovery and module ownership

* fix(run): keep root toolchains for nested modules and resolve them once per module

- Python run configurations of a nested module without its own virtualenv use the closest one up to the project root.
- Gradle builds and runs fall back to the JDK matching the project's Java version when the Gradle version is unknown.
- Module-local interpreters and toolchains are resolved lazily, once per owning module.
- Tests build expected paths per segment so they hold on Windows.
This commit is contained in:
l0ng-ai
2026-11-06 15:51:41 +08:00
committed by GitHub
parent 763bcc056c
commit ba9ce1d842
29 changed files with 1770 additions and 309 deletions
+7 -13
View File
@@ -234,7 +234,12 @@ pub fn plan(
Ok(BuildPlan { system, title, steps })
}
fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<&Path>) -> IdeResult<Vec<BuildStep>> {
pub(super) fn maven_steps(
root: &Path,
model: &BuildModel,
kind: BuildKind,
module: Option<&Path>,
) -> IdeResult<Vec<BuildStep>> {
let fallback;
let m = match &model.maven {
Some(m) => m,
@@ -383,18 +388,7 @@ fn go_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
/// `pnpm` / `yarn` / `bun` / `npm`, from the lock file next to `package.json` or above it.
fn package_manager(dir: &Path) -> &'static str {
for d in dir.ancestors() {
if d.join("pnpm-lock.yaml").is_file() {
return "pnpm";
} else if d.join("yarn.lock").is_file() {
return "yarn";
} else if d.join("bun.lockb").is_file() || d.join("bun.lock").is_file() {
return "bun";
} else if d.join("package-lock.json").is_file() {
return "npm";
}
}
"npm"
ide_core::project_roots::package_manager(dir)
}
fn has_build_script(dir: &Path) -> bool {
+61 -16
View File
@@ -218,21 +218,14 @@ fn normalize(p: &Path) -> PathBuf {
out
}
/// The directory of the Gradle build under `root`: the root itself, or a direct
/// subfolder, holding a settings or build script.
/// The first automatically discovered build, for callers operating on one build.
pub fn build_dir(root: &Path) -> Option<PathBuf> {
let has = |d: &Path| SETTINGS_FILES.iter().chain(BUILD_FILES).any(|f| d.join(f).is_file());
if has(root) {
return Some(root.to_path_buf());
}
let mut dirs: Vec<PathBuf> = std::fs::read_dir(root)
.ok()?
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()) && !e.file_name().to_string_lossy().starts_with('.'))
.map(|e| e.path())
.collect();
dirs.sort();
dirs.into_iter().find(|d| SETTINGS_FILES.iter().any(|f| d.join(f).is_file()))
build_dirs(root).into_iter().next()
}
fn build_dirs(root: &Path) -> Vec<PathBuf> {
let markers: Vec<_> = SETTINGS_FILES.iter().chain(BUILD_FILES).copied().collect();
ide_core::project_roots::discover(root, &markers)
}
fn wrapper(dir: &Path) -> String {
@@ -495,7 +488,7 @@ pub fn linked_project_key(root: &Path, dir: &str) -> String {
/// Linked build identities without reading their scripts (also used by index hot paths).
pub(crate) fn linked_dirs(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec<String> {
match &settings.linked_projects {
None => build_dir(root).map(|dir| rel(root, &dir)).into_iter().collect(),
None => build_dirs(root).iter().map(|dir| rel(root, dir)).collect(),
Some(dirs) => {
let mut out = Vec::new();
for dir in dirs {
@@ -686,6 +679,55 @@ pub(crate) fn has_war_plugin(script: &str) -> bool {
#[cfg(test)]
mod tests {
#[test]
fn default_linkage_discovers_all_shallow_builds() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
for file in [
"Cargo.toml",
"package.json",
"fixtures/kotlin-gradle/settings.gradle.kts",
"fixtures/kotlin-gradle/build.gradle.kts",
"fixtures/kotlin-gradle/tools/settings.gradle",
"services/backend/api/settings.gradle",
"standalone/build.gradle",
"ignored/src/settings.gradle",
"ignored/out/build.gradle",
"node_modules/example/settings.gradle",
"target/example/build.gradle",
"build/example/settings.gradle",
"dist/example/build.gradle",
"vendor/example/settings.gradle",
".hidden/settings.gradle",
"too/deep/to/find/settings.gradle",
] {
let path = root.join(file);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
let models = super::load_linked(root, &Default::default());
assert_eq!(
models.iter().map(|m| m.dir.as_str()).collect::<Vec<_>>(),
["fixtures/kotlin-gradle", "ignored/src", "services/backend/api", "standalone"]
);
std::fs::write(root.join("build.gradle.kts"), "").unwrap();
let models = super::load_linked(root, &Default::default());
assert_eq!(models.len(), 1);
assert!(models[0].dir.is_empty());
}
#[cfg(unix)]
#[test]
fn default_linkage_does_not_follow_symlinks() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
std::fs::create_dir(root.join("real")).unwrap();
std::fs::write(root.join("real/build.gradle"), "").unwrap();
std::os::unix::fs::symlink(root.join("real"), root.join("alias")).unwrap();
let models = super::load_linked(root, &Default::default());
assert_eq!(models.iter().map(|m| m.dir.as_str()).collect::<Vec<_>>(), ["real"]);
}
#[test]
fn a_path_from_the_ui_finds_the_saved_link() {
let temp = tempfile::tempdir().unwrap();
@@ -718,7 +760,10 @@ mod tests {
std::fs::create_dir(root.join(dir)).unwrap();
std::fs::write(root.join(dir).join("settings.gradle"), format!("rootProject.name = '{dir}'")).unwrap();
}
assert_eq!(super::load_linked(root, &Default::default())[0].dir, "a");
assert_eq!(
super::load_linked(root, &Default::default()).iter().map(|m| m.dir.as_str()).collect::<Vec<_>>(),
["a", "b"]
);
let mut settings = ide_core::settings::GradleSettings {
linked_projects: Some(vec!["b".into(), "./b".into(), "a".into()]),
..Default::default()
+116 -9
View File
@@ -53,8 +53,12 @@ pub struct Outcome {
pub success: bool,
}
/// Inspect sources, excluding generated/build/VCS trees and never following symlinks.
pub fn has_kotlin(root: &Path) -> bool {
/// Only linked Maven modules contribute sources to the bridge.
pub fn has_kotlin(root: &Path, model: &BuildModel) -> bool {
model.maven.iter().flat_map(|m| &m.projects).any(|p| has_kotlin_sources(&root.join(&p.dir)))
}
fn has_kotlin_sources(root: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(root) else { return false };
entries.flatten().any(|entry| {
let Ok(kind) = entry.file_type() else { return false };
@@ -66,11 +70,25 @@ pub fn has_kotlin(root: &Path) -> bool {
entry.file_name().to_str(),
Some("target" | "build" | "out" | "bin" | "node_modules" | ".git" | ".openidea")
)
&& has_kotlin(&entry.path())
&& !["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"]
.iter()
.any(|marker| entry.path().join(marker).is_file())
&& has_kotlin_sources(&entry.path())
}
})
}
fn plan(root: &Path, model: &BuildModel) -> IdeResult<compile::BuildPlan> {
if model.maven.as_ref().is_none_or(|m| m.projects.is_empty()) {
return Err(IdeError::invalid("Kotlin bridge requires a Maven build root"));
}
Ok(compile::BuildPlan {
system: compile::BuildSystem::Maven,
title: root.file_name().unwrap_or_default().to_string_lossy().into_owned(),
steps: compile::maven_steps(root, model, BuildKind::Build, None)?,
})
}
/// Run the project's configured wrapper/settings/JDK and lifecycle, with an output
/// redirector loaded before Maven calculates its lifecycle execution plans.
pub fn build(
@@ -81,10 +99,7 @@ pub fn build(
cancel: &CancellationToken,
) -> IdeResult<Outcome> {
std::fs::create_dir_all(generation)?;
let mut plan = compile::plan(root, model, BuildKind::Build, None, None)?;
if plan.system != compile::BuildSystem::Maven {
return Err(IdeError::invalid("Kotlin bridge requires a Maven build root"));
}
let mut plan = plan(root, model)?;
let mut manifests = Vec::new();
for (index, step) in plan.steps.iter_mut().enumerate() {
step.args.push("--fail-at-end".into());
@@ -447,14 +462,106 @@ mod tests {
assert_eq!(error.message, "Kotlin bridge requires a Maven build root");
}
#[test]
fn kotlin_sources_belong_to_linked_maven_modules() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
for file in [
"java/pom.xml",
"gradle/build.gradle.kts",
"gradle/src/main/kotlin/App.kt",
"java/unlinked/pom.xml",
"java/unlinked/src/main/kotlin/App.kt",
"java/gradle/settings.gradle.kts",
"java/gradle/src/main/kotlin/App.kt",
"java/target/generated/Old.kt",
"java/node_modules/dependency/App.kt",
"java/linked/pom.xml",
"java/linked/src/test/kotlin/Test.kt",
] {
let path = root.join(file);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
let mut model = BuildModel {
maven: Some(crate::MavenModel {
projects: vec![crate::MavenProject { dir: "java".into(), ..Default::default() }],
..Default::default()
}),
..Default::default()
};
assert!(!has_kotlin(root, &model));
model.maven.as_mut().unwrap().projects.push(crate::MavenProject {
dir: "java/linked".into(),
aggregated: true,
..Default::default()
});
assert!(has_kotlin(root, &model));
model.maven.as_mut().unwrap().projects.clear();
assert!(!has_kotlin(root, &model));
assert!(!has_kotlin(root, &BuildModel::default()));
}
#[test]
fn maven_bridge_ignores_the_workspace_build_system() {
for marker in ["Cargo.toml", "package.json", "build.gradle.kts", "go.mod", "CMakeLists.txt"] {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join(marker), "").unwrap();
let mut model = BuildModel { maven: Some(crate::MavenModel::default()), ..Default::default() };
for name in ["java", "kotlin"] {
let module = root.join(name);
std::fs::create_dir_all(&module).unwrap();
model.maven.as_mut().unwrap().projects.push(crate::MavenProject {
dir: name.into(),
command: format!("{name}-mvnw"),
options: vec!["-o".into(), "-Peditor".into()],
env: BTreeMap::from([("JAVA_HOME".into(), "/jdk".into())]),
..Default::default()
});
}
std::fs::write(root.join("kotlin/App.kt"), "class App").unwrap();
assert!(has_kotlin(root, &model), "{marker}");
let plan = plan(root, &model).unwrap();
assert_eq!(plan.system, compile::BuildSystem::Maven);
assert_eq!(plan.steps.len(), 2);
for (step, name) in plan.steps.iter().zip(["java", "kotlin"]) {
assert_eq!(step.cwd, root.join(name));
assert_eq!(step.program, format!("{name}-mvnw"));
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-o", "-Peditor", "test-compile"]);
assert_eq!(step.env.get("JAVA_HOME").map(String::as_str), Some("/jdk"));
}
}
}
#[test]
fn external_linked_maven_sources_are_included() {
let workspace = tempfile::tempdir().unwrap();
let external = tempfile::tempdir().unwrap();
std::fs::write(external.path().join("App.kt"), "class App").unwrap();
let model = BuildModel {
maven: Some(crate::MavenModel {
projects: vec![crate::MavenProject {
dir: external.path().to_string_lossy().into_owned(),
command: "mvn".into(),
..Default::default()
}],
..Default::default()
}),
..Default::default()
};
assert!(has_kotlin(workspace.path(), &model));
assert_eq!(plan(workspace.path(), &model).unwrap().steps[0].cwd, external.path());
}
#[test]
fn does_not_find_deleted_sources_in_build_outputs() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("target/generated")).unwrap();
std::fs::write(dir.path().join("target/generated/Old.kt"), "").unwrap();
assert!(!has_kotlin(dir.path()));
assert!(!has_kotlin_sources(dir.path()));
std::fs::write(dir.path().join("New.kt"), "").unwrap();
assert!(has_kotlin(dir.path()));
assert!(has_kotlin_sources(dir.path()));
}
#[test]
+1 -41
View File
@@ -1013,10 +1013,7 @@ pub(crate) fn plugin(
// ---------------------------------------------------------------- loading
/// Directories never searched for POMs.
const SKIP_DIRS: &[&str] = &["node_modules", "target", "build", "out", "dist", "src", "vendor"];
const MAX_PROJECTS: usize = 1000;
const POM_SEARCH_DEPTH: usize = 3;
fn rel_path(root: &Path, p: &Path) -> String {
p.strip_prefix(root).unwrap_or(p).to_string_lossy().replace('\\', "/")
@@ -1027,44 +1024,7 @@ fn rel_path(root: &Path, p: &Path) -> String {
/// aggregate POM directly inside `src/` counts too (issue #1001: `src/pom.xml`
/// with its modules beside it, `src/app/pom.xml` and so on).
fn top_level_poms(root: &Path) -> Vec<PathBuf> {
let top = root.join("pom.xml");
if top.is_file() {
return vec![top];
}
let mut out = Vec::new();
let src_pom = root.join("src").join("pom.xml");
if src_pom.is_file() {
out.push(src_pom);
}
let mut queue: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)];
while let Some((dir, depth)) = queue.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
let mut dirs: Vec<PathBuf> = Vec::new();
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if e.file_type().is_ok_and(|t| t.is_dir()) && !name.starts_with('.') && !SKIP_DIRS.contains(&name.as_str())
{
dirs.push(e.path());
}
}
dirs.sort();
for d in dirs.into_iter().rev() {
let pom = d.join("pom.xml");
if pom.is_file() {
out.push(pom);
continue;
}
let src_pom = d.join("src").join("pom.xml");
if src_pom.is_file() {
out.push(src_pom);
}
if depth + 1 < POM_SEARCH_DEPTH {
queue.push((d, depth + 1));
}
}
}
out.sort();
out
ide_core::project_roots::discover(root, &["pom.xml"]).into_iter().map(|dir| dir.join("pom.xml")).collect()
}
/// Whether `root` holds a Maven build (cheap: the same search as [`load`] without parsing).
+1
View File
@@ -16,6 +16,7 @@ pub mod jsonc;
pub mod output_encoding;
pub mod process;
pub mod project_dir;
pub mod project_roots;
pub mod proxy_settings;
pub mod remote_settings;
pub mod settings;
+197
View File
@@ -0,0 +1,197 @@
//! Shared filesystem rules for discovering builds and resolving their owners.
use std::path::{Component, Path, PathBuf};
pub const SEARCH_DEPTH: usize = 3;
pub fn children(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
let mut dirs: Vec<_> = entries
.flatten()
.filter(|entry| {
if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
return false;
}
let name = entry.file_name();
let name = name.to_string_lossy();
!name.starts_with('.')
&& !matches!(
name.as_ref(),
"node_modules" | "target" | "build" | "dist" | "vendor" | "out" | "bin" | "obj" | "venv"
)
})
.map(|entry| entry.path())
.collect();
dirs.sort();
dirs
}
pub fn discover(root: &Path, markers: &[&str]) -> Vec<PathBuf> {
discover_matching(root, SEARCH_DEPTH, true, |dir| markers.iter().any(|marker| dir.join(marker).is_file()))
}
pub fn discover_matching(
root: &Path,
max_depth: usize,
stop_at_match: bool,
marked: impl Fn(&Path) -> bool,
) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut pending = vec![(root.to_path_buf(), 0)];
while let Some((dir, depth)) = pending.pop() {
if marked(&dir) {
found.push(dir.clone());
if stop_at_match {
continue;
}
}
// A source folder may itself be a build root (e.g. src/pom.xml), but
// source trees without their own build marker are never searched.
let source = dir.join("src");
if source.is_dir() && !source.is_symlink() && marked(&source) {
found.push(source);
}
if depth < max_depth {
pending.extend(
children(&dir)
.into_iter()
.rev()
.filter(|child| !child.ends_with("src"))
.map(|child| (child, depth + 1)),
);
}
}
found.sort();
found.dedup();
found
}
/// The closest package manager declaration or lockfile, shared by build and run.
pub fn package_manager(root: &Path) -> &'static str {
for dir in root.ancestors() {
let package = std::fs::read(dir.join("package.json"))
.ok()
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok());
if let Some(pm) = package.as_ref().and_then(|p| p.get("packageManager")).and_then(|p| p.as_str()) {
match pm.split('@').next().unwrap_or_default() {
"pnpm" => return "pnpm",
"yarn" => return "yarn",
"bun" => return "bun",
"npm" => return "npm",
_ => {}
}
}
for (marker, pm) in [
("pnpm-lock.yaml", "pnpm"),
("yarn.lock", "yarn"),
("bun.lock", "bun"),
("bun.lockb", "bun"),
("package-lock.json", "npm"),
] {
if dir.join(marker).is_file() {
return pm;
}
}
}
"npm"
}
pub fn resolve(root: &Path, path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in root.join(path).components() {
match component {
Component::CurDir => {}
Component::ParentDir if out.file_name().is_some() => {
out.pop();
}
component => out.push(component),
}
}
out
}
pub fn relative(root: &Path, path: &Path) -> PathBuf {
let path = resolve(root, path);
path.strip_prefix(resolve(root, Path::new(""))).unwrap_or(&path).to_path_buf()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn package_manager_declarations_are_shared_by_build_and_run() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("pnpm-lock.yaml"), "").unwrap();
let child = temp.path().join("packages/web");
std::fs::create_dir_all(&child).unwrap();
assert_eq!(package_manager(&child), "pnpm");
std::fs::write(child.join("package.json"), r#"{"packageManager":"yarn@4.0"}"#).unwrap();
assert_eq!(package_manager(&child), "yarn");
}
#[test]
fn discovery_has_one_depth_boundary_and_exclusion_policy_for_all_families() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let markers = [
"package.json",
"Cargo.toml",
"go.mod",
"pom.xml",
"build.gradle.kts",
"pyproject.toml",
"composer.json",
"Gemfile",
"CMakeLists.txt",
"project.godot",
"deno.json",
"Makefile",
"justfile",
];
for marker in markers {
for dir in [
"apps/one",
"services/backend/two",
"src",
"a/b/c/src",
"too/deep/to/find",
"node_modules/ignored",
"target/ignored",
"build/ignored",
"vendor/ignored",
".hidden",
"src/ignored",
] {
let path = root.join(dir).join(marker);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
assert_eq!(
discover(root, &[marker]),
["a/b/c/src", "apps/one", "services/backend/two", "src"].map(|dir| root.join(dir))
);
}
std::fs::write(root.join("Cargo.toml"), "").unwrap();
assert_eq!(discover(root, &["Cargo.toml"]), [root]);
}
#[cfg(unix)]
#[test]
fn discovery_never_follows_directory_symlinks() {
let temp = tempfile::tempdir().unwrap();
std::fs::create_dir(temp.path().join("real")).unwrap();
std::fs::write(temp.path().join("real/go.mod"), "").unwrap();
std::os::unix::fs::symlink(temp.path().join("real"), temp.path().join("alias")).unwrap();
assert_eq!(discover(temp.path(), &["go.mod"]), [temp.path().join("real")]);
}
#[test]
fn paths_keep_component_boundaries_and_external_roots() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("workspace");
assert_eq!(relative(&root, &root.join("apps/../api")), Path::new("api"));
assert_eq!(relative(&root, &temp.path().join("workspace-other")), temp.path().join("workspace-other"));
assert_eq!(resolve(&root, Path::new("./api/../web")), root.join("web"));
}
}
+1 -2
View File
@@ -170,8 +170,7 @@ impl KotlinBridge {
let settings = self.settings.effective();
let has_sources = tokio::task::spawn_blocking(move || {
let model = crate::ops::build::load(&root, &cache, &settings);
let enabled =
model.maven.as_ref().is_some_and(|m| !m.projects.is_empty()) && kotlin_bridge::has_kotlin(&root);
let enabled = kotlin_bridge::has_kotlin(&root, &model);
(model, enabled)
})
.await;
+92 -8
View File
@@ -47,8 +47,8 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
}
}
for g in &mut model.gradle {
g.env = ide_sdk::apply::gradle_env(root, settings, java_env(jdk.as_ref(), g.java_version));
g.command = ide_sdk::apply::gradle_command(root, &root.join(&g.dir), settings).to_string_lossy().into_owned();
g.env = ide_sdk::apply::gradle_build_env(root, &root.join(&g.dir), &g.command, settings, g.java_version);
}
// A failed sync of a project no installed JDK can build says so first (with a Download JDK action).
let missing = |release: Option<u32>| match jdk {
@@ -76,6 +76,42 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
model
}
/// Build and Run use the same selected toolchain and module-local runtime discovery.
fn prepare_plan(mut plan: compile::BuildPlan, root: &Path, settings: &Settings) -> compile::BuildPlan {
let kind = match plan.system {
compile::BuildSystem::Cargo => "cargo",
compile::BuildSystem::Go => "go",
compile::BuildSystem::TypeScript => "npm",
_ => return plan,
};
for step in &mut plan.steps {
let mut config = ide_run::RunConfig {
id: String::new(),
name: String::new(),
kind: kind.into(),
command: step.program.clone(),
args: step.args.clone(),
cwd: Some(step.cwd.clone().into()),
env: step.env.clone(),
detected: true,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
};
config.extras.module = Some(ide_core::ipc_path::to_ipc(&step.cwd));
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, settings);
if kind == "go" {
config.extras.params.insert("useTags".into(), "true".into());
ide_sdk::go::apply_to_run(&mut config, &settings.languages.go.build_tags);
}
step.program = config.command;
step.args = config.args;
step.env = config.env;
}
plan
}
/// `model` building with the Maven profiles of the last sync (`-P`), as Maven runs from
/// the tool window do.
fn with_profiles(mut model: BuildModel, cache: &Path) -> BuildModel {
@@ -467,7 +503,11 @@ pub async fn build_run(
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
let member_cache = cache.join(crate::project::project_key(member));
let model = with_profiles(load(member, &member_cache, &settings), &member_cache);
let plan = compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?;
let plan = prepare_plan(
compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?,
member,
&settings,
);
match &mut combined {
Some(combined) => combined.steps.extend(plan.steps),
None => combined = Some(plan),
@@ -477,13 +517,17 @@ pub async fn build_run(
plan.title = workspace.name;
plan
} else {
compile::plan(
prepare_plan(
compile::plan(
&root,
&with_profiles(load(&root, &cache, &settings), &cache),
kind,
file.as_deref(),
settings.cmake.profiles.as_deref(),
)?,
&root,
&with_profiles(load(&root, &cache, &settings), &cache),
kind,
file.as_deref(),
settings.cmake.profiles.as_deref(),
)?
&settings,
)
};
let result = compile::run(&root, &plan, &token, &|e| {
let _ = on_event.send(e);
@@ -559,6 +603,46 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod gradle_settings_tests {
use super::*;
#[test]
fn builds_apply_the_same_explicit_toolchains_as_runs() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let tool = |name: &str| format!("{name}{}", std::env::consts::EXE_SUFFIX);
for name in ["cargo", "go"] {
let path = root.join("sdk").join("bin").join(tool(name));
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "").unwrap();
}
let mut settings = Settings::default();
settings.languages.rust.toolchain = "sdk".into();
settings.languages.go.goroot = "sdk".into();
settings.languages.go.build_tags.custom = "integration".into();
settings.languages.node.package_manager = "yarn".into();
for (marker, source, program) in [
(
"Cargo.toml",
"[package]\nname='demo'\nversion='0.1.0'",
root.join("sdk").join("bin").join(tool("cargo")).to_string_lossy().into_owned(),
),
("go.mod", "module demo", root.join("sdk").join("bin").join(tool("go")).to_string_lossy().into_owned()),
("package.json", r#"{"scripts":{"build":"echo built"},"packageManager":"pnpm@10"}"#, "yarn".into()),
] {
let dir = root.join(marker.replace('.', "-"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(marker), source).unwrap();
let plan = prepare_plan(
compile::plan(&dir, &Default::default(), BuildKind::Artifacts, None, None).unwrap(),
root,
&settings,
);
assert_eq!(plan.steps[0].program, program);
assert_eq!(plan.steps[0].cwd, dir);
if marker == "go.mod" {
assert!(plan.steps[0].args.contains(&"-tags=integration".into()));
}
}
}
#[test]
fn linking_retains_the_implicit_build_and_unlinking_all_stays_empty() {
let tmp = tempfile::tempdir().unwrap();
+95 -10
View File
@@ -28,9 +28,12 @@ pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
task.target = format!("workspace:{}:{}", key, task.target);
}
}
for id in &mut config.extras.compound {
*id = format!("workspace:{key}:{id}");
}
config.id = format!("workspace:{}:{}", key, config.id);
config.name = format!("{name} / {}", config.name);
config.cwd = Some(config.cwd.map(|p| member.join(&*p)).unwrap_or_else(|| member.clone()).into());
ide_run::scope::rebase(&mut config, &member, None);
config.detected = true;
if !user.iter().any(|u| u.id == config.id) && !root_suppressed.contains(&config.id) {
all.push(config);
@@ -51,10 +54,18 @@ fn detected_and_saved(
suppressed: &[String],
) -> Vec<RunConfig> {
let excluded_poms = crate::project::unlinked_maven_poms(root, settings);
let gradle = settings
.languages
.java
.gradle
.linked_projects
.as_ref()
.map(|_| ide_build::gradle::load_linked(root, &settings.languages.java.gradle));
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))
.filter(|d| linked_config(root, gradle.as_deref(), &excluded_poms, d))
.collect();
all.extend(saved.iter().cloned());
for config in all.iter_mut().filter(|c| !matches!(c.kind.as_str(), "gradle" | "maven")) {
@@ -63,6 +74,41 @@ fn detected_and_saved(
all
}
fn linked_config(
root: &std::path::Path,
gradle: Option<&[ide_build::GradleModel]>,
excluded_poms: &[std::path::PathBuf],
config: &RunConfig,
) -> bool {
let module = config
.extras
.module
.as_deref()
.map(std::path::Path::new)
.or(config.cwd.as_deref())
.unwrap_or(std::path::Path::new(""));
let owner = ide_core::project_roots::resolve(root, module);
if matches!(config.kind.as_str(), "maven" | "application" | "spring-boot")
&& excluded_poms.iter().any(|pom| ide_core::project_roots::resolve(root, pom).parent() == Some(owner.as_path()))
{
return false;
}
if gradle.is_some()
&& (config.kind == "gradle"
|| (matches!(config.kind.as_str(), "application" | "spring-boot")
&& ["build.gradle", "build.gradle.kts"].iter().any(|name| owner.join(name).is_file())))
{
return gradle.unwrap_or_default().iter().any(|build| {
if config.kind == "gradle" {
root.join(&build.dir) == owner
} else {
build.projects.iter().any(|p| root.join(&p.dir) == owner)
}
});
}
true
}
/// Saves the user configurations. They come from [`configs`], toolchains applied, so what
/// settings derived is dropped again: only the user's own choices reach `run.toml`.
pub fn save_configs(
@@ -87,11 +133,22 @@ pub fn save_configs(
fn settings_for(
host: &Host,
project: &crate::project::Project,
config: &RunConfig,
config: &mut RunConfig,
) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings)> {
let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| {
let cwd = config.cwd.as_deref()?;
workspace.projects.into_iter().filter(|root| cwd.starts_with(root)).max_by_key(|root| root.components().count())
let workspace = ide_workspace::workspace::Workspace::load(&project.root)?;
if workspace.is_some() {
if config.extras.module.is_none() && !matches!(config.kind.as_str(), "application" | "spring-boot") {
config.extras.module = config.cwd.as_deref().map(ide_core::ipc_path::to_ipc);
}
ide_run::scope::rebase(config, &project.root, None);
}
let member = workspace.and_then(|workspace| {
let owner = ide_run::scope::owner(&project.root, config);
workspace
.projects
.into_iter()
.filter(|root| owner.starts_with(root))
.max_by_key(|root| root.components().count())
});
Ok(match member {
Some(root) => {
@@ -109,11 +166,11 @@ pub fn without_derived(host: &Host, window: &str, configs: Vec<RunConfig>) -> Id
let project = host.project(window)?;
configs
.into_iter()
.map(|c| {
.map(|mut c| {
if c.kind == "gradle" {
return Ok(c);
}
let (root, settings) = settings_for(host, &project, &c)?;
let (root, settings) = settings_for(host, &project, &mut c)?;
Ok(ide_sdk::apply::without_derived(&c, &root, &settings))
})
.collect()
@@ -127,7 +184,7 @@ pub fn apply_settings(host: &Host, window: &str, configs: Vec<RunConfig>) -> Ide
.into_iter()
.map(|mut c| {
if c.kind != "gradle" {
let (root, settings) = settings_for(host, &project, &c)?;
let (root, settings) = settings_for(host, &project, &mut c)?;
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut c), &root, &settings);
}
Ok(c)
@@ -174,13 +231,13 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
.run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c)))
.await;
}
let (root, settings) = settings_for(host, &project, &config)?;
let mut config = config;
let (root, settings) = settings_for(host, &project, &mut config)?;
let root = &root;
// Configurations made in the UI name bare tools (`godot`) that may live outside PATH.
// Maven re-applies too: a config listed before a settings change (daemon toggle,
// Maven home) keeps the launcher that was current then; re-resolving at launch is
// what makes the setting take effect without reopening the project.
let mut config = config;
if config.kind == "maven" {
ide_sdk::apply::prepare_maven_run(&mut config, root, &settings);
} else if matches!(config.kind.as_str(), "gradle" | "tomcat") {
@@ -295,3 +352,31 @@ pub fn list(host: &Host) -> Vec<RunInfo> {
mod commands;
mod container;
pub use commands::*;
#[cfg(test)]
mod ownership_tests {
use super::*;
#[test]
fn unlinked_builds_do_not_reappear_as_detected_application_configs() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
for name in ["maven", "gradle"] {
let dir = root.join(name);
std::fs::create_dir_all(dir.join("src/main/java")).unwrap();
std::fs::write(
dir.join("src/main/java/App.java"),
"@SpringBootApplication class App { public static void main(String[] a) {} }",
)
.unwrap();
}
std::fs::write(root.join("maven/pom.xml"), "<project><build><plugins><plugin><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>").unwrap();
std::fs::write(root.join("gradle/build.gradle.kts"), "plugins { id(\"org.springframework.boot\") }").unwrap();
let defaults = detected_and_saved(root, &Default::default(), &[], &[]);
assert!(defaults.iter().any(|c| c.kind == "spring-boot"));
let mut settings = ide_core::settings::Settings::default();
settings.languages.java.maven.unlinked_projects = vec!["maven/pom.xml".into()];
settings.languages.java.gradle.linked_projects = Some(vec![]);
let configs = detected_and_saved(root, &settings, &[], &[]);
assert!(configs.is_empty(), "{configs:?}");
}
}
+14 -3
View File
@@ -46,10 +46,21 @@ pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<V
}
/// The interpreter the project uses: the configured one, else its virtualenv.
pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
pub fn sdk_python_effective(host: &Host, window: &str, near: Option<PathBuf>) -> IdeResult<Option<ide_core::IpcPath>> {
host.require_trust(window)?;
let project = host.project(window)?;
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|w| {
let near = near.as_deref()?;
w.projects.into_iter().filter(|root| near.starts_with(root)).max_by_key(|root| root.components().count())
});
let (root, settings) = match member {
Some(root) => {
let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&root)).effective();
(root, settings)
}
None => (project.root.clone(), project.settings.effective()),
};
Ok(ide_sdk::apply::python_interpreter_near(&root, near.as_deref(), &settings).map(Into::into))
}
/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
@@ -232,7 +243,7 @@ pub async fn sdk_python_pip(
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) });
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window, c.arg("near")?) });
d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) });
d.add(
"sdk_php_language_level",
+1 -1
View File
@@ -160,7 +160,7 @@ async fn light_notebooks_open_without_interpreter_detection_or_execution() {
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_python_effective(&host, "main", None).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"));
+1 -1
View File
@@ -276,7 +276,7 @@ async fn python_venv() {
// The project's virtualenv: listed first, and the interpreter the project uses.
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Python).await.unwrap();
assert_eq!(found.first().map(|s| s.source.as_str()), Some("venv"), "{found:?}");
let effective = ops::sdk::sdk_python_effective(&p.host, WINDOW).unwrap().expect("an interpreter");
let effective = ops::sdk::sdk_python_effective(&p.host, WINDOW, None).unwrap().expect("an interpreter");
assert!(effective.starts_with(p.root.join(".venv")), "{effective:?}");
}
+182 -100
View File
@@ -27,18 +27,51 @@ pub(crate) fn detect_with_profiles(
) -> Vec<RunConfig> {
let mut out = Vec::new();
detect_node(root, &mut out);
detect_deno(root, &mut out);
detect_cargo(root, &mut out);
detect_projects(root, &["deno.json", "deno.jsonc"], detect_deno, &mut out);
detect_projects(root, &["Cargo.toml"], detect_cargo, &mut out);
detect_gradle(root, &mut out);
detect_maven(root, excluded_poms, &mut out);
detect_go(root, &mut out);
detect_python(root, &mut out);
detect_php(root, &mut out);
detect_ruby(root, &mut out);
cmake::detect(root, profiles, &mut out);
detect_projects_matching(
root,
|dir| {
["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "pytest.ini", "tox.ini", "manage.py"]
.iter()
.any(|name| dir.join(name).is_file())
|| [".venv", "venv"]
.iter()
.any(|name| dir.join(name).join(PyEnv::bin_dir()).join(PyEnv::python_exe()).is_file())
},
detect_python,
&mut out,
);
detect_projects(
root,
&["composer.json", "phpunit.xml", "phpunit.xml.dist", "phpunit.dist.xml"],
detect_php,
&mut out,
);
detect_projects_matching(root, ruby_project, detect_ruby, &mut out);
for dir in ide_core::project_roots::discover(root, &["CMakeLists.txt"]) {
let start = out.len();
cmake::detect(&dir, if dir == root { profiles } else { None }, &mut out);
if dir != root {
let scope = Scope::new(root, &dir);
for config in &mut out[start..] {
config.id = scope.id(&config.id);
config.name = scope.name(&config.name);
for task in &mut config.extras.before_launch {
if task.kind == "run" {
task.target = scope.id(&task.target);
}
}
crate::scope::rebase(config, &dir, Some(root));
}
}
}
dotnet::detect(root, &mut out);
detect_make(root, &mut out);
detect_just(root, &mut out);
detect_projects(root, &["Makefile", "makefile", "GNUmakefile"], detect_make, &mut out);
detect_projects(root, &["justfile", "Justfile", ".justfile"], detect_just, &mut out);
detect_godot(root, &mut out);
crate::external::detect(root, &mut out);
@@ -47,6 +80,80 @@ pub(crate) fn detect_with_profiles(
out
}
fn detect_projects(
root: &Path,
markers: &[&str],
detect: impl Fn(&Path, &mut Vec<RunConfig>),
out: &mut Vec<RunConfig>,
) {
detect_projects_matching(
root,
|dir| {
markers.iter().any(|marker| dir.join(marker).is_file())
&& (markers != ["Cargo.toml"] || !cargo_workspace_owns(root, dir))
},
detect,
out,
);
}
fn detect_projects_matching(
root: &Path,
marked: impl Fn(&Path) -> bool,
detect: impl Fn(&Path, &mut Vec<RunConfig>),
out: &mut Vec<RunConfig>,
) {
let mut covered = HashSet::new();
for dir in ide_core::project_roots::discover_matching(root, ide_core::project_roots::SEARCH_DEPTH, false, marked) {
if covered.contains(&dir) {
continue;
}
let mut configs = Vec::new();
detect(&dir, &mut configs);
for config in &configs {
if let Some(module) = &config.extras.module {
covered.insert(ide_core::project_roots::resolve(&dir, Path::new(module)));
}
}
if dir != root {
let scope = Scope::new(root, &dir);
for config in &mut configs {
config.id = scope.id(&config.id);
config.name = scope.name(&config.name);
for task in &mut config.extras.before_launch {
if task.kind == "run" {
task.target = scope.id(&task.target);
}
}
crate::scope::rebase(config, &dir, Some(root));
}
}
out.extend(configs);
}
}
fn cargo_workspace_owns(root: &Path, dir: &Path) -> bool {
let Some(manifest) = read_toml(&dir.join("Cargo.toml")) else { return false };
if manifest.contains_key("workspace") {
return false;
}
dir.ancestors()
.skip(1)
.take_while(|parent| parent.starts_with(root))
.find_map(|parent| {
let manifest = read_toml(&parent.join("Cargo.toml"))?;
let workspace = manifest.get("workspace")?;
let excluded = string_list(workspace.get("exclude"));
Some(
!excluded.iter().any(|excluded| dir.starts_with(parent.join(excluded)))
&& !expand_members(parent, &excluded, "Cargo.toml")
.iter()
.any(|excluded| dir.starts_with(excluded)),
)
})
.unwrap_or(false)
}
fn config(id: impl Into<String>, name: impl Into<String>, kind: &str, command: &str, args: &[&str]) -> RunConfig {
RunConfig {
id: id.into(),
@@ -103,19 +210,7 @@ fn first_existing(root: &Path, names: &[&str]) -> Option<PathBuf> {
/// Sorted immediate subdirectories, skipping hidden and vendored ones.
fn subdirs(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = fs::read_dir(dir) else { return Vec::new() };
let mut dirs: Vec<PathBuf> = entries
.filter_map(Result::ok)
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.filter(|e| {
let name = e.file_name();
let name = name.to_string_lossy();
!name.starts_with('.') && !matches!(name.as_ref(), "node_modules" | "target" | "build" | "dist" | "vendor")
})
.map(|e| e.path())
.collect();
dirs.sort();
dirs
ide_core::project_roots::children(dir)
}
/// Expands workspace member patterns one level deep: `dir`, `dir/*`, `dir/**`.
@@ -151,25 +246,8 @@ fn relative(root: &Path, dir: &Path) -> PathBuf {
// ---------------------------------------------------------------- node
pub(crate) fn package_manager(root: &Path, pkg: &Json) -> &'static str {
if let Some(pm) = pkg.get("packageManager").and_then(Json::as_str) {
match pm.split('@').next().unwrap_or_default() {
"pnpm" => return "pnpm",
"yarn" => return "yarn",
"bun" => return "bun",
"npm" => return "npm",
_ => {}
}
}
if root.join("pnpm-lock.yaml").is_file() {
"pnpm"
} else if root.join("yarn.lock").is_file() {
"yarn"
} else if root.join("bun.lock").is_file() || root.join("bun.lockb").is_file() {
"bun"
} else {
"npm"
}
pub(crate) fn package_manager(root: &Path, _pkg: &Json) -> &'static str {
ide_core::project_roots::package_manager(root)
}
pub(crate) fn is_test_script(name: &str) -> bool {
@@ -186,6 +264,7 @@ fn push_scripts(pkg: &Json, pm: &str, prefix: Option<&str>, cwd: Option<PathBuf>
None => (format!("npm:{script}"), format!("{pm} run {script}")),
};
let mut c = config(id, name, "npm", pm, &["run", script]).in_dir(cwd.clone()).debug("js-debug");
c.extras.module = cwd.as_deref().map(ide_core::ipc_path::to_ipc);
if is_test_script(script) {
c = c.test();
}
@@ -221,27 +300,34 @@ fn node_workspace_patterns(root: &Path, pkg: &Json) -> Vec<String> {
}
fn detect_node(root: &Path, out: &mut Vec<RunConfig>) {
let Some(pkg) = read_json(&root.join("package.json")) else {
// A polyglot repository: front-end packages one level down (`web/`, `frontend/`).
for dir in subdirs(root) {
let Some(member) = read_json(&dir.join("package.json")) else { continue };
let rel = relative(root, &dir);
let label = rel.to_string_lossy().replace('\\', "/");
push_scripts(&member, package_manager(&dir, &member), Some(&label), Some(rel), out);
let mut covered = HashSet::new();
let projects =
ide_core::project_roots::discover_matching(root, ide_core::project_roots::SEARCH_DEPTH, false, |dir| {
dir.join("package.json").is_file()
});
for dir in projects {
if !covered.insert(dir.clone()) {
continue;
}
return;
};
let pm = package_manager(root, &pkg);
push_scripts(&pkg, pm, None, None, out);
for dir in expand_members(root, &node_workspace_patterns(root, &pkg), "package.json") {
let Some(member) = read_json(&dir.join("package.json")) else { continue };
let Some(pkg) = read_json(&dir.join("package.json")) else { continue };
let pm = package_manager(&dir, &pkg);
let rel = relative(root, &dir);
let label = member
.get("name")
.and_then(Json::as_str)
.map(str::to_owned)
.unwrap_or_else(|| rel.to_string_lossy().replace('\\', "/"));
push_scripts(&member, pm, Some(&label), Some(rel), out);
let label = ide_core::ipc_path::to_ipc(&rel);
push_scripts(&pkg, pm, (dir != root).then_some(label.as_str()), (dir != root).then_some(rel), out);
for member_dir in expand_members(&dir, &node_workspace_patterns(&dir, &pkg), "package.json") {
if !covered.insert(member_dir.clone()) {
continue;
}
let Some(member) = read_json(&member_dir.join("package.json")) else { continue };
let rel = relative(root, &member_dir);
let name = member
.get("name")
.and_then(Json::as_str)
.map(str::to_owned)
.unwrap_or_else(|| ide_core::ipc_path::to_ipc(&rel));
let name = if dir == root { name } else { format!("{label}:{name}") };
push_scripts(&member, pm, Some(&name), Some(rel), out);
}
}
}
@@ -355,12 +441,31 @@ fn detect_cargo(root: &Path, out: &mut Vec<RunConfig>) {
} else {
format!("cargo:run:{pkg}:{bin}")
};
out.push(config(id, format!("cargo run {bin}"), "cargo", "cargo", &args).debug("codelldb"));
let mut run = config(id, format!("cargo run {bin}"), "cargo", "cargo", &args).debug("codelldb");
if dir != root {
run.extras.module = Some(ide_core::ipc_path::to_ipc(&relative(root, dir)));
}
out.push(run);
}
}
if packages.is_empty() && !is_workspace {
return;
}
if is_workspace {
for (pkg, dir, _) in &packages {
let mut test = config(
format!("cargo:test:{pkg}"),
format!("cargo test {pkg}"),
"cargo",
"cargo",
&["test", "-p", pkg],
)
.test()
.debug("codelldb");
test.extras.module = Some(ide_core::ipc_path::to_ipc(&relative(root, dir)));
out.push(test);
}
}
let ws_flag: &[&str] = if is_workspace { &["--workspace"] } else { &[] };
let test_args: Vec<&str> = ["test"].iter().chain(ws_flag).copied().collect();
let build_args: Vec<&str> = ["build"].iter().chain(ws_flag).copied().collect();
@@ -410,32 +515,8 @@ fn applies_application_plugin(text: &str) -> bool {
})
}
/// How deep a build is looked for when the project root has none (a folder holding
/// several repositories: `backend/pom.xml`, `apps/server/settings.gradle`).
const NESTED_BUILD_DEPTH: usize = 3;
/// The builds marked by any of `markers`: the root itself, else the shallowest marked
/// folder of each branch, up to [`NESTED_BUILD_DEPTH`] levels down.
fn build_dirs(root: &Path, markers: &[&str]) -> Vec<PathBuf> {
let marked = |d: &Path| markers.iter().any(|m| d.join(m).is_file());
if marked(root) {
return vec![root.to_path_buf()];
}
let mut out = Vec::new();
let mut level = vec![root.to_path_buf()];
for _ in 0..NESTED_BUILD_DEPTH {
let mut next = Vec::new();
for sub in level.iter().flat_map(|d| subdirs(d)).filter(|d| !d.ends_with("src") && !d.ends_with("out")) {
if marked(&sub) {
out.push(sub);
} else {
next.push(sub);
}
}
level = next;
}
out.sort();
out
ide_core::project_roots::discover(root, markers)
}
/// Ids, names and working directory of the configurations of a build nested in
@@ -546,7 +627,7 @@ fn spring_boot_apps(root: &Path, build: &Path, out: &mut Vec<RunConfig>) {
let mut budget = SPRING_SCAN_FILES;
let mut modules = vec![build.to_path_buf()];
let mut level = vec![build.to_path_buf()];
for _ in 0..NESTED_BUILD_DEPTH {
for _ in 0..ide_core::project_roots::SEARCH_DEPTH {
level = level.iter().flat_map(|d| subdirs(d)).filter(|d| !d.ends_with("src")).collect();
modules.extend(level.iter().cloned());
}
@@ -708,16 +789,11 @@ fn detect_go(root: &Path, out: &mut Vec<RunConfig>) {
}
return;
}
if root.join("go.mod").is_file() {
detect_go_module(root, None, out);
return;
}
// A polyglot repository: Go modules one level down (`api/go.mod`).
for dir in subdirs(root) {
if dir.join("go.mod").is_file() {
let rel = relative(root, &dir);
detect_go_module(&dir, Some(rel), out);
}
for dir in ide_core::project_roots::discover_matching(root, ide_core::project_roots::SEARCH_DEPTH, false, |dir| {
dir.join("go.mod").is_file()
}) {
let rel = relative(root, &dir);
detect_go_module(&dir, (!rel.as_os_str().is_empty()).then_some(rel), out);
}
}
@@ -1069,14 +1145,18 @@ fn uses_rspec(root: &Path) -> bool {
|| read(&root.join("Gemfile")).is_some_and(|g| g.contains("rspec"))
}
fn detect_ruby(root: &Path, out: &mut Vec<RunConfig>) {
fn ruby_project(root: &Path) -> bool {
let markers = ["Gemfile", "Rakefile", "rakefile", "Rakefile.rb", ".ruby-version", "config.ru"];
let gemspec = fs::read_dir(root)
.into_iter()
.flatten()
.flatten()
.any(|e| e.path().extension().is_some_and(|x| x == "gemspec"));
if !gemspec && !markers.iter().any(|m| root.join(m).is_file()) {
gemspec || markers.iter().any(|m| root.join(m).is_file())
}
fn detect_ruby(root: &Path, out: &mut Vec<RunConfig>) {
if !ruby_project(root) {
return;
}
if root.join("bin/rails").is_file() {
@@ -1217,8 +1297,10 @@ pub(crate) fn just_recipes(src: &str) -> Vec<String> {
/// Godot projects (`project.godot`) at the root or one level down: "Godot Project"
/// runs the main scene (`godot --path .`), debuggable through the editor's adapter.
fn detect_godot(root: &Path, out: &mut Vec<RunConfig>) {
let dirs = std::iter::once(root.to_path_buf()).chain(subdirs(root));
for dir in dirs.filter(|d| d.join("project.godot").is_file()) {
let dirs = ide_core::project_roots::discover_matching(root, ide_core::project_roots::SEARCH_DEPTH, false, |dir| {
dir.join("project.godot").is_file()
});
for dir in dirs {
let rel = relative(root, &dir);
let (id, name, cwd) = if rel.as_os_str().is_empty() {
("godot:project".to_owned(), "Godot Project".to_owned(), None)
+3 -1
View File
@@ -14,6 +14,7 @@ mod process;
pub mod python_path;
pub mod ruby_preload;
pub mod ruby_reporter;
pub mod scope;
pub mod tomcat;
mod utf8;
pub mod which;
@@ -76,7 +77,8 @@ fn is_true(b: &bool) -> bool {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RunExtras {
/// Module supplying a JVM application's classpath; empty means the project root.
/// Owning module, independent of the working directory; also supplies a JVM application's classpath.
/// Empty means the project root.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub module: Option<String>,
+125
View File
@@ -0,0 +1,125 @@
//! A configuration's module and relative paths move together when projects are nested.
use crate::RunConfig;
use ide_core::{ipc_path::to_ipc, project_roots};
use std::path::{Path, PathBuf};
pub fn owner(root: &Path, config: &RunConfig) -> PathBuf {
if matches!(config.kind.as_str(), "dotnet" | "dotnet-test")
&& let Some(project) = config.extras.params.get("project").filter(|p| !p.is_empty())
{
return project_roots::resolve(root, Path::new(project)).parent().unwrap_or(root).to_path_buf();
}
let module = (config.kind == "go")
.then(|| config.extras.params.get("module"))
.flatten()
.map(String::as_str)
.or(config.extras.module.as_deref())
.map(Path::new)
.or(config.cwd.as_deref())
.unwrap_or(Path::new(""));
project_roots::resolve(root, module)
}
pub fn rebase(config: &mut RunConfig, from: &Path, relative_to: Option<&Path>) {
let path = |value: &str| {
let absolute = project_roots::resolve(from, Path::new(value));
to_ipc(&relative_to.map_or(absolute.clone(), |root| project_roots::relative(root, &absolute)))
};
let cwd = config.cwd.as_deref().unwrap_or(Path::new("")).to_path_buf();
config.cwd = Some(path(&to_ipc(&cwd)).into());
if config.extras.module.is_some() || !matches!(config.kind.as_str(), "application" | "spring-boot") {
config.extras.module = Some(path(config.extras.module.as_deref().unwrap_or("")));
}
if config.kind == "go" && config.extras.params.contains_key("goKind") {
config.extras.params.entry("module".into()).or_default();
}
for file in &mut config.extras.env_files {
*file = path(file);
}
for file in [&mut config.extras.stdin_file, &mut config.extras.output_file].into_iter().flatten() {
*file = path(file);
}
let keys: &[&str] = match config.kind.as_str() {
"go" => &["module", "outputDir"],
"dotnet" | "dotnet-test" => &["project", "targetPath"],
_ => &[],
};
for key in keys {
if let Some(value) = config.extras.params.get_mut(*key)
&& (*key == "module" || !value.is_empty())
{
*value = path(value);
}
}
if !matches!(config.kind.as_str(), "shell" | "compound") && Path::new(&config.command).components().count() > 1 {
// Executables are relative to the old cwd, unlike env/input/output files.
let cwd = project_roots::resolve(from, &cwd);
config.command = to_ipc(&project_roots::resolve(&cwd, Path::new(&config.command)));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn default_go_module_moves_with_member_but_unknown_jvm_module_stays_inferred() {
let temp = tempfile::tempdir().unwrap();
for (kind, params) in
[("go", serde_json::json!({"goKind":"build", "target":"."})), ("application", serde_json::json!({}))]
{
let mut config: RunConfig = serde_json::from_value(serde_json::json!({
"id":"run", "name":"run", "kind":kind, "command":"tool", "args":[], "cwd":null,
"env":{}, "detected":false, "isTest":false, "debugAdapter":null, "params":params
}))
.unwrap();
rebase(&mut config, temp.path(), None);
if kind == "go" {
assert_eq!(config.extras.params["module"], to_ipc(temp.path()));
} else {
assert_eq!(config.extras.module, None);
}
}
}
#[test]
fn workspace_members_rebase_every_typed_root_without_changing_targets() {
let temp = tempfile::tempdir().unwrap();
let member = temp.path().join("member with spaces");
for (kind, params) in [
("application", BTreeMap::<String, String>::new()),
(
"go",
BTreeMap::from([
("module".into(), "api".into()),
("target".into(), "./cmd/server".into()),
("outputDir".into(), "bin".into()),
]),
),
("dotnet", BTreeMap::from([("project".into(), "src/App.csproj".into())])),
] {
let mut config: RunConfig = serde_json::from_value(serde_json::json!({
"id":"run", "name":"run", "kind":kind, "command":"tool", "args":["unchanged"], "cwd":null,
"env":{}, "detected":true, "isTest":false, "debugAdapter":null,
"module":"app", "params":params, "envFiles":[".env"], "stdinFile":"input.txt", "outputFile":"out/result.txt"
})).unwrap();
rebase(&mut config, &member, None);
assert_eq!(config.cwd.as_deref(), Some(member.as_path()));
assert_eq!(config.extras.module.as_deref(), Some(to_ipc(&member.join("app")).as_str()));
assert_eq!(config.extras.env_files, [to_ipc(&member.join(".env"))]);
assert_eq!(config.extras.stdin_file, Some(to_ipc(&member.join("input.txt"))));
assert_eq!(config.extras.output_file, Some(to_ipc(&member.join("out/result.txt"))));
assert_eq!(config.args, ["unchanged"]);
if kind == "go" {
assert_eq!(config.extras.params["module"], to_ipc(&member.join("api")));
assert_eq!(config.extras.params["outputDir"], to_ipc(&member.join("bin")));
assert_eq!(config.extras.params["target"], "./cmd/server");
}
if kind == "dotnet" {
assert_eq!(config.extras.params["project"], to_ipc(&member.join("src/App.csproj")));
}
}
}
}
+125 -5
View File
@@ -131,9 +131,25 @@ fn cargo_workspace() {
("cli/src/main.rs", ""),
]);
let c = detect(dir.path());
assert_eq!(ids(&c), ["cargo:run:openidea-cli", "cargo:test", "cargo:build"]);
assert_eq!(
ids(&c),
[
"cargo:run:openidea-cli",
"cargo:test:core",
"cargo:test:openidea-cli",
"cargo:test",
"cargo:build",
"cargo:run:skip:crates/skip",
"cargo:test:crates/skip",
"cargo:build:crates/skip"
]
);
assert_eq!(cmdline(&c[0]), "cargo run -p openidea-cli --bin openidea-cli");
assert_eq!(cmdline(find(&c, "cargo:test")), "cargo test --workspace");
let test = find(&c, "cargo:test:core");
assert_eq!(test.extras.module.as_deref(), Some("crates/core"));
assert_eq!(cmdline(test), "cargo test -p core");
assert_eq!(find(&c, "cargo:run:skip:crates/skip").cwd.as_deref(), Some(Path::new("crates/skip")));
}
#[test]
@@ -300,7 +316,22 @@ fn nested_builds_of_every_kind_and_depth() {
assert_eq!((billing.cwd.as_deref(), billing.extras.module.as_deref()), (None, Some("billing")));
let builds: Vec<&str> =
ids(&c).into_iter().filter(|id| id.starts_with("maven:package") || id.starts_with("gradle:build")).collect();
assert_eq!(builds, ["gradle:build:services/api", "maven:package:billing"]);
assert_eq!(builds, ["gradle:build:services/api", "maven:package:billing", "maven:package:tool/src"]);
let model = ide_build::load(dir.path(), &dir.path().join("cache"), None);
for config in c.iter().filter(|c| c.kind == "gradle") {
let cwd = config.cwd.as_deref().unwrap();
assert!(model.gradle.iter().any(|g| Path::new(&g.dir) == cwd), "{} has no build model", config.id);
}
let plan = ide_build::compile::plan(
dir.path(),
&model,
ide_build::compile::BuildKind::Build,
Some(&dir.path().join("services/api/app/src/main/kotlin/com/acme/Api.kt")),
None,
)
.unwrap();
assert_eq!(plan.steps[0].cwd, dir.path().join("services/api"));
assert_eq!(plan.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]);
}
#[test]
@@ -362,6 +393,49 @@ fn go_projects() {
assert_eq!(ids(&detect(dir.path())), ["go:test"]);
}
#[test]
fn nested_go_modules_keep_their_working_directories() {
let dir = project(&[
("Cargo.toml", "[package]\nname = 'workspace'\n"),
("fixtures/go-app/go.mod", "module example.com/openidea/notes\n"),
("fixtures/go-app/cmd/server/main.go", "package main\nfunc main() {}\n"),
("services/backend/api/go.mod", "module example.com/api\n"),
("services/backend/api/main.go", "package main\nfunc main() {}\n"),
("fixtures/go-app/nested/go.mod", "module ignored\n"),
("vendor/ignored/go.mod", "module ignored\n"),
("node_modules/ignored/go.mod", "module ignored\n"),
("target/ignored/go.mod", "module ignored\n"),
("build/ignored/go.mod", "module ignored\n"),
("dist/ignored/go.mod", "module ignored\n"),
(".hidden/ignored/go.mod", "module ignored\n"),
]);
let configs = detect(dir.path());
let go: Vec<_> = configs.iter().filter(|c| c.kind == "go").map(|c| c.id.as_str()).collect();
assert_eq!(
go,
[
"go:run:server:fixtures/go-app",
"go:test:fixtures/go-app",
"go:test:fixtures/go-app/nested",
"go:run:services/backend/api",
"go:test:services/backend/api",
]
);
let run = find(&configs, "go:run:server:fixtures/go-app");
assert_eq!(cmdline(run), "go run ./cmd/server");
assert_eq!(run.cwd.as_deref(), Some(Path::new("fixtures/go-app")));
let test = find(&configs, "go:test:services/backend/api");
assert_eq!(test.cwd.as_deref(), Some(Path::new("services/backend/api")));
}
#[cfg(unix)]
#[test]
fn nested_go_discovery_does_not_follow_symlinks() {
let dir = project(&[("real/go.mod", "module real\n")]);
std::os::unix::fs::symlink(dir.path().join("real"), dir.path().join("alias")).unwrap();
assert_eq!(ids(&detect(dir.path())), ["go:test:real"]);
}
#[test]
fn python_projects() {
let dir = project(&[
@@ -545,7 +619,7 @@ fn nested_packages_in_polyglot_repo() {
}
#[test]
fn root_package_disables_nested_scan() {
fn independent_packages_remain_visible_under_a_root_package() {
let dir = project(&[
("package.json", r#"{"scripts":{"build":"tsc"}}"#),
("tools/package.json", r#"{"scripts":{"gen":"x"}}"#),
@@ -553,9 +627,9 @@ fn root_package_disables_nested_scan() {
("svc/go.mod", "module y\n"),
]);
let c = detect(dir.path());
assert!(!ids(&c).contains(&"npm:tools:gen"));
assert!(ids(&c).contains(&"npm:tools:gen"));
assert!(ids(&c).contains(&"go:test"));
assert!(!ids(&c).contains(&"go:test:svc"));
assert!(ids(&c).contains(&"go:test:svc"));
}
#[test]
@@ -847,3 +921,49 @@ fn ruby_without_bundler_runs_bare_tools() {
assert_eq!(cmdline(&c[0]), "rake test");
assert!(detect(project(&[("a.rb", "puts 1\n")]).path()).is_empty());
}
#[test]
fn all_builtin_families_find_independent_nested_projects_and_keep_owners() {
let cases = [
("package.json", r#"{"scripts":{"test":"vitest"}}"#, "npm"),
("deno.json", r#"{"tasks":{"test":"deno test"}}"#, "deno"),
("Cargo.toml", "[package]\nname='demo'\nversion='0.1.0'\n[workspace]", "cargo"),
("go.mod", "module demo", "go"),
("pom.xml", "<project/>", "maven"),
("build.gradle.kts", "plugins { application }", "gradle"),
("pyproject.toml", "[tool.pytest.ini_options]", "pytest"),
("phpunit.xml", "<phpunit/>", "phpunit"),
("Rakefile.rb", "task :test do\nend", "rake"),
("CMakeLists.txt", "project(demo)", "cmake"),
("Makefile", "test:\n\techo ok", "make"),
("justfile", "test:\n echo ok", "just"),
("project.godot", "[application]\nrun/main_scene=\"res://main.tscn\"", "godot"),
];
for (marker, text, kind) in cases {
let dir = project(&[
("package.json", r#"{"scripts":{"build":"echo root"}}"#),
(&format!("packages/apps/demo/{marker}"), text),
]);
let configs = detect(dir.path());
let found: Vec<_> = configs
.iter()
.filter(|c| c.kind == kind && c.cwd.as_deref() == Some(Path::new("packages/apps/demo")))
.collect();
assert!(!found.is_empty(), "{kind}: {configs:?}");
for c in found {
// JVM and Go configs already identify ownership through cwd / their typed model.
if !matches!(kind, "gradle" | "maven" | "go" | "godot") {
assert_eq!(c.extras.module.as_deref(), Some("packages/apps/demo"));
}
}
}
}
#[test]
fn cmake_subdirectories_are_not_independent_builds() {
let dir = project(&[
("CMakeLists.txt", "project(demo)\nadd_subdirectory(lib)"),
("lib/CMakeLists.txt", "add_library(lib lib.cpp)"),
]);
assert!(detect(dir.path()).iter().filter(|c| c.kind == "cmake").all(|c| c.cwd.is_none()));
}
+205 -15
View File
@@ -11,6 +11,7 @@ use ide_core::{IdeError, IdeResult};
use ide_language::{LanguageConfig, LanguageServerConfig};
use ide_run::RunConfig;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
@@ -70,6 +71,18 @@ pub fn python_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
.or_else(|| pinned::pinned_python(root))
}
/// An explicit project interpreter wins; otherwise use the closest virtualenv to the file.
pub fn python_interpreter_near(root: &Path, near: Option<&Path>, s: &Settings) -> Option<PathBuf> {
if let Some(python) = setting_path(root, &s.languages.python.interpreter) {
return Some(python);
}
let near = near.filter(|dir| dir.starts_with(root)).unwrap_or(root);
near.ancestors()
.take_while(|dir| dir.starts_with(root))
.find_map(project_venv_python)
.or_else(|| pinned::pinned_python(near))
}
/// The project Ruby: the configured one, else the `.ruby-version` / mise / asdf
/// install, else `ruby` on PATH.
pub fn ruby_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
@@ -245,6 +258,103 @@ fn resolve_gradle_command(root: &Path, build_dir: &Path, s: &Settings, use_wrapp
.unwrap_or_else(|| name.into())
}
fn gradle_run_jdk<'a>(
release: Option<u32>,
configured: Option<&'a crate::java::Jdk>,
installed: &'a [crate::java::Jdk],
gradle: Option<(u32, u32)>,
) -> Option<&'a crate::java::Jdk> {
configured.or_else(|| {
let release = release?;
// An unknown Gradle version cannot narrow the choice: the release alone decides.
let Some(gradle) = gradle else { return crate::java::pick_jdk(installed, Some(release)) };
let compatible: Vec<_> = installed.iter().filter(|jdk| gradle_supports_java(gradle, jdk.major)).collect();
compatible
.iter()
.copied()
.find(|jdk| jdk.major == release)
.or_else(|| compatible.iter().copied().filter(|jdk| jdk.major > release).min_by_key(|jdk| jdk.major))
.or_else(|| compatible.iter().copied().max_by_key(|jdk| jdk.major))
})
}
/// Default environment shared by Gradle model builds and Run configurations.
pub fn gradle_build_env(
root: &Path,
build_dir: &Path,
command: &str,
settings: &Settings,
release: Option<u32>,
) -> std::collections::BTreeMap<String, String> {
let configured = project_jdk(root, settings);
let installed = crate::java::installed_jdks();
let jdk = gradle_run_jdk(
release.or_else(|| crate::java::project_java_version(build_dir)),
configured.as_ref(),
&installed,
gradle_distribution_version(build_dir, command),
);
let env = jdk
.map(|jdk| std::collections::BTreeMap::from([("JAVA_HOME".into(), jdk.home.to_string_lossy().into_owned())]))
.unwrap_or_default();
gradle_env(root, settings, env)
}
fn gradle_supports_java(gradle: (u32, u32), java: u32) -> bool {
// https://docs.gradle.org/current/userguide/compatibility.html#java_runtime
let minimum = match java {
8 => (2, 0),
9 => (4, 3),
10 => (4, 7),
11 => (5, 0),
12 => (5, 4),
13 => (6, 0),
14 => (6, 3),
15 => (6, 7),
16 => (7, 0),
17 => (7, 3),
18 => (7, 5),
19 => (7, 6),
20 => (8, 3),
21 => (8, 5),
22 => (8, 8),
23 => (8, 10),
24 => (8, 14),
25 => (9, 1),
26 => (9, 4),
27 => (9, 8),
_ => return false,
};
gradle >= minimum && (java >= 17 || gradle < (9, 0))
}
fn gradle_distribution_version(build_dir: &Path, command: &str) -> Option<(u32, u32)> {
let version = |text: &str| {
let mut parts = text.split('.');
Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?))
};
if Path::new(command).file_stem().is_some_and(|name| name == "gradlew") {
let properties = std::fs::read_to_string(build_dir.join("gradle/wrapper/gradle-wrapper.properties")).ok()?;
let url = properties.lines().find_map(|line| line.trim().strip_prefix("distributionUrl="))?;
return version(url.rsplit_once("gradle-")?.1.split('-').next()?);
}
let path = Path::new(command);
let path = if path.is_absolute() { path.to_path_buf() } else { crate::exec::which(command)? };
let path = dunce::canonicalize(path).ok()?;
let home = path.parent()?.parent()?;
for lib in [home.join("lib"), home.join("libexec/lib")] {
let Ok(entries) = std::fs::read_dir(lib) else { continue };
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if let Some(v) = name.strip_prefix("gradle-launcher-").and_then(|name| name.strip_suffix(".jar")) {
return version(v);
}
}
}
None
}
/// Gradle JVM overrides Project JDK; model callers supply the module's fallback environment.
pub fn gradle_env(
root: &Path,
@@ -948,19 +1058,38 @@ fn add_maven_options(args: &mut Vec<String>, options: &[String]) {
/// (and Maven the configured home and options).
pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings) {
let explicit_python = setting_path(root, &s.languages.python.interpreter);
let python = python_interpreter(root, s);
let pm = non_empty(&s.languages.node.package_manager);
let node = node_dir(root, s);
let php = setting_path(root, &s.languages.php.interpreter);
let rust = rust_bin(root, s);
let go = goroot(root, s);
let gopath = setting_path(root, &s.languages.go.gopath);
let jdk = project_jdk(root, s);
let mvn = maven_command(root, s);
let mvn_options = maven_options(root, s);
let ruby = ruby_interpreter(root, s);
// Module-local toolchains, looked up once per owning module and only for the kinds using them.
let mut toolchains: HashMap<(&'static str, PathBuf), Option<PathBuf>> = HashMap::new();
let mut toolchain = |tool: &'static str, owner: &Path| {
toolchains
.entry((tool, owner.to_path_buf()))
.or_insert_with(|| {
// A module outside the project (an external linked build) keeps the project's choices.
let near = if owner.starts_with(root) { owner } else { root };
match tool {
"python" => python_interpreter_near(root, Some(near), s),
"node" if non_empty(&s.languages.node.interpreter).is_none() => {
node_dir(near, s).or_else(|| node_dir(root, s))
}
"node" => node_dir(root, s),
"go" if non_empty(&s.languages.go.goroot).is_none() => goroot(near, s).or_else(|| goroot(root, s)),
"go" => goroot(root, s),
_ => setting_path(root, &s.languages.ruby.interpreter).or_else(|| ruby_interpreter(near, s)),
}
})
.clone()
};
for c in configs.iter_mut() {
let owner = ide_run::scope::owner(root, c);
resolve_run_command(c, root, s);
let mut path_dirs: Vec<PathBuf> = Vec::new();
match c.kind.as_str() {
@@ -984,6 +1113,7 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
c.args = ["-m".to_owned(), "pytest".to_owned()].into_iter().chain(rest).collect();
}
}
let python = toolchain("python", &owner);
if let Some(venv) = python.as_deref().and_then(venv_of)
&& !c.env.contains_key("VIRTUAL_ENV")
{
@@ -991,8 +1121,9 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
path_dirs.push(venv.join(bin_dir()));
}
}
"npm" => {
"npm" | "node" | "deno" | "bun" => {
if let Some(pm) = pm
&& c.kind == "npm"
&& c.detected
&& matches!(c.command.as_str(), "npm" | "pnpm" | "yarn" | "bun")
{
@@ -1000,7 +1131,9 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
}
// The configuration's own Node interpreter, else the project's.
let own = c.extras.params.get("nodeInterpreter").and_then(|p| setting_path(root, p));
if let Some(dir) = own.and_then(|p| p.parent().map(Path::to_path_buf)).or_else(|| node.clone()) {
if let Some(dir) =
own.and_then(|p| p.parent().map(Path::to_path_buf)).or_else(|| toolchain("node", &owner))
{
path_dirs.push(dir);
}
}
@@ -1027,7 +1160,7 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
for (key, value) in crate::go::module_environment(root, &s.languages.go.modules) {
c.env.entry(key).or_insert(value);
}
if let Some(goroot) = &go {
if let Some(goroot) = &toolchain("go", &owner) {
let bin = goroot.join("bin");
if c.command == "go" && bin.join(exe("go")).is_file() {
c.command = bin.join(exe("go")).to_string_lossy().into_owned();
@@ -1044,18 +1177,12 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
if matches!(c.command.as_str(), "gradle" | "gradle.bat") {
c.command = resolve_gradle_command(root, &build_dir, s, false).to_string_lossy().into_owned();
}
let defaults = jdk
.as_ref()
.map(|j| {
std::collections::BTreeMap::from([("JAVA_HOME".into(), j.home.to_string_lossy().into_owned())])
})
.unwrap_or_default();
for (key, value) in gradle_env(root, s, defaults) {
for (key, value) in gradle_build_env(root, &build_dir, &c.command, s, None) {
c.env.entry(key).or_insert(value);
}
}
"ruby" | "rake" | "rspec" => {
if let Some(ruby) = &ruby
if let Some(ruby) = &toolchain("ruby", &owner)
&& let Some(bin) = crate::ruby::bin_dir_of(ruby)
{
// `ruby`, `bundle`, `rake`, `rspec` of the chosen interpreter when installed
@@ -1302,6 +1429,69 @@ mod java_compiler_tests {
}
}
#[cfg(test)]
mod gradle_runtime_tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn gradle_runs_use_the_builds_toolchain_without_overriding_user_choices() {
let temp = tempfile::tempdir().unwrap();
let installed =
[25, 21, 17, 11, 8].map(|major| crate::java::Jdk { major, home: temp.path().join(format!("jdk{major}")) });
for (name, script, expected) in [
("kotlin", "kotlin { jvmToolchain(21) }", Some(21)),
("java", "java { toolchain { languageVersion = JavaLanguageVersion.of(17) } }", Some(17)),
("unspecified", "plugins { id 'java' }", None),
] {
let dir = temp.path().join(name);
std::fs::create_dir(&dir).unwrap();
std::fs::write(dir.join("build.gradle.kts"), script).unwrap();
let picked = gradle_run_jdk(crate::java::project_java_version(&dir), None, &installed, Some((9, 0)));
assert_eq!(picked.map(|jdk| jdk.major), expected);
assert_eq!(gradle_run_jdk(expected, Some(&installed[0]), &installed, Some((9, 0))), Some(&installed[0]));
let mut settings = Settings::default();
settings.languages.java.gradle.jvm = "custom-gradle-jdk".into();
let env = gradle_env(temp.path(), &settings, BTreeMap::from([("JAVA_HOME".into(), "/fallback".into())]));
assert_eq!(env["JAVA_HOME"], temp.path().join("custom-gradle-jdk").to_string_lossy());
}
let dir = temp.path().join("kotlin");
for (gradle, target, expected) in
[((9, 0), 8, 17), ((8, 4), 21, 17), ((8, 5), 21, 21), ((7, 2), 21, 11), ((9, 0), 25, 21), ((9, 1), 25, 25)]
{
std::fs::write(dir.join("build.gradle.kts"), format!("kotlin {{ jvmToolchain({target}) }}")).unwrap();
assert_eq!(gradle_run_jdk(Some(target), None, &installed, Some(gradle)).unwrap().major, expected);
}
assert_eq!(gradle_run_jdk(Some(21), None, &installed, None).unwrap().major, 21);
assert_eq!(gradle_run_jdk(Some(19), None, &installed, None).unwrap().major, 21);
assert!(gradle_run_jdk(None, None, &installed, None).is_none());
}
#[test]
fn reads_wrapper_and_local_gradle_versions_without_running_them() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
std::fs::create_dir_all(root.join("gradle/wrapper")).unwrap();
std::fs::write(
root.join("gradle/wrapper/gradle-wrapper.properties"),
"distributionUrl=https\\://services.gradle.org/distributions/gradle-8.4.0-bin.zip\n",
)
.unwrap();
for command in ["./gradlew", "gradlew.bat"] {
assert_eq!(gradle_distribution_version(root, command), Some((8, 4)));
}
for layout in ["lib", "libexec/lib"] {
let home = root.join(layout.replace('/', "-"));
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::create_dir_all(home.join(layout)).unwrap();
let command = home.join("bin/gradle");
std::fs::write(&command, "").unwrap();
std::fs::write(home.join(layout).join("gradle-launcher-9.0.0.jar"), "").unwrap();
assert_eq!(gradle_distribution_version(root, &command.to_string_lossy()), Some((9, 0)));
}
}
}
#[cfg(test)]
mod maven_linkage_tests {
use super::*;
+14 -4
View File
@@ -540,10 +540,12 @@ pub fn project_java_version(module_dir: &Path) -> Option<u32> {
}
for name in ["build.gradle.kts", "build.gradle"] {
let Some(text) = read(name) else { continue };
if let Some(i) = text.find("JavaLanguageVersion.of(") {
let rest = &text[i + "JavaLanguageVersion.of(".len()..];
if let Some(v) = parse_java_version(rest.split(')').next().unwrap_or_default()) {
return Some(v);
for key in ["JavaLanguageVersion.of(", "jvmToolchain("] {
if let Some(i) = text.find(key) {
let rest = &text[i + key.len()..];
if let Some(v) = parse_java_version(rest.split(')').next().unwrap_or_default()) {
return Some(v);
}
}
}
for key in ["sourceCompatibility", "targetCompatibility"] {
@@ -1138,6 +1140,14 @@ mod tests {
assert_eq!(project_java_version(dir), Some(11));
std::fs::write(dir.join("build.gradle.kts"), "sourceCompatibility = '1.8'").unwrap();
assert_eq!(project_java_version(dir), Some(8));
for (script, version) in [
("kotlin { jvmToolchain(21) }", Some(21)),
("kotlin { jvmToolchain(17) }", Some(17)),
("kotlin { jvmToolchain(projectVersion) }", None),
] {
std::fs::write(dir.join("build.gradle.kts"), script).unwrap();
assert_eq!(project_java_version(dir), version);
}
}
}
+37
View File
@@ -1615,3 +1615,40 @@ fn saving_a_loaded_run_configuration_drops_what_settings_derived() {
assert_eq!(next[1].args, ["-Dmaven.repo.local=/m3/repo", "-s", "/mine.xml", "test"]);
}
}
#[test]
fn nested_python_configs_get_their_own_virtualenv_but_explicit_settings_stay_project_relative() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let outer = fake_venv(root, ".venv");
let nested = fake_venv(&root.join("apps").join("python"), ".venv");
let mut config = run("python", "python3", &["main.py"]);
config.extras.module = Some("apps/python".into());
apply_to_run_configs(std::slice::from_mut(&mut config), root, &Settings::default());
assert_eq!(config.env["VIRTUAL_ENV"], nested.parent().unwrap().parent().unwrap().to_string_lossy());
// A workspace member without its own virtualenv uses the one at the project root (uv workspaces).
let mut member = run("pytest", "python3", &["-m", "pytest"]);
member.extras.module = Some("packages/lib".into());
apply_to_run_configs(std::slice::from_mut(&mut member), root, &Settings::default());
assert_eq!(member.env["VIRTUAL_ENV"], outer.parent().unwrap().parent().unwrap().to_string_lossy());
let mut settings = Settings::default();
settings.languages.python.interpreter = "custom/bin/python".into();
let mut config = run("python", "python3", &["main.py"]);
config.extras.module = Some("apps/python".into());
apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
assert_eq!(config.command, root.join("custom/bin/python").to_string_lossy());
}
#[test]
fn file_runtime_uses_nearest_virtualenv_and_explicit_project_settings_win() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let outer = fake_venv(root, ".venv");
let inner = fake_venv(&root.join("apps/api"), ".venv");
let near = root.join("apps/api/src/package");
let mut settings = Settings::default();
assert_eq!(crate::apply::python_interpreter_near(root, Some(&near), &settings), Some(inner));
assert_eq!(crate::apply::python_interpreter_near(root, Some(&root.join("apps/other")), &settings), Some(outer));
settings.languages.python.interpreter = "selected/python".into();
assert_eq!(crate::apply::python_interpreter_near(root, Some(&near), &settings), Some(root.join("selected/python")));
}
+6 -2
View File
@@ -20,8 +20,12 @@ pub async fn sdk_detect(window: Window, state: State<'_, AppState>, kind: SdkKin
/// The interpreter the project uses: the configured one, else its virtualenv.
#[tauri::command]
#[specta::specta]
pub fn sdk_python_effective(window: Window, state: State<'_, AppState>) -> IdeResult<Option<ide_core::IpcPath>> {
ide_host::ops::sdk::sdk_python_effective(&state.host, window.label())
pub fn sdk_python_effective(
window: Window,
state: State<'_, AppState>,
near: Option<PathBuf>,
) -> IdeResult<Option<ide_core::IpcPath>> {
ide_host::ops::sdk::sdk_python_effective(&state.host, window.label(), near)
}
/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
+9 -3
View File
@@ -792,7 +792,7 @@ export const commands = {
/** Installed interpreters / toolchains of `kind`; project-local ones first. */
sdkDetect: (kind: SdkKind) => typedError<Sdk[], IdeError>(__TAURI_INVOKE("sdk_detect", { kind })),
/** The interpreter the project uses: the configured one, else its virtualenv. */
sdkPythonEffective: () => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_python_effective")),
sdkPythonEffective: (near: string | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_python_effective", { near })),
/** The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`. */
sdkPhpEffective: () => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_php_effective")),
/** The PHP language level of the project `path` belongs to: the effective one and Auto's. */
@@ -6470,7 +6470,10 @@ export type RunConfig_Serialize = {
export type RunExtras = RunExtras_Serialize | RunExtras_Deserialize;
export type RunExtras_Deserialize = {
/** Module supplying a JVM application's classpath; empty means the project root. */
/**
* Owning module, independent of the working directory; also supplies a JVM application's classpath.
* Empty means the project root.
*/
module?: string | null,
/** Values of the configuration type's own form fields (the command line derives from them). */
params?: { [key in string]: string },
@@ -6509,7 +6512,10 @@ export type RunExtras_Deserialize = {
};
export type RunExtras_Serialize = {
/** Module supplying a JVM application's classpath; empty means the project root. */
/**
* Owning module, independent of the working directory; also supplies a JVM application's classpath.
* Empty means the project root.
*/
module?: string | null,
/** Values of the configuration type's own form fields (the command line derives from them). */
params?: { [key in string]: string },
+1 -1
View File
@@ -23,7 +23,7 @@ export const notebookBackend = {
request: RequestId,
output: Channel<NotebookOutputEvent>,
) => call(commands.notebookExecute(id, cells, python, request, output)),
environments: () => Promise.all([call(commands.sdkDetect("python")), call(commands.sdkPythonEffective())]),
environments: () => Promise.all([call(commands.sdkDetect("python")), call(commands.sdkPythonEffective(null))]),
cancel: (request: RequestId) => commands.cancelRequest(request),
};
+37
View File
@@ -235,6 +235,43 @@ describe("context configuration", () => {
]);
});
it("does not borrow another Go module's run or test configuration", () => {
const go = [
cfg({ id: "go:run:api", kind: "go", command: "go", args: ["run", "."], cwd: "api" }),
cfg({ id: "go:test:api", kind: "go", command: "go", args: ["test", "./..."], cwd: "api", isTest: true }),
];
expect(contextConfig("other/main.go", go, "package main\nfunc main() {}")).toBeNull();
expect(contextConfig("other/main_test.go", go, "package main")).toBeNull();
});
it("uses a nested Go module even when only the parent has a run configuration", () => {
const go = [
cfg({ id: "go:run", kind: "go", command: "go", args: ["run", "."] }),
cfg({ id: "go:test:api", kind: "go", command: "go", args: ["test", "./..."], cwd: "api", isTest: true }),
];
expect(contextConfig("api/tool/main.go", go, "package main\nfunc main() {}")).toMatchObject({
cwd: "api",
args: ["run", "./tool"],
});
});
it("does not pass typed Go test flags to an application build", () => {
const go = cfg({
id: "user:go-test",
kind: "go",
command: "go",
args: ["test", "-count=1", "./..."],
isTest: true,
params: { goKind: "test", module: "api", toolArgs: "-count=1" },
});
expect(contextConfig("api/cmd/server/main.go", [go], "package main\nfunc main() {}")).toMatchObject({
cwd: "api",
args: ["run", "./cmd/server"],
params: undefined,
isTest: false,
});
});
it("finds run targets and script invocations", () => {
expect(contextConfig("src/bin/server.rs", configs)!.id).toBe("cargo:run:server");
expect(contextConfig("tools/gen.py", configs)).toMatchObject({
+73 -21
View File
@@ -7,6 +7,7 @@ import type { RunConfig } from "@/platform/ipc";
import { djangoLabel, djangoTestsOf, hasDjangoTests } from "./djangoTypes";
import { phpunitArgs } from "./testing/phpunit";
import { goTestParts } from "./goArguments";
import { goBuildType } from "./goTypes";
import { isWindows } from "./jvmTypes";
export type FileRole = "test" | "main" | "script" | null;
@@ -51,16 +52,28 @@ function testKinds(relPath: string): string[] {
return [];
}
/** The module owning a configuration's targets, relative to the project root. */
export function configScope(config: RunConfig): string {
return (
config.kind === "go"
? (config.params?.module ?? config.module ?? config.cwd ?? "")
: (config.module ?? config.cwd ?? "")
)
.replace(/^\.(?:\/|$)/, "")
.replace(/\/+$/, "");
}
/** Configs whose working directory most specifically contains `relPath` (ties in their original order). */
function allClosest(configs: RunConfig[], relPath: string): RunConfig[] {
export function allClosest(configs: readonly RunConfig[], relPath: string): RunConfig[] {
const score = (c: RunConfig) => {
const cwd = (c.kind === "go" ? (c.params?.module ?? c.cwd ?? "") : (c.cwd ?? ""))
.replace(/^\.\/?/, "")
.replace(/\/+$/, "");
const cwd = configScope(c);
if (!cwd) return 0;
return relPath === cwd || relPath.startsWith(cwd + "/") ? cwd.length : -1;
};
const scored = configs.map((c) => [c, score(c)] as const).filter(([, s]) => s >= 0);
const scored = configs
.filter((c) => !c.id.startsWith("context:"))
.map((c) => [c, score(c)] as const)
.filter(([, s]) => s >= 0);
const best = Math.max(...scored.map(([, s]) => s));
return scored.filter(([, s]) => s === best).map(([c]) => c);
}
@@ -72,10 +85,10 @@ function closest(configs: RunConfig[], relPath: string): RunConfig | undefined {
/** `relPath` relative to the working directory of `config`. */
export function relToCwd(relPath: string, config: RunConfig): string {
const cwd = (config.kind === "go" ? (config.params?.module ?? config.cwd ?? "") : (config.cwd ?? ""))
.replace(/^\.\/?/, "")
const cwd = (config.kind === "go" ? configScope(config) : (config.cwd ?? ""))
.replace(/^\.(?:\/|$)/, "")
.replace(/\/+$/, "");
return cwd && relPath.startsWith(cwd + "/") ? relPath.slice(cwd.length + 1) : relPath;
return relPath === cwd ? "" : cwd && relPath.startsWith(cwd + "/") ? relPath.slice(cwd.length + 1) : relPath;
}
function derived(base: RunConfig, relPath: string, patch: Partial<RunConfig>): RunConfig {
@@ -94,14 +107,22 @@ function derived(base: RunConfig, relPath: string, patch: Partial<RunConfig>): R
* The project's Python interpreter as `(command, leading args)`, derived from
* detected configs (venv / uv / poetry), or null when none says.
*/
export function projectPython(configs: RunConfig[]): { command: string; prefix: string[] } | null {
export function projectPython(configs: RunConfig[], relPath?: string): { command: string; prefix: string[] } | null {
if (relPath !== undefined)
configs = allClosest(
configs.filter((c) => ["python", "pytest", "django-test"].includes(c.kind)),
relPath,
);
const py = configs.find((c) => c.kind === "python");
if (py) {
// The interpreter part ends where the program starts: `-m`, or the script (`manage.py`). What follows
// (`runserver`, `test`…) belongs to that program, never to the interpreter.
const end = py.args.findIndex((a) => a === "-m" || a.endsWith(".py"));
const python = py.args.findIndex((a) => /^python[\d.]*$/.test(a));
return { command: py.command, prefix: py.args.slice(0, end >= 0 ? end : python + 1) };
return {
command: py.command,
prefix: py.args.slice(0, end >= 0 ? end : python + 1),
};
}
const pytest = configs.find((c) => c.kind === "pytest");
if (!pytest) return null;
@@ -120,15 +141,20 @@ export function projectPython(configs: RunConfig[]): { command: string; prefix:
*/
function implicitPytest(relPath: string, configs: RunConfig[]): RunConfig | null {
if (!relPath.endsWith(".py")) return null;
const py = projectPython(configs);
const owners = allClosest(
configs.filter((c) => ["python", "pytest", "django-test"].includes(c.kind)),
relPath,
);
const py = projectPython(owners);
return {
id: "pytest:implicit",
name: "pytest",
kind: "pytest",
command: py?.command ?? "python3",
args: [...(py?.prefix ?? []), "-m", "pytest"],
cwd: null,
env: {},
cwd: owners[0]?.cwd ?? null,
module: owners[0]?.module,
env: { ...owners[0]?.env },
detected: true,
isTest: true,
debugAdapter: "debugpy",
@@ -147,7 +173,7 @@ export function testBasesFor(relPath: string, configs: RunConfig[]): RunConfig[]
.sort((a, b) => kinds.indexOf(a.kind) - kinds.indexOf(b.kind));
const best = allClosest(candidates, relPath);
if (best.length) return best;
const fallback = candidates[0] ?? implicitPytest(relPath, configs);
const fallback = implicitPytest(relPath, configs);
return fallback ? [fallback] : [];
}
@@ -213,21 +239,47 @@ export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunC
function mainConfigFor(relPath: string, configs: RunConfig[]): RunConfig | null {
if (relPath.endsWith(".rs")) {
const bin = /src\/bin\/([^/]+)\.rs$/.exec(relPath)?.[1];
const runs = configs.filter((c) => c.kind === "cargo" && c.id.startsWith("cargo:run"));
const runs = allClosest(
configs.filter((c) => c.kind === "cargo" && c.args[0] === "run"),
relPath,
);
const byBin = bin ? runs.find((c) => c.id === `cargo:run:${bin}` || c.args.includes(bin)) : undefined;
return byBin ?? closest(runs, relPath) ?? runs[0] ?? null;
return byBin ?? runs[0] ?? null;
}
if (relPath.endsWith(".go")) {
const gos = configs.filter((c) => c.kind === "go");
const runs = gos.filter((c) => c.args[0] === "run");
const gos = allClosest(
configs.filter((c) => c.kind === "go" && !c.id.startsWith("context:")),
relPath,
);
const runs = gos.filter((c) => c.args[0] === "run" || c.params?.goKind === "build");
// The module containing the file (nested modules run from their own directory).
const base = closest(runs, relPath) ?? closest(gos, relPath) ?? runs[0] ?? gos[0];
const base = runs[0] ?? gos[0];
if (!base) return null;
const dir = dirOf(relToCwd(relPath, base));
const target = dir ? `./${dir}` : ".";
const hit = runs.find((c) => (c.cwd ?? "") === (base.cwd ?? "") && c.args[1] === target);
if (base.params?.goKind === "build") {
const params = {
...base.params,
goKind: "build",
scope: "package",
target,
runAfterBuild: "true",
};
return derived(base, relPath, {
...goBuildType.commandLine(params),
params,
name: `go run ${target}`,
isTest: false,
});
}
const hit = runs.find((c) => configScope(c) === configScope(base) && c.args[1] === target);
if (hit) return hit;
return derived(base, relPath, { args: ["run", target], name: `go run ${target}`, isTest: false });
return derived(base, relPath, {
args: ["run", target],
name: `go run ${target}`,
isTest: false,
...(base.params?.goKind === "test" ? { params: undefined, cwd: base.params.module ?? base.cwd } : {}),
});
}
if (/\.(kt|java)$/.test(relPath)) {
// The build containing the file (builds nested in a folder of repositories carry a `:<dir>` suffix).
+184 -1
View File
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as projectRoots from "@/platform/projectRoots";
import type { HierarchyItem, RunConfig } from "@/platform/ipc";
import {
innermostTarget,
@@ -273,6 +274,82 @@ describe("single-file producers (scratches)", () => {
expect(p.config).toMatchObject({ args: ["run", "./cmd/tool"] });
});
it("Go: nested modules win over previous single-file runs", () => {
const go = [
cfg({
id: "context:file:fixtures/go-app/cmd/server/main.go",
kind: "go",
args: ["run", "fixtures/go-app/cmd/server/main.go"],
}),
cfg({
id: "go:run:server:fixtures/go-app",
kind: "go",
args: ["run", "./cmd/server"],
cwd: "fixtures/go-app",
}),
];
const input = {
path: `${ROOT}/fixtures/go-app/cmd/server/main.go`,
root: ROOT,
configs: go,
content: "package main\nfunc main() {}",
};
for (const line of [undefined, 1]) {
expect(produceContext({ ...input, line }, ALL)?.config).toMatchObject({
id: "go:run:server:fixtures/go-app",
args: ["run", "./cmd/server"],
cwd: "fixtures/go-app",
});
}
});
it("Go: single-file runs never become module configurations", () => {
const input = {
path: `${ROOT}/standalone/main.go`,
root: ROOT,
configs: [] as RunConfig[],
content: "package main\nfunc main() {}",
};
const first = produceContext(input, ALL)!;
expect(first.config).toMatchObject({ id: "context:file:standalone/main.go", args: ["run", "standalone/main.go"] });
expect(produceContext({ ...input, configs: [first.config!] }, ALL)?.config).toEqual(first.config);
});
it("Go: typed modules own only their own files", () => {
const configs = [
...["api", "worker"].map((module) =>
cfg({
id: `go:run:${module}`,
kind: "go",
args: ["build", "-race", "."],
params: { module, goKind: "build", scope: "package", target: ".", toolArgs: "-race" },
}),
),
];
for (const module of ["api", "worker"]) {
expect(
produceContext(
{
path: `${ROOT}/${module}/cmd/server/main.go`,
root: ROOT,
configs,
content: "package main\nfunc main() {}",
},
ALL,
)?.config,
).toMatchObject({
args: ["build", "-race", "./cmd/server"],
params: { module, target: "./cmd/server", goKind: "build", runAfterBuild: "true" },
});
}
expect(
produceContext(
{ path: `${ROOT}/standalone/main.go`, root: ROOT, configs, content: "package main\nfunc main() {}" },
ALL,
)?.config,
).toMatchObject({ id: "context:file:standalone/main.go", args: ["run", "standalone/main.go"] });
});
it("Rust: rustc into the compile directory, then the binary; codelldb debugs the binary", () => {
const p = produceContext(scratch("hihi.rs", 'fn main() {\n println!("hi");\n}'), ALL)!;
expect(p.name).toBe("hihi.rs");
@@ -1003,3 +1080,109 @@ describe("CMake file context", () => {
expect(p?.missing?.body).toContain("CMake Configure");
});
});
describe("module ownership across languages", () => {
it.each([
["npm", "other.test.ts", ["run", "test"]],
["deno", "other.test.ts", ["test"]],
["cargo", "tests/other.rs", ["test"]],
["go", "other_test.go", ["test", "./..."]],
["phpunit", "OtherTest.php", []],
["rspec", "other_spec.rb", []],
["maven", "OtherTest.java", ["test"]],
["gradle", "OtherTest.kt", ["test"]],
])("%s never borrows a sibling test configuration", (kind, file, args) => {
const configs = [cfg({ id: "sibling", kind, args, cwd: "apps/one", isTest: true })];
const result = produceFile({ root: ROOT, path: `${ROOT}/apps/two/${file}`, configs }, OPTIMISTIC);
expect(result?.config?.id ?? "").not.toContain("sibling");
});
it("selects the nearest Python interpreter and ignores temporary configs", () => {
const configs = [
cfg({
id: "one",
kind: "pytest",
command: "/env/one/bin/pytest",
cwd: "one",
isTest: true,
}),
cfg({
id: "two",
kind: "pytest",
command: "/env/two/bin/pytest",
cwd: "two",
isTest: true,
}),
cfg({
id: "context:old",
kind: "python",
command: "/env/stale/python",
cwd: "two",
}),
];
expect(produceFile({ root: ROOT, path: `${ROOT}/two/main.py`, configs }, OPTIMISTIC)?.config?.command).toBe(
"/env/two/bin/python",
);
});
it("a Cargo package owns its binary even when commands run at the workspace root", () => {
const configs = [
cfg({
id: "cargo:run:one",
kind: "cargo",
module: "crates/one",
args: ["run", "-p", "one", "--bin", "server"],
}),
cfg({
id: "workspace:member:cargo:run:two",
kind: "cargo",
module: "crates/two",
args: ["run", "-p", "two", "--bin", "server"],
}),
];
const result = produceFile({ root: ROOT, path: `${ROOT}/crates/two/src/bin/server.rs`, configs }, OPTIMISTIC);
expect(result?.config?.args).toEqual(configs[1]!.args);
expect(result?.config?.cwd).toBeNull();
});
it.each(["/work/app", "C:/work/app"])("matches absolute workspace configs under %s", (root) => {
const configs = [
cfg({
id: "test",
kind: "npm",
cwd: `${root}/apps/web`,
module: `${root}/apps/web`,
args: ["run", "test"],
isTest: true,
}),
];
const result = produceFile({ root, path: `${root}/apps/web/a.test.ts`, configs }, OPTIMISTIC);
expect(result?.config).toMatchObject({
cwd: "apps/web",
args: ["run", "test", "--", "a.test.ts"],
});
});
});
it("runs scripts from their owning module with arguments relative to that directory", () => {
const configs = [cfg({ id: "web", kind: "npm", cwd: "apps/web", module: "apps/web" })];
expect(produceFile({ root: ROOT, path: `${ROOT}/apps/web/src/main.js`, configs }, OPTIMISTIC)?.config).toMatchObject({
cwd: "apps/web",
args: ["src/main.js"],
});
});
it("nested Ruby scripts use their own source paths relative to their cwd", () => {
const spy = vi.spyOn(projectRoots, "currentRoots").mockReturnValue([
{ path: "apps/ruby/lib", kind: "source", detectedFrom: null },
{ path: "apps/other/lib", kind: "source", detectedFrom: null },
]);
try {
const configs = [cfg({ id: "ruby", kind: "rake", cwd: "apps/ruby", args: ["test"] })];
expect(
produceFile({ root: ROOT, path: `${ROOT}/apps/ruby/bin/main.rb`, configs }, OPTIMISTIC)?.config,
).toMatchObject({ cwd: "apps/ruby", args: ["-Ilib", "bin/main.rb"] });
} finally {
spy.mockRestore();
}
});
+117 -37
View File
@@ -10,13 +10,22 @@
* globally installed tools.
*/
import type { RunConfig } from "@/platform/ipc";
import { ipcPath, isSameOrInside } from "@/platform/paths";
import { isTestPath } from "@/platform/languageSettings";
import { currentRoots } from "@/platform/projectRoots";
import { applicationConfig, jvmModuleDir } from "./application";
import { formatArgs } from "./configs";
import { goGenerateConfig } from "./goGenerate";
import { goTestParts } from "./goArguments";
import { contextConfig, fileRole, projectPython, testBasesFor, testConfigFor } from "./context";
import {
allClosest,
contextConfig,
fileRole,
projectPython,
testBasesFor,
testConfigFor,
relToCwd as relativeToCwd,
} from "./context";
import { djangoLabel, djangoTestsOf, hasDjangoTests } from "./djangoTypes";
import { firstTopLevelType, scanJvmTargets, simpleClassName } from "./jvmTargets";
import { isWindows } from "./jvmTypes";
@@ -103,6 +112,26 @@ function relTo(path: string, root: string | null): string {
return root && path.startsWith(root + "/") ? path.slice(root.length + 1) : path;
}
function scopedInput<T extends Pick<ContextInput, "root" | "configs">>(input: T): T {
const root = input.root && ipcPath(input.root).replace(/\/+$/, "");
if (!root) return input;
const relative = (value: string) => {
const path = ipcPath(value);
return path === root ? "" : path.startsWith(root + "/") ? path.slice(root.length + 1) : path;
};
return {
...input,
configs: input.configs.map((c) => ({
...c,
cwd: c.cwd == null ? c.cwd : relative(c.cwd),
...(c.module == null ? {} : { module: relative(c.module) }),
...(c.kind === "go" && c.params?.module != null
? { params: { ...c.params, module: relative(c.params.module) } }
: {}),
})),
};
}
/** Short stable hash, so compiled binaries of same-named files don't collide. */
function hash(s: string): string {
let h = 5381;
@@ -136,7 +165,7 @@ function mkdirLine(dir: string, windows = false): string {
/** Absolute directory of a configuration's working directory (a Go module). */
export function goModuleDir(root: string, cwd: string | null): string {
const rel = (cwd ?? "").replace(/^\.(?:\/|$)/, "").replace(/\/+$/, "");
if (rel.startsWith("/")) return rel;
if (rel.startsWith("/") || /^[A-Za-z]:\//.test(rel)) return rel;
return rel ? `${root}/${rel}` : root;
}
@@ -384,10 +413,25 @@ function isProjectPath(input: Pick<ContextInput, "path" | "root" | "contentRoots
function site(input: ContextInput): FileSite {
const inside = isProjectPath(input);
const rel = relTo(input.path, input.root);
const language = scriptLanguage(input.path, input.content);
const kinds: Record<string, string[]> = {
js: ["npm", "deno", "bun", "node"],
ts: ["npm", "deno", "bun", "node"],
python: ["python", "pytest", "django-test"],
ruby: ["ruby", "rake", "rspec"],
php: ["php", "phpunit", "composer"],
};
const owner =
inside && language
? allClosest(
input.configs.filter((c) => kinds[language]?.includes(c.kind)),
rel,
)[0]
: undefined;
return {
idPath: rel,
arg: inside ? rel : baseName(input.path),
cwd: inside ? null : dirName(input.path),
arg: inside ? (owner ? relativeToCwd(rel, owner) : rel) : baseName(input.path),
cwd: inside ? owner?.cwd || null : dirName(input.path),
file: baseName(input.path),
inProject: inside,
};
@@ -442,13 +486,21 @@ function pythonModule(input: ContextInput): { name: string; dir: string } | null
/** Python: the interpreter from settings, the detected project interpreter, then `python3` on the PATH. */
function pythonCommand(input: ContextInput, env: ToolEnv, s: FileSite): { command: string; prefix: string[] } | null {
if (env.python) return { command: env.python, prefix: [] };
const py = projectPython([...input.configs]);
const py = projectPython([...input.configs], relTo(input.path, input.root));
if (py) {
// A venv-relative interpreter is relative to the project root.
const owner = allClosest(
input.configs.filter((c) => ["python", "pytest", "django-test"].includes(c.kind)),
relTo(input.path, input.root),
)[0];
const cwd = owner?.cwd ?? "";
const relativeCommand = py.command.includes("/") && !py.command.startsWith("/") && !/^[A-Za-z]:/.test(py.command);
const command =
!s.inProject && input.root && py.command.includes("/") && !py.command.startsWith("/")
? `${input.root}/${py.command.replace(/^\.\//, "")}`
: py.command;
relativeCommand && cwd
? `${goModuleDir(input.root ?? "", cwd)}/${py.command.replace(/^\.\//, "")}`
: !s.inProject && input.root && relativeCommand
? `${input.root}/${py.command.replace(/^\.\//, "")}`
: py.command;
return { command, prefix: py.prefix };
}
const t = pick(env, ["python3", "python"]);
@@ -599,8 +651,20 @@ function simple(
}
/** `-I<dir>` for each Sources root of the project (the project root itself is already the cwd). */
const rubyLoadPath = (roots: readonly { path: string; kind: string }[]): string[] =>
roots.filter((r) => r.kind === "source" && r.path !== "").map((r) => `-I${r.path}`);
const rubyLoadPath = (
roots: readonly { path: string; kind: string }[],
cwd: string | null,
root: string | null,
): string[] =>
roots
.filter((r) => r.kind === "source" && r.path !== "")
.flatMap((r) => {
if (!cwd) return [`-I${r.path}`];
const dir = goModuleDir(root ?? "", cwd);
const source = goModuleDir(root ?? "", r.path);
if (!isSameOrInside(source, dir)) return [];
return [`-I${source === dir ? "." : source.slice(dir.length + 1)}`];
});
const hasGoMain = (content?: string) => content === undefined || /^\s*package\s+main\b/m.test(content);
const hasRustMain = (content?: string) =>
@@ -614,11 +678,10 @@ const hasKotlinMain = (content?: string) => content === undefined || /^(?:suspen
* directory at or above it): a build nested in a folder of repositories owns only its files.
*/
function buildOwners(rel: string, configs: readonly RunConfig[], kinds: readonly string[]): RunConfig[] {
return configs.filter((c) => {
if (!kinds.includes(c.kind)) return false;
const cwd = (c.cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, "");
return !cwd || rel.startsWith(cwd + "/");
});
return allClosest(
configs.filter((c) => kinds.includes(c.kind)),
rel,
);
}
const cargoOwner = (rel: string, configs: readonly RunConfig[]) => buildOwners(rel, configs, ["cargo"]);
@@ -655,6 +718,7 @@ function godotScene(rel: string, configs: readonly RunConfig[]): Produced | null
/** The file as a whole: tests of a test file, the project's run target, or the runtime for its language. */
export function produceFile(input: ContextInput, env: ToolEnv): Produced | null {
input = scopedInput(input);
const s = site(input);
const lang = scriptLanguage(input.path, input.content);
const configs = [...input.configs];
@@ -685,12 +749,23 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null
if (lang === "rust" && cargoOwner(rel, configs).length) {
const example = /(?:^|\/)examples\/([^/]+)\.rs$/.exec(rel)?.[1];
if (example && hasRustMain(input.content)) {
const base = cargoOwner(rel, configs).find((c) => c.id.startsWith("cargo:run")) ?? cargoOwner(rel, configs)[0]!;
const base = cargoOwner(rel, configs).find((c) => c.args[0] === "run") ?? cargoOwner(rel, configs)[0]!;
const config: RunConfig = {
...base,
id: `context:file:${rel}`,
name: `Run ${example}`,
args: ["run", "--example", example],
args: [
"run",
...base.args.flatMap((arg, i, args) =>
["-p", "--package", "--manifest-path"].includes(arg)
? [arg, args[i + 1]!]
: /^(?:--package=|--manifest-path=|-p.)/.test(arg)
? [arg]
: [],
),
"--example",
example,
],
env: { ...base.env },
isTest: false,
detected: true,
@@ -811,7 +886,10 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null
}
case "ruby":
// Sources roots go on the load path (`-Ilib`), as `rake test` does; paths are project-relative.
return simple(lang, s, env, lang, () => [...(s.inProject ? rubyLoadPath(currentRoots()) : []), s.arg]);
return simple(lang, s, env, lang, () => [
...(s.inProject ? rubyLoadPath(currentRoots(), s.cwd, input.root) : []),
s.arg,
]);
case "php":
if (env.php) return { name: s.file, scope: "file", config: fileConfig(s, "php", env.php, [s.arg], "php-debug") };
return simple(lang, s, env, lang, () => [s.arg], "php-debug");
@@ -926,6 +1004,7 @@ export function fileTargets(
input: Pick<ContextInput, "path" | "root" | "configs" | "contentRoots">,
content: string,
): RunTarget[] {
input = scopedInput(input);
const s = { inProject: isProjectPath(input), rel: relTo(input.path, input.root) };
const rust = /\.rs$/.test(input.path);
if (/\.(c|cpp|cc|cxx)$/.test(input.path)) return standaloneTargets(content, input.path);
@@ -980,6 +1059,7 @@ function elementProduction(input: ContextInput, env: ToolEnv, target: RunTarget)
* main function — else the file.
*/
export function produceContext(input: ContextInput, env: ToolEnv): Produced | null {
input = scopedInput(input);
if (input.content !== undefined && input.line !== undefined) {
const targets = fileTargets(input, input.content.slice(0, 400_000));
const inner = innermostTarget(input.content, targets, input.line, /\.(py|rb)$/.test(input.path));
@@ -997,6 +1077,7 @@ export function produceContext(input: ContextInput, env: ToolEnv): Produced | nu
* like IDEA, the user then chooses one. A single production otherwise.
*/
export function produceContextChoices(input: ContextInput, env: ToolEnv): Produced[] {
input = scopedInput(input);
const produced = produceContext(input, env);
if (!produced?.config || produced.scope === "directory" || !isProjectPath(input)) return produced ? [produced] : [];
const rel = relTo(input.path, input.root);
@@ -1046,6 +1127,7 @@ function rubyDirectory(
relToCwd: (c: RunConfig) => string,
make: (base: RunConfig, label: string, args: string[]) => Produced,
label: string,
root: string | null,
): Produced | null {
const rspec = closest(["rspec"]);
if (rspec) return make(rspec, label, [...rspec.args, relToCwd(rspec)]);
@@ -1058,13 +1140,14 @@ function rubyDirectory(
const minitest = closest(["ruby"]);
if (!minitest) return null;
const at = minitest.args.indexOf("-e");
const flags = rubyLoadPath(currentRoots()).filter((f) => !minitest.args.includes(f));
const flags = rubyLoadPath(currentRoots(), minitest.cwd, root).filter((f) => !minitest.args.includes(f));
const args = [...minitest.args.slice(0, -1)];
args.splice(at < 0 ? args.length : at, 0, ...flags);
return make(minitest, label, [...args, relToCwd(minitest)]);
}
export function produceDirectory(input: ContextInput): Produced | null {
input = scopedInput(input);
const { root } = input;
if (!root || !isProjectPath(input) || input.path === root) return null;
const rel = relTo(input.path, root);
@@ -1074,26 +1157,23 @@ export function produceDirectory(input: ContextInput): Produced | null {
const marked = isTestPath(currentRoots(), rel);
const tests = input.configs.filter((c) => c.isTest);
if (!TEST_DIR.test(rel) && !jvmRoot && !jvmPackage && !marked && !/(^|\/)src\/test(\/|$)/.test(rel)) return null;
const closest = (kinds: string[]) => {
const cands = tests.filter((c) => kinds.includes(c.kind));
const score = (c: RunConfig) => {
const cwd = (c.cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, "");
if (!cwd) return 0;
return rel === cwd || rel.startsWith(cwd + "/") ? cwd.length : -1;
};
return cands
.map((c) => [c, score(c)] as const)
.filter(([, n]) => n >= 0)
.sort((a, b) => b[1] - a[1])[0]?.[0];
};
const relToCwd = (c: RunConfig) => {
const cwd = (c.cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, "");
return cwd && rel.startsWith(cwd + "/") ? rel.slice(cwd.length + 1) : rel === cwd ? "" : rel;
};
const closest = (kinds: string[]) =>
allClosest(
tests.filter((c) => kinds.includes(c.kind)),
rel,
)[0];
const relToCwd = (c: RunConfig) => relativeToCwd(rel, c);
const make = (base: RunConfig, label: string, args: string[]): Produced => ({
name: label,
scope: "directory",
config: { ...base, id: `context:dir:${base.id}:${rel}`, name: label, args, env: { ...base.env }, detected: true },
config: {
...base,
id: `context:dir:${base.id}:${rel}`,
name: label,
args,
env: { ...base.env },
detected: true,
},
});
const testsIn = `Tests in '${name}'`;
@@ -1114,7 +1194,7 @@ export function produceDirectory(input: ContextInput): Produced | null {
}
}
const js = closest(["npm", "bun", "deno"]);
const ruby = marked && !js && rubyDirectory(closest, relToCwd, make, testsIn);
const ruby = marked && !js && rubyDirectory(closest, relToCwd, make, testsIn, root);
if (ruby) return ruby;
const py = closest(["pytest"]);
const django = closest(["django-test"]);
+43
View File
@@ -0,0 +1,43 @@
import { beforeEach, expect, it, vi } from "vitest";
const { entries, pythonNear } = vi.hoisted(() => ({
entries: new Map<string, string[]>(),
pythonNear: vi.fn(),
}));
vi.mock("@/platform/ipc", () => ({
isTauri: () => true,
call: (promise: Promise<unknown>) => promise,
commands: {
fsReadDir: async (dir: string) => (entries.get(dir) ?? []).map((name) => ({ name })),
sdkPythonEffective: pythonNear,
sdkLocateTools: async () => ({ compileDir: "/tmp/run", tools: [] }),
},
}));
import { clearRuntimeCache, rootMarkers, toolEnv } from "./runtimes";
beforeEach(() => {
entries.clear();
pythonNear.mockReset().mockResolvedValue("/env/python");
clearRuntimeCache();
});
it("a nested runtime wins and a package boundary blocks a parent's runtime", async () => {
entries.set("/repo", ["deno.json"]);
entries.set("/repo/apps/node", ["package.json"]);
entries.set("/repo/apps/bun", ["bun.lock", "package.json"]);
expect(await rootMarkers("/repo", "/repo/apps/node/src/main.ts")).toEqual({ deno: false, bun: false });
expect(await rootMarkers("/repo", "/repo/apps/bun/src/main.ts")).toEqual({ deno: false, bun: true });
expect(await rootMarkers("/repo", "/repo/apps/deno/src/main.ts")).toEqual({ deno: true, bun: false });
});
it("a nested Deno project is discovered under a Node root", async () => {
entries.set("/repo", ["package.json"]);
entries.set("/repo/apps/deno", ["deno.jsonc"]);
expect(await rootMarkers("/repo", "/repo/apps/deno/main.ts")).toEqual({ deno: true, bun: false });
});
it("the production Python runtime query includes the source directory", async () => {
const env = await toolEnv(["python3"], "/repo/apps/python/src/main.py", "/repo", true);
expect(pythonNear).toHaveBeenCalledWith("/repo/apps/python/src");
expect(env.python).toBe("/env/python");
});
+21 -14
View File
@@ -78,9 +78,9 @@ export async function locateTools(
return out;
}
async function projectPython(): Promise<string | null> {
async function projectPython(file: string | null): Promise<string | null> {
try {
return await call(commands.sdkPythonEffective());
return await call(commands.sdkPythonEffective(file ? dirOf(file) : null));
} catch {
return null;
}
@@ -94,18 +94,23 @@ async function projectPhp(): Promise<string | null> {
}
}
async function rootMarkers(root: string | null): Promise<{ deno: boolean; bun: boolean }> {
export async function rootMarkers(root: string | null, file: string | null): Promise<{ deno: boolean; bun: boolean }> {
if (!root) return { deno: false, bun: false };
try {
const entries = await call(commands.fsReadDir(root));
const names = new Set(entries.map((e) => e.name));
return {
deno: names.has("deno.json") || names.has("deno.jsonc"),
bun: names.has("bun.lockb") || names.has("bun.lock"),
};
} catch {
return { deno: false, bun: false };
let dir = file && file.startsWith(root + "/") ? dirOf(file) : root;
while (dir === root || dir.startsWith(root + "/")) {
try {
const entries = await call(commands.fsReadDir(dir));
const names = new Set(entries.map((e) => e.name));
const deno = names.has("deno.json") || names.has("deno.jsonc");
const bun = names.has("bun.lockb") || names.has("bun.lock");
if (deno || bun || names.has("package.json")) return { deno, bun };
} catch {
/* Unreadable ancestors contribute no runtime markers. */
}
if (dir === root) break;
dir = dirOf(dir);
}
return { deno: false, bun: false };
}
/** The tool environment for running `file` with any of `names`. */
@@ -118,9 +123,11 @@ export async function toolEnv(
): Promise<ToolEnv> {
const [tools, py, phpInterpreter, markers] = await Promise.all([
locateTools(names, file),
python ? projectPython() : Promise.resolve(null),
python ? projectPython(file) : Promise.resolve(null),
php ? projectPhp() : Promise.resolve(null),
names.includes("deno") || names.includes("bun") ? rootMarkers(root) : Promise.resolve({ deno: false, bun: false }),
names.includes("deno") || names.includes("bun")
? rootMarkers(root, file)
: Promise.resolve({ deno: false, bun: false }),
]);
return {
tool: (n) => tools.get(n),
+1 -1
View File
@@ -103,7 +103,7 @@ export function pip(
/** Interpreter the project uses when none is configured (its virtualenv). */
export function effectivePython(): Promise<string | null> {
return isTauri() ? call(commands.sdkPythonEffective()) : Promise.resolve(null);
return isTauri() ? call(commands.sdkPythonEffective(null)) : Promise.resolve(null);
}
/** The PHP language level of the project `path` belongs to; null outside Tauri. */