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