diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs index e0cfd9ae..ce41a108 100644 --- a/crates/ide-build/src/compile/mod.rs +++ b/crates/ide-build/src/compile/mod.rs @@ -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> { +pub(super) fn maven_steps( + root: &Path, + model: &BuildModel, + kind: BuildKind, + module: Option<&Path>, +) -> IdeResult> { let fallback; let m = match &model.maven { Some(m) => m, @@ -383,18 +388,7 @@ fn go_steps(dir: &Path, kind: BuildKind) -> Vec { /// `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 { diff --git a/crates/ide-build/src/gradle.rs b/crates/ide-build/src/gradle.rs index b4bc8839..f8a593ec 100644 --- a/crates/ide-build/src/gradle.rs +++ b/crates/ide-build/src/gradle.rs @@ -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 { - 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 = 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 { + 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 { 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::>(), + ["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::>(), ["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::>(), + ["a", "b"] + ); let mut settings = ide_core::settings::GradleSettings { linked_projects: Some(vec!["b".into(), "./b".into(), "a".into()]), ..Default::default() diff --git a/crates/ide-build/src/kotlin_bridge.rs b/crates/ide-build/src/kotlin_bridge.rs index e5ea5f3a..d5c56b0c 100644 --- a/crates/ide-build/src/kotlin_bridge.rs +++ b/crates/ide-build/src/kotlin_bridge.rs @@ -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 { + 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 { 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] diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index 5fd70ba6..a22a4685 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -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 { - 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 = 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). diff --git a/crates/ide-core/src/lib.rs b/crates/ide-core/src/lib.rs index 7bbb35da..aa7db963 100644 --- a/crates/ide-core/src/lib.rs +++ b/crates/ide-core/src/lib.rs @@ -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; diff --git a/crates/ide-core/src/project_roots.rs b/crates/ide-core/src/project_roots.rs new file mode 100644 index 00000000..458c9e96 --- /dev/null +++ b/crates/ide-core/src/project_roots.rs @@ -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 { + 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 { + 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 { + 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::(&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")); + } +} diff --git a/crates/ide-host/src/kotlin_bridge.rs b/crates/ide-host/src/kotlin_bridge.rs index c29be5a2..bfd591b4 100644 --- a/crates/ide-host/src/kotlin_bridge.rs +++ b/crates/ide-host/src/kotlin_bridge.rs @@ -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; diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 074cbcad..45517081 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -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| 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(); diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index b3477991..513d6d0d 100644 --- a/crates/ide-host/src/ops/run.rs +++ b/crates/ide-host/src/ops/run.rs @@ -28,9 +28,12 @@ pub fn configs(host: &Host, window: &str) -> IdeResult> { 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 { 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 = 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) -> 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) -> 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 { 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"), "spring-boot-maven-plugin").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:?}"); + } +} diff --git a/crates/ide-host/src/ops/sdk.rs b/crates/ide-host/src/ops/sdk.rs index 33947a3b..2136d0e6 100644 --- a/crates/ide-host/src/ops/sdk.rs +++ b/crates/ide-host/src/ops/sdk.rs @@ -46,10 +46,21 @@ pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult IdeResult> { +pub fn sdk_python_effective(host: &Host, window: &str, near: Option) -> IdeResult> { 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", diff --git a/crates/ide-host/tests/light_edit.rs b/crates/ide-host/tests/light_edit.rs index 5fa24553..7861b684 100644 --- a/crates/ide-host/tests/light_edit.rs +++ b/crates/ide-host/tests/light_edit.rs @@ -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")); diff --git a/crates/ide-host/tests/project_smoke.rs b/crates/ide-host/tests/project_smoke.rs index 9ed4cd4f..029b7f01 100644 --- a/crates/ide-host/tests/project_smoke.rs +++ b/crates/ide-host/tests/project_smoke.rs @@ -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:?}"); } diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 9b1e98bc..6a2d8976 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -27,18 +27,51 @@ pub(crate) fn detect_with_profiles( ) -> Vec { 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), + out: &mut Vec, +) { + 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), + out: &mut Vec, +) { + 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, name: impl Into, kind: &str, command: &str, args: &[&str]) -> RunConfig { RunConfig { id: id.into(), @@ -103,19 +210,7 @@ fn first_existing(root: &Path, names: &[&str]) -> Option { /// Sorted immediate subdirectories, skipping hidden and vendored ones. fn subdirs(dir: &Path) -> Vec { - let Ok(entries) = fs::read_dir(dir) else { return Vec::new() }; - let mut dirs: Vec = 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 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 { } fn detect_node(root: &Path, out: &mut Vec) { - 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) { } 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 { - 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) { 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) { } 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) { +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) { + if !ruby_project(root) { return; } if root.join("bin/rails").is_file() { @@ -1217,8 +1297,10 @@ pub(crate) fn just_recipes(src: &str) -> Vec { /// 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) { - 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) diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index 4b249304..a352ee83 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -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, diff --git a/crates/ide-run/src/scope.rs b/crates/ide-run/src/scope.rs new file mode 100644 index 00000000..a9944778 --- /dev/null +++ b/crates/ide-run/src/scope.rs @@ -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::::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"))); + } + } + } +} diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 5cbb5818..0c35cc03 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -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", "", "maven"), + ("build.gradle.kts", "plugins { application }", "gradle"), + ("pyproject.toml", "[tool.pytest.ini_options]", "pytest"), + ("phpunit.xml", "", "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())); +} diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 91e1d6e8..4d7b5d7b 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -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 { .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 { + 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 { @@ -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, + 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, +) -> std::collections::BTreeMap { + 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, 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> = 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 = 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::*; diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index b81b9dd0..40f4ccf2 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -540,10 +540,12 @@ pub fn project_java_version(module_dir: &Path) -> Option { } 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); + } } } diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 1cf235e5..59c14114 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -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"))); +} diff --git a/src-tauri/src/commands/sdk.rs b/src-tauri/src/commands/sdk.rs index c006c26b..98fbd297 100644 --- a/src-tauri/src/commands/sdk.rs +++ b/src-tauri/src/commands/sdk.rs @@ -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> { - ide_host::ops::sdk::sdk_python_effective(&state.host, window.label()) +pub fn sdk_python_effective( + window: Window, + state: State<'_, AppState>, + near: Option, +) -> IdeResult> { + 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`. diff --git a/src/bindings.ts b/src/bindings.ts index 73c45b57..bdb23e09 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -792,7 +792,7 @@ export const commands = { /** Installed interpreters / toolchains of `kind`; project-local ones first. */ sdkDetect: (kind: SdkKind) => typedError(__TAURI_INVOKE("sdk_detect", { kind })), /** The interpreter the project uses: the configured one, else its virtualenv. */ - sdkPythonEffective: () => typedError(__TAURI_INVOKE("sdk_python_effective")), + sdkPythonEffective: (near: string | null) => typedError(__TAURI_INVOKE("sdk_python_effective", { near })), /** The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`. */ sdkPhpEffective: () => typedError(__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 }, diff --git a/src/features/notebook/session.ts b/src/features/notebook/session.ts index 26c78892..c89d76ba 100644 --- a/src/features/notebook/session.ts +++ b/src/features/notebook/session.ts @@ -23,7 +23,7 @@ export const notebookBackend = { request: RequestId, output: Channel, ) => 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), }; diff --git a/src/features/run/configs.test.ts b/src/features/run/configs.test.ts index 1d515d92..91b143b5 100644 --- a/src/features/run/configs.test.ts +++ b/src/features/run/configs.test.ts @@ -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({ diff --git a/src/features/run/context.ts b/src/features/run/context.ts index 26ba771f..be404e8a 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -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 { @@ -94,14 +107,22 @@ function derived(base: RunConfig, relPath: string, patch: Partial): 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 `:` suffix). diff --git a/src/features/run/producers.test.ts b/src/features/run/producers.test.ts index 2487c44e..6a9a42b2 100644 --- a/src/features/run/producers.test.ts +++ b/src/features/run/producers.test.ts @@ -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(); + } +}); diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index 20119565..a0a90bf6 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -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>(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 = { + 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` 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, 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"]); diff --git a/src/features/run/runtimes.test.ts b/src/features/run/runtimes.test.ts new file mode 100644 index 00000000..44ef2f6d --- /dev/null +++ b/src/features/run/runtimes.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +const { entries, pythonNear } = vi.hoisted(() => ({ + entries: new Map(), + pythonNear: vi.fn(), +})); +vi.mock("@/platform/ipc", () => ({ + isTauri: () => true, + call: (promise: Promise) => 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"); +}); diff --git a/src/features/run/runtimes.ts b/src/features/run/runtimes.ts index 883d2c7b..be2deb15 100644 --- a/src/features/run/runtimes.ts +++ b/src/features/run/runtimes.ts @@ -78,9 +78,9 @@ export async function locateTools( return out; } -async function projectPython(): Promise { +async function projectPython(file: string | null): Promise { 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 { } } -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 { 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), diff --git a/src/features/settings/languages/api.ts b/src/features/settings/languages/api.ts index 64551173..5001dba9 100644 --- a/src/features/settings/languages/api.ts +++ b/src/features/settings/languages/api.ts @@ -103,7 +103,7 @@ export function pip( /** Interpreter the project uses when none is configured (its virtualenv). */ export function effectivePython(): Promise { - 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. */