diff --git a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift index 35a621351..a37f68216 100644 --- a/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift +++ b/macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -91,9 +91,7 @@ package final class RunFeatureModel: ObservableObject { package var configurationSaveError: String? { service.configurationSaveError } package var projectToolchain: ProjectToolchainSelection { service.projectToolchain } package var blockingToolchainDiagnostic: RunConfigurationDiagnostic? { - service.configurationDiagnostics.first { - $0.code == "missingToolchain" || $0.code == "toolchainVersionMismatch" - } + service.blockingToolchainDiagnostic(for: service.selectedConfiguration) } package var sourceSearchRoots: [URL] { service.sourceSearchRoots } diff --git a/macos/Sources/LitheExecutionModule/Services/RunService.swift b/macos/Sources/LitheExecutionModule/Services/RunService.swift index b5d4a6d0b..cfaf89d93 100644 --- a/macos/Sources/LitheExecutionModule/Services/RunService.swift +++ b/macos/Sources/LitheExecutionModule/Services/RunService.swift @@ -417,7 +417,7 @@ package final class RunService: ObservableObject { fail("Project run configuration is missing. Identify the project before running.") return } - if let diagnostic = configurationDiagnostics.first(where: { Self.isBlockingToolchainDiagnostic($0) }) { + if let diagnostic = blockingToolchainDiagnostic(for: configuration) { fail(diagnostic.message) return } @@ -636,6 +636,16 @@ package final class RunService: ObservableObject { return descriptor.id != "java" } + package func blockingToolchainDiagnostic( + for configuration: RunConfiguration? + ) -> RunConfigurationDiagnostic? { + configurationDiagnostics.first { diagnostic in + Self.isBlockingToolchainDiagnostic(diagnostic) + && (diagnostic.configurationID == nil + || diagnostic.configurationID == configuration?.id) + } + } + private static func isBlockingToolchainDiagnostic(_ diagnostic: RunConfigurationDiagnostic) -> Bool { diagnostic.code == "missingToolchain" || diagnostic.code == "toolchainVersionMismatch" } @@ -866,6 +876,17 @@ package final class RunService: ObservableObject { guard configurationStatus == .ready, let projectURL else { return } moduleSessions.removeAll { $0.id == configuration.id } + if let diagnostic = blockingToolchainDiagnostic(for: configuration) { + moduleSessions.append(RunSession( + id: configuration.id, + configurationID: configuration.id, + title: configuration.name, + output: diagnostic.message + "\n", + isRunning: false, + exitCode: 1 + )) + return + } if extensionRequiredLanguageIDs.contains(configuration.kind.providerID), languageRunExtension(providerID: configuration.kind.providerID) == nil { moduleSessions.append(RunSession( diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 74dd8177a..4ed857a67 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -2674,6 +2674,64 @@ struct RunConfigurationIntegrationTests { #expect(requests[1].environment?["JAVA_HOME"] == nil) } + @Test + func scopedNodeDiagnosticBlocksOnlyTheFrontendService() async throws { + let backend = JavaRunConfiguration( + id: "module:backend", + name: "backend", + kind: .mavenModule, + execution: .service, + modulePath: "backend", + mainClass: nil + ) + let frontend = RunConfiguration( + id: "npm.script:web/dev", + name: "dev", + kind: .process(provider: "npm.script"), + execution: .service, + modulePath: nil, + mainClass: nil + ) + let backendPlan = SharedLaunchPlan( + executable: .toolchain("project-maven"), + arguments: ["spring-boot:run"], + workingDirectory: "." + ) + let diagnostic = RunConfigurationDiagnostic( + configurationID: frontend.id, + code: "missingToolchain", + message: "No local node toolchain is selected" + ) + let fixture = makeFixture( + status: .ready, + effective: [backend, frontend].map { + EffectiveRunConfiguration(configuration: $0, options: RunOptions()) + }, + plans: [backend.id: backendPlan], + diagnostics: [diagnostic] + ) + + await fixture.service.loadProject( + at: fixture.root, + files: [], + mavenProject: fixture.mavenProject + ) + fixture.service.runAllServices() + + #expect(fixture.operations.launchPlanIDs == [backend.id]) + #expect(fixture.processFactory.processes.count == 1) + let backendSession = try #require( + fixture.service.moduleSessions.first(where: { $0.id == backend.id }) + ) + let frontendSession = try #require( + fixture.service.moduleSessions.first(where: { $0.id == frontend.id }) + ) + #expect(backendSession.isRunning) + #expect(!frontendSession.isRunning) + #expect(frontendSession.exitCode == 1) + #expect(frontendSession.output.contains("No local node toolchain")) + } + @Test func serviceAddressUsesExplicitArgumentsAndEnvironmentPorts() async throws { let argumentService = JavaRunConfiguration( @@ -3789,6 +3847,7 @@ struct RunConfigurationIntegrationTests { plans: [String: SharedLaunchPlan] = [:], generationEntryCount: Int? = nil, defaultConfigurationID: String? = nil, + diagnostics: [RunConfigurationDiagnostic] = [], preferences: RunTestKeyValueStore = RunTestKeyValueStore() ) -> RunServiceFixture { let root = URL(fileURLWithPath: "/tmp/lithe-run-service", isDirectory: true) @@ -3797,7 +3856,8 @@ struct RunConfigurationIntegrationTests { effective: effective, plans: plans, generationEntryCount: generationEntryCount, - defaultConfigurationID: defaultConfigurationID + defaultConfigurationID: defaultConfigurationID, + diagnostics: diagnostics ) let process = RecordingStreamingProcess() let processFactory = RecordingProcessFactory() @@ -3942,6 +4002,7 @@ private final class RecordingRunConfigurationOperations: RunConfigurationOperati let plans: [String: SharedLaunchPlan] let generationEntryCount: Int? let defaultConfigurationID: String? + let diagnostics: [RunConfigurationDiagnostic] private(set) var resolveCalls = 0 private(set) var migrationCalls = 0 private(set) var launchPlanIDs: [String] = [] @@ -3959,13 +4020,15 @@ private final class RecordingRunConfigurationOperations: RunConfigurationOperati effective: [EffectiveRunConfiguration], plans: [String: SharedLaunchPlan], generationEntryCount: Int? = nil, - defaultConfigurationID: String? = nil + defaultConfigurationID: String? = nil, + diagnostics: [RunConfigurationDiagnostic] = [] ) { self.status = status self.effective = effective self.plans = plans self.generationEntryCount = generationEntryCount self.defaultConfigurationID = defaultConfigurationID + self.diagnostics = diagnostics } func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { @@ -3985,7 +4048,7 @@ private final class RecordingRunConfigurationOperations: RunConfigurationOperati } return RunConfigurationResolution( configurations: effective, - diagnostics: [], + diagnostics: diagnostics, defaultConfigurationID: defaultConfigurationID ) } diff --git a/rust/lithe-core/src/execution/configuration.rs b/rust/lithe-core/src/execution/configuration.rs index f2a00c92b..6a3807e31 100644 --- a/rust/lithe-core/src/execution/configuration.rs +++ b/rust/lithe-core/src/execution/configuration.rs @@ -93,6 +93,9 @@ pub struct ToolchainPaths { pub maven_executable_path: String, #[serde(default)] pub maven_java_home_path: String, + /// Explicit executables for generic runtime toolchain IDs such as `project-node`. + #[serde(default)] + pub runtime_executable_paths: BTreeMap, } #[derive(Debug, Deserialize)] @@ -341,6 +344,7 @@ pub fn inspect(request: InspectRequest) -> Result { let root = existing_root(&request.root)?; let generated = read_document(&root, "run/generated.json")?; let requirements = read_requirements(&root)?; + let local_toolchains = read_local_toolchains(&root)?; for relative in [ "run/configurations.json", "run/local.json", @@ -406,6 +410,7 @@ pub fn inspect(request: InspectRequest) -> Result { "status": if generated.is_some() { "ready" } else { "missing" }, "generated": generated, "toolchainRequirements": requirements, + "localToolchains": local_toolchains, "diagnostics": diagnostics, "paths": { "generated": ".lithe/run/generated.json", "configurations": ".lithe/run/configurations.json", "local": ".lithe/run/local.json" } })) @@ -757,10 +762,9 @@ fn within_maven_module(path: &str, module: &str, maven_root: Option<&str>) -> bo /// Translates detector output into the run-configuration contract. /// /// Nearly every detection is process-based: the detector resolved the command, -/// so there is no toolchain binding and no ecosystem-specific launch assembly, -/// which is what keeps a new ecosystem from needing changes in -/// `create_launch_plan`. A detection that names toolchains instead carries a -/// provider `create_launch_plan` already handles. +/// so there is no ecosystem-specific launch assembly. A process may still name +/// a runtime requirement for scoped compatibility diagnostics; a detection that +/// transfers executable ownership to toolchains carries no command. fn detected_configurations( root: &Path, maven_root: Option<&Path>, @@ -853,6 +857,7 @@ pub fn resolve(request: ResolveRequest) -> Result { let team = read_document_value(&root, "run/configurations.json")? .unwrap_or_else(|| json!({"version": VERSION, "configurations": []})); let local = local_layer_document(&root, request.local_document)?; + let local_toolchains = read_local_toolchains(&root)?; let manifest = read_document_value(&root, "project.json")?; validate_version_value(&generated)?; validate_version_value(&team)?; @@ -862,7 +867,6 @@ pub fn resolve(request: ResolveRequest) -> Result { } let generated_ids = configuration_ids(&generated)?; let mut diagnostics = Vec::new(); - diagnostics.extend(toolchain_diagnostics(&root, &request.toolchain_candidates)?); for source in [&team, &local] { for id in configuration_ids(source)?.keys() { if !generated_ids.contains_key(id) && !id.starts_with("user:") { @@ -875,10 +879,16 @@ pub fn resolve(request: ResolveRequest) -> Result { } } let mut configurations = merge_values(&generated, &team, &local)?; + normalize_runtime_consumption(&mut configurations); let global_toolchain = local.get("toolchain").cloned(); if let Some(toolchain) = global_toolchain.as_ref() { apply_global_toolchain(&mut configurations, toolchain); } + diagnostics.extend(toolchain_diagnostics( + &root, + &request.toolchain_candidates, + &configurations, + )?); for configuration in &mut configurations { validate_configuration(configuration)?; if configuration.disabled { @@ -944,7 +954,8 @@ pub fn resolve(request: ResolveRequest) -> Result { "configurations": configurations, "diagnostics": diagnostics, "defaultRunConfiguration": default_run_configuration, - "toolchain": global_toolchain + "toolchain": global_toolchain, + "localToolchains": local_toolchains })) } @@ -1010,6 +1021,8 @@ pub fn save_editor_changes(mut request: UpdateOptionsRequest) -> Result Result, +) -> Result, CoreError> { + if runtime_executable_paths.is_empty() { + return Ok(None); + } + let mut document = read_local_toolchains(root)? + .unwrap_or_else(|| json!({"version": SIDECAR_VERSION, "toolchains": {}})); + validate_sidecar_version_value(&document)?; + let toolchains = document + .get_mut("toolchains") + .and_then(Value::as_object_mut) + .ok_or_else(|| { + CoreError::new( + ErrorCode::ParseFailed, + "Local toolchains must contain a toolchains object", + ) + })?; + for (id, executable_path) in runtime_executable_paths { + if executable_path.trim().is_empty() { + toolchains.remove(id); + } else { + toolchains.insert(id.clone(), json!({ "executable": executable_path.trim() })); + } + } + Ok(Some(document)) +} + fn update_configuration_options( root: &Path, request: UpdateOptionsRequest, @@ -1785,6 +1830,31 @@ fn merge_values( .collect() } +/// Reconciles runtime bindings from the effective command for generated v2 +/// documents written before detectors declared their consumption explicitly. +fn normalize_runtime_consumption(configurations: &mut [RunConfiguration]) { + for configuration in configurations { + let command = configuration + .command + .as_deref() + .unwrap_or("") + .to_ascii_lowercase(); + if matches!( + command.as_str(), + "npm" | "npm.cmd" | "pnpm" | "pnpm.cmd" | "yarn" | "yarn.cmd" + ) { + configuration + .toolchains + .entry("runtime".to_string()) + .or_insert_with(|| "project-node".to_string()); + } else if matches!(command.as_str(), "bun" | "bun.exe") + && configuration.toolchains.get("runtime").map(String::as_str) == Some("project-node") + { + configuration.toolchains.remove("runtime"); + } + } +} + /// Deep-merges the `extensions` object one namespace at a time. /// /// A shallow insert would let a layer that touches a single maven key drop the @@ -2147,6 +2217,24 @@ fn read_requirements(root: &Path) -> Result Result, CoreError> { + let Some(document) = read_document_value(root, "toolchains/local.json")? else { + return Ok(None); + }; + validate_sidecar_version_value(&document)?; + if document + .get("toolchains") + .and_then(Value::as_object) + .is_none() + { + return Err(CoreError::new( + ErrorCode::ParseFailed, + "Local toolchains must contain a toolchains object", + )); + } + Ok(Some(document)) +} + fn detect_requirements( root: &Path, maven_root: Option<&Path>, @@ -2188,62 +2276,26 @@ fn detect_requirements( } maven.version = maven_wrapper_version(maven_root); let mut toolchains = BTreeMap::new(); - if has_java_ecosystem { + let consumes = |toolchain: &str| { + configurations.iter().any(|configuration| { + configuration + .toolchains + .values() + .any(|candidate| candidate == toolchain) + }) + }; + if has_java_ecosystem || consumes("project-jdk") { toolchains.insert("project-jdk".to_string(), jdk); } if maven_root.join("pom.xml").is_file() || maven_root.join("mvnw").is_file() { toolchains.insert("project-maven".to_string(), maven); } - let providers = configurations - .iter() - .map(|configuration| configuration.provider.as_str()) - .collect::>(); - if providers.iter().any(|provider| provider.starts_with("go.")) { - toolchains.insert( - "project-go".to_string(), - generic_requirement("go", declared_go_version(root)), - ); - } - if providers - .iter() - .any(|provider| provider.starts_with("python.")) - { - toolchains.insert( - "project-python".to_string(), - generic_requirement("python", declared_python_version(root)), - ); - } - if providers - .iter() - .any(|provider| provider.starts_with("npm.")) - { + if consumes("project-node") { toolchains.insert( "project-node".to_string(), generic_requirement("node", declared_node_version(root)), ); } - if providers - .iter() - .any(|provider| provider.starts_with("cargo.")) - { - toolchains.insert( - "project-cargo".to_string(), - generic_requirement("rust", declared_rust_version(root)), - ); - } - if providers - .iter() - .any(|provider| provider.starts_with("gradle.")) - { - let mut gradle = generic_requirement("gradle", None); - // A Gradle build runs on the JVM, so the requirement carries the same - // JDK binding Maven uses rather than resolving a runtime of its own. - gradle.java = Some("project-jdk".to_string()); - if root.join("gradlew").exists() { - gradle.wrapper = Some("./gradlew".to_string()); - } - toolchains.insert("project-gradle".to_string(), gradle); - } Ok(ToolchainRequirementsDocument { version: SIDECAR_VERSION, toolchains, @@ -2264,6 +2316,7 @@ fn generic_requirement(kind: &str, minimum_version: Option) -> Toolchain fn toolchain_diagnostics( root: &Path, candidates: &[ToolchainCandidate], + configurations: &[RunConfiguration], ) -> Result, CoreError> { let Some(requirements) = read_requirements(root)? else { return Ok(Vec::new()); @@ -2271,15 +2324,31 @@ fn toolchain_diagnostics( validate_sidecar_version(requirements.version)?; let mut diagnostics = Vec::new(); for (id, requirement) in requirements.toolchains { + let mut consumer_ids = configurations + .iter() + .filter(|configuration| { + configuration + .toolchains + .values() + .any(|toolchain| toolchain == &id) + }) + .map(|configuration| configuration.id.clone()) + .collect::>(); + consumer_ids.sort(); + consumer_ids.dedup(); let Some(candidate) = candidates .iter() .find(|candidate| candidate.id == id && candidate.kind == requirement.kind) else { - diagnostics.push(json!({ - "code": "missingToolchain", - "toolchain": id, - "message": format!("No local {} toolchain is selected", requirement.kind) - })); + append_toolchain_diagnostics( + &mut diagnostics, + &consumer_ids, + json!({ + "code": "missingToolchain", + "toolchain": id, + "message": format!("No local {} toolchain is selected", requirement.kind) + }), + ); continue; }; let required_version = requirement @@ -2292,14 +2361,18 @@ fn toolchain_diagnostics( required, requirement.minimum_version.is_some(), ) { - diagnostics.push(json!({ - "code": "toolchainVersionMismatch", - "toolchain": id, - "message": format!( - "{} {} does not satisfy required version {}", - requirement.kind, candidate.version, required - ) - })); + append_toolchain_diagnostics( + &mut diagnostics, + &consumer_ids, + json!({ + "code": "toolchainVersionMismatch", + "toolchain": id, + "message": format!( + "{} {} does not satisfy required version {}", + requirement.kind, candidate.version, required + ) + }), + ); } } if let Some(vendor) = requirement.preferred_vendor.as_deref() { @@ -2308,17 +2381,36 @@ fn toolchain_diagnostics( .to_lowercase() .contains(&vendor.to_lowercase()) { - diagnostics.push(json!({ - "code": "toolchainVendorMismatch", - "toolchain": id, - "message": format!("Preferred Java vendor is {vendor}") - })); + append_toolchain_diagnostics( + &mut diagnostics, + &consumer_ids, + json!({ + "code": "toolchainVendorMismatch", + "toolchain": id, + "message": format!("Preferred Java vendor is {vendor}") + }), + ); } } } Ok(diagnostics) } +fn append_toolchain_diagnostics( + diagnostics: &mut Vec, + consumer_ids: &[String], + diagnostic: Value, +) { + if consumer_ids.is_empty() { + return; + } + for configuration_id in consumer_ids { + let mut scoped = diagnostic.clone(); + scoped["id"] = json!(configuration_id); + diagnostics.push(scoped); + } +} + fn version_satisfies(actual: &str, required: &str, minimum: bool) -> bool { let actual_parts = version_parts(actual); let required_parts = version_parts(required); @@ -2467,39 +2559,6 @@ fn fingerprint_input(path: &Path) -> bool { ) } -fn declared_go_version(root: &Path) -> Option { - highest_version( - project_manifest_paths(root, &["go.mod"]) - .into_iter() - .filter_map(|path| fs::read_to_string(path).ok()) - .filter_map(|text| { - text.lines().find_map(|line| { - line.trim() - .strip_prefix("go ") - .and_then(first_numeric_version) - }) - }) - .collect(), - ) -} - -fn declared_python_version(root: &Path) -> Option { - let expression = - regex::Regex::new(r#"(?m)^\s*(?:requires-python|python)\s*=\s*["']([^"']+)["']"#).ok()?; - highest_version( - project_manifest_paths(root, &["pyproject.toml"]) - .into_iter() - .filter_map(|path| fs::read_to_string(path).ok()) - .filter_map(|text| { - expression - .captures(&text) - .and_then(|capture| capture.get(1)) - .and_then(|value| first_numeric_version(value.as_str())) - }) - .collect(), - ) -} - fn declared_node_version(root: &Path) -> Option { highest_version( project_manifest_paths(root, &["package.json"]) @@ -2517,35 +2576,6 @@ fn declared_node_version(root: &Path) -> Option { ) } -fn declared_rust_version(root: &Path) -> Option { - let mut versions = Vec::new(); - for path in project_manifest_paths(root, &["Cargo.toml"]) { - let Some(document) = fs::read_to_string(path) - .ok() - .and_then(|text| text.parse::().ok()) - else { - continue; - }; - if let Some(version) = document - .get("package") - .and_then(|package| package.get("rust-version")) - .and_then(|value| value.as_str()) - .and_then(first_numeric_version) - { - versions.push(version); - } - } - for path in project_manifest_paths(root, &["rust-toolchain", "rust-toolchain.toml"]) { - let Ok(text) = fs::read_to_string(path) else { - continue; - }; - if let Some(version) = first_numeric_version(&text) { - versions.push(version); - } - } - highest_version(versions) -} - fn first_numeric_version(value: &str) -> Option { regex::Regex::new(r"[0-9]+(?:\.[0-9]+)+") .ok()? diff --git a/rust/lithe-core/src/execution/detectors/gradle.rs b/rust/lithe-core/src/execution/detectors/gradle.rs index be27a5521..523c7311f 100644 --- a/rust/lithe-core/src/execution/detectors/gradle.rs +++ b/rust/lithe-core/src/execution/detectors/gradle.rs @@ -161,6 +161,7 @@ fn configuration( detected .with_cwd(&invocation.cwd) .with_confidence(Confidence::Declared) + .requiring_toolchain("java", "project-jdk") } fn service_plugin(text: &str) -> Option<(&'static str, &'static str)> { diff --git a/rust/lithe-core/src/execution/detectors/mod.rs b/rust/lithe-core/src/execution/detectors/mod.rs index 759971a94..8ac47c621 100644 --- a/rust/lithe-core/src/execution/detectors/mod.rs +++ b/rust/lithe-core/src/execution/detectors/mod.rs @@ -49,8 +49,8 @@ pub struct Detected { /// Project-relative directory the command runs in. pub cwd: String, pub env: BTreeMap, - /// Toolchain bindings by role, e.g. `{java: project-jdk}`. Empty for a - /// detection that spawns `command` directly. + /// Toolchain bindings by role, e.g. `{java: project-jdk}`. A process-based + /// detection may retain `command` while declaring a runtime it consumes. pub toolchains: BTreeMap, /// The debug adapter this service supports, when launching it under a /// debugger is something the launch layer already knows how to assemble. @@ -161,6 +161,15 @@ impl Detected { self } + /// Declares a runtime requirement while keeping the detector-owned command. + /// + /// Process-based detectors still own their command, but Core needs the + /// binding to scope compatibility diagnostics to the consumers. + pub fn requiring_toolchain(mut self, role: &str, name: &str) -> Self { + self.toolchains.insert(role.to_string(), name.to_string()); + self + } + /// Declares the debug adapter supported by this detection. pub fn with_debug(mut self, adapter: &str) -> Self { self.debug = Some(adapter.to_string()); diff --git a/rust/lithe-core/src/execution/detectors/npm.rs b/rust/lithe-core/src/execution/detectors/npm.rs index 03f08df12..057a89537 100644 --- a/rust/lithe-core/src/execution/detectors/npm.rs +++ b/rust/lithe-core/src/execution/detectors/npm.rs @@ -127,6 +127,11 @@ pub fn detect(ctx: &DirectoryContext) -> Vec { if let Some(server) = server { extension["server"] = server.into(); } + let detected = if manager == "bun" { + detected + } else { + detected.requiring_toolchain("runtime", "project-node") + }; detected.with_extension("npm", extension) }) .collect() diff --git a/rust/lithe-core/src/tests/detectors.rs b/rust/lithe-core/src/tests/detectors.rs index d34090a3e..a4aabc775 100644 --- a/rust/lithe-core/src/tests/detectors.rs +++ b/rust/lithe-core/src/tests/detectors.rs @@ -186,6 +186,40 @@ fn npm_detector_inherits_the_workspace_package_manager() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn bun_scripts_keep_the_bun_command_without_consuming_node() { + let root = temporary_root("detect-bun-runtime"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("bun.lock"), "").unwrap(); + fs::write( + root.join("package.json"), + r#"{"packageManager":"bun@1.2.0","scripts":{"dev":"vite"}}"#, + ) + .unwrap(); + + let dev = generated_configurations(&root) + .into_iter() + .find(|item| item["id"] == "npm.script:dev") + .unwrap(); + assert_eq!(dev["command"], "bun"); + assert!(dev["toolchains"].as_object().unwrap().is_empty(), "{dev}"); + let generated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-bun-runtime", + "command": "runConfig.generate", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap(); + assert!( + generated["data"]["toolchainRequirements"]["toolchains"]["project-node"].is_null(), + "{generated}" + ); + + fs::remove_dir_all(root).unwrap(); +} + #[test] fn detectors_preserve_application_service_and_task_semantics() { let root = temporary_root("detect-execution-semantics"); @@ -669,11 +703,11 @@ fn gradle_detector_ignores_commented_plugins() { fs::remove_dir_all(root).unwrap(); } -/// A Gradle service runs on the JVM, so it must reach the host's JDK and Gradle -/// wrapper the same way a Maven module does. Without the requirement the host has -/// no toolchain to resolve and the wrapper in the project is ignored. +/// Gradle consumes a JDK but remains a process command until every host provides +/// a Gradle registry. Binding a synthetic Gradle toolchain would block PATH-based +/// execution on hosts that cannot discover or configure it. #[test] -fn gradle_projects_require_a_jdk_and_prefer_the_wrapper() { +fn gradle_projects_require_a_jdk_without_replacing_the_path_command() { let root = temporary_root("detect-gradle-toolchain"); fs::create_dir_all(&root).unwrap(); fs::write( @@ -695,12 +729,47 @@ fn gradle_projects_require_a_jdk_and_prefer_the_wrapper() { assert_eq!(response["ok"], true, "{response}"); let toolchains = &response["data"]["toolchainRequirements"]["toolchains"]; - assert_eq!(toolchains["project-gradle"]["type"], "gradle"); - assert_eq!(toolchains["project-gradle"]["wrapper"], "./gradlew"); - assert_eq!(toolchains["project-gradle"]["java"], "project-jdk"); + assert!(toolchains["project-gradle"].is_null(), "{toolchains}"); // Kotlin and Groovy sources mean a Gradle build can need a JDK with no // `.java` file anywhere in the project. assert_eq!(toolchains["project-jdk"]["type"], "java"); + let configuration = response["data"]["generated"]["configurations"] + .as_array() + .unwrap() + .iter() + .find(|item| item["provider"] == "gradle.service") + .unwrap(); + assert_eq!(configuration["command"], "gradle"); + assert_eq!(configuration["toolchains"]["java"], "project-jdk"); + assert!(configuration["toolchains"]["runtime"].is_null()); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn nested_gradle_service_declares_the_jdk_requirement_from_its_consumer() { + let root = temporary_root("detect-nested-gradle-toolchain"); + fs::create_dir_all(root.join("services/orders")).unwrap(); + fs::write(root.join("settings.gradle"), "include 'services:orders'\n").unwrap(); + fs::write( + root.join("services/orders/build.gradle"), + "plugins {\n id 'org.springframework.boot'\n}\n", + ) + .unwrap(); + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-nested-gradle", + "command": "runConfig.generate", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + let toolchains = &response["data"]["toolchainRequirements"]["toolchains"]; + assert_eq!(toolchains["project-jdk"]["type"], "java", "{response}"); + assert!(toolchains["project-gradle"].is_null(), "{response}"); fs::remove_dir_all(root).unwrap(); } diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs index d969d8828..c5abd2fd1 100644 --- a/rust/lithe-core/src/tests/run_configuration.rs +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -1050,6 +1050,89 @@ fn project_editor_save_prepares_local_and_team_documents_without_writing() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn editor_save_updates_generic_local_toolchains_without_dropping_other_entries() { + let root = temporary_root("run-config-editor-runtime-toolchain"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::create_dir_all(root.join(".lithe/toolchains")).unwrap(); + fs::create_dir_all(root.join("web")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":2,"configurations":[{"id":"npm:dev","name":"dev","provider":"npm.script","execution":"service","command":"npm","args":["run","dev"],"cwd":"web","toolchains":{"runtime":"project-node"}}]}"#, + ) + .unwrap(); + fs::write( + root.join(".lithe/toolchains/local.json"), + r#"{"version":1,"toolchains":{"project-go":{"executable":"C:/Go/bin/go.exe"}}}"#, + ) + .unwrap(); + + let save = |node_path: &str| -> Value { + serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "save-runtime-toolchain", + "command": "runConfig.saveEditorChanges", + "payload": { + "root": root, + "scope": "local", + "configurationId": "npm:dev", + "workingDirectory": "web", + "toolchain": { + "runtimeExecutablePaths": {"project-node": node_path} + } + } + }) + .to_string(), + )) + .unwrap() + }; + + let saved = save("C:/Program Files/nodejs/node.exe"); + assert_eq!(saved["ok"], true, "{saved}"); + let toolchains: Value = + serde_json::from_str(saved["data"]["toolchainDocument"].as_str().unwrap()).unwrap(); + assert_eq!( + toolchains["toolchains"]["project-node"]["executable"], + "C:/Program Files/nodejs/node.exe" + ); + assert_eq!( + toolchains["toolchains"]["project-go"]["executable"], + "C:/Go/bin/go.exe" + ); + fs::write( + root.join(".lithe/toolchains/local.json"), + saved["data"]["toolchainDocument"].as_str().unwrap(), + ) + .unwrap(); + + let removed = save(""); + let removed_toolchains: Value = + serde_json::from_str(removed["data"]["toolchainDocument"].as_str().unwrap()).unwrap(); + assert!(removed_toolchains["toolchains"] + .get("project-node") + .is_none()); + assert_eq!( + removed_toolchains["toolchains"]["project-go"]["executable"], + "C:/Go/bin/go.exe" + ); + + let inspected: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "inspect-runtime-toolchain", + "command": "runConfig.inspect", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!( + inspected["data"]["localToolchains"]["toolchains"]["project-node"]["executable"], + "C:/Program Files/nodejs/node.exe" + ); + + fs::remove_dir_all(root).unwrap(); +} + #[test] fn run_configuration_inspect_reports_malformed_and_unsupported_documents() { let root = temporary_root("run-config-errors"); @@ -1422,7 +1505,7 @@ fn run_configuration_resolve_matches_toolchains_and_rejects_unsafe_paths() { fs::create_dir_all(root.join(".lithe/toolchains")).unwrap(); fs::write( root.join(".lithe/run/generated.json"), - r#"{"version":1,"configurations":[{"id":"current-file","name":"Current File","type":"java.current-file"}]}"#, + r#"{"version":1,"configurations":[{"id":"current-file","name":"Current File","type":"java.current-file","toolchains":{"java":"project-jdk"}}]}"#, ) .unwrap(); fs::write( @@ -1480,6 +1563,198 @@ fn run_configuration_resolve_matches_toolchains_and_rejects_unsafe_paths() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn hybrid_project_scopes_node_diagnostics_to_npm_configurations() { + let root = temporary_root("run-config-hybrid-toolchains"); + let java_source = "src/main/java/com/example/DemoApplication.java"; + fs::create_dir_all(root.join("src/main/java/com/example")).unwrap(); + fs::create_dir_all(root.join("web")).unwrap(); + fs::write( + root.join(java_source), + "package com.example; @SpringBootApplication class DemoApplication { public static void main(String[] args) {} }", + ) + .unwrap(); + fs::write( + root.join("pom.xml"), + "demo21spring-boot-maven-plugin", + ) + .unwrap(); + fs::write( + root.join("web/package.json"), + r#"{"engines":{"node":">=22.0"},"scripts":{"dev":"vite","build":"vite build"}}"#, + ) + .unwrap(); + fs::write( + root.join("package.json"), + r#"{"private":true,"engines":{"node":">=22.0"}}"#, + ) + .unwrap(); + + let generated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-hybrid", + "command": "runConfig.generate", + "payload": {"root": root, "paths": [java_source]} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(generated["ok"], true, "{generated}"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::create_dir_all(root.join(".lithe/toolchains")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + serde_json::to_string(&generated["data"]["generated"]).unwrap(), + ) + .unwrap(); + fs::write( + root.join(".lithe/toolchains/requirements.json"), + serde_json::to_string(&generated["data"]["toolchainRequirements"]).unwrap(), + ) + .unwrap(); + + let resolve = |node_version: Option<&str>| -> Value { + let mut candidates = vec![ + serde_json::json!({"id":"project-jdk","type":"java","version":"21","vendor":"Temurin"}), + serde_json::json!({"id":"project-maven","type":"maven","version":"3.9.9","vendor":""}), + ]; + if let Some(version) = node_version { + candidates.push(serde_json::json!({ + "id":"project-node","type":"node","version":version,"vendor":"Node.js" + })); + } + serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "resolve-hybrid", + "command": "runConfig.resolve", + "payload": {"root": root, "toolchainCandidates": candidates} + }) + .to_string(), + )) + .unwrap() + }; + + let missing = resolve(None); + let configurations = missing["data"]["configurations"].as_array().unwrap(); + let npm_ids = configurations + .iter() + .filter(|configuration| configuration["provider"] == "npm.script") + .map(|configuration| configuration["id"].as_str().unwrap().to_string()) + .collect::>(); + assert!(!npm_ids.is_empty(), "{missing}"); + let node_diagnostics = missing["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .filter(|diagnostic| diagnostic["toolchain"] == "project-node") + .collect::>(); + assert_eq!(node_diagnostics.len(), npm_ids.len(), "{missing}"); + assert!(node_diagnostics + .iter() + .all(|diagnostic| npm_ids.iter().any(|id| diagnostic["id"] == id.as_str()))); + + let spring_id = configurations + .iter() + .find(|configuration| configuration["provider"] == "spring-boot.maven") + .and_then(|configuration| configuration["id"].as_str()) + .unwrap(); + let spring_plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "plan-hybrid-spring", + "command": "runConfig.createLaunchPlan", + "payload": {"root": root, "configurationId": spring_id} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(spring_plan["ok"], true, "{spring_plan}"); + assert_eq!( + spring_plan["data"]["executable"]["toolchain"], + "project-maven" + ); + + let mismatch = resolve(Some("18.20.4")); + let version_mismatches = mismatch["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .filter(|diagnostic| diagnostic["code"] == "toolchainVersionMismatch") + .collect::>(); + assert!(!version_mismatches.is_empty(), "{mismatch}"); + assert!(version_mismatches + .iter() + .all(|diagnostic| npm_ids.iter().any(|id| diagnostic["id"] == id.as_str()))); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn legacy_v2_runtime_requirements_are_reconciled_without_regeneration() { + let root = temporary_root("run-config-legacy-runtime-consumption"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::create_dir_all(root.join(".lithe/toolchains")).unwrap(); + fs::create_dir_all(root.join("web")).unwrap(); + fs::create_dir_all(root.join("bun-web")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":2,"configurations":[ + {"id":"spring","name":"backend","provider":"spring-boot.maven","execution":"service","cwd":".","toolchains":{"java":"project-jdk","maven":"project-maven"}}, + {"id":"npm","name":"web","provider":"npm.script","execution":"service","command":"npm","args":["run","dev"],"cwd":"web","toolchains":{}}, + {"id":"bun","name":"bun web","provider":"npm.script","execution":"service","command":"bun","args":["run","dev"],"cwd":"bun-web","toolchains":{"runtime":"project-node"}} + ]}"#, + ) + .unwrap(); + fs::write( + root.join(".lithe/toolchains/requirements.json"), + r#"{"version":1,"toolchains":{ + "project-jdk":{"type":"java"}, + "project-maven":{"type":"maven","java":"project-jdk"}, + "project-node":{"type":"node","minimumVersion":"22"} + }}"#, + ) + .unwrap(); + + let resolved: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "resolve-legacy-runtime-consumption", + "command": "runConfig.resolve", + "payload": { + "root": root, + "toolchainCandidates": [ + {"id":"project-jdk","type":"java","version":"21","vendor":"Temurin"}, + {"id":"project-maven","type":"maven","version":"3.9.9","vendor":""} + ] + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(resolved["ok"], true, "{resolved}"); + let node_diagnostics = resolved["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .filter(|diagnostic| diagnostic["toolchain"] == "project-node") + .collect::>(); + assert_eq!(node_diagnostics.len(), 1, "{resolved}"); + assert_eq!(node_diagnostics[0]["id"], "npm"); + let configurations = resolved["data"]["configurations"].as_array().unwrap(); + assert_eq!( + configurations + .iter() + .find(|configuration| configuration["id"] == "npm") + .unwrap()["toolchains"]["runtime"], + "project-node" + ); + assert!(configurations + .iter() + .find(|configuration| configuration["id"] == "bun") + .unwrap()["toolchains"]["runtime"] + .is_null()); + + fs::remove_dir_all(root).unwrap(); +} + #[test] fn run_configuration_main_class_validation_uses_the_declared_package() { let root = temporary_root("run-config-main-class-package"); @@ -1537,6 +1812,73 @@ fn shared_run_configuration_fixtures_have_the_versioned_contract_shape() { assert!(fixture_count >= 6); } +#[test] +fn shared_hybrid_fixture_executes_scoped_toolchain_expectations() { + let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../shared/fixtures/run-configuration/hybrid-spring-vue.json"); + let fixture: Value = serde_json::from_str(&fs::read_to_string(fixture_path).unwrap()).unwrap(); + let root = temporary_root("run-config-hybrid-fixture"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::create_dir_all(root.join(".lithe/toolchains")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + serde_json::to_string(&fixture["generated"]).unwrap(), + ) + .unwrap(); + fs::write( + root.join(".lithe/toolchains/requirements.json"), + serde_json::to_string(&fixture["requirements"]).unwrap(), + ) + .unwrap(); + + let resolved: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "resolve-hybrid-fixture", + "command": "runConfig.resolve", + "payload": { + "root": root, + "toolchainCandidates": [ + {"id":"project-jdk","type":"java","version":"21","vendor":"Temurin"}, + {"id":"project-maven","type":"maven","version":"3.9.9","vendor":""} + ] + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(resolved["ok"], true, "{resolved}"); + let missing_node_ids = resolved["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .filter(|diagnostic| diagnostic["toolchain"] == "project-node") + .filter_map(|diagnostic| diagnostic["id"].as_str()) + .collect::>(); + assert_eq!( + missing_node_ids, + fixture["expected"]["missingNodeDiagnosticConfigurationIds"] + .as_array() + .unwrap() + .iter() + .filter_map(Value::as_str) + .collect::>() + ); + for id in fixture["expected"]["unblockedConfigurationIds"] + .as_array() + .unwrap() + .iter() + .filter_map(Value::as_str) + { + assert!(resolved["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .all(|diagnostic| diagnostic["id"] != id)); + } + + fs::remove_dir_all(root).unwrap(); +} + #[test] fn shared_editor_save_fixture_executes_the_document_contract() { let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -1849,7 +2191,7 @@ fn pure_go_generation_does_not_require_java_or_add_java_current_file() { } #[test] -fn multi_language_generation_declares_runtime_requirements_and_versions() { +fn multi_language_generation_declares_only_consumed_runtime_requirements() { let root = temporary_root("generic-toolchain-requirements"); for directory in ["python", "web", "worker/src"] { fs::create_dir_all(root.join(directory)).unwrap(); @@ -1884,19 +2226,27 @@ fn multi_language_generation_declares_runtime_requirements_and_versions() { .unwrap(); assert_eq!(generated["ok"], true, "{generated}"); let requirements = &generated["data"]["toolchainRequirements"]["toolchains"]; - for (id, kind, version) in [ - ("project-go", "go", "1.24"), - ("project-python", "python", "3.12"), - ("project-node", "node", "22.4"), - ("project-cargo", "rust", "1.82"), - ] { - assert_eq!(requirements[id]["type"], kind, "{requirements}"); - assert_eq!( - requirements[id]["minimumVersion"], version, - "{requirements}" - ); + assert_eq!( + requirements["project-node"]["type"], "node", + "{requirements}" + ); + assert_eq!( + requirements["project-node"]["minimumVersion"], "22.4", + "{requirements}" + ); + for id in ["project-go", "project-python", "project-cargo"] { + assert!(requirements[id].is_null(), "{id}: {requirements}"); } assert!(requirements["project-jdk"].is_null(), "{requirements}"); + for provider in ["go.main", "python.script", "cargo.binary"] { + let configuration = generated["data"]["generated"]["configurations"] + .as_array() + .unwrap() + .iter() + .find(|configuration| configuration["provider"] == provider) + .unwrap(); + assert!(configuration["toolchains"].as_object().unwrap().is_empty()); + } fs::remove_dir_all(root).unwrap(); } diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index d2470fce3..4d7b371a6 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -222,4 +222,12 @@ is an explicit user action. Shared project overrides stay in `.lithe/run/configurations.json`. Machine-local overrides may live in `.lithe/run/local.json` or in a host-owned document supplied as `localDocument`; absolute toolchain paths belong only in that local layer and -are excluded from project visibility and Git by default. +are excluded from project visibility and Git by default. Generic runtime +executables such as Node are selected in `.lithe/toolchains/local.json`; this +machine-local document is also excluded from Git by default. Missing or +incompatible toolchains block only configurations that consume the affected +toolchain, while diagnostics without a configuration ID apply to the project. +Runtime consumption is declared by the detector from the actual command rather +than inferred from the provider namespace. An automatically discovered runtime +path is session-effective: validation and launch share it, but persistence +still requires an explicit user selection. diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 91ae46085..cf874e152 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -510,7 +510,21 @@ configurations by stable ID using this precedence: replaced by the higher layer, while toolchain maps merge by key. It returns effective configurations, their source, the team default, structured diagnostics for stale, orphaned, missing, disabled, and toolchain mismatch -states, and the effective global `toolchain`. A document-level `toolchain` +states, the effective global `toolchain`, and the machine-local +`localToolchains` document. Toolchain diagnostics carry the affected run +configuration ID when a requirement is consumed by one or more configurations; +requirements with no configuration consumer do not emit a blocking diagnostic. +A process detector declares a runtime binding only when that command genuinely +consumes the runtime. npm, pnpm, and Yarn scripts consume `project-node`; Bun +scripts keep their independent `bun` command and do not acquire a Node +requirement. Go, Python, Cargo, and Gradle remain command-based until every host +provides the corresponding configurable runtime registry, so their PATH-based +launch behavior is not blocked by an unavailable platform selector. +During resolution, Core also reconciles npm, pnpm, and Yarn commands from older +v2 generated documents with the same `project-node` binding. This compatibility +normalization is based on the effective command, does not mutate the stored +document, and keeps legacy hybrid projects scoped without requiring regeneration. +A document-level `toolchain` object in the local layer (e.g. `{ "java": { "homePath": ... }, "maven": { "executablePath": ..., "javaHomePath": ... } }`) provides defaults for every configuration's `extensions.java.*`. A non-empty @@ -531,18 +545,33 @@ global toolchain into the local layer instead of patching a configuration; project scope rejects this payload because toolchain paths are machine-local. `runConfig.saveEditorChanges` accepts the normal option-edit payload plus the -required `toolchain` object. It applies the global toolchain and configuration -override edits together, returning `localDocument` and either a -`projectDocument` string or `null`. Local scope combines both edits in the one -local document. Project scope returns the local defaults and team options as -two fully prepared documents so the platform adapter can write them as one -transaction with rollback. Empty per-configuration toolchain paths remove the +required `toolchain` object. In addition to Java and Maven paths, that object +may contain `runtimeExecutablePaths`, keyed by stable generic toolchain ID. It +applies the global toolchain and configuration override edits together, +returning `localDocument`, either a `projectDocument` string or `null`, and +either a `toolchainDocument` string or `null`. The latter updates +`.lithe/toolchains/local.json`, preserves unrelated toolchain IDs, and removes +an entry when its supplied executable path is empty. Local scope combines the +run-option edits in the local run document. Project scope returns the local +defaults and team options as separate fully prepared documents; the platform +adapter writes all returned documents as one transaction with rollback. Empty +per-configuration toolchain paths remove the corresponding override keys while preserving unrelated extension fields. Platform clients report the editor save as successful only after the written documents resolve again. Failures identify whether preparation, document writing, or post-save reload failed; a reload failure keeps the last usable UI snapshot and states that the documents were already saved. +Automatic runtime discovery produces an effective executable path for the +current session. Platforms use that same path both to construct +`toolchainCandidates` and to resolve the launch command. An automatic path is +not a persisted user selection and is written to `.lithe/toolchains/local.json` +only after an explicit editor save. +On Windows, Node-backed commands resolve their package-manager shim from the +selected Node installation. They do not fall back to a PATH shim from another +installation, and Windows executable extensions take precedence over extensionless +shell scripts. + `runConfig.createLaunchPlan` accepts `root`, `configurationId`, optional `currentFile` and `classPath`, optional `debugPort`, and optional `localDocument`. It returns a toolchain diff --git a/shared/contracts/toolchain-requirements-v1.schema.json b/shared/contracts/toolchain-requirements-v1.schema.json index b9d53c141..5eedb3781 100644 --- a/shared/contracts/toolchain-requirements-v1.schema.json +++ b/shared/contracts/toolchain-requirements-v1.schema.json @@ -16,7 +16,7 @@ "type": "object", "required": ["type"], "properties": { - "type": { "enum": ["java", "maven"] }, + "type": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, "minimumVersion": { "type": "string", "minLength": 1 }, "preferredVendor": { "type": "string", "minLength": 1 }, "wrapper": { "type": "string", "pattern": "^(?!/)(?!.*(^|/)\\.\\.(?:/|$)).*$" }, diff --git a/shared/fixtures/run-configuration/hybrid-spring-vue.json b/shared/fixtures/run-configuration/hybrid-spring-vue.json new file mode 100644 index 000000000..1b341033e --- /dev/null +++ b/shared/fixtures/run-configuration/hybrid-spring-vue.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "generated": { + "version": 2, + "configurations": [ + { + "id": "spring-boot.maven:backend", + "name": "backend", + "provider": "spring-boot.maven", + "execution": "service", + "cwd": ".", + "toolchains": { + "java": "project-jdk", + "maven": "project-maven" + } + }, + { + "id": "npm.script:web/dev", + "name": "dev", + "provider": "npm.script", + "execution": "service", + "command": "npm", + "args": ["run", "dev"], + "cwd": "web", + "toolchains": { + "runtime": "project-node" + } + } + ] + }, + "requirements": { + "version": 1, + "toolchains": { + "project-jdk": { "type": "java", "minimumVersion": "21" }, + "project-maven": { "type": "maven", "java": "project-jdk" }, + "project-node": { "type": "node", "minimumVersion": "22" } + } + }, + "expected": { + "missingNodeDiagnosticConfigurationIds": ["npm.script:web/dev"], + "unblockedConfigurationIds": ["spring-boot.maven:backend"] + } +} diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index 534dca180..323e0b205 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -35,7 +35,7 @@ const SKIPPED_DIRECTORIES: &[&str] = &[ ".hg", ]; const MAX_JAVA_SOURCES: usize = 8_000; -const LITHE_GITIGNORE: &str = "run/local.json\n**/*.tmp\n"; +const LITHE_GITIGNORE_ENTRIES: &[&str] = &["run/local.json", "toolchains/local.json", "**/*.tmp"]; pub struct RunProcessManager; @@ -83,6 +83,7 @@ pub struct RunDocumentWrite { pub struct DiscoveredToolchains { pub java: Vec, pub maven: Vec, + pub runtimes: Vec, } #[derive(Debug, Clone, Serialize)] @@ -100,6 +101,17 @@ pub struct MavenRuntime { pub version: String, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GenericRuntime { + pub id: String, + #[serde(rename = "type")] + pub kind: String, + pub executable_path: String, + pub version: String, + pub vendor: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResolveLaunchArgs { @@ -113,6 +125,8 @@ pub struct ResolveLaunchArgs { #[serde(default)] pub maven_java_home_path: String, #[serde(default)] + pub runtime_executable_paths: HashMap, + #[serde(default)] pub environment: Map, } @@ -164,8 +178,8 @@ pub fn run_write_generated(args: WriteGeneratedArgs) -> Result<(), String> { #[tauri::command] pub fn run_write_documents(args: WriteDocumentsArgs) -> Result<(), String> { let root = existing_directory(&args.root)?; - if args.documents.is_empty() || args.documents.len() > 2 { - return Err("A run configuration save must contain one or two documents.".into()); + if args.documents.is_empty() || args.documents.len() > 3 { + return Err("A run configuration save must contain one to three documents.".into()); } let mut seen = std::collections::HashSet::new(); let mut prepared = Vec::with_capacity(args.documents.len()); @@ -181,6 +195,7 @@ pub fn run_write_documents(args: WriteDocumentsArgs) -> Result<(), String> { fs::create_dir_all(parent).map_err(|error| error.to_string())?; } } + ensure_lithe_gitignore(&root.join(".lithe").join(".gitignore"))?; write_document_transaction(&prepared, atomic_write) } @@ -189,12 +204,14 @@ pub fn run_discover_toolchains( root: PathBuf, java_home_path: Option, maven_executable_path: Option, + runtime_executable_paths: Option>, ) -> Result { let project_root = existing_directory(&root).ok(); Ok(discover_toolchains_with_overrides( project_root.as_deref(), java_home_path.as_deref(), maven_executable_path.as_deref(), + runtime_executable_paths.as_ref(), )) } @@ -214,6 +231,7 @@ pub fn run_resolve_launch(args: ResolveLaunchArgs) -> Result>(); if let Some(home) = &java_home { @@ -229,6 +247,11 @@ pub fn run_resolve_launch(args: ResolveLaunchArgs) -> Result Result<(), String> { result } +fn ensure_lithe_gitignore(path: &Path) -> Result<(), String> { + let existing = if path.is_file() { + fs::read_to_string(path).map_err(|error| error.to_string())? + } else { + String::new() + }; + let mut lines = existing.lines().map(str::to_string).collect::>(); + for entry in LITHE_GITIGNORE_ENTRIES { + if !lines.iter().any(|line| line.trim() == *entry) { + lines.push((*entry).to_string()); + } + } + let contents = lines.join("\n") + "\n"; + atomic_write(path, contents.as_bytes()) +} + #[cfg(target_os = "windows")] fn replace_run_document(source: &Path, destination: &Path) -> Result<(), String> { use std::os::windows::ffi::OsStrExt; @@ -482,11 +519,9 @@ fn run_document_target(root: &Path, relative_path: &str) -> Result Result { } pub(crate) fn discover_toolchains(project_root: Option<&Path>) -> DiscoveredToolchains { - discover_toolchains_with_overrides(project_root, None, None) + discover_toolchains_with_overrides(project_root, None, None, None) } fn discover_toolchains_with_overrides( project_root: Option<&Path>, java_home_path: Option<&str>, maven_executable_path: Option<&str>, + runtime_executable_paths: Option<&HashMap>, ) -> DiscoveredToolchains { let mut java = Vec::new(); let mut seen_homes = std::collections::HashSet::new(); @@ -594,7 +630,104 @@ fn discover_toolchains_with_overrides( maven.push(runtime); } } - DiscoveredToolchains { java, maven } + + let mut runtimes = Vec::new(); + let mut seen_runtimes = std::collections::HashSet::new(); + let mut node_executables = node_executable_candidates(project_root); + if let Some(path) = runtime_executable_paths + .and_then(|paths| paths.get("project-node")) + .filter(|value| !value.trim().is_empty()) + { + node_executables.splice(0..0, custom_node_executable_candidates(Path::new(path))); + } + for executable in node_executables { + let normalized = normalize_path(&executable); + if !seen_runtimes.insert(normalized.clone()) { + continue; + } + if let Some(runtime) = probe_node(&normalized) { + runtimes.push(runtime); + } + } + runtimes.sort_by(|left, right| { + runtime_version_parts(&right.version) + .cmp(&runtime_version_parts(&left.version)) + .then(left.executable_path.cmp(&right.executable_path)) + }); + DiscoveredToolchains { + java, + maven, + runtimes, + } +} + +fn node_executable_candidates(project_root: Option<&Path>) -> Vec { + let mut executables = Vec::new(); + for name in ["node.exe", "node"] { + if let Some(path) = lookup_on_path(name) { + executables.push(path); + } + } + for key in ["NVM_SYMLINK", "NODE_HOME"] { + if let Ok(path) = std::env::var(key) { + executables.extend(custom_node_executable_candidates(Path::new(&path))); + } + } + for key in ["ProgramFiles", "LOCALAPPDATA"] { + if let Ok(base) = std::env::var(key) { + let base = PathBuf::from(base); + executables.push(base.join("nodejs").join("node.exe")); + executables.push(base.join("Programs").join("nodejs").join("node.exe")); + } + } + for key in ["NVM_HOME", "APPDATA"] { + if let Ok(base) = std::env::var(key) { + append_node_versions(&mut executables, Path::new(&base)); + append_node_versions(&mut executables, &PathBuf::from(base).join("nvm")); + } + } + if let Ok(profile) = std::env::var("USERPROFILE") { + let profile = PathBuf::from(profile); + executables.push(profile.join("scoop/apps/nodejs/current/node.exe")); + executables.push(profile.join("scoop/apps/nodejs-lts/current/node.exe")); + append_node_versions(&mut executables, &profile.join("AppData/Roaming/nvm")); + } + if let Some(root) = project_root { + executables.push(root.join(".lithe/toolchains/node/node.exe")); + } + executables +} + +fn append_node_versions(executables: &mut Vec, root: &Path) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + if entry.path().is_dir() { + executables.push(entry.path().join("node.exe")); + } + } +} + +fn custom_node_executable_candidates(path: &Path) -> Vec { + if path.is_file() { + return vec![path.to_path_buf()]; + } + vec![path.join("node.exe"), path.join("node")] +} + +fn probe_node(executable: &Path) -> Option { + if !executable.is_file() { + return None; + } + let output = command_output(executable, &["--version"]); + Some(GenericRuntime { + id: "project-node".to_string(), + kind: "node".to_string(), + executable_path: normalize_path(executable).to_string_lossy().into_owned(), + version: node_version(&output)?, + vendor: "Node.js".to_string(), + }) } fn java_home_candidates(project_root: Option<&Path>) -> Vec { @@ -761,6 +894,7 @@ fn resolve_executable( executable: &LaunchExecutable, maven_override: &str, java_home: Option<&str>, + runtime_executable_paths: &HashMap, ) -> Result { if let Some(toolchain) = executable.toolchain.as_deref() { return match toolchain { @@ -775,7 +909,7 @@ fn resolve_executable( }) } "project-maven" => resolve_maven_executable(root, working_directory, maven_override), - other => Err(format!("No resolver is registered for toolchain {other}.")), + other => resolve_generic_runtime(root, other, runtime_executable_paths), }; } if let Some(command) = executable @@ -783,15 +917,152 @@ fn resolve_executable( .as_deref() .filter(|value| !value.is_empty()) { - return lookup_on_path(command) - .or_else(|| lookup_on_path(&format!("{command}.cmd"))) - .or_else(|| lookup_on_path(&format!("{command}.exe"))) + return resolve_command_executable(command, runtime_executable_paths) .map(|path| path.to_string_lossy().into_owned()) .ok_or_else(|| format!("Could not find executable: {command}")); } Err("The launch plan names neither a toolchain nor a command.".into()) } +fn resolve_generic_runtime( + root: &Path, + toolchain: &str, + runtime_executable_paths: &HashMap, +) -> Result { + if let Some(configured) = runtime_executable_paths + .get(toolchain) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + let path = if Path::new(configured).is_absolute() { + PathBuf::from(configured) + } else { + root.join(configured) + }; + let candidates = if toolchain == "project-node" { + custom_node_executable_candidates(&path) + } else { + vec![path] + }; + return candidates + .into_iter() + .find(|candidate| candidate.is_file()) + .map(|candidate| normalize_path(&candidate).to_string_lossy().into_owned()) + .ok_or_else(|| format!("Configured executable for {toolchain} does not exist.")); + } + discover_toolchains(Some(root)) + .runtimes + .into_iter() + .find(|runtime| runtime.id == toolchain) + .map(|runtime| runtime.executable_path) + .ok_or_else(|| format!("No executable was found for toolchain {toolchain}.")) +} + +fn resolve_command_executable( + command: &str, + runtime_executable_paths: &HashMap, +) -> Option { + resolve_command_executable_with(command, runtime_executable_paths, lookup_on_path) +} + +fn resolve_command_executable_with( + command: &str, + runtime_executable_paths: &HashMap, + lookup: impl Fn(&str) -> Option, +) -> Option { + let command_lower = command.to_ascii_lowercase(); + let consumes_node = matches!( + command_lower.as_str(), + "node" | "node.exe" | "npm" | "npm.cmd" | "pnpm" | "pnpm.cmd" | "yarn" | "yarn.cmd" + ); + if consumes_node { + if let Some(directory) = + selected_runtime_directory(runtime_executable_paths, "project-node") + { + for name in command_file_names(command) { + let candidate = directory.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + // A package-manager shim can bind to a node.exe beside itself. Once + // Node is selected, falling back to PATH could launch a different + // runtime from the one Core validated. + return None; + } + } + command_file_names(command) + .into_iter() + .find_map(|name| lookup(&name)) +} + +fn command_file_names(command: &str) -> Vec { + if Path::new(command).extension().is_some() { + return vec![command.to_string()]; + } + vec![ + format!("{command}.exe"), + format!("{command}.cmd"), + format!("{command}.bat"), + command.to_string(), + ] +} + +fn selected_runtime_directory( + runtime_executable_paths: &HashMap, + toolchain: &str, +) -> Option { + let path = PathBuf::from(runtime_executable_paths.get(toolchain)?.trim()); + if path.is_dir() { + Some(path) + } else { + path.parent().map(Path::to_path_buf) + } +} + +fn selected_runtime_directories( + runtime_executable_paths: &HashMap, +) -> Vec { + runtime_executable_paths + .values() + .filter_map(|value| { + let path = PathBuf::from(value.trim()); + if path.is_dir() { + Some(path) + } else { + path.parent().map(Path::to_path_buf) + } + }) + .collect() +} + +fn prepend_runtime_paths( + environment: &mut HashMap, + runtime_executable_paths: &HashMap, + resolved_executable: &str, +) -> Result<(), String> { + let mut directories = selected_runtime_directories(runtime_executable_paths); + if let Some(parent) = Path::new(resolved_executable).parent() { + directories.push(parent.to_path_buf()); + } + if directories.is_empty() { + return Ok(()); + } + if let Some(existing) = environment + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case("PATH")) + .map(|(_, value)| value.clone()) + { + directories.extend(std::env::split_paths(&existing)); + } + let mut seen = std::collections::HashSet::new(); + directories.retain(|path| seen.insert(path.to_string_lossy().to_ascii_lowercase())); + let joined = std::env::join_paths(directories).map_err(|error| error.to_string())?; + environment.retain(|key, _| !key.eq_ignore_ascii_case("PATH")); + environment.insert("PATH".to_string(), joined.to_string_lossy().into_owned()); + Ok(()) +} + fn resolve_maven_executable( root: &Path, working_directory: &Path, @@ -1127,6 +1398,27 @@ fn maven_version(output: &str) -> Option { }) } +fn node_version(output: &str) -> Option { + output + .lines() + .map(str::trim) + .find_map(|line| line.strip_prefix('v')) + .filter(|version| { + !version.is_empty() + && version + .chars() + .all(|character| character.is_ascii_digit() || character == '.') + }) + .map(str::to_string) +} + +fn runtime_version_parts(version: &str) -> Vec { + version + .split('.') + .filter_map(|part| part.parse::().ok()) + .collect() +} + fn spawn_output_reader( app: AppHandle, session_id: String, @@ -1282,7 +1574,7 @@ mod tests { .is_file()); assert_eq!( fs::read_to_string(root.join(".lithe").join(".gitignore")).expect("gitignore"), - LITHE_GITIGNORE + LITHE_GITIGNORE_ENTRIES.join("\n") + "\n" ); let manifest: Value = serde_json::from_str( &fs::read_to_string(root.join(".lithe").join("project.json")).expect("manifest"), @@ -1332,6 +1624,58 @@ mod tests { fs::remove_dir_all(root).ok(); } + #[test] + fn three_document_transaction_restores_run_and_toolchain_documents() { + let root = temp_project(); + let run = root.join(".lithe/run"); + let toolchains = root.join(".lithe/toolchains"); + fs::create_dir_all(&run).unwrap(); + fs::create_dir_all(&toolchains).unwrap(); + let local = run.join("local.json"); + let project = run.join("configurations.json"); + let runtime = toolchains.join("local.json"); + fs::write(&local, b"old-local").unwrap(); + fs::write(&project, b"old-project").unwrap(); + fs::write(&runtime, b"old-runtime").unwrap(); + let documents = vec![ + (local.clone(), b"new-local".to_vec()), + (project.clone(), b"new-project".to_vec()), + (runtime.clone(), b"new-runtime".to_vec()), + ]; + let mut writes = 0; + let result = write_document_transaction(&documents, |path, contents| { + writes += 1; + if writes == 3 { + return Err("injected third write failure".into()); + } + atomic_write(path, contents) + }); + + assert_eq!(result.unwrap_err(), "injected third write failure"); + assert_eq!(fs::read(&local).unwrap(), b"old-local"); + assert_eq!(fs::read(&project).unwrap(), b"old-project"); + assert_eq!(fs::read(&runtime).unwrap(), b"old-runtime"); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn gitignore_update_preserves_existing_entries_and_adds_local_toolchains() { + let root = temp_project(); + let lithe = root.join(".lithe"); + fs::create_dir_all(&lithe).unwrap(); + let path = lithe.join(".gitignore"); + fs::write(&path, "custom-cache/\nrun/local.json\n").unwrap(); + + ensure_lithe_gitignore(&path).unwrap(); + + let contents = fs::read_to_string(path).unwrap(); + assert!(contents.starts_with("custom-cache/\nrun/local.json\n")); + assert_eq!(contents.matches("run/local.json").count(), 1); + assert!(contents.contains("toolchains/local.json\n")); + assert!(contents.contains("**/*.tmp\n")); + fs::remove_dir_all(root).ok(); + } + #[test] fn custom_maven_home_discovers_its_bin_executable() { let root = temp_project(); @@ -1344,6 +1688,7 @@ mod tests { Some(&root), None, Some(home.to_string_lossy().as_ref()), + None, ); assert!(discovered .maven @@ -1372,6 +1717,102 @@ mod tests { ); } + #[test] + fn node_version_reads_standard_banner() { + assert_eq!(node_version("v22.14.0\r\n").as_deref(), Some("22.14.0")); + assert_eq!(node_version("node 22.14.0"), None); + } + + #[test] + fn custom_node_path_accepts_an_executable_or_install_directory() { + let root = temp_project(); + let executable = root.join("node.exe"); + fs::write(&executable, b"node").unwrap(); + assert_eq!( + custom_node_executable_candidates(&executable), + vec![executable] + ); + assert_eq!( + custom_node_executable_candidates(&root), + vec![root.join("node.exe"), root.join("node")] + ); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn selected_node_directory_resolves_package_manager_before_path() { + let root = temp_project(); + let node = root.join("node.exe"); + let shell_script = root.join("npm"); + let npm = root.join("npm.cmd"); + fs::write(&node, b"node").unwrap(); + fs::write(&shell_script, b"#!/bin/sh\n").unwrap(); + fs::write(&npm, b"npm").unwrap(); + let paths = HashMap::from([( + "project-node".to_string(), + node.to_string_lossy().into_owned(), + )]); + assert_eq!( + resolve_command_executable("npm", &paths).as_deref(), + Some(npm.as_path()) + ); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn selected_node_does_not_fall_back_to_another_installations_npm() { + let root = temp_project(); + let selected_node = root.join("selected/node.exe"); + let path_npm = root.join("path/npm.cmd"); + fs::create_dir_all(selected_node.parent().unwrap()).unwrap(); + fs::create_dir_all(path_npm.parent().unwrap()).unwrap(); + fs::write(&selected_node, b"node").unwrap(); + fs::write(&path_npm, b"npm").unwrap(); + let paths = HashMap::from([( + "project-node".to_string(), + selected_node.to_string_lossy().into_owned(), + )]); + + assert_eq!( + resolve_command_executable_with("npm", &paths, |_| Some(path_npm.clone())), + None + ); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn selected_runtime_directories_are_prepended_to_launch_path() { + let root = temp_project(); + let node = root.join("node/node.exe"); + let package_manager = root.join("tools/npm.cmd"); + let original = root.join("existing"); + let mut environment = HashMap::from([( + "PATH".to_string(), + std::env::join_paths([&original]) + .unwrap() + .to_string_lossy() + .into_owned(), + )]); + let paths = HashMap::from([( + "project-node".to_string(), + node.to_string_lossy().into_owned(), + )]); + + prepend_runtime_paths( + &mut environment, + &paths, + package_manager.to_string_lossy().as_ref(), + ) + .unwrap(); + let launch_path = environment.get("PATH").unwrap(); + let directories = std::env::split_paths(launch_path).collect::>(); + assert_eq!( + directories, + vec![root.join("node"), root.join("tools"), original] + ); + fs::remove_dir_all(root).ok(); + } + #[test] fn launch_environment_resolves_java_home_from_toolchain_reference() { let home = resolve_environment_value( diff --git a/windows/tauri/src/features/run/api/run-core-api.test.ts b/windows/tauri/src/features/run/api/run-core-api.test.ts index 78326f46c..dd5066e49 100644 --- a/windows/tauri/src/features/run/api/run-core-api.test.ts +++ b/windows/tauri/src/features/run/api/run-core-api.test.ts @@ -14,6 +14,7 @@ const emptyToolchain = { javaHomePath: "", mavenExecutablePath: "", mavenJavaHomePath: "", + runtimeExecutablePaths: {}, }; beforeEach(() => { @@ -146,6 +147,7 @@ describe("saveRunConfigurationEditorChanges", () => { javaHomePath: "C:/Java/jdk-21", mavenExecutablePath: "D:/Tools/apache-maven", mavenJavaHomePath: "C:/Java/jdk-17", + runtimeExecutablePaths: { "project-node": "C:/Program Files/nodejs/node.exe" }, }, ); @@ -161,6 +163,7 @@ describe("saveRunConfigurationEditorChanges", () => { javaHomePath: "C:/Java/jdk-21", mavenExecutablePath: "D:/Tools/apache-maven", mavenJavaHomePath: "C:/Java/jdk-17", + runtimeExecutablePaths: { "project-node": "C:/Program Files/nodejs/node.exe" }, }, }), }), diff --git a/windows/tauri/src/features/run/api/run-core-api.ts b/windows/tauri/src/features/run/api/run-core-api.ts index dedd81439..5a6c9cf17 100644 --- a/windows/tauri/src/features/run/api/run-core-api.ts +++ b/windows/tauri/src/features/run/api/run-core-api.ts @@ -71,7 +71,11 @@ export function saveRunConfigurationEditorChanges( options: RunOptions, toolchain: GlobalToolchain, ) { - return runCore<{ localDocument: string; projectDocument: string | null }>( + return runCore<{ + localDocument: string; + projectDocument: string | null; + toolchainDocument: string | null; + }>( "runConfig.saveEditorChanges", { root, diff --git a/windows/tauri/src/features/run/api/run-host-api.ts b/windows/tauri/src/features/run/api/run-host-api.ts index 31d59eb3c..0e587b8ee 100644 --- a/windows/tauri/src/features/run/api/run-host-api.ts +++ b/windows/tauri/src/features/run/api/run-host-api.ts @@ -1,5 +1,10 @@ import { invoke } from "@/platform/tauri-core"; -import type { GlobalToolchain, JavaRuntime, MavenRuntime } from "../types/run.types"; +import type { + GenericRuntime, + GlobalToolchain, + JavaRuntime, + MavenRuntime, +} from "../types/run.types"; export function listJavaSources(root: string) { return invoke("run_list_java_sources", { root }); @@ -26,10 +31,11 @@ export function writeRunStdin(sessionId: string, input: string) { } export function discoverRunToolchains(root: string, selected?: GlobalToolchain) { - return invoke<{ java: JavaRuntime[]; maven: MavenRuntime[] }>("run_discover_toolchains", { + return invoke<{ java: JavaRuntime[]; maven: MavenRuntime[]; runtimes: GenericRuntime[] }>("run_discover_toolchains", { root, javaHomePath: selected?.javaHomePath, mavenExecutablePath: selected?.mavenExecutablePath, + runtimeExecutablePaths: selected?.runtimeExecutablePaths, }); } @@ -40,6 +46,7 @@ export function resolveRunLaunch(args: { javaHomePath?: string; mavenExecutablePath?: string; mavenJavaHomePath?: string; + runtimeExecutablePaths?: Record; environment?: Record; }) { return invoke<{ diff --git a/windows/tauri/src/features/run/components/run-configuration-editor.tsx b/windows/tauri/src/features/run/components/run-configuration-editor.tsx index 43f88d790..a2264c73f 100644 --- a/windows/tauri/src/features/run/components/run-configuration-editor.tsx +++ b/windows/tauri/src/features/run/components/run-configuration-editor.tsx @@ -9,6 +9,7 @@ import { FolderIcon, PlayIcon } from "@/ui/icons"; import { useTranslation } from "@/i18n/locale-provider"; import type { GlobalToolchain, + GenericRuntime, JavaRuntime, MavenRuntime, RunConfiguration, @@ -17,7 +18,9 @@ import type { } from "../types/run.types"; import { configurationOverrides, + configurationUsesJava, configurationUsesMaven, + configurationUsesNode, environmentFromText, environmentText, } from "../utils/run-configuration"; @@ -28,6 +31,7 @@ interface RunConfigurationEditorProps { saveError: string | null; discoveredJava: JavaRuntime[]; discoveredMaven: MavenRuntime[]; + discoveredRuntimes: GenericRuntime[]; globalToolchain: GlobalToolchain; onClose: () => void; onSave: ( @@ -96,6 +100,7 @@ export function RunConfigurationEditor({ saveError, discoveredJava, discoveredMaven, + discoveredRuntimes, globalToolchain, onClose, onSave, @@ -108,6 +113,8 @@ export function RunConfigurationEditor({ const [saving, setSaving] = useState(false); const projectUsesMaven = configurationUsesMaven(configuration); + const projectUsesJava = configurationUsesJava(configuration); + const projectUsesNode = configurationUsesNode(configuration); const javaCandidates = discoveredJava.map((runtime) => ({ value: runtime.homePath, label: runtime.version ? `${runtime.homePath} (${runtime.version})` : runtime.homePath, @@ -118,6 +125,14 @@ export function RunConfigurationEditor({ ? `${runtime.executablePath} (${runtime.version})` : runtime.executablePath, })); + const nodeCandidates = discoveredRuntimes + .filter((runtime) => runtime.id === "project-node") + .map((runtime) => ({ + value: runtime.executablePath, + label: runtime.version + ? `${runtime.executablePath} (${runtime.version})` + : runtime.executablePath, + })); const pickDirectory = (field: "javaHomePath" | "mavenJavaHomePath" | "workingDirectoryPath") => { void open({ directory: true, multiple: false }).then((selected) => { @@ -147,6 +162,20 @@ export function RunConfigurationEditor({ }); }; + const pickNodeExecutable = () => { + void open({ directory: false, multiple: false }).then((selected) => { + if (typeof selected === "string" && selected) { + setToolchainDraft((current) => ({ + ...current, + runtimeExecutablePaths: { + ...current.runtimeExecutablePaths, + "project-node": selected, + }, + })); + } + }); + }; + const save = async () => { setSaving(true); const runOptions = { ...draft, environment: environmentFromText(envText) }; @@ -183,17 +212,19 @@ export function RunConfigurationEditor({ {t("run.projectDefaultsSection")} · {t("run.saveScopeLocal")}

{t("run.saveScopeLocalHint")}

- setToolchainDraft((current) => ({ ...current, javaHomePath: value }))} - onPick={() => pickToolchainDirectory("javaHomePath")} - /> + {projectUsesJava ? ( + setToolchainDraft((current) => ({ ...current, javaHomePath: value }))} + onPick={() => pickToolchainDirectory("javaHomePath")} + /> + ) : null} {projectUsesMaven ? ( <> ) : null} + {projectUsesNode ? ( + setToolchainDraft((current) => ({ + ...current, + runtimeExecutablePaths: { + ...current.runtimeExecutablePaths, + "project-node": value, + }, + }))} + onPick={pickNodeExecutable} + /> + ) : null}
@@ -266,17 +316,19 @@ export function RunConfigurationEditor({
{t("run.configurationOverridesSection")}
- setDraft((current) => ({ ...current, javaHomePath: value }))} - onPick={() => pickDirectory("javaHomePath")} - /> + {projectUsesJava ? ( + setDraft((current) => ({ ...current, javaHomePath: value }))} + onPick={() => pickDirectory("javaHomePath")} + /> + ) : null} {projectUsesMaven ? ( <> state.generationNotice); const discoveredJava = useRunStore((state) => state.discoveredJava); const discoveredMaven = useRunStore((state) => state.discoveredMaven); + const discoveredRuntimes = useRunStore((state) => state.discoveredRuntimes); const globalToolchain = useRunStore((state) => state.globalToolchain); const actions = useRunStore((state) => state.actions); const [editingId, setEditingId] = useState(null); @@ -76,7 +77,10 @@ export default function RunPane() { const selectedConfiguration = configurations.find((configuration) => configuration.id === selectedConfigurationId) ?? null; const selectedSession = sessions.find((session) => session.id === selectedSessionId); - const blockingDiagnostic = diagnostics.find(isBlockingToolchainDiagnostic); + const blockingDiagnostic = blockingToolchainDiagnosticForConfiguration( + diagnostics, + selectedConfiguration?.id, + ); const staleDiagnostic = diagnostics.find((diagnostic) => diagnostic.code === "staleFingerprint"); const isSelectedRunning = selectedSession ? selectedSession.isRunning : primaryRunning; const output = selectedSession ? selectedSession.output : primaryOutput; @@ -256,6 +260,7 @@ export default function RunPane() { saveError={saveError} discoveredJava={discoveredJava} discoveredMaven={discoveredMaven} + discoveredRuntimes={discoveredRuntimes} globalToolchain={globalToolchain} onClose={() => setEditingId(null)} onSave={(options, toolchain, scope) => diff --git a/windows/tauri/src/features/run/stores/run.store.ts b/windows/tauri/src/features/run/stores/run.store.ts index 4a86d4729..0531364c7 100644 --- a/windows/tauri/src/features/run/stores/run.store.ts +++ b/windows/tauri/src/features/run/stores/run.store.ts @@ -23,6 +23,7 @@ import { EMPTY_GLOBAL_TOOLCHAIN, EMPTY_RUN_OPTIONS, PRIMARY_SESSION_ID, + type GenericRuntime, type GlobalToolchain, type JavaRuntime, type MavenRuntime, @@ -36,7 +37,8 @@ import { } from "../types/run.types"; import { defaultGeneratedConfigurationId, - isBlockingToolchainDiagnostic, + blockingToolchainDiagnosticForConfiguration, + effectiveRuntimeExecutablePaths, mapCoreConfiguration, mapCoreToolchain, mapDiagnostics, @@ -72,7 +74,9 @@ interface RunState { generationNotice: string | null; discoveredJava: JavaRuntime[]; discoveredMaven: MavenRuntime[]; + discoveredRuntimes: GenericRuntime[]; globalToolchain: GlobalToolchain; + effectiveRuntimeExecutablePaths: Record; actions: { loadProject: (root: string) => Promise; generate: (root: string) => Promise; @@ -99,7 +103,9 @@ interface ResolvedRunProject { defaultConfigurationId: string | null; discoveredJava: JavaRuntime[]; discoveredMaven: MavenRuntime[]; + discoveredRuntimes: GenericRuntime[]; globalToolchain: GlobalToolchain; + effectiveRuntimeExecutablePaths: Record; } type RunProjectSnapshot = @@ -118,7 +124,9 @@ type ReadyRunState = Pick< | "defaultConfigurationId" | "discoveredJava" | "discoveredMaven" + | "discoveredRuntimes" | "globalToolchain" + | "effectiveRuntimeExecutablePaths" | "isLoading" >; @@ -141,18 +149,34 @@ function optionsFromConfiguration(configuration: RunConfiguration): RunOptions { async function resolveConfigurations(root: string): Promise { const automatic = await discoverRunToolchains(root); + const automaticRuntimePaths = effectiveRuntimeExecutablePaths(automatic.runtimes, {}); const preliminary = await resolveRunConfiguration( root, - selectedToolchainCandidates(automatic, EMPTY_GLOBAL_TOOLCHAIN), + selectedToolchainCandidates(automatic, { + ...EMPTY_GLOBAL_TOOLCHAIN, + runtimeExecutablePaths: automaticRuntimePaths, + }), + ); + const globalToolchain = mapCoreToolchain( + preliminary.toolchain, + preliminary.localToolchains, ); - const globalToolchain = mapCoreToolchain(preliminary.toolchain); const hasSelectedToolchain = Boolean( - globalToolchain.javaHomePath || globalToolchain.mavenExecutablePath, + globalToolchain.javaHomePath || + globalToolchain.mavenExecutablePath || + Object.values(globalToolchain.runtimeExecutablePaths).some(Boolean), ); const discovered = hasSelectedToolchain ? await discoverRunToolchains(root, globalToolchain) : automatic; - const candidates = selectedToolchainCandidates(discovered, globalToolchain); + const effectiveRuntimePaths = effectiveRuntimeExecutablePaths( + discovered.runtimes, + globalToolchain.runtimeExecutablePaths, + ); + const candidates = selectedToolchainCandidates(discovered, { + ...globalToolchain, + runtimeExecutablePaths: effectiveRuntimePaths, + }); const resolved = hasSelectedToolchain ? await resolveRunConfiguration(root, candidates) : preliminary; @@ -162,7 +186,9 @@ async function resolveConfigurations(root: string): Promise defaultConfigurationId: resolved.defaultRunConfiguration ?? null, discoveredJava: discovered.java, discoveredMaven: discovered.maven, + discoveredRuntimes: discovered.runtimes, globalToolchain, + effectiveRuntimeExecutablePaths: effectiveRuntimePaths, }; } @@ -202,7 +228,9 @@ function readyRunState( defaultConfigurationId: snapshot.defaultConfigurationId, discoveredJava: snapshot.discoveredJava, discoveredMaven: snapshot.discoveredMaven, + discoveredRuntimes: snapshot.discoveredRuntimes, globalToolchain: snapshot.globalToolchain, + effectiveRuntimeExecutablePaths: snapshot.effectiveRuntimeExecutablePaths, isLoading: false, }; } @@ -228,7 +256,9 @@ export const createRunStore = () => generationNotice: null, discoveredJava: [], discoveredMaven: [], + discoveredRuntimes: [], globalToolchain: EMPTY_GLOBAL_TOOLCHAIN, + effectiveRuntimeExecutablePaths: {}, actions: { loadProject: async (root) => { set({ @@ -296,7 +326,9 @@ export const createRunStore = () => defaultConfigurationId: resolved.defaultConfigurationId, discoveredJava: resolved.discoveredJava, discoveredMaven: resolved.discoveredMaven, + discoveredRuntimes: resolved.discoveredRuntimes, globalToolchain: resolved.globalToolchain, + effectiveRuntimeExecutablePaths: resolved.effectiveRuntimeExecutablePaths, generationNotice: notice, isGenerating: false, isLoading: false, @@ -322,7 +354,10 @@ export const createRunStore = () => const root = state.root; const configuration = state.configurations.find((item) => item.id === id); if (!root || !configuration) return; - const blocking = state.diagnostics.find(isBlockingToolchainDiagnostic); + const blocking = blockingToolchainDiagnosticForConfiguration( + state.diagnostics, + configuration.id, + ); if (blocking) { set({ primaryOutput: trimOutput(`${state.primaryOutput}${blocking.message}\n`), @@ -354,6 +389,7 @@ export const createRunStore = () => javaHomePath: configuration.javaHomePath, mavenExecutablePath: configuration.mavenExecutablePath, mavenJavaHomePath: configuration.mavenJavaHomePath, + runtimeExecutablePaths: state.effectiveRuntimeExecutablePaths, environment: mergeLaunchEnvironment(configuration.env, plan), }); const commandLine = `$ ${resolved.executable.split(/[\\/]/).pop()} ${plan.arguments.join(" ")}\n\n`; @@ -460,6 +496,12 @@ export const createRunStore = () => contents: mutation.projectDocument, }); } + if (mutation.toolchainDocument !== null) { + documents.push({ + relativePath: "toolchains/local.json", + contents: mutation.toolchainDocument, + }); + } return writeRunDocuments(root, documents); }, reload: async () => { diff --git a/windows/tauri/src/features/run/types/run.types.ts b/windows/tauri/src/features/run/types/run.types.ts index 46a40cc67..f6bc4af4b 100644 --- a/windows/tauri/src/features/run/types/run.types.ts +++ b/windows/tauri/src/features/run/types/run.types.ts @@ -68,6 +68,14 @@ export interface MavenRuntime { version: string; } +export interface GenericRuntime { + id: string; + type: string; + executablePath: string; + version: string; + vendor: string; +} + export interface LaunchPlan { executable: { toolchain?: string | null; @@ -82,6 +90,7 @@ export interface LaunchPlan { export interface CoreInspectResult { status: string; diagnostics?: Array>; + localToolchains?: CoreLocalToolchains | null; } export interface CoreGenerateResult { @@ -95,6 +104,7 @@ export interface CoreResolveResult { diagnostics?: Array>; defaultRunConfiguration?: string | null; toolchain?: CoreGlobalToolchain | null; + localToolchains?: CoreLocalToolchains | null; } export interface CoreGlobalToolchain { @@ -102,10 +112,16 @@ export interface CoreGlobalToolchain { maven?: { executablePath?: string; javaHomePath?: string }; } +export interface CoreLocalToolchains { + version: number; + toolchains?: Record; +} + export interface GlobalToolchain { javaHomePath: string; mavenExecutablePath: string; mavenJavaHomePath: string; + runtimeExecutablePaths: Record; } export interface CoreResolvedConfiguration { @@ -152,4 +168,5 @@ export const EMPTY_GLOBAL_TOOLCHAIN: GlobalToolchain = { javaHomePath: "", mavenExecutablePath: "", mavenJavaHomePath: "", + runtimeExecutablePaths: {}, }; diff --git a/windows/tauri/src/features/run/utils/run-configuration.test.ts b/windows/tauri/src/features/run/utils/run-configuration.test.ts index ccefa726c..86d2ce9bc 100644 --- a/windows/tauri/src/features/run/utils/run-configuration.test.ts +++ b/windows/tauri/src/features/run/utils/run-configuration.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from "bun:test"; import { configurationsForExecution, + blockingToolchainDiagnosticForConfiguration, configurationOverrides, configurationUsesMaven, defaultGeneratedConfigurationId, + effectiveRuntimeExecutablePaths, isBlockingToolchainDiagnostic, mapCoreConfiguration, mergeLaunchEnvironment, @@ -90,11 +92,13 @@ describe("run configuration mapping", () => { { homePath: "D:\\SDKs\\custom", version: "21", vendor: "Custom" }, ], maven: [], + runtimes: [], }, { javaHomePath: "d:/sdks/custom/", mavenExecutablePath: "", mavenJavaHomePath: "", + runtimeExecutablePaths: {}, }, ); @@ -110,11 +114,13 @@ describe("run configuration mapping", () => { maven: [ { executablePath: "D:\\Tools\\apache-maven\\bin\\mvn.cmd", version: "3.9.9" }, ], + runtimes: [], }, { javaHomePath: "", mavenExecutablePath: "d:/tools/apache-maven/", mavenJavaHomePath: "", + runtimeExecutablePaths: {}, }, ); @@ -133,6 +139,7 @@ describe("run configuration mapping", () => { javaHomePath: "C:/SDKs/jdk-21", mavenExecutablePath: "C:/SDKs/maven", mavenJavaHomePath: "C:/SDKs/maven-jdk", + runtimeExecutablePaths: {}, }; const options = { ...defaults, @@ -167,4 +174,95 @@ describe("run configuration mapping", () => { JAVA_HOME: { toolchain: "project-jdk", property: "home" }, }); }); + + test("blocks only the configuration named by a toolchain diagnostic", () => { + const diagnostics = [ + { + id: "npm.script:web/dev", + code: "missingToolchain", + message: "No local node toolchain is selected", + }, + ]; + + expect( + blockingToolchainDiagnosticForConfiguration(diagnostics, "spring-boot.maven:backend"), + ).toBeUndefined(); + expect( + blockingToolchainDiagnosticForConfiguration(diagnostics, "npm.script:web/dev")?.message, + ).toContain("node"); + expect( + blockingToolchainDiagnosticForConfiguration( + [{ code: "missingToolchain", message: "Project toolchain is missing" }], + "spring-boot.maven:backend", + ), + ).toBeDefined(); + }); + + test("selects the configured generic runtime candidate", () => { + const candidates = selectedToolchainCandidates( + { + java: [], + maven: [], + runtimes: [ + { + id: "project-node", + type: "node", + executablePath: "C:/Program Files/nodejs/node.exe", + version: "22.5.1", + vendor: "Node.js", + }, + { + id: "project-node", + type: "node", + executablePath: "D:/runtimes/node.exe", + version: "20.18.0", + vendor: "Node.js", + }, + ], + }, + { + javaHomePath: "", + mavenExecutablePath: "", + mavenJavaHomePath: "", + runtimeExecutablePaths: { "project-node": "d:\\runtimes\\node.exe" }, + }, + ); + + expect(candidates).toEqual([ + { id: "project-node", type: "node", version: "20.18.0", vendor: "Node.js" }, + ]); + }); + + test("uses the same automatic runtime path for validation and launch", () => { + const runtimes = [ + { + id: "project-node", + type: "node", + executablePath: "C:/Program Files/nodejs/node.exe", + version: "22.5.1", + vendor: "Node.js", + }, + { + id: "project-node", + type: "node", + executablePath: "D:/path-node/node.exe", + version: "18.20.4", + vendor: "Node.js", + }, + ]; + const effectivePaths = effectiveRuntimeExecutablePaths(runtimes, {}); + const selected = { + javaHomePath: "", + mavenExecutablePath: "", + mavenJavaHomePath: "", + runtimeExecutablePaths: effectivePaths, + }; + + expect(effectivePaths).toEqual({ + "project-node": "C:/Program Files/nodejs/node.exe", + }); + expect(selectedToolchainCandidates({ java: [], maven: [], runtimes }, selected)).toEqual([ + { id: "project-node", type: "node", version: "22.5.1", vendor: "Node.js" }, + ]); + }); }); diff --git a/windows/tauri/src/features/run/utils/run-configuration.ts b/windows/tauri/src/features/run/utils/run-configuration.ts index fd46ca505..6f4bc3f2c 100644 --- a/windows/tauri/src/features/run/utils/run-configuration.ts +++ b/windows/tauri/src/features/run/utils/run-configuration.ts @@ -1,6 +1,8 @@ import { CURRENT_FILE_ID, type CoreGlobalToolchain, + type CoreLocalToolchains, + type GenericRuntime, type CoreResolvedConfiguration, type GlobalToolchain, type JavaRuntime, @@ -63,11 +65,20 @@ export function normalizeExecution(execution: string | undefined, provider: stri return "application"; } -export function mapCoreToolchain(toolchain: CoreGlobalToolchain | null | undefined): GlobalToolchain { +export function mapCoreToolchain( + toolchain: CoreGlobalToolchain | null | undefined, + localToolchains?: CoreLocalToolchains | null, +): GlobalToolchain { + const runtimeExecutablePaths = Object.fromEntries( + Object.entries(localToolchains?.toolchains ?? {}) + .filter((entry): entry is [string, { executable: string }] => Boolean(entry[1].executable)) + .map(([id, value]) => [id, value.executable]), + ); return { javaHomePath: toolchain?.java?.homePath ?? "", mavenExecutablePath: toolchain?.maven?.executablePath ?? "", mavenJavaHomePath: toolchain?.maven?.javaHomePath ?? "", + runtimeExecutablePaths, }; } @@ -88,6 +99,17 @@ export function isBlockingToolchainDiagnostic(diagnostic: RunDiagnostic): boolea return diagnostic.code === "missingToolchain" || diagnostic.code === "toolchainVersionMismatch"; } +export function blockingToolchainDiagnosticForConfiguration( + diagnostics: RunDiagnostic[], + configurationId: string | null | undefined, +): RunDiagnostic | undefined { + return diagnostics.find( + (diagnostic) => + isBlockingToolchainDiagnostic(diagnostic) && + (diagnostic.id === undefined || diagnostic.id === configurationId), + ); +} + export function recoveryActionForError(code: string | undefined): RunRecoveryAction { switch (code) { case "not_supported": @@ -150,6 +172,14 @@ export function configurationUsesMaven(configuration: { toolchains?: Record }): boolean { + return Boolean(configuration.toolchains?.java || configuration.toolchains?.maven); +} + +export function configurationUsesNode(configuration: { toolchains?: Record }): boolean { + return configuration.toolchains?.runtime === "project-node"; +} + export function configurationOverrides( options: RunOptions, defaults: GlobalToolchain, @@ -172,7 +202,7 @@ export function configurationOverrides( } export function selectedToolchainCandidates( - discovered: { java: JavaRuntime[]; maven: MavenRuntime[] }, + discovered: { java: JavaRuntime[]; maven: MavenRuntime[]; runtimes: GenericRuntime[] }, selected: GlobalToolchain, ): Array<{ id: string; type: string; version: string; vendor: string }> { const java = selected.javaHomePath @@ -184,12 +214,44 @@ export function selectedToolchainCandidates( runtime.executablePath, )) : discovered.maven[0]; + const effectiveRuntimePaths = effectiveRuntimeExecutablePaths( + discovered.runtimes, + selected.runtimeExecutablePaths, + ); + const runtimes = Object.entries(effectiveRuntimePaths).flatMap(([id, executablePath]) => { + const runtime = discovered.runtimes.find( + (candidate) => candidate.id === id && sameWindowsPath(candidate.executablePath, executablePath), + ); + return runtime + ? [{ id: runtime.id, type: runtime.type, version: runtime.version, vendor: runtime.vendor }] + : []; + }); return [ ...(java ? [{ id: "project-jdk", type: "java", version: java.version, vendor: java.vendor }] : []), ...(maven ? [{ id: "project-maven", type: "maven", version: maven.version, vendor: "" }] : []), + ...runtimes, ]; } +export function effectiveRuntimeExecutablePaths( + discovered: GenericRuntime[], + configured: Record, +): Record { + const runtimeIds = new Set(discovered.map((runtime) => runtime.id)); + return Object.fromEntries( + [...runtimeIds].flatMap((id) => { + const configuredPath = configured[id]; + const runtime = configuredPath + ? discovered.find( + (candidate) => + candidate.id === id && sameWindowsPath(candidate.executablePath, configuredPath), + ) + : discovered.find((candidate) => candidate.id === id); + return runtime ? [[id, runtime.executablePath]] : []; + }), + ); +} + function mavenSelectionMatchesRuntime(selection: string, executable: string): boolean { if (sameWindowsPath(selection, executable)) return true; const home = selection.replace(/[\\/]+$/, ""); diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index 5b655fec8..4669292b5 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -1543,6 +1543,8 @@ const catalogs = { "run.jdkHomeHint": "Leave empty to use the detected JDK.", "run.mavenExecutable": "Maven home or executable", "run.mavenExecutableHint": "Choose a Maven home directory, or leave empty to use the project wrapper or detected Maven.", + "run.nodeExecutable": "Node.js executable", + "run.nodeExecutableHint": "Choose node.exe, or leave empty to use the detected Node.js runtime.", "run.mavenJdkHome": "Maven JDK Home", "run.mavenJdkHomeHint": "Leave empty to use the same JDK as the application.", "run.toolchainAuto": "Auto-detect (leave empty)", @@ -5663,6 +5665,8 @@ const catalogs = { "run.jdkHomeHint": "留空则使用自动检测到的 JDK。", "run.mavenExecutable": "Maven 主目录 / 可执行文件", "run.mavenExecutableHint": "可选择 Maven 主目录;留空则使用项目 Wrapper 或系统 Maven。", + "run.nodeExecutable": "Node.js 可执行文件", + "run.nodeExecutableHint": "可选择 node.exe;留空则使用自动检测到的 Node.js 运行时。", "run.mavenJdkHome": "Maven JDK 主目录", "run.mavenJdkHomeHint": "留空则与应用使用同一个 JDK。", "run.toolchainAuto": "自动检测(留空)",