From a842e8ab74452662e6a60885e26b5d6d5bc5fde4 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 4 Nov 2026 22:04:36 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat(core):=20=E9=A1=B9=E7=9B=AE=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=99=A8=E5=A2=9E=E5=8A=A0=E9=80=9A=E7=94=A8=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=AD=97=E6=AE=B5=EF=BC=88projectGenerators=202?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 type:"tool" 字段:由 tool 指定工具定义 ID,minimumVersion 过滤过旧的安装, pinAs:"project" 让计划带出创建后要写入的工具 pin。步骤里可用 ${field.} 和 ${field..executable}。表单返回候选安装与 toolTooOld 问题,目录条目返回声明 该工具的插件。版本 1 的 runtime 字段保持不变,版本 2 不允许 runtime 字段, 版本 1 不允许 tool 字段。 Refs #1495 --- rust/lithe-core/src/plugins/capabilities.rs | 6 +- rust/lithe-core/src/plugins/declarative.rs | 15 +- .../src/project_generators/commands.rs | 29 +- .../lithe-core/src/project_generators/form.rs | 237 +++++++++++++++- .../src/project_generators/manifest.rs | 116 ++++++-- rust/lithe-core/src/project_generators/mod.rs | 6 + .../lithe-core/src/project_generators/plan.rs | 27 +- .../src/project_generators/template.rs | 54 +++- .../src/tests/declarative_plugins.rs | 6 + .../src/tests/project_generators.rs | 103 ++++++- .../v1/fixtures/invalid-manifests.json | 11 +- .../tool-field/README.md | 4 + .../project-generator-tool-field.plugin.json | 44 +++ .../plugins/v1/fixtures/project-plans.json | 265 +++++++++++++++++- 14 files changed, 890 insertions(+), 33 deletions(-) create mode 100644 shared/contracts/plugins/v1/fixtures/project-generator-templates/tool-field/README.md create mode 100644 shared/contracts/plugins/v1/fixtures/project-generator-tool-field.plugin.json diff --git a/rust/lithe-core/src/plugins/capabilities.rs b/rust/lithe-core/src/plugins/capabilities.rs index 47f366d4d1..0748637b61 100644 --- a/rust/lithe-core/src/plugins/capabilities.rs +++ b/rust/lithe-core/src/plugins/capabilities.rs @@ -57,7 +57,9 @@ pub(crate) const VCS_PROVIDERS: &str = "vcsProviders"; /// `workspace` host service; it needs plugin-function protocol 3 for that. pub(crate) const BACKEND: &str = "backend"; -/// `projectGenerators[]`: entries of the New Project window. +/// `projectGenerators[]`: entries of the New Project window. Version 1 lets a +/// field select a host-managed runtime; version 2 selects installations of +/// any plugin-defined tool with a `tool` field instead. pub(crate) const PROJECT_GENERATORS: &str = "projectGenerators"; /// `debugAdapters[]`: debug adapters implemented by a plugin process; see @@ -90,7 +92,7 @@ pub const SUPPORTED_CAPABILITIES: &[(&str, &[u32])] = &[ (LANGUAGE_SERVERS, &[1, 2]), (LANGUAGES, &[1, 2]), (PACKAGE_TRUST, &[1]), - (PROJECT_GENERATORS, &[1]), + (PROJECT_GENERATORS, &[1, 2]), (SERVER_PROCESS, &[1, 2]), (SETTINGS, &[1, 2]), (TOOL_DEFINITIONS, &[1]), diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index 97f5331933..1c77e6110c 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -1065,7 +1065,13 @@ impl DeclarativePluginManifest { "Project generators require plugin API level 3.", )); } - crate::project_generators::validate_declarations(&self.project_generators)?; + crate::project_generators::validate_declarations( + &self.project_generators, + self.requires + .get(capabilities::PROJECT_GENERATORS) + .copied() + .unwrap_or(1), + )?; crate::build::validate_declarations( &mut self.build_systems, &crate::build::DeclarationContext { @@ -1723,6 +1729,13 @@ impl DeclarativePluginManifest { self.languages.iter().map(|language| language.id.as_str()) } + /// IDs of the tool definitions the plugin declares. + pub(crate) fn tool_definition_ids(&self) -> impl Iterator { + self.tool_definitions + .iter() + .filter_map(|definition| definition["id"].as_str()) + } + /// Validated project generators, in declaration order. pub(crate) fn project_generators( &self, diff --git a/rust/lithe-core/src/project_generators/commands.rs b/rust/lithe-core/src/project_generators/commands.rs index 5d1b311b36..7ebdfc232c 100644 --- a/rust/lithe-core/src/project_generators/commands.rs +++ b/rust/lithe-core/src/project_generators/commands.rs @@ -7,7 +7,7 @@ use super::archive; use super::form::{self, FormInput}; -use super::manifest::{GeneratorSection, ProjectGeneratorDeclaration}; +use super::manifest::{GeneratorSection, ProjectGeneratorDeclaration, FIELD_TOOL}; use super::plan; use crate::plugins::capabilities::HostCapabilities; use crate::plugins::declarative::{ @@ -139,6 +139,7 @@ pub(crate) fn project_generator_catalog_command(payload: Value) -> Result Result Result bool) -> Vec, +) -> Vec { + generator + .fields + .iter() + .filter(|field| field.is(FIELD_TOOL)) + .filter_map(|field| { + let tool = field.tool.as_deref()?; + Some(json!({ + "fieldID": field.id, + "tool": tool, + "providers": providers(&|candidate| { + candidate.tool_definition_ids().any(|id| id == tool) + }), + })) + }) + .collect() +} + /// `project.generatorForm`: field values, visibility, and problems. pub(crate) fn project_generator_form_command(payload: Value) -> Result { let payload: FormPayload = decode_payload(payload, "project.generatorForm")?; diff --git a/rust/lithe-core/src/project_generators/form.rs b/rust/lithe-core/src/project_generators/form.rs index 23e52c4cd6..4d4043e6d0 100644 --- a/rust/lithe-core/src/project_generators/form.rs +++ b/rust/lithe-core/src/project_generators/form.rs @@ -7,7 +7,7 @@ use super::manifest::{ Conditions, GeneratorField, ProjectGeneratorDeclaration, FIELD_BOOLEAN, FIELD_ENUM, - FIELD_MULTI_SELECT, FIELD_RUNTIME, FIELD_TEXT, + FIELD_MULTI_SELECT, FIELD_RUNTIME, FIELD_TEXT, FIELD_TOOL, }; use super::template::{FieldValue, Values}; use crate::plugins::declarative::version_components; @@ -26,13 +26,24 @@ pub(crate) struct RuntimeSelection { pub(crate) version: String, } +/// An installation of a tool that the host found through the tool +/// definition's discovery, so the host has already checked that it is real. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ToolInstallation { + pub(crate) home: String, + pub(crate) executable: String, + pub(crate) version: String, +} + /// What the user has entered so far. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct FormInput { pub(crate) project_name: String, /// Values by field ID. A text value counts only if its ID is in - /// `edited`; otherwise the field keeps following its default. + /// `edited`; otherwise the field keeps following its default. A tool + /// field's value is the home of the installation the user chose. #[serde(default)] pub(crate) values: BTreeMap, #[serde(default)] @@ -40,6 +51,9 @@ pub(crate) struct FormInput { /// Selected installations of runtime fields. #[serde(default)] pub(crate) runtimes: BTreeMap, + /// Installations the host found for each tool field, by field ID. + #[serde(default)] + pub(crate) tools: BTreeMap>, } /// Why a value is not accepted. Hosts localize by code. @@ -51,6 +65,8 @@ pub(crate) enum FieldProblem { InvalidFormat, UnsupportedValue, RuntimeTooOld, + /// The selected tool installation is older than the field's `minimumVersion`. + ToolTooOld, /// The value names a newer version than `maximumVersion` allows. VersionTooHigh, } @@ -63,6 +79,7 @@ impl FieldProblem { Self::InvalidFormat => "invalidFormat", Self::UnsupportedValue => "unsupportedValue", Self::RuntimeTooOld => "runtimeTooOld", + Self::ToolTooOld => "toolTooOld", Self::VersionTooHigh => "versionTooHigh", } } @@ -76,6 +93,20 @@ pub(crate) struct FieldState { pub(crate) value: Value, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) problem: Option, + /// A tool field's candidates, for the host to list. Empty for other fields. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) installations: Vec, +} + +/// One installation a tool field offers, with Core's verdict on its version. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ToolChoice { + home: String, + executable: String, + version: String, + /// False when the version is older than the field's `minimumVersion`. + meets_minimum: bool, } /// The evaluated form. @@ -120,6 +151,7 @@ pub(crate) fn evaluate(generator: &ProjectGeneratorDeclaration, input: &FormInpu visible, value, problem: problem.filter(|_| visible), + installations: tool_choices(field, input), }); } let active_requirements = generator @@ -276,10 +308,83 @@ fn resolve( (!field.optional).then_some(FieldProblem::Required), ), }, + FIELD_TOOL => resolve_tool(field, input), _ => (Value::Null, FieldValue::Text(String::new()), None), } } +/// A tool field holds the installation the user chose, or else the newest +/// one the host found. A choice the host no longer lists (the tool was +/// uninstalled meanwhile) falls back to the newest, and the field shows it. +fn resolve_tool( + field: &GeneratorField, + input: &FormInput, +) -> (Value, FieldValue, Option) { + let found = tool_installations(field, input); + let chosen = input + .values + .get(&field.id) + .and_then(Value::as_str) + .and_then(|home| found.iter().find(|installation| installation.home == home)); + match chosen.or_else(|| newest(found)) { + Some(installation) => ( + json!(installation.home), + FieldValue::Tool { + home: installation.home.clone(), + executable: installation.executable.clone(), + }, + (!meets_minimum(field, installation)).then_some(FieldProblem::ToolTooOld), + ), + None => ( + json!(""), + FieldValue::Tool { + home: String::new(), + executable: String::new(), + }, + (!field.optional).then_some(FieldProblem::Required), + ), + } +} + +fn tool_installations<'a>(field: &GeneratorField, input: &'a FormInput) -> &'a [ToolInstallation] { + input + .tools + .get(&field.id) + .map(Vec::as_slice) + .unwrap_or_default() +} + +/// The highest version; among equal versions the host's first one, because +/// the host lists installations in the order it prefers them. +fn newest(found: &[ToolInstallation]) -> Option<&ToolInstallation> { + // `max_by_key` keeps the last of several maxima, so walk backwards. + found + .iter() + .rev() + .max_by_key(|installation| version_components(&installation.version)) +} + +fn meets_minimum(field: &GeneratorField, installation: &ToolInstallation) -> bool { + field.minimum_version.as_deref().is_none_or(|minimum| { + version_components(&installation.version) >= version_components(minimum) + }) +} + +fn tool_choices(field: &GeneratorField, input: &FormInput) -> Vec { + if !field.is(FIELD_TOOL) { + return Vec::new(); + } + tool_installations(field, input) + .iter() + .map(|installation| ToolChoice { + home: installation.home.clone(), + executable: installation.executable.clone(), + version: installation.version.clone(), + meets_minimum: meets_minimum(field, installation), + }) + .collect() +} + /// Compares a value with the field's `maximumVersion`, expanded over the /// earlier fields. An empty value or bound (no JDK chosen yet) is left to the /// fields' own checks. @@ -358,6 +463,15 @@ mod tests { values: serde_json::from_value(values).unwrap(), edited: edited.iter().map(|id| id.to_string()).collect(), runtimes: BTreeMap::new(), + tools: BTreeMap::new(), + } + } + + fn installation(home: &str, version: &str) -> ToolInstallation { + ToolInstallation { + home: home.into(), + executable: format!("{home}/bin/tool"), + version: version.into(), } } @@ -474,6 +588,125 @@ mod tests { } } + fn tool_generator(optional: bool) -> ProjectGeneratorDeclaration { + generator(json!([{ + "id": "sdk", "type": "tool", "title": "Python", "tool": "python", + "minimumVersion": "3.11", "pinAs": "project", "optional": optional + }])) + } + + fn with_tools(mut form: FormInput, found: &[(&str, &str)]) -> FormInput { + let installations = found + .iter() + .map(|(home, version)| installation(home, version)) + .collect(); + form.tools.insert("sdk".into(), installations); + form + } + + #[test] + fn a_tool_field_follows_the_newest_installation_until_chosen() { + let generator = tool_generator(false); + let form = with_tools( + input("demo", json!({}), &[]), + &[ + ("/py/3.11", "3.11.9"), + ("/py/3.13", "3.13.1"), + ("/py/3.12", "3.12.4"), + ], + ); + let state = evaluate(&generator, &form); + assert_eq!(field(&state, "sdk").value, json!("/py/3.13")); + assert_eq!(field(&state, "sdk").problem, None); + assert_eq!(field(&state, "sdk").installations.len(), 3); + assert_eq!( + state.values["sdk"], + FieldValue::Tool { + home: "/py/3.13".into(), + executable: "/py/3.13/bin/tool".into(), + } + ); + + let mut chosen = form.clone(); + chosen.values.insert("sdk".into(), json!("/py/3.12")); + let state = evaluate(&generator, &chosen); + assert_eq!(field(&state, "sdk").value, json!("/py/3.12")); + } + + #[test] + fn equal_versions_keep_the_hosts_order() { + let form = with_tools( + input("demo", json!({}), &[]), + &[("/first", "3.12.0"), ("/second", "3.12.0")], + ); + let state = evaluate(&tool_generator(false), &form); + assert_eq!(field(&state, "sdk").value, json!("/first")); + } + + /// A choice the host no longer lists is not trusted: the field falls + /// back to what is installed instead of keeping a stale path. + #[test] + fn a_vanished_choice_falls_back_to_the_newest() { + let mut form = with_tools( + input("demo", json!({"sdk": "/py/gone"}), &[]), + &[("/py/3.12", "3.12.4")], + ); + let state = evaluate(&tool_generator(false), &form); + assert_eq!(field(&state, "sdk").value, json!("/py/3.12")); + + form.values.insert("sdk".into(), json!(7)); + let state = evaluate(&tool_generator(false), &form); + assert_eq!(field(&state, "sdk").value, json!("/py/3.12")); + } + + #[test] + fn a_tool_below_the_minimum_is_reported() { + let form = with_tools( + input("demo", json!({}), &[]), + &[("/py/3.9", "3.9.18"), ("/py/3.10", "3.10.14")], + ); + let state = evaluate(&tool_generator(false), &form); + assert_eq!(field(&state, "sdk").problem, Some(FieldProblem::ToolTooOld)); + assert!(!state.is_valid()); + assert!(field(&state, "sdk") + .installations + .iter() + .all(|choice| !choice.meets_minimum)); + + // Choosing a sufficient installation clears the problem. + let form = with_tools( + input("demo", json!({"sdk": "/py/3.11"}), &[]), + &[("/py/3.9", "3.9.18"), ("/py/3.11", "3.11.0")], + ); + let state = evaluate(&tool_generator(false), &form); + assert_eq!(field(&state, "sdk").problem, None); + let meets: Vec = field(&state, "sdk") + .installations + .iter() + .map(|choice| choice.meets_minimum) + .collect(); + assert_eq!(meets, [false, true]); + } + + #[test] + fn a_tool_field_without_candidates_is_required_unless_optional() { + let form = input("demo", json!({}), &[]); + let state = evaluate(&tool_generator(false), &form); + assert_eq!(field(&state, "sdk").problem, Some(FieldProblem::Required)); + assert_eq!(field(&state, "sdk").value, json!("")); + + let state = evaluate(&tool_generator(true), &form); + assert_eq!(field(&state, "sdk").problem, None); + assert!(state.is_valid()); + assert_eq!( + state.values["sdk"], + FieldValue::Tool { + home: String::new(), + executable: String::new(), + } + ); + } + #[test] fn project_names_are_single_directory_names() { assert_eq!(project_name_problem("demo"), None); diff --git a/rust/lithe-core/src/project_generators/manifest.rs b/rust/lithe-core/src/project_generators/manifest.rs index c2c9b4aca7..ea43cdc166 100644 --- a/rust/lithe-core/src/project_generators/manifest.rs +++ b/rust/lithe-core/src/project_generators/manifest.rs @@ -83,12 +83,26 @@ pub(crate) const FIELD_ENUM: &str = "enum"; pub(crate) const FIELD_BOOLEAN: &str = "boolean"; pub(crate) const FIELD_MULTI_SELECT: &str = "multiSelect"; pub(crate) const FIELD_RUNTIME: &str = "runtime"; +pub(crate) const FIELD_TOOL: &str = "tool"; /// Host-managed runtimes a runtime field may select. The host discovers /// installations; Core only checks the version. const KNOWN_RUNTIMES: &[&str] = &["java"]; +/// The `requires.projectGenerators` version that introduces tool fields and +/// retires runtime fields. A generator declared with version 2 selects every +/// tool through its definition; version 1 keeps the runtime field. +const TOOL_FIELDS_VERSION: u32 = 2; + +/// Where a tool field's selection is saved besides the generation steps. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum ToolPin { + /// The created project's own tool choice, so its builds and runs use + /// the installation the user picked here. + Project, +} /// One input of a generator. The types follow plugin settings, minus -/// integer and paths, plus `runtime`. +/// integer and paths, plus `runtime` (version 1) and `tool` (version 2). #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct GeneratorField { @@ -123,15 +137,22 @@ pub(crate) struct GeneratorField { pub(crate) max_length: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) placeholder: Option, - /// A text or runtime field may be left empty. + /// A text, runtime, or tool field may be left empty. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub(crate) optional: bool, /// Runtime a runtime field selects. #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) runtime: Option, - /// Oldest acceptable runtime version. + /// Tool definition ID a tool field selects an installation of. Which + /// plugin defines it is not fixed here, so any plugin may add a tool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) tool: Option, + /// Oldest acceptable runtime or tool version. #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) minimum_version: Option, + /// Saves a tool field's selection once the project exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) pin_as: Option, /// Highest version a text or enum value may name, as a template over /// earlier fields, such as `${field.jdk.major}` so a target Java /// release never exceeds the selected JDK. An empty bound checks nothing. @@ -406,7 +427,13 @@ impl GeneratorField { /// Validates every generator and sorts fields-free collections that have no /// meaningful order. Field and step order are kept: fields are drawn and /// defaults evaluated in order, steps run in order. -pub(crate) fn validate_declarations(generators: &[ProjectGeneratorDeclaration]) -> Validation<()> { +/// +/// `version` is the manifest's `requires.projectGenerators`; it decides +/// whether fields select a runtime (1) or a tool (2). +pub(crate) fn validate_declarations( + generators: &[ProjectGeneratorDeclaration], + version: u32, +) -> Validation<()> { if generators.len() > MAX_GENERATORS { return Err(invalid( "projectGenerators", @@ -422,13 +449,13 @@ pub(crate) fn validate_declarations(generators: &[ProjectGeneratorDeclaration]) "A project generator is declared more than once.", )); } - generator.validate(&field)?; + generator.validate(&field, version)?; } Ok(()) } impl ProjectGeneratorDeclaration { - fn validate(&self, field: &str) -> Validation<()> { + fn validate(&self, field: &str, version: u32) -> Validation<()> { if !is_generator_id(&self.id) { return Err(invalid( format!("{field}.id"), @@ -463,7 +490,7 @@ impl ProjectGeneratorDeclaration { let item_field = format!("{field}.fields[{index}]"); // A field reads only fields declared before it, so defaults are // evaluated in one pass and visibility never forms a cycle. - item.validate(&item_field, &declared)?; + item.validate(&item_field, &declared, version)?; if declared.insert(item.id.as_str(), item).is_some() { return Err(invalid( format!("{item_field}.id"), @@ -510,7 +537,13 @@ impl ProjectGeneratorDeclaration { } impl GeneratorField { - fn validate(&self, field: &str, earlier: &BTreeMap<&str, &GeneratorField>) -> Validation<()> { + fn validate( + &self, + field: &str, + earlier: &BTreeMap<&str, &GeneratorField>, + version: u32, + ) -> Validation<()> { + self.check_kind_for_version(field, version)?; if !is_setting_identifier(&self.id) { return Err(invalid( format!("{field}.id"), @@ -533,8 +566,10 @@ impl GeneratorField { "options" => matches!(kind, FIELD_ENUM | FIELD_MULTI_SELECT), "presentation" => kind == FIELD_ENUM, "text" => kind == FIELD_TEXT, - "optional" => matches!(kind, FIELD_TEXT | FIELD_RUNTIME), + "optional" => matches!(kind, FIELD_TEXT | FIELD_RUNTIME | FIELD_TOOL), "runtime" => kind == FIELD_RUNTIME, + "tool" => kind == FIELD_TOOL, + "minimumVersion" => matches!(kind, FIELD_RUNTIME | FIELD_TOOL), "maximumVersion" => matches!(kind, FIELD_TEXT | FIELD_ENUM), _ => false, }; @@ -546,10 +581,9 @@ impl GeneratorField { self.pattern.is_some() || self.max_length.is_some() || self.placeholder.is_some(), ), ("optional", self.optional), - ( - "runtime", - self.runtime.is_some() || self.minimum_version.is_some(), - ), + ("runtime", self.runtime.is_some()), + ("tool", self.tool.is_some() || self.pin_as.is_some()), + ("minimumVersion", self.minimum_version.is_some()), ("maximumVersion", self.maximum_version.is_some()), ]; if let Some((name, _)) = present @@ -684,15 +718,58 @@ impl GeneratorField { )); } } + FIELD_TOOL => { + if !self + .tool + .as_deref() + .is_some_and(crate::toolchain::definitions::tool_id) + { + return Err(invalid( + format!("{field}.tool"), + "A tool field names a tool definition ID.", + )); + } + if let Some(version) = &self.minimum_version { + crate::plugins::declarative::validate_minimum_version( + version, + &format!("{field}.minimumVersion"), + )?; + } + if self.default.is_some() { + return Err(invalid( + format!("{field}.default"), + "The host chooses the initial installation.", + )); + } + } _ => { return Err(invalid( format!("{field}.type"), - "A field type is text, enum, boolean, multiSelect, or runtime.", + "A field type is text, enum, boolean, multiSelect, runtime, or tool.", )) } } Ok(()) } + + /// Runtime fields belong to `projectGenerators` 1 and tool fields to 2, + /// so one generator never mixes the old runtime path with tool + /// definitions. The old path is removed once no plugin uses version 1. + fn check_kind_for_version(&self, field: &str, version: u32) -> Validation<()> { + if self.is(FIELD_TOOL) && version < TOOL_FIELDS_VERSION { + return Err(invalid( + format!("{field}.type"), + "A tool field needs requires.projectGenerators 2.", + )); + } + if self.is(FIELD_RUNTIME) && version >= TOOL_FIELDS_VERSION { + return Err(invalid( + format!("{field}.type"), + "projectGenerators 2 selects installations with a tool field, not a runtime field.", + )); + } + Ok(()) + } } impl GeneratorStep { @@ -837,7 +914,9 @@ fn check_conditions( let comparable = |value: &Value| match target.kind.as_str() { FIELD_ENUM => value.as_str().is_some_and(|text| target.offers(text)), FIELD_BOOLEAN => value.is_boolean(), - FIELD_TEXT | FIELD_RUNTIME => value.is_string(), + // A runtime or tool field compares as its home, so + // `notEquals ""` asks whether an installation was chosen. + FIELD_TEXT | FIELD_RUNTIME | FIELD_TOOL => value.is_string(), _ => false, }; if !comparisons.into_iter().all(comparable) { @@ -894,15 +973,15 @@ fn check_variables( if !variable_is_known(variable, |id| fields.get(id).copied()) { return Err(invalid( field, - "The template reads a field that is not declared earlier, or a runtime version of a field that is not a runtime.", + "The template reads a field that is not declared earlier, a runtime version of a field that is not a runtime, or the executable of a field that is not a tool.", )); } } Ok(()) } -/// Whether a variable names a field `lookup` knows, with a runtime suffix -/// only on runtime fields. +/// Whether a variable names a field `lookup` knows, with a version suffix +/// only on runtime fields and `.executable` only on tool fields. pub(crate) fn variable_is_known<'a>( variable: &Variable, lookup: impl Fn(&str) -> Option<&'a GeneratorField>, @@ -913,6 +992,7 @@ pub(crate) fn variable_is_known<'a>( Source::RuntimeVersion(id) | Source::RuntimeMajor(id) => { lookup(id).is_some_and(|field| field.is(FIELD_RUNTIME)) } + Source::ToolExecutable(id) => lookup(id).is_some_and(|field| field.is(FIELD_TOOL)), } } diff --git a/rust/lithe-core/src/project_generators/mod.rs b/rust/lithe-core/src/project_generators/mod.rs index 98e1167547..e1facf5bce 100644 --- a/rust/lithe-core/src/project_generators/mod.rs +++ b/rust/lithe-core/src/project_generators/mod.rs @@ -12,6 +12,12 @@ //! processes in a staging directory, and move the finished project into //! place. //! +//! A version 2 generator picks tool installations with `tool` fields. The +//! host lists the installations of the tool a field names (through the same +//! tool definitions and discovery the settings use); Core chooses among them +//! (the newest that meets `minimumVersion`), reports the choice in the form, +//! and puts the pins the host saves after creation in the plan. +//! //! Core reads template files from the plugin package and extracts archives //! into a staging directory the host created; it never writes anywhere else. //! diff --git a/rust/lithe-core/src/project_generators/plan.rs b/rust/lithe-core/src/project_generators/plan.rs index 4864204210..6bf6f57f74 100644 --- a/rust/lithe-core/src/project_generators/plan.rs +++ b/rust/lithe-core/src/project_generators/plan.rs @@ -2,7 +2,7 @@ use super::form::{self, FormInput, FormState}; use super::manifest::{ - variable_is_known, GeneratorStep, ProjectGeneratorDeclaration, FIELD_RUNTIME, + variable_is_known, GeneratorStep, ProjectGeneratorDeclaration, FIELD_RUNTIME, FIELD_TOOL, }; use super::template::{self, FieldValue, Values, Variable}; use crate::plugins::declarative::{is_safe_relative_path, PluginRejection, Validation}; @@ -162,10 +162,35 @@ pub(crate) fn plan( "steps": steps, "openFiles": open_files, "runtimes": runtimes, + "toolPins": tool_pins(generator, &state), "usesNetwork": uses_network, })) } +/// The tool selections the host saves once the project exists, so the +/// project builds and runs with the installations its creator picked. A +/// hidden field or one without an installation leaves nothing to save. +fn tool_pins(generator: &ProjectGeneratorDeclaration, state: &FormState) -> Vec { + generator + .fields + .iter() + .filter(|field| field.is(FIELD_TOOL)) + .filter_map(|field| { + let pin_as = field.pin_as?; + match state.values.get(&field.id) { + Some(FieldValue::Tool { home, executable }) if !home.is_empty() => Some(json!({ + "field": field.id, + "tool": field.tool, + "pinAs": pin_as, + "home": home, + "executable": executable, + })), + _ => None, + } + }) + .collect() +} + fn reject_invalid_form(state: &FormState) -> Validation<()> { if let Some(problem) = state.project_name_problem { return Err(PluginRejection::at( diff --git a/rust/lithe-core/src/project_generators/template.rs b/rust/lithe-core/src/project_generators/template.rs index ec7edc14bc..1be3789b8e 100644 --- a/rust/lithe-core/src/project_generators/template.rs +++ b/rust/lithe-core/src/project_generators/template.rs @@ -15,12 +15,15 @@ pub(crate) enum Source { /// The project name the user typed. ProjectName, /// A field's value: text, the chosen option, `true`/`false`, a - /// comma-separated list, or a runtime's home directory. + /// comma-separated list, or the home directory of a runtime or tool. Field(String), /// The full version of a runtime field's selection. RuntimeVersion(String), /// The major version of a runtime field's selection, such as `21`. RuntimeMajor(String), + /// The executable of a tool field's selection, the file the tool + /// definition resolved inside the home. + ToolExecutable(String), } /// A transformation applied after the value is read, in order. @@ -54,6 +57,7 @@ impl Variable { None => Source::Field(rest.to_string()), Some((id, "version")) => Source::RuntimeVersion(id.to_string()), Some((id, "major")) => Source::RuntimeMajor(id.to_string()), + Some((id, "executable")) => Source::ToolExecutable(id.to_string()), Some(_) => return None, } }; @@ -73,7 +77,10 @@ impl Variable { pub(crate) fn field(&self) -> Option<&str> { match &self.source { Source::ProjectName => None, - Source::Field(id) | Source::RuntimeVersion(id) | Source::RuntimeMajor(id) => Some(id), + Source::Field(id) + | Source::RuntimeVersion(id) + | Source::RuntimeMajor(id) + | Source::ToolExecutable(id) => Some(id), } } } @@ -89,7 +96,15 @@ pub(crate) enum FieldValue { Text(String), Flag(bool), List(Vec), - Runtime { home: String, version: String }, + Runtime { + home: String, + version: String, + }, + /// The installation a tool field selected. + Tool { + home: String, + executable: String, + }, } impl FieldValue { @@ -98,7 +113,7 @@ impl FieldValue { Self::Text(text) => text.clone(), Self::Flag(flag) => flag.to_string(), Self::List(items) => items.join(","), - Self::Runtime { home, .. } => home.clone(), + Self::Runtime { home, .. } | Self::Tool { home, .. } => home.clone(), } } } @@ -126,6 +141,10 @@ impl Values<'_> { Some(FieldValue::Runtime { version, .. }) => major_version(version), _ => String::new(), }, + Source::ToolExecutable(id) => match self.fields.get(id) { + Some(FieldValue::Tool { executable, .. }) => executable.clone(), + _ => String::new(), + }, }; variable .modifiers @@ -246,11 +265,38 @@ mod tests { Variable::parse("field.jdk.major").map(|variable| variable.source), Some(Source::RuntimeMajor("jdk".into())) ); + assert_eq!( + Variable::parse("field.python.executable").map(|variable| variable.source), + Some(Source::ToolExecutable("python".into())) + ); assert!(Variable::parse("field.jdk.home").is_none()); assert!(Variable::parse("project.path").is_none()); assert!(Variable::parse("field.name|upper").is_none()); } + #[test] + fn tool_fields_read_as_home_and_executable() { + let fields = values(&[( + "python", + FieldValue::Tool { + home: "/opt/python".into(), + executable: "/opt/python/bin/python3".into(), + }, + )]); + let values = Values { + project_name: "demo", + fields: &fields, + }; + let expand = |text: &str| values.expand(&parse(text).unwrap()); + assert_eq!(expand("${field.python}"), "/opt/python"); + assert_eq!( + expand("${field.python.executable} -m venv ."), + "/opt/python/bin/python3 -m venv ." + ); + // A field that holds no tool reads as empty, never as another value. + assert_eq!(expand("${field.missing.executable}"), ""); + } + #[test] fn package_modifier_makes_a_valid_package() { assert_eq!(package_name("com.Example.my-app"), "com.example.myapp"); diff --git a/rust/lithe-core/src/tests/declarative_plugins.rs b/rust/lithe-core/src/tests/declarative_plugins.rs index d32a9ebbbd..5b748a35fe 100644 --- a/rust/lithe-core/src/tests/declarative_plugins.rs +++ b/rust/lithe-core/src/tests/declarative_plugins.rs @@ -40,6 +40,9 @@ const DEBUG_ADAPTER_REMOTE_ATTACH_FIXTURE: &str = const DEBUG_ADAPTER_REJECTIONS: &str = contract_file!("fixtures/debug-adapter-rejections.json"); const EDITOR_ACTIONS_FIXTURE: &str = contract_file!("fixtures/editor-actions.plugin.json"); const GRAMMAR_FIXTURE: &str = contract_file!("fixtures/language-grammar.plugin.json"); +const PROJECT_GENERATOR_FIXTURE: &str = contract_file!("fixtures/project-generator.plugin.json"); +const PROJECT_GENERATOR_TOOL_FIXTURE: &str = + contract_file!("fixtures/project-generator-tool-field.plugin.json"); const INVALID_MANIFESTS: &str = contract_file!("fixtures/invalid-manifests.json"); const COMMAND_PLANS: &str = contract_file!("fixtures/command-plans.json"); const RUNTIME_VERSIONS: &str = contract_file!("fixtures/runtime-versions.json"); @@ -69,6 +72,8 @@ fn fixture(name: &str) -> Value { "debug-adapter-language-server.plugin.json" => DEBUG_ADAPTER_LANGUAGE_SERVER_FIXTURE, "debug-adapter-remote-attach.plugin.json" => DEBUG_ADAPTER_REMOTE_ATTACH_FIXTURE, "language-grammar.plugin.json" => GRAMMAR_FIXTURE, + "project-generator.plugin.json" => PROJECT_GENERATOR_FIXTURE, + "project-generator-tool-field.plugin.json" => PROJECT_GENERATOR_TOOL_FIXTURE, other => panic!("unknown fixture {other}"), }; serde_json::from_str(source).expect("fixture manifest is JSON") @@ -94,6 +99,7 @@ fn contract_fixtures_and_official_plugins_validate() { DEBUG_ADAPTER_REMOTE_ATTACH_FIXTURE, GRAMMAR_FIXTURE, EDITOR_ACTIONS_FIXTURE, + PROJECT_GENERATOR_TOOL_FIXTURE, OFFICIAL_JAVA, ] { let outcome = validate(serde_json::from_str(source).unwrap()); diff --git a/rust/lithe-core/src/tests/project_generators.rs b/rust/lithe-core/src/tests/project_generators.rs index 47543415e7..5016e2f6c6 100644 --- a/rust/lithe-core/src/tests/project_generators.rs +++ b/rust/lithe-core/src/tests/project_generators.rs @@ -15,6 +15,14 @@ const PLANS: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../shared/contracts/plugins/v1/fixtures/project-plans.json" )); +const TOOL_FIELD_FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/project-generator-tool-field.plugin.json" +)); +const TOOL_DEFINITION_FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/contracts/plugins/v1/fixtures/tool-definition.plugin.json" +)); const MAVEN_BUILD_FIXTURE: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../shared/contracts/plugins/v1/fixtures/build-host-integration.plugin.json" @@ -64,16 +72,28 @@ fn official_package_path() -> PathBuf { fn host() -> Value { json!({"languages": [1], "languageServers": [1, 2], "serverProcess": [1], "settings": [1, 2], - "buildSystems": [1], "projectGenerators": [1], "debugAdapters": [1], "packageTrust": [1], "toolDefinitions": [1], "executableResolution": [1, 2]}) + "buildSystems": [1], "projectGenerators": [1, 2], "debugAdapters": [1], "packageTrust": [1], "toolDefinitions": [1], "executableResolution": [1, 2]}) +} + +/// A case names its manifest fixture in `manifest`; the default is the +/// version 1 generator fixture. +fn case_manifest(case: &Value) -> Value { + match case["manifest"].as_str() { + Some(file) => json( + &std::fs::read_to_string(fixtures_path().join(file)) + .unwrap_or_else(|error| panic!("{file}: {error}")), + ), + None => json(FIXTURE), + } } #[test] fn golden_project_plans_match() { let plans = json(PLANS); assert_eq!(plans["schemaVersion"], 1); - let manifest = json(FIXTURE); for case in plans["cases"].as_array().unwrap() { let name = case["name"].as_str().unwrap(); + let manifest = case_manifest(case); let actual = match case["operation"].as_str().unwrap() { "generatorForm" => project_generator_form_command(json!({ "manifest": manifest, @@ -149,6 +169,85 @@ fn catalog_skips_plugins_the_host_cannot_run() { ); } +/// The catalog tells the host which plugins declare the tool a field selects, +/// so a field with no installation can point at the plugin to enable. A tool +/// nobody declares has no provider, which is how an unknown ID shows up. +#[test] +fn catalog_names_the_plugins_that_define_each_tool() { + let mut generators = json(TOOL_FIELD_FIXTURE); + // The definition fixture declares `java`, so point the first field at it. + generators["projectGenerators"][0]["fields"][0]["tool"] = json!("java"); + let result = project_generator_catalog_command(json!({ + "plugins": [ + {"manifest": generators, "enabled": true}, + {"manifest": json(TOOL_DEFINITION_FIXTURE), "enabled": false}, + ], + "hostCapabilities": host(), + })) + .unwrap(); + assert_eq!(result["status"], "ok", "{result}"); + assert_eq!(result["catalog"]["issues"], json!([]), "{result}"); + let entries = result["catalog"]["generators"].as_array().unwrap(); + assert_eq!( + entries[0]["tools"], + json!([{ + "fieldID": "python", "tool": "java", + "providers": [{ + "pluginID": "dev.lithe.fixture.java-support", + "pluginName": "Tool definition fixture", + "enabled": false + }] + }]) + ); + assert_eq!( + entries[1]["tools"], + json!([{"fieldID": "node", "tool": "node", "providers": []}]) + ); + // Version 1 generators have no tool fields. + let version_1 = project_generator_catalog_command(json!({ + "plugins": [{"manifest": json(FIXTURE), "enabled": true}], + "hostCapabilities": host(), + })) + .unwrap(); + assert_eq!(version_1["catalog"]["generators"][0]["tools"], json!([])); +} + +#[test] +fn catalog_skips_tool_fields_on_hosts_without_projectgenerators_2() { + let result = project_generator_catalog_command(json!({ + "plugins": [{"manifest": json(TOOL_FIELD_FIXTURE), "enabled": true}], + "hostCapabilities": {"projectGenerators": [1]}, + })) + .unwrap(); + assert_eq!(result["catalog"]["generators"], json!([])); + assert_eq!( + result["catalog"]["issues"][0]["error"]["field"], + "requires.projectGenerators" + ); +} + +/// A form value naming an installation the host did not list never reaches +/// the plan: the plan is built from the newest listed installation instead. +#[test] +fn a_plan_never_uses_an_installation_the_host_did_not_list() { + let plan = project_generation_plan_command(json!({ + "manifest": json(TOOL_FIELD_FIXTURE), + "generatorID": "python-app", + "packagePath": fixtures_path(), + "form": { + "projectName": "demo", + "values": {"python": "/tmp/planted"}, + "tools": {"python": [ + {"home": "/opt/python-3.12", "executable": "/opt/python-3.12/bin/python3", "version": "3.12.4"} + ]}, + }, + })) + .unwrap(); + assert_eq!(plan["status"], "ok", "{plan}"); + assert_eq!(plan["plan"]["toolPins"][0]["home"], "/opt/python-3.12"); + assert!(!plan.to_string().contains("/tmp/planted"), "{plan}"); +} + #[test] fn official_java_templates_validate_and_plan() { let manifest = json(OFFICIAL_JAVA); diff --git a/shared/contracts/plugins/v1/fixtures/invalid-manifests.json b/shared/contracts/plugins/v1/fixtures/invalid-manifests.json index 5ca1ec5364..65ce2362f5 100644 --- a/shared/contracts/plugins/v1/fixtures/invalid-manifests.json +++ b/shared/contracts/plugins/v1/fixtures/invalid-manifests.json @@ -1054,6 +1054,15 @@ {"name": "languages 2 is used by a grammar or configuration", "base": "language-grammar.plugin.json", "edits": [{"op": "remove", "path": "/languages/0/grammar"}, {"op": "remove", "path": "/languages/0/configuration"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "a grammar names a known engine", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/languages/0/grammar/kind", "value": "textmate"}], "expectedCode": "plugin.manifest.invalid"}, {"name": "a grammar file stays in the package", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/languages/0/grammar/path", "value": "../http.monarch.json"}], "expectedCode": "plugin.manifest.invalid"}, - {"name": "a comment delimiter is one short line", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/languages/0/configuration/lineComment", "value": "#\n"}], "expectedCode": "plugin.manifest.invalid"} + {"name": "a comment delimiter is one short line", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/languages/0/configuration/lineComment", "value": "#\n"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "a tool field needs projectGenerators 2", "base": "project-generator.plugin.json", "edits": [{"op": "set", "path": "/projectGenerators/0/fields/1", "value": {"id": "sdk", "type": "tool", "title": "SDK", "tool": "python"}}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "projectGenerators 2 has no runtime field", "base": "project-generator.plugin.json", "edits": [{"op": "set", "path": "/requires/projectGenerators", "value": 2}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "a tool field names a tool definition ID", "base": "project-generator-tool-field.plugin.json", "edits": [{"op": "set", "path": "/projectGenerators/0/fields/0/tool", "value": "Python Interpreter"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "a tool field names a tool", "base": "project-generator-tool-field.plugin.json", "edits": [{"op": "remove", "path": "/projectGenerators/0/fields/0/tool"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "a tool field minimum version is dotted numbers", "base": "project-generator-tool-field.plugin.json", "edits": [{"op": "set", "path": "/projectGenerators/0/fields/0/minimumVersion", "value": "latest"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "a tool field is pinned to a known scope", "base": "project-generator-tool-field.plugin.json", "edits": [{"op": "set", "path": "/projectGenerators/0/fields/0/pinAs", "value": "workspace"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "the host chooses a tool field's initial installation", "base": "project-generator-tool-field.plugin.json", "edits": [{"op": "set", "path": "/projectGenerators/0/fields/0/default", "value": "/usr/bin"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "only a tool field has an executable", "base": "project-generator-tool-field.plugin.json", "edits": [{"op": "set", "path": "/projectGenerators/0/steps/1/arguments/2", "value": "${field.venv.executable}"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "a tool property needs a tool field", "base": "project-generator-tool-field.plugin.json", "edits": [{"op": "set", "path": "/projectGenerators/0/fields/1/tool", "value": "python"}], "expectedCode": "plugin.manifest.invalid"} ] } diff --git a/shared/contracts/plugins/v1/fixtures/project-generator-templates/tool-field/README.md b/shared/contracts/plugins/v1/fixtures/project-generator-templates/tool-field/README.md new file mode 100644 index 0000000000..9bbb191cac --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/project-generator-templates/tool-field/README.md @@ -0,0 +1,4 @@ +# {{ project.name }} + +Interpreter: {{ field.python.executable }} +Installation: {{ field.python }} diff --git a/shared/contracts/plugins/v1/fixtures/project-generator-tool-field.plugin.json b/shared/contracts/plugins/v1/fixtures/project-generator-tool-field.plugin.json new file mode 100644 index 0000000000..774ac793a6 --- /dev/null +++ b/shared/contracts/plugins/v1/fixtures/project-generator-tool-field.plugin.json @@ -0,0 +1,44 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.project-generator-tool-field", + "displayName": "Project Generator Tool Field Fixture", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": {"projectGenerators": 2}, + "vendor": {"id": "org.example", "displayName": "Example", "signatureRequirement": "publisherPackage"}, + "permissions": ["project.generate"], + "activationEvents": [], + "projectGenerators": [ + { + "id": "python-app", + "displayName": "Python App", + "section": "project", + "fields": [ + {"id": "python", "type": "tool", "title": "Python", "tool": "python", + "minimumVersion": "3.11", "pinAs": "project"}, + {"id": "venv", "type": "boolean", "title": "Create a virtual environment", "default": true} + ], + "steps": [ + {"kind": "files", "source": "project-generator-templates/tool-field"}, + {"kind": "command", "command": "uv", "mayUseNetwork": false, + "arguments": ["venv", "--python", "${field.python.executable}"], + "environment": {"PYTHON_HOME": "${field.python}"}, + "when": {"field": "venv", "equals": true}} + ] + }, + { + "id": "node-app", + "displayName": "Node App", + "section": "generator", + "fields": [ + {"id": "node", "type": "tool", "title": "Node.js", "tool": "node", "pinAs": "project", "optional": true} + ], + "steps": [ + {"kind": "command", "command": "npm", "mayUseNetwork": true, + "arguments": ["init", "--yes"], + "environment": {"NODE": "${field.node.executable}"}, + "when": {"field": "node", "notEquals": ""}} + ] + } + ] +} diff --git a/shared/contracts/plugins/v1/fixtures/project-plans.json b/shared/contracts/plugins/v1/fixtures/project-plans.json index b4a16feae7..7cdfa228e3 100644 --- a/shared/contracts/plugins/v1/fixtures/project-plans.json +++ b/shared/contracts/plugins/v1/fixtures/project-plans.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "description": "Golden answers of the project.* commands for project-generator.plugin.json. generationPlan cases read templates with the fixtures directory as the package path.", + "description": "Golden answers of the project.* commands for project-generator.plugin.json, or for the fixture a case names in manifest. generationPlan cases read templates with the fixtures directory as the package path.", "cases": [ { "name": "form defaults", @@ -201,6 +201,7 @@ "version": "21.0.4" } ], + "toolPins": [], "usesNetwork": false } } @@ -277,6 +278,7 @@ "version": "21.0.4" } ], + "toolPins": [], "usesNetwork": true } } @@ -308,6 +310,7 @@ ], "openFiles": [], "runtimes": [], + "toolPins": [], "usesNetwork": true } } @@ -334,6 +337,266 @@ } } }, + { + "name": "tool form selects the newest installation", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generatorForm", + "generatorID": "python-app", + "form": { + "projectName": "demo", + "tools": { + "python": [ + {"home": "/opt/python-3.10", "executable": "/opt/python-3.10/bin/python3", "version": "3.10.14"}, + {"home": "/opt/python-3.12", "executable": "/opt/python-3.12/bin/python3", "version": "3.12.4"} + ] + } + }, + "expected": { + "status": "ok", + "form": { + "projectName": {"problem": null}, + "fields": [ + { + "id": "python", + "visible": true, + "value": "/opt/python-3.12", + "installations": [ + {"home": "/opt/python-3.10", "executable": "/opt/python-3.10/bin/python3", "version": "3.10.14", "meetsMinimum": false}, + {"home": "/opt/python-3.12", "executable": "/opt/python-3.12/bin/python3", "version": "3.12.4", "meetsMinimum": true} + ] + }, + {"id": "venv", "visible": true, "value": true} + ], + "activeRequirements": [], + "valid": true + } + } + }, + { + "name": "tool form keeps the chosen installation", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generatorForm", + "generatorID": "python-app", + "form": { + "projectName": "demo", + "values": {"python": "/opt/python-3.11"}, + "tools": { + "python": [ + {"home": "/opt/python-3.11", "executable": "/opt/python-3.11/bin/python3", "version": "3.11.9"}, + {"home": "/opt/python-3.13", "executable": "/opt/python-3.13/bin/python3", "version": "3.13.1"} + ] + } + }, + "expected": { + "status": "ok", + "form": { + "projectName": {"problem": null}, + "fields": [ + { + "id": "python", + "visible": true, + "value": "/opt/python-3.11", + "installations": [ + {"home": "/opt/python-3.11", "executable": "/opt/python-3.11/bin/python3", "version": "3.11.9", "meetsMinimum": true}, + {"home": "/opt/python-3.13", "executable": "/opt/python-3.13/bin/python3", "version": "3.13.1", "meetsMinimum": true} + ] + }, + {"id": "venv", "visible": true, "value": true} + ], + "activeRequirements": [], + "valid": true + } + } + }, + { + "name": "tool form reports an installation below the minimum", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generatorForm", + "generatorID": "python-app", + "form": { + "projectName": "demo", + "tools": { + "python": [ + {"home": "/opt/python-3.10", "executable": "/opt/python-3.10/bin/python3", "version": "3.10.14"} + ] + } + }, + "expected": { + "status": "ok", + "form": { + "projectName": {"problem": null}, + "fields": [ + { + "id": "python", + "visible": true, + "value": "/opt/python-3.10", + "problem": "toolTooOld", + "installations": [ + {"home": "/opt/python-3.10", "executable": "/opt/python-3.10/bin/python3", "version": "3.10.14", "meetsMinimum": false} + ] + }, + {"id": "venv", "visible": true, "value": true} + ], + "activeRequirements": [], + "valid": false + } + } + }, + { + "name": "tool form without an installation is required", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generatorForm", + "generatorID": "python-app", + "form": {"projectName": "demo"}, + "expected": { + "status": "ok", + "form": { + "projectName": {"problem": null}, + "fields": [ + {"id": "python", "visible": true, "value": "", "problem": "required"}, + {"id": "venv", "visible": true, "value": true} + ], + "activeRequirements": [], + "valid": false + } + } + }, + { + "name": "plan expands the tool and pins it", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generationPlan", + "generatorID": "python-app", + "form": { + "projectName": "demo", + "tools": { + "python": [ + {"home": "/opt/python-3.12", "executable": "/opt/python-3.12/bin/python3", "version": "3.12.4"} + ] + } + }, + "expected": { + "status": "ok", + "plan": { + "projectName": "demo", + "steps": [ + { + "kind": "writeFiles", + "files": [ + { + "path": "README.md", + "contents": "# demo\n\nInterpreter: /opt/python-3.12/bin/python3\nInstallation: /opt/python-3.12\n", + "executable": false + } + ] + }, + { + "kind": "command", + "command": "uv", + "arguments": ["venv", "--python", "/opt/python-3.12/bin/python3"], + "environment": {"PYTHON_HOME": "/opt/python-3.12"}, + "collect": null, + "mayUseNetwork": false, + "timeoutSeconds": 600 + } + ], + "openFiles": [], + "runtimes": [], + "toolPins": [ + { + "field": "python", + "tool": "python", + "pinAs": "project", + "home": "/opt/python-3.12", + "executable": "/opt/python-3.12/bin/python3" + } + ], + "usesNetwork": false + } + } + }, + { + "name": "plan refuses a tool below the minimum", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generationPlan", + "generatorID": "python-app", + "form": { + "projectName": "demo", + "tools": { + "python": [ + {"home": "/opt/python-3.10", "executable": "/opt/python-3.10/bin/python3", "version": "3.10.14"} + ] + } + }, + "expected": { + "status": "rejected", + "error": { + "code": "plugin.project.invalidInput", + "message": "The value of python is not accepted (toolTooOld).", + "field": "values.python" + } + } + }, + { + "name": "plan leaves an optional tool without an installation unpinned", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generationPlan", + "generatorID": "node-app", + "form": {"projectName": "demo"}, + "expected": { + "status": "ok", + "plan": { + "projectName": "demo", + "steps": [], + "openFiles": [], + "runtimes": [], + "toolPins": [], + "usesNetwork": false + } + } + }, + { + "name": "plan pins an optional tool that has an installation", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generationPlan", + "generatorID": "node-app", + "form": { + "projectName": "demo", + "tools": { + "node": [ + {"home": "/opt/node-22", "executable": "/opt/node-22/bin/node", "version": "22.11.0"} + ] + } + }, + "expected": { + "status": "ok", + "plan": { + "projectName": "demo", + "steps": [ + { + "kind": "command", + "command": "npm", + "arguments": ["init", "--yes"], + "environment": {"NODE": "/opt/node-22/bin/node"}, + "collect": null, + "mayUseNetwork": true, + "timeoutSeconds": 600 + } + ], + "openFiles": [], + "runtimes": [], + "toolPins": [ + { + "field": "node", + "tool": "node", + "pinAs": "project", + "home": "/opt/node-22", + "executable": "/opt/node-22/bin/node" + } + ], + "usesNetwork": true + } + } + }, { "name": "name accepted", "operation": "checkName", From 1bbca0697e9386017453287cea3dd44cf94b71ea Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 4 Nov 2026 22:07:35 +0800 Subject: [PATCH 02/11] =?UTF-8?q?docs(contract):=20=E8=AE=B0=E5=BD=95=20pr?= =?UTF-8?q?ojectGenerators=202=20=E7=9A=84=20tool=20=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E3=80=81=E5=AE=89=E8=A3=85=E5=80=99=E9=80=89=E5=92=8C=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=20pin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README 增加 Tool fields 小节、模板变量 ${field..executable}、表单 tools 输入、 installations 输出、toolTooOld 问题、计划 toolPins 与目录 tools。manifest schema 接受 projectGenerators 2、tool 字段类型、tool 与 pinAs 属性。共享契约校验脚本检查 project-plans 用例指定的 manifest 夹具存在。 Refs #1495 --- .../architecture/verify-shared-contracts.sh | 5 + shared/contracts/plugins/v1/README.md | 102 ++++++++++++++---- .../plugins/v1/plugin-manifest.schema.json | 15 ++- 3 files changed, 95 insertions(+), 27 deletions(-) diff --git a/scripts/architecture/verify-shared-contracts.sh b/scripts/architecture/verify-shared-contracts.sh index e8416f22c7..9138734d99 100755 --- a/scripts/architecture/verify-shared-contracts.sh +++ b/scripts/architecture/verify-shared-contracts.sh @@ -206,6 +206,11 @@ done abort "plan case #{item.fetch("name")} uses an undocumented code" unless documented.include?(expected.fetch("code")) end end + # A project plan case runs against the version 1 generator fixture unless it names another. + JSON.parse(File.read(File.join(root, "fixtures", "project-plans.json"))).fetch("cases").each do |item| + next unless item.key?("manifest") + abort "project plan case #{item.fetch("name")} uses an unknown manifest" unless fixture_names.include?(item.fetch("manifest")) + end build_operations = %w[ invocationPlan checkToolVersion resolveOptions modelPlan taskPlan testPlan dependencyPlan decodeModel decodeTestResults decodeDiagnostics decodeDependencies syncFingerprint diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 9e40d06ad3..481dbe2e37 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -21,7 +21,8 @@ This directory is the cross-platform source of truth: | `fixtures/build-plans.json` | Golden build-system plans and decoded outputs | | `fixtures/function-protocol.json` | Golden plugin-function negotiation, registration, invocation, outcome, progress, and refusal cases | | `fixtures/project-generator.plugin.json`, `fixtures/project-generator-templates/` | Project generators: template files, a scaffolding command, and an archive download | -| `fixtures/project-plans.json` | Golden `project.*` form evaluations, generation plans, and name checks | +| `fixtures/project-generator-tool-field.plugin.json`, `fixtures/project-generator-templates/tool-field/` | Project generators that pick tool installations with `tool` fields (`projectGenerators` 2) | +| `fixtures/project-plans.json` | Golden `project.*` form evaluations, generation plans, and name checks; a case's optional `manifest` names the fixture it runs against (the version 1 generator by default) | | `plugin-index.schema.json` | JSON shape of a marketplace source index | | `fixtures/marketplace/*.json` | Marketplace index, availability, source URL, and install review golden cases | | `toolchains.md` | Implemented Core foundation: plugin definitions/provider declarations, verified-origin registry, reply validation, directory pins, snapshot resolution, version matching and discovery plans; native hosts are not connected | @@ -663,7 +664,7 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | `executableResolution` | Shared executable candidate ordering and strict selection; v2 canonical project/subproject toolchain registry bindings | `1`, `2` | | `vcsProviders` | `vcsProviders[]` | `1` | | `debugAdapters` | [`debugAdapters[]`](#api-level-4-debug-adapters); v2 adds target arguments/environment | `1`, `2` | -| `projectGenerators` | [`projectGenerators[]`](#project-generators): entries of the New Project window | `1` | +| `projectGenerators` | [`projectGenerators[]`](#project-generators): entries of the New Project window; v2 adds the [`tool` field](#tool-fields) | `1`, `2` | | `backend` | [`backend`](#plugin-backend): the plugin's own long-lived process | `1`, `2` (adds [start without a page and workspace contexts](#backend-2-workspace-contexts)) | | `toolDefinitions` | [Tool identities, layouts, discovery and four-layer candidates](toolchains.md) | `1` (Core; macOS read-only settings) | | `toolchainProviders` | [Provider declarations and permission-bound answer validation](toolchains.md) | `1` (Core; macOS read-only settings) | @@ -2010,8 +2011,8 @@ process starts and what it learns about workspaces. ## Project generators -`projectGenerators` (requires `projectGenerators` 1 and the `project.generate` -permission) adds entries to the host's New Project window. The host owns the +`projectGenerators` (requires `projectGenerators` 1 or 2 and the +`project.generate` permission) adds entries to the host's New Project window. The host owns the window: the entry list and its search, the project name and location, the "create Git repository" option, the empty project, and the Create button. A generator declares only its own fields, the languages and build systems the @@ -2054,7 +2055,8 @@ generator ID. | `enum` | One option | `options` (1–16 `{value, title}`), `default` (the first option otherwise), `presentation` (`segmented` or `menu`), `maximumVersion` | | `boolean` | `true` or `false` | `default` | | `multiSelect` | A list of options, kept in declaration order | `options` (1–128, each with an optional `group` heading), `default` | -| `runtime` | A host-discovered installation | `runtime` (`java`), `minimumVersion`, `optional`. The host chooses the initial value. | +| `runtime` | A host-discovered installation (`projectGenerators` 1 only) | `runtime` (`java`), `minimumVersion`, `optional`. The host chooses the initial value. | +| `tool` | An installation of a [tool definition](toolchains.md), by home directory (`projectGenerators` 2 only) | `tool` (a tool definition ID), `minimumVersion`, `pinAs`, `optional`. See [Tool fields](#tool-fields). | `maximumVersion` is a template over earlier fields naming the highest version the value may be, compared numerically by dotted components. A target Java @@ -2073,21 +2075,58 @@ directory name: not empty, at most 128 characters, no `/`, `\`, `:`, or control characters, not `.` or `..`, no leading `-`, and no surrounding spaces. +### Tool fields + +A `tool` field (requires `projectGenerators` 2) lets the user pick one +installation of any tool that a plugin declares in +[`toolDefinitions`](toolchains.md), such as a Python interpreter, a Node.js +runtime, or a JDK. The generator names no host feature and no language: the +host lists installations through its generic tool discovery and the user's +tool settings, exactly as the tool's own settings page does. + +```json +{"id": "sdk", "type": "tool", "title": "Python", "tool": "python", + "minimumVersion": "3.11", "pinAs": "project"} +``` + +| Property | Meaning | +| --- | --- | +| `tool` | Required. A tool definition ID: the short ID of a verified official definition, otherwise `:`. There is no closed list; a tool nobody declares has no installations. | +| `minimumVersion` | Optional, one to three dotted numbers. An installation below it is not selectable. | +| `pinAs` | Optional. `project` saves the selection as the created project's own tool choice (the same per-directory pin the tool's settings page edits), so the project builds and runs with the installation picked here. Without it the selection only feeds the steps. | +| `optional` | Optional. Without a usable installation an optional field stays empty and leaves no pin; otherwise the form reports `required`. | + +A tool field has no `default`; the host chooses the initial value. Core picks, +among the installations the host listed, the user's choice if it is still +listed and otherwise the newest version (the host's first among equal +versions). An installation below `minimumVersion` is reported as +`toolTooOld` and is marked in the field's `installations`, so the host can +show it disabled. + +A generator with `projectGenerators` 2 must not declare a `runtime` field, and +a generator with `projectGenerators` 1 must not declare a `tool` field +(`plugin.manifest.invalid`). Version 1 generators keep working unchanged. +A host that lacks `projectGenerators` 2 refuses the whole manifest with +`plugin.api.unsupported` (`requires.projectGenerators`). + ### Conditions `when` and `visibleWhen` take one condition or a list that must all hold: `{"field": "", "equals" | "notEquals": }` or `{"field": "", "oneOf": [, …]}`. Values are option values for `enum`, `true`/`false` -for `boolean`, and strings for `text` and `runtime` (`notEquals: ""` tests -that an optional field is filled). A condition on a hidden field is false. +for `boolean`, and strings for `text`, `runtime`, and `tool` (`notEquals: ""` +tests that an optional field is filled; a tool field compares as its home +directory). A condition on a hidden field is false. ### Templates Strings in the manifest use `${…}`: `${project.name}`, `${field.}` (a -text or option value, `true`/`false`, a comma-separated list, or a runtime's -home directory), `${field..version}`, and `${field..major}` (`21` for -`21.0.4`, `8` for `1.8.0_412`). Modifiers follow `|` and apply in order: -`path` turns dots into `/`, `package` makes a valid package name +text or option value, `true`/`false`, a comma-separated list, or the home +directory of a runtime or tool), `${field..version}` and +`${field..major}` (runtime fields only; `21` for `21.0.4`, `8` for +`1.8.0_412`), and `${field..executable}` (tool fields only: the path of +the tool's executable in the chosen installation). Modifiers follow `|` and +apply in order: `path` turns dots into `/`, `package` makes a valid package name (`com.Example.my-app` → `com.example.myapp`), and `lower` lowercases. Template files use `{{ … }}` with the same variables, because POMs, Gradle @@ -2117,26 +2156,43 @@ file, and 8 MiB in total; archives to 20,000 entries and 512 MiB extracted. | Command | Payload | Result | | --- | --- | --- | -| `project.generatorCatalog` | `{plugins: [{manifest, enabled}], hostCapabilities?}` | `catalog: {generators: [{pluginID, pluginName, pluginEnabled, generator, requirements: [{languageID or buildSystemID, providers: [{pluginID, pluginName, enabled}]}], usesNetwork}], issues: [{pluginID, error}]}` | -| `project.generatorForm` | `{manifest, generatorID, form}` | `form: {projectName: {problem}, fields: [{id, visible, value, problem?}], activeRequirements, valid}` | -| `project.generationPlan` | `{manifest, generatorID, packagePath, form}` | `plan: {projectName, steps, openFiles, runtimes: [{field, runtime, home, version}], usesNetwork}` | +| `project.generatorCatalog` | `{plugins: [{manifest, enabled}], hostCapabilities?}` | `catalog: {generators: [{pluginID, pluginName, pluginEnabled, generator, requirements: [{languageID or buildSystemID, providers: [{pluginID, pluginName, enabled}]}], tools: [{fieldID, tool, providers: [{pluginID, pluginName, enabled}]}], usesNetwork}], issues: [{pluginID, error}]}` | +| `project.generatorForm` | `{manifest, generatorID, form}` | `form: {projectName: {problem}, fields: [{id, visible, value, problem?, installations?}], activeRequirements, valid}` | +| `project.generationPlan` | `{manifest, generatorID, packagePath, form}` | `plan: {projectName, steps, openFiles, runtimes: [{field, runtime, home, version}], toolPins: [{field, tool, pinAs, home, executable}], usesNetwork}` | | `project.checkName` | `{projectName}` | `name: {problem}` | | `project.extractArchive` | `{archivePath, destination}` | `extracted: {files}` | -`form` is `{projectName, values, edited, runtimes}`. `values` maps field IDs -to values; a text value counts only when its ID is in `edited`, so derived +`form` is `{projectName, values, edited, runtimes, tools}`. `values` maps field +IDs to values; a text value counts only when its ID is in `edited`, so derived defaults keep following the fields they read. `runtimes` maps runtime field -IDs to `{home, version}` of the user's selection. A field `problem` is one of -`required`, `tooLong`, `invalidFormat`, `unsupportedValue`, `runtimeTooOld`, -and `versionTooHigh`; hosts localize by code. Plan steps are `{kind: +IDs to `{home, version}` of the user's selection. `tools` maps tool field IDs +to the installations the host found for the field's tool, in the order the +host prefers them, as `[{home, executable, version}]`; the field's `values` +entry is the `home` of the user's choice. A tool field in the result carries +`installations: [{home, executable, version, meetsMinimum}]`, so the host +draws its picker from Core's answer, and its `value` is the installation Core +selected. A field `problem` is one of `required`, `tooLong`, `invalidFormat`, +`unsupportedValue`, `runtimeTooOld`, `toolTooOld`, and `versionTooHigh`; hosts +localize by code. Plan steps are `{kind: "writeFiles", files: [{path, contents, executable}]}`, `{kind: "command", command, arguments, environment, collect, mayUseNetwork, timeoutSeconds}`, and -`{kind: "archive", url, host, format: "zip"}`. +`{kind: "archive", url, host, format: "zip"}`. `toolPins` lists, for each +visible tool field with `pinAs` and an installation, the tool and the chosen +`home` and `executable` that the host saves as the project's pin. + +The catalog entry's `tools` lists, for each tool field, the plugins that +declare the field's tool and whether they are enabled, in the same way as +`requirements`: the host can offer to enable one, or open the marketplace, +when the field has no installation. Core only names declarations; whether a +definition is trusted, and whether the tool is installed, is decided by the +host's tool registry and discovery. After the steps, the host moves the staging directory to its final location -(refusing an existing one), may record a `java` runtime as the project's JDK, -creates the Git repository when asked, and opens the project. A failure or -cancellation removes the staging directory and leaves nothing behind +(refusing an existing one), may record a `java` runtime as the project's JDK +(version 1 runtime fields only), saves the `toolPins` as the project's tool +choices through the same store the tool settings page edits, creates the Git +repository when asked, and opens the project. A failure or cancellation +removes the staging directory and leaves nothing behind (`plugin.project.generationFailed`). ## Plugin functions diff --git a/shared/contracts/plugins/v1/plugin-manifest.schema.json b/shared/contracts/plugins/v1/plugin-manifest.schema.json index 55c66948ee..dbf4a98360 100644 --- a/shared/contracts/plugins/v1/plugin-manifest.schema.json +++ b/shared/contracts/plugins/v1/plugin-manifest.schema.json @@ -46,7 +46,7 @@ "languageServers": { "description": "Required exactly when languageServers is non-empty. Version 2 adds languageServers[].workspaceConfiguration.", "enum": [1, 2] }, "languages": { "description": "Version 2 adds languages[].grammar and languages[].configuration and is required exactly when one of them is used.", "enum": [1, 2] }, "packageTrust": { "description": "Required exactly when vendor.bundledSignatureRequirement is present.", "enum": [1] }, - "projectGenerators": { "description": "Entries of the New Project window: fields, requirements, and steps.", "enum": [1] }, + "projectGenerators": { "description": "Entries of the New Project window: fields, requirements, and steps. Version 2 adds the tool field and retires the runtime field.", "enum": [1, 2] }, "serverProcess": { "description": "The launch fields of language servers, of build providers that start their own process, of VCS providers, of debug adapters, and of the plugin backend. Version 2 adds runSelectedRuntime and is required exactly when a debug adapter uses it (pluginAPIVersion 4).", "enum": [1, 2] }, "settings": { "description": "Required exactly when settings or settingActions is non-empty. Version 1 is the API level 2 section: integer, enum, and boolean options. Version 2 adds scope, effect, page, section, the multiSelect, text, file, and directory types, and settingActions.", "enum": [1, 2] }, "toolDefinitions": { "const": 1 }, @@ -847,12 +847,12 @@ "required": ["id", "type", "title"], "properties": { "id": { "type": "string", "pattern": "^[a-z][A-Za-z0-9]*$" }, - "type": { "enum": ["text", "enum", "boolean", "multiSelect", "runtime"] }, + "type": { "enum": ["text", "enum", "boolean", "multiSelect", "runtime", "tool"], "description": "runtime needs requires.projectGenerators 1; tool needs requires.projectGenerators 2." }, "title": { "type": "string", "minLength": 1, "maxLength": 120 }, "description": { "type": "string", "minLength": 1, "maxLength": 480 }, "advanced": { "type": "boolean" }, "visibleWhen": { "$ref": "#/$defs/generatorConditions" }, - "default": { "description": "text: a template over ${project.name} and earlier fields; enum: an option; boolean; multiSelect: options. Not allowed for runtime." }, + "default": { "description": "text: a template over ${project.name} and earlier fields; enum: an option; boolean; multiSelect: options. Not allowed for runtime and tool." }, "options": { "type": "array", "items": { @@ -872,7 +872,14 @@ "placeholder": { "type": "string", "minLength": 1, "maxLength": 120 }, "optional": { "type": "boolean" }, "runtime": { "enum": ["java"] }, - "minimumVersion": { "type": "string" }, + "tool": { + "description": "tool: a tool definition ID, the short ID of a verified official definition or :.", + "type": "string", + "maxLength": 192, + "pattern": "^([a-z0-9]+([.-][a-z0-9]+)*:)?[a-z][a-z0-9-]{0,63}$" + }, + "pinAs": { "description": "tool: project saves the selection as the created project's own tool choice.", "enum": ["project"] }, + "minimumVersion": { "type": "string", "description": "runtime and tool: one to three dotted numbers." }, "maximumVersion": { "type": "string", "description": "text and enum: the highest version the value may name, a template over earlier fields such as ${field.jdk.major}." } } }, From 9bbaa4689cfded6a49d0001afe0075cabb23f037 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 4 Nov 2026 22:16:56 +0800 Subject: [PATCH 03/11] =?UTF-8?q?fix(core):=20=E9=A1=B9=E7=9B=AE=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=99=A8=E7=9B=AE=E5=BD=95=E6=8C=89=E9=99=90=E5=AE=9A?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=20ID=20=E5=8C=B9=E9=85=8D=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/lithe-core/src/plugins/declarative.rs | 12 ++++++++-- .../src/project_generators/commands.rs | 4 +--- .../src/tests/project_generators.rs | 23 +++++++++++++++++++ 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/rust/lithe-core/src/plugins/declarative.rs b/rust/lithe-core/src/plugins/declarative.rs index 1c77e6110c..06c74182e6 100644 --- a/rust/lithe-core/src/plugins/declarative.rs +++ b/rust/lithe-core/src/plugins/declarative.rs @@ -1729,11 +1729,19 @@ impl DeclarativePluginManifest { self.languages.iter().map(|language| language.id.as_str()) } - /// IDs of the tool definitions the plugin declares. - pub(crate) fn tool_definition_ids(&self) -> impl Iterator { + /// Whether the plugin declares a definition that `tool` names. A + /// definition is addressed by its short ID (verified official plugins) or + /// as `:`; which of the two the host honors is its registry's + /// decision, so both spellings count as a declaration here. + pub(crate) fn declares_tool(&self, tool: &str) -> bool { + // `Some(local)` when `tool` is spelled `:`. + let local_name = tool + .strip_prefix(self.id.as_str()) + .and_then(|rest| rest.strip_prefix(':')); self.tool_definitions .iter() .filter_map(|definition| definition["id"].as_str()) + .any(|id| tool == id || local_name == Some(id)) } /// Validated project generators, in declaration order. diff --git a/rust/lithe-core/src/project_generators/commands.rs b/rust/lithe-core/src/project_generators/commands.rs index 7ebdfc232c..1e1ae0f701 100644 --- a/rust/lithe-core/src/project_generators/commands.rs +++ b/rust/lithe-core/src/project_generators/commands.rs @@ -184,9 +184,7 @@ fn tool_providers( Some(json!({ "fieldID": field.id, "tool": tool, - "providers": providers(&|candidate| { - candidate.tool_definition_ids().any(|id| id == tool) - }), + "providers": providers(&|candidate| candidate.declares_tool(tool)), })) }) .collect() diff --git a/rust/lithe-core/src/tests/project_generators.rs b/rust/lithe-core/src/tests/project_generators.rs index 5016e2f6c6..c696772573 100644 --- a/rust/lithe-core/src/tests/project_generators.rs +++ b/rust/lithe-core/src/tests/project_generators.rs @@ -203,6 +203,29 @@ fn catalog_names_the_plugins_that_define_each_tool() { entries[1]["tools"], json!([{"fieldID": "node", "tool": "node", "providers": []}]) ); + // A third-party definition is addressed as `:`; that spelling + // names the same declaration, but another plugin's prefix does not. + for (tool, provider_count) in [ + ("dev.lithe.fixture.java-support:java", 1), + ("dev.lithe.fixture.other:java", 0), + ] { + let mut generators = json(TOOL_FIELD_FIXTURE); + generators["projectGenerators"][0]["fields"][0]["tool"] = json!(tool); + let qualified = project_generator_catalog_command(json!({ + "plugins": [ + {"manifest": generators, "enabled": true}, + {"manifest": json(TOOL_DEFINITION_FIXTURE), "enabled": true}, + ], + "hostCapabilities": host(), + })) + .unwrap(); + let providers = &qualified["catalog"]["generators"][0]["tools"][0]["providers"]; + assert_eq!( + providers.as_array().unwrap().len(), + provider_count, + "{tool}" + ); + } // Version 1 generators have no tool fields. let version_1 = project_generator_catalog_command(json!({ "plugins": [{"manifest": json(FIXTURE), "enabled": true}], From 63658a361676c42694c3d80d936d22a2e5b7821f Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 4 Nov 2026 22:41:28 +0800 Subject: [PATCH 04/11] =?UTF-8?q?feat(macos):=20=E6=96=B0=E5=BB=BA?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E7=AA=97=E5=8F=A3=E6=94=AF=E6=8C=81=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=AD=97=E6=AE=B5=EF=BC=88projectGenerators=202?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建项目窗口为 type:tool 字段显示工具下拉:候选来自通用的工具发现,低于最低版本的安装置灰,找不到工具时提示启用或到插件市场查找 - 新增 MacProjectToolInstallations:新建项目没有工作区,在空的临时目录里复用设置页的工具发现,用完删除 - 新增 MacProjectToolPins:创建成功后把所选安装写入新项目的工具 pin(.lithe/run/local.json),与设置页共用同一个存储;写入失败不让创建失败,改为提示 - 新建项目窗口监听插件变化并重新载入条目;当前生效的插件由启动时登记的插件和实时注册表合并得到 - hostCapabilities 声明 projectGenerators 1 和 2 - 新增 5 条中英文界面文案并重新生成 Localizable.strings Refs #1495 --- macos/Resources/en.lproj/Localizable.strings | 10 +++ .../zh-Hans.lproj/Localizable.strings | 10 +++ .../Application/Composition/AppServices.swift | 4 + .../Features/NewProjectFeatureModel.swift | 86 +++++++++++++++++++ .../Models/AppModel/AppModel+NewProject.swift | 8 +- .../ProjectGenerationModels.swift | 67 ++++++++++++++- .../Platform/MacOS/MacServiceContainer.swift | 64 +++++++++----- .../MacProjectToolInstallations.swift | 65 ++++++++++++++ .../MacProjectToolPins.swift | 29 +++++++ .../ProjectGenerationService.swift | 32 ++++++- .../Views/Workspace/NewProjectView.swift | 64 ++++++++++++++ .../LitheModuleAPI/Plugins/PluginTypes.swift | 2 +- shared/i18n/locales/en-US.json | 5 ++ shared/i18n/locales/zh-CN.json | 5 ++ shared/i18n/messages.json | 65 ++++++++++++++ 15 files changed, 487 insertions(+), 29 deletions(-) create mode 100644 macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolInstallations.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolPins.swift diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 0cfc9538a2..1fff4bc1d7 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -12,6 +12,7 @@ "%@ (%lld)" = "%@ (%lld)"; "%@ (default)" = "%@ (default)"; "%@ (not available for this Lithe or Mac)" = "%@ (not available for this Lithe or Mac)"; +"%@ (older than required)" = "%@ (older than required)"; "%@ / %@ rows · line %@" = "%@ / %@ rows · line %@"; "%@ already exists. Choose another name or location." = "%@ already exists. Choose another name or location."; "%@ answered with status %lld. %@" = "%@ answered with status %lld. %@"; @@ -2300,6 +2301,7 @@ "Looked in: %@" = "Looked in: %@"; "Looking for Git…" = "Looking for Git…"; "Looking for installed JDKs…" = "Looking for installed JDKs…"; +"Looking for installed tools…" = "Looking for installed tools…"; "Looking up the location sends this address to ipinfo.io. Lithe does this only when you click Look Up." = "Looking up the location sends this address to ipinfo.io. Lithe does this only when you click Look Up."; "Low" = "Low"; "Low (recommended)" = "Low (recommended)"; @@ -2607,6 +2609,7 @@ "No history recorded yet" = "No history recorded yet"; "No host yet" = "No host yet"; "No implementations found" = "No implementations found"; +"No installation of this tool was found." = "No installation of this tool was found."; "No installed plugin supports %@ yet." = "No installed plugin supports %@ yet."; "No items" = "No items"; "No language dependency sources registered" = "No language dependency sources registered"; @@ -4392,6 +4395,7 @@ "The project folder on the host; empty uses the home directory. Browse connects, installs the Lithe server on the host if needed, and lets you pick a folder." = "The project folder on the host; empty uses the home directory. Browse connects, installs the Lithe server on the host if needed, and lets you pick a folder."; "The project model is not loaded." = "The project model is not loaded."; "The project was created, but Git could not be set up: %@" = "The project was created, but Git could not be set up: %@"; +"The project was created, but the tool you chose could not be saved for it: %@" = "The project was created, but the tool you chose could not be saved for it: %@"; "The provider looks up the build tool itself: %@" = "The provider looks up the build tool itself: %@"; "The proxy answered with HTTP %@." = "The proxy answered with HTTP %@."; "The proxy server's host name or IP address, for example 127.0.0.1." = "The proxy server's host name or IP address, for example 127.0.0.1."; @@ -4565,6 +4569,7 @@ "This folder cannot be read." = "This folder cannot be read."; "This folder is empty" = "This folder is empty"; "This host cannot receive a compatible plugin package. Update the remote server." = "This host cannot receive a compatible plugin package. Update the remote server."; +"This installation is older than the generator requires." = "This installation is older than the generator requires."; "This native configuration source is read-only. Installed applications and plugin packages cannot be edited." = "This native configuration source is read-only. Installed applications and plugin packages cannot be edited."; "This page" = "This page"; "This panel needs exactly one matching plugin instance in the workspace." = "This panel needs exactly one matching plugin instance in the workspace."; @@ -7508,6 +7513,7 @@ "native.lookedIn" = "Looked in: %1$@"; "native.lookingForGit" = "Looking for Git…"; "native.lookingForInstalledJDKs" = "Looking for installed JDKs…"; +"native.lookingForInstalledTools" = "Looking for installed tools…"; "native.lookingUpTheLocationSendsThisAddressToIpinfoIoLitheDoesThisOnlyWhenYouClickLookUp" = "Looking up the location sends this address to ipinfo.io. Lithe does this only when you click Look Up."; "native.low" = "Low"; "native.lowEffortAndASmallOutputLimitAreRecommendedForFastCommitMessageGeneration" = "Low effort and a small output limit are recommended for fast commit-message generation."; @@ -7803,6 +7809,7 @@ "native.noHooksYet" = "No Hooks yet."; "native.noHostYet" = "No host yet"; "native.noImplementationsFound" = "No implementations found"; +"native.noInstallationOfThisToolWasFound" = "No installation of this tool was found."; "native.noInstalledPluginSupportsYet" = "No installed plugin supports %1$@ yet."; "native.noItems" = "No items"; "native.noJDKFoundSetJAVAHOMEInstallAJDKOrChooseAJDKDirectory" = "No JDK found. Set JAVA_HOME, install a JDK, or choose a JDK directory."; @@ -7953,6 +7960,7 @@ "native.officialPluginDeclaresASignaturePolicyThatDoes" = "Official plugin %1$@ declares a signature policy that does not match the host trust policy."; "native.olderCommitsAreNotListed" = "Older commits are not listed."; "native.olderCommitsAreOutsideTheLoadedHistory" = "Older commits are outside the loaded history"; +"native.olderThanRequired" = "%1$@ (older than required)"; "native.on" = "On"; "native.oneArgumentPerLineArgumentsKeepSpacesWithoutQuoting" = "One argument per line. Arguments keep spaces without quoting."; "native.oneEntryPerLineDirectoryNamesHideMatchingFoldersFileEntriesSupportAnd" = "One entry per line. Directory names hide matching folders; file entries support * and ?."; @@ -9400,6 +9408,7 @@ "native.theProjectFolderOnTheHostEmptyUsesTheHomeDirectoryBrowseConnectsInstallsTheLitheServerOnTh" = "The project folder on the host; empty uses the home directory. Browse connects, installs the Lithe server on the host if needed, and lets you pick a folder."; "native.theProjectModelIsNotLoaded" = "The project model is not loaded."; "native.theProjectWasCreatedButGitCouldNotBeSetUp" = "The project was created, but Git could not be set up: %1$@"; +"native.theProjectWasCreatedButTheToolYouChoseCouldNotBeSavedForIt" = "The project was created, but the tool you chose could not be saved for it: %1$@"; "native.theProviderDoesNotSupport" = "The %1$@ provider does not support %2$@."; "native.theProviderLooksUpTheBuildToolItself" = "The provider looks up the build tool itself: %1$@"; "native.thePublishedUpdateManifestIsInvalidAndCannotBeTrustedOpenTheReleasePageAndDowna6815c0bf9" = "The published update manifest is invalid and cannot be trusted. Open the Release page and download the update manually."; @@ -9534,6 +9543,7 @@ "native.thisFolderCannotBeRead" = "This folder cannot be read."; "native.thisFolderIsEmpty" = "This folder is empty"; "native.thisHookNoLongerExistsReloadTheLibrary" = "This Hook no longer exists. Reload the library."; +"native.thisInstallationIsOlderThanTheGeneratorRequires" = "This installation is older than the generator requires."; "native.thisMac" = "This Mac"; "native.thisMacOnly" = "This Mac only"; "native.thisMarkdownDocumentIsReadOnly" = "This Markdown document is read-only"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 7c8c6f7db3..648f23b2cf 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -12,6 +12,7 @@ "%@ (%lld)" = "%@(%lld)"; "%@ (default)" = "%@(默认)"; "%@ (not available for this Lithe or Mac)" = "%@(当前 Lithe 或这台 Mac 无法使用)"; +"%@ (older than required)" = "%@(低于要求的版本)"; "%@ / %@ rows · line %@" = "%@ / %@ 条 · 第 %@ 行"; "%@ already exists. Choose another name or location." = "%@ 已存在,请更换名称或位置。"; "%@ answered with status %lld. %@" = "%1$@ 返回状态 %2$lld。%3$@"; @@ -2300,6 +2301,7 @@ "Looked in: %@" = "已查找:%@"; "Looking for Git…" = "正在查找 Git…"; "Looking for installed JDKs…" = "正在查找已安装的 JDK…"; +"Looking for installed tools…" = "正在查找已安装的工具…"; "Looking up the location sends this address to ipinfo.io. Lithe does this only when you click Look Up." = "查询地区会把这个地址发送给 ipinfo.io。只有你点击“查询地区”时 Lithe 才会这样做。"; "Low" = "低"; "Low (recommended)" = "低(推荐)"; @@ -2607,6 +2609,7 @@ "No history recorded yet" = "还没有历史记录"; "No host yet" = "尚未填写主机"; "No implementations found" = "没有找到实现"; +"No installation of this tool was found." = "未找到此工具的安装。"; "No installed plugin supports %@ yet." = "尚未安装支持 %@ 的插件。"; "No items" = "暂无项目"; "No language dependency sources registered" = "未注册语言依赖来源"; @@ -4392,6 +4395,7 @@ "The project folder on the host; empty uses the home directory. Browse connects, installs the Lithe server on the host if needed, and lets you pick a folder." = "主机上的项目文件夹,留空时使用主目录。“浏览”会连接主机,必要时在主机上安装 Lithe 远端服务,然后让你选择文件夹。"; "The project model is not loaded." = "项目模型尚未加载。"; "The project was created, but Git could not be set up: %@" = "项目已创建,但无法初始化 Git 仓库:%@"; +"The project was created, but the tool you chose could not be saved for it: %@" = "项目已创建,但无法为它保存你选择的工具:%@"; "The provider looks up the build tool itself: %@" = "Provider 自行查找构建工具:%@"; "The proxy answered with HTTP %@." = "代理返回了 HTTP %@。"; "The proxy server's host name or IP address, for example 127.0.0.1." = "代理服务器的主机名或 IP 地址,例如 127.0.0.1。"; @@ -4565,6 +4569,7 @@ "This folder cannot be read." = "无法读取此文件夹。"; "This folder is empty" = "此文件夹为空"; "This host cannot receive a compatible plugin package. Update the remote server." = "此主机无法接收兼容的插件包,请更新远程服务。"; +"This installation is older than the generator requires." = "此安装版本低于模板要求。"; "This native configuration source is read-only. Installed applications and plugin packages cannot be edited." = "此原生配置来源为只读。不能编辑已安装的应用和插件包。"; "This page" = "此页面"; "This panel needs exactly one matching plugin instance in the workspace." = "此面板要求工作区中恰好有一个匹配的插件实例。"; @@ -7508,6 +7513,7 @@ "native.lookedIn" = "已查找:%1$@"; "native.lookingForGit" = "正在查找 Git…"; "native.lookingForInstalledJDKs" = "正在查找已安装的 JDK…"; +"native.lookingForInstalledTools" = "正在查找已安装的工具…"; "native.lookingUpTheLocationSendsThisAddressToIpinfoIoLitheDoesThisOnlyWhenYouClickLookUp" = "查询地区会把这个地址发送给 ipinfo.io。只有你点击“查询地区”时 Lithe 才会这样做。"; "native.low" = "低"; "native.lowEffortAndASmallOutputLimitAreRecommendedForFastCommitMessageGeneration" = "为了快速生成提交信息,建议使用低推理强度和较小的输出限制。"; @@ -7803,6 +7809,7 @@ "native.noHooksYet" = "还没有 Hook。"; "native.noHostYet" = "尚未填写主机"; "native.noImplementationsFound" = "没有找到实现"; +"native.noInstallationOfThisToolWasFound" = "未找到此工具的安装。"; "native.noInstalledPluginSupportsYet" = "尚未安装支持 %1$@ 的插件。"; "native.noItems" = "暂无项目"; "native.noJDKFoundSetJAVAHOMEInstallAJDKOrChooseAJDKDirectory" = "未找到 JDK。请设置 JAVA_HOME、安装 JDK,或选择 JDK 目录。"; @@ -7953,6 +7960,7 @@ "native.officialPluginDeclaresASignaturePolicyThatDoes" = "官方插件 %1$@ 声明的签名策略与宿主信任策略不一致。"; "native.olderCommitsAreNotListed" = "更早的提交未列出。"; "native.olderCommitsAreOutsideTheLoadedHistory" = "更早的提交不在当前加载的历史范围内"; +"native.olderThanRequired" = "%1$@(低于要求的版本)"; "native.on" = "开启"; "native.oneArgumentPerLineArgumentsKeepSpacesWithoutQuoting" = "每行一个参数。参数中的空格无需额外引号。"; "native.oneEntryPerLineDirectoryNamesHideMatchingFoldersFileEntriesSupportAnd" = "每行输入一项。目录名用于隐藏匹配的文件夹;文件条目支持 * 和 ?。"; @@ -9400,6 +9408,7 @@ "native.theProjectFolderOnTheHostEmptyUsesTheHomeDirectoryBrowseConnectsInstallsTheLitheServerOnTh" = "主机上的项目文件夹,留空时使用主目录。“浏览”会连接主机,必要时在主机上安装 Lithe 远端服务,然后让你选择文件夹。"; "native.theProjectModelIsNotLoaded" = "项目模型尚未加载。"; "native.theProjectWasCreatedButGitCouldNotBeSetUp" = "项目已创建,但无法初始化 Git 仓库:%1$@"; +"native.theProjectWasCreatedButTheToolYouChoseCouldNotBeSavedForIt" = "项目已创建,但无法为它保存你选择的工具:%1$@"; "native.theProviderDoesNotSupport" = "%1$@ Provider 不支持 %2$@。"; "native.theProviderLooksUpTheBuildToolItself" = "Provider 自行查找构建工具:%1$@"; "native.thePublishedUpdateManifestIsInvalidAndCannotBeTrustedOpenTheReleasePageAndDowna6815c0bf9" = "已发布的更新清单无效,无法信任。请打开 Release 页面手动下载更新。"; @@ -9534,6 +9543,7 @@ "native.thisFolderCannotBeRead" = "无法读取此文件夹。"; "native.thisFolderIsEmpty" = "此文件夹为空"; "native.thisHookNoLongerExistsReloadTheLibrary" = "该 Hook 已不存在,请重新加载资源库。"; +"native.thisInstallationIsOlderThanTheGeneratorRequires" = "此安装版本低于模板要求。"; "native.thisMac" = "本机"; "native.thisMacOnly" = "仅本机"; "native.thisMarkdownDocumentIsReadOnly" = "此 Markdown 文档为只读"; diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index 785dfeaa5f..ee490679e2 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -44,6 +44,10 @@ final class AppServices { /// Installed plugins with their enabled state, for the New Project /// catalog and the plugins that provide a generator's requirements. var projectGeneratorSources: (@MainActor () -> [ProjectGeneratorPluginSource])? + /// Installations of the tools a generator's `tool` fields pick, by tool + /// definition ID, found without a workspace; `nil` when the platform + /// cannot discover tools. + var projectToolInstallations: (@MainActor ([String]) async -> [String: [ProjectToolInstallation]])? /// Read-only plugin toolchain projection; absent until platform composition. var toolchainSettingsLoader: (any ToolchainSettingsLoading)? var toolchainPreferences: (any ToolchainPreferencesStoring)? diff --git a/macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift b/macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift index 3eaccc3829..be5ea9aa46 100644 --- a/macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import LitheCoreContracts import LitheModuleAPI @@ -18,6 +19,13 @@ struct NewProjectEnvironment { let openMarketplace: @MainActor (String) -> Void let openProject: @MainActor (CreatedProject) -> Void let store: any KeyValueStore + /// Installations of the tools `tool` fields pick, by tool definition ID and + /// best first. A new project has no workspace yet, so this is the + /// machine-wide discovery; a tool nothing defines is simply absent. + var toolInstallations: @MainActor ([String]) async -> [String: [ProjectToolInstallation]] = { _ in [:] } + /// Fires after the installed plugins or their enablement changed, so the + /// entry list follows an install, update, or removal without reopening. + var pluginChanges: AnyPublisher = Empty().eraseToAnyPublisher() } /// State of the New Project window: the entry list, the host-owned name, @@ -85,8 +93,16 @@ final class NewProjectFeatureModel: ObservableObject { @Published private(set) var progressOutput = "" @Published private(set) var errorMessage: String? @Published private(set) var enablingPluginID: PluginID? + /// True from the moment tool installations are requested until they arrive, + /// so an empty list reads as "none installed" rather than "still looking". + @Published private(set) var isListingTools = false private let environment: NewProjectEnvironment + /// Installations found per tool definition ID. A listing replaces the + /// entries it asked for; the others keep their last answer. + private var listedTools: [String: [ProjectToolInstallation]] = [:] + private var toolListing: Task? + private var pluginChangeObservation: AnyCancellable? /// Inputs per generator, so switching entries keeps what the user typed. private var inputs: [String: ProjectGeneratorFormInput] = [:] private var creation: Task? @@ -99,6 +115,11 @@ final class NewProjectFeatureModel: ObservableObject { self.environment = environment location = environment.store.string(forKey: Self.locationKey) ?? Self.defaultLocation createGitRepository = environment.store.object(forKey: Self.createGitKey) as? Bool ?? false + pluginChangeObservation = environment.pluginChanges + .receive(on: DispatchQueue.main) + .sink { [weak self] in + MainActor.assumeIsolated { self?.reloadCatalog() } + } } // MARK: Catalog @@ -131,6 +152,9 @@ final class NewProjectFeatureModel: ObservableObject { selection = .empty } evaluate() + // A plugin change may add or remove the definition of a tool, so the + // installations are looked up again rather than trusted. + listToolInstallations() } var selectedEntry: ProjectGeneratorEntry? { @@ -236,6 +260,11 @@ final class NewProjectFeatureModel: ObservableObject { private func input(for entry: ProjectGeneratorEntry) -> ProjectGeneratorFormInput { var input = inputs[entry.id] ?? ProjectGeneratorFormInput(projectName: projectName) input.projectName = projectName + // Core accepts only installations the host listed, so each tool field + // is sent what was found for its tool. + for field in entry.generator.fields where field.kind == .tool { + input.tools[field.id] = field.tool.flatMap { listedTools[$0] } ?? [] + } // Every runtime field starts on the best JDK the generator accepts. for field in entry.generator.fields where field.kind == .runtime && input.runtimes[field.id] == nil { if let runtime = Self.preferredRuntime(javaRuntimes, minimum: field.minimumVersion) { @@ -257,6 +286,10 @@ final class NewProjectFeatureModel: ObservableObject { errorMessage = nil showsAdvancedFields = false evaluate() + // Tools another generator already needed are not looked up again. + if toolIDs(of: selectedEntry).contains(where: { listedTools[$0] == nil }) { + listToolInstallations() + } } /// Asks Rust Core for the form after every change. @@ -305,6 +338,59 @@ final class NewProjectFeatureModel: ObservableObject { ) } + // MARK: Tool fields + + /// The installations a tool field offers, with Core's verdict on each. + func toolChoices(for field: ProjectGeneratorField) -> [ProjectGeneratorFormState.ToolChoice] { + formState?.field(field.id)?.installations ?? [] + } + + /// What to offer when a tool field found no installation because no enabled + /// plugin defines its tool: enable the plugin that does, or look for one. + /// Nil while listing, when installations exist, and when the defining + /// plugin is already enabled (the tool is then just not installed). + func toolNoticeAction(for field: ProjectGeneratorField) -> RequirementNotice.Action? { + guard !isListingTools, toolChoices(for: field).isEmpty, + let tool = selectedEntry?.tools.first(where: { $0.fieldID == field.id }), + !tool.providers.contains(where: \.enabled) else { return nil } + if let provider = tool.providers.first { + return .enable(PluginID(provider.pluginID), name: provider.pluginName) + } + // A third-party definition is `:`; the plugin is what the + // marketplace lists. + let query = tool.tool.split(separator: ":").first.map(String.init) ?? tool.tool + return .findInMarketplace(query) + } + + /// The distinct tools the entry's tool fields pick, in declaration order. + private func toolIDs(of entry: ProjectGeneratorEntry?) -> [String] { + var seen = Set() + return (entry?.generator.fields ?? []) + .filter { $0.kind == .tool } + .compactMap(\.tool) + .filter { seen.insert($0).inserted } + } + + /// Looks up the installations of the selected generator's tools and + /// re-evaluates the form with them. A newer request replaces an older one. + private func listToolInstallations() { + toolListing?.cancel() + let tools = toolIDs(of: selectedEntry) + guard !tools.isEmpty else { + isListingTools = false + return + } + isListingTools = true + toolListing = Task { [weak self] in + guard let self else { return } + let found = await environment.toolInstallations(tools) + guard !Task.isCancelled else { return } + for tool in tools { listedTools[tool] = found[tool] ?? [] } + isListingTools = false + evaluate() + } + } + // MARK: Actions func enablePlugin(_ pluginID: PluginID) async { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+NewProject.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+NewProject.swift index 974461be38..1c2b97ca1c 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+NewProject.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+NewProject.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import LitheCoreContracts import LitheModuleAPI @@ -31,7 +32,9 @@ extension AppModel { }, openMarketplace: { [weak self] query in self?.showPluginMarketplace(searching: query) }, openProject: { [weak self] project in self?.openCreatedProject(project) }, - store: services.store + store: services.store, + toolInstallations: { [weak self] tools in await self?.services.projectToolInstallations?(tools) ?? [:] }, + pluginChanges: services.pluginRegistrationChanges ?? Empty().eraseToAnyPublisher() )) } @@ -61,6 +64,9 @@ extension AppModel { if let warning = project.gitWarning { showNotification(String(format: L10n.string("The project was created, but Git could not be set up: %@"), warning), logReference: notificationRejection(operation: "project_creation", reason: "git_setup_failed")) } + if let warning = project.toolPinWarning { + showNotification(String(format: L10n.string("The project was created, but the tool you chose could not be saved for it: %@"), warning), logReference: notificationRejection(operation: "project_creation", reason: "tool_pin_failed")) + } openProject(project.url) } } diff --git a/macos/Sources/Lithe/Models/ProjectGeneration/ProjectGenerationModels.swift b/macos/Sources/Lithe/Models/ProjectGeneration/ProjectGenerationModels.swift index 49ecb57785..7dca658cd4 100644 --- a/macos/Sources/Lithe/Models/ProjectGeneration/ProjectGenerationModels.swift +++ b/macos/Sources/Lithe/Models/ProjectGeneration/ProjectGenerationModels.swift @@ -32,6 +32,8 @@ enum ProjectGeneratorSection: String, Decodable, Sendable { struct ProjectGeneratorField: Decodable, Equatable, Sendable, Identifiable { enum Kind: String, Decodable, Sendable { case text, `enum`, boolean, multiSelect, runtime + /// An installation of the tool `tool` names (projectGenerators 2). + case tool } enum Presentation: String, Decodable, Sendable { @@ -54,11 +56,13 @@ struct ProjectGeneratorField: Decodable, Equatable, Sendable, Identifiable { let placeholder: String? let optional: Bool let runtime: String? + /// The tool definition a `tool` field picks an installation of. + let tool: String? let minimumVersion: String? private enum CodingKeys: String, CodingKey { case id, kind = "type", title, description, advanced, options, presentation, placeholder, optional - case runtime, minimumVersion + case runtime, tool, minimumVersion } init(from decoder: Decoder) throws { @@ -73,6 +77,7 @@ struct ProjectGeneratorField: Decodable, Equatable, Sendable, Identifiable { placeholder = try values.decodeIfPresent(String.self, forKey: .placeholder) optional = try values.decodeIfPresent(Bool.self, forKey: .optional) ?? false runtime = try values.decodeIfPresent(String.self, forKey: .runtime) + tool = try values.decodeIfPresent(String.self, forKey: .tool) minimumVersion = try values.decodeIfPresent(String.self, forKey: .minimumVersion) } } @@ -115,6 +120,14 @@ struct ProjectGeneratorRequirement: Decodable, Equatable, Sendable { } } +/// The plugins that declare the tool one `tool` field selects. +struct ProjectToolProviders: Decodable, Equatable, Sendable { + let fieldID: String + /// Tool definition ID, as the generator wrote it. + let tool: String + let providers: [ProjectRequirementProvider] +} + /// One entry of `project.generatorCatalog`. struct ProjectGeneratorEntry: Decodable, Equatable, Sendable, Identifiable { struct Generator: Decodable, Equatable, Sendable { @@ -145,6 +158,8 @@ struct ProjectGeneratorEntry: Decodable, Equatable, Sendable, Identifiable { let pluginEnabled: Bool let generator: Generator let requirements: [ProjectGeneratorRequirement] + /// One element per `tool` field of the generator. + let tools: [ProjectToolProviders] let usesNetwork: Bool var id: String { "\(pluginID)/\(generator.id)" } @@ -171,6 +186,7 @@ struct CoreRejection: Decodable, Equatable, Sendable, Error { /// Why Rust Core does not accept a value. Hosts localize by code. enum ProjectFieldProblem: String, Decodable, Equatable, Sendable { case required, tooLong, invalidFormat, unsupportedValue, runtimeTooOld, versionTooHigh + case toolTooOld } /// A selected runtime installation of a runtime field. @@ -179,6 +195,14 @@ struct ProjectRuntimeSelection: Encodable, Equatable, Sendable { let version: String } +/// An installation of a tool the host found, offered to a `tool` field. Core +/// accepts only a `home` that is in this list. +struct ProjectToolInstallation: Codable, Equatable, Sendable { + let home: String + let executable: String + let version: String +} + /// What the user entered, sent to `project.generatorForm` and /// `project.generationPlan`. struct ProjectGeneratorFormInput: Encodable, Equatable, Sendable { @@ -187,9 +211,12 @@ struct ProjectGeneratorFormInput: Encodable, Equatable, Sendable { /// Text fields the user typed in; the others follow their defaults. var edited: Set = [] var runtimes: [String: ProjectRuntimeSelection] = [:] + /// The installations found for each `tool` field, by field ID. A tool + /// field's entry in `values` is the `home` of the one the user chose. + var tools: [String: [ProjectToolInstallation]] = [:] private enum CodingKeys: String, CodingKey { - case projectName, values, edited, runtimes + case projectName, values, edited, runtimes, tools } func encode(to encoder: Encoder) throws { @@ -198,6 +225,7 @@ struct ProjectGeneratorFormInput: Encodable, Equatable, Sendable { try container.encode(values, forKey: .values) try container.encode(edited.sorted(), forKey: .edited) try container.encode(runtimes, forKey: .runtimes) + try container.encode(tools, forKey: .tools) } } @@ -207,11 +235,34 @@ struct ProjectGeneratorFormState: Decodable, Equatable, Sendable { let problem: ProjectFieldProblem? } + /// An installation a `tool` field offers, with Core's verdict on its version. + struct ToolChoice: Decodable, Equatable, Sendable { + let home: String + let executable: String + let version: String + let meetsMinimum: Bool + } + struct FieldState: Decodable, Equatable, Sendable { let id: String let visible: Bool let value: ToolingJSONValue let problem: ProjectFieldProblem? + /// Only `tool` fields list installations; Core omits the key otherwise. + let installations: [ToolChoice] + + private enum CodingKeys: String, CodingKey { + case id, visible, value, problem, installations + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + visible = try values.decode(Bool.self, forKey: .visible) + value = try values.decode(ToolingJSONValue.self, forKey: .value) + problem = try values.decodeIfPresent(ProjectFieldProblem.self, forKey: .problem) + installations = try values.decodeIfPresent([ToolChoice].self, forKey: .installations) ?? [] + } } let projectName: NameState @@ -277,9 +328,21 @@ struct ProjectGenerationPlan: Decodable, Equatable, Sendable { let version: String } + /// The installation a `tool` field chose, to record in the project's tool + /// pins once the project exists. + struct ToolPin: Decodable, Equatable, Sendable { + let field: String + /// Tool definition ID, as the generator wrote it. + let tool: String + let pinAs: String + let home: String + let executable: String + } + let projectName: String let steps: [Step] let openFiles: [String] let runtimes: [Runtime] + let toolPins: [ToolPin] let usesNetwork: Bool } diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 30072b954d..76eceb0d55 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -1292,6 +1292,25 @@ final class MacServiceContainer { }, servers: MacRemoteServerBundleLocator.locate() ) + let installedPlugins = pluginStartup.registeredPlugins + // The plugins whose declarations apply right now. Ones registered at + // launch follow the plugin manager's enablement; ones that apply + // without a restart (generators, tool definitions) follow the live + // registry. It is read on every call, so a plugin installed, enabled or + // removed from the UI shows up in the next list at once. + let pluginsInEffect: @MainActor @Sendable () -> [(package: InstalledPluginPackage, isEnabled: Bool)] = { [pluginManager] in + let enabled = Set(pluginManager.snapshots.filter(\.isEnabled).map(\.id)) + let registered = installedPlugins.map { (package: $0, isEnabled: enabled.contains($0.descriptor.id)) } + let live = liveSnapshotBox.snapshot.entries.values + .sorted { $0.package.manifest.id < $1.package.manifest.id } + .map { (package: $0.package, isEnabled: $0.isEnabled) } + return registered + live + } + let toolchainPreferences = MacToolchainPreferencesStore(core: rustCore, storage: fileStorage, + immutableRoots: { [pluginPackageStore] in + [Bundle.main.bundleURL] + pluginPackageStore.runtimeReadOnlyDirectories + + pluginsInEffect().map(\.package.packageURL) + }) // New Project window. Plugin packages are read-only; projects are // written into a staging directory inside the folder the user chose. services.projectGeneration = ProjectGenerationService( @@ -1308,21 +1327,22 @@ final class MacServiceContainer { }, recordJavaHome: { root, home in try await runtimeService.presetJavaHome(home, for: root) + }, + saveToolPin: { root, pin in + try await MacProjectToolPins.save(pin, in: root, using: toolchainPreferences) } ) - let installedPlugins = pluginStartup.registeredPlugins services.loginShellEnvironmentInspector = MacLoginShellEnvironment.shared - let toolchainSources: @MainActor @Sendable () -> [ToolchainSettingsPluginSource] = { [pluginManager] in - let enabled = Set(pluginManager.snapshots.filter(\.isEnabled).map(\.id)) - return installedPlugins.map { plugin in + let toolchainSources: @MainActor @Sendable () -> [ToolchainSettingsPluginSource] = { + pluginsInEffect().map { plugin in ToolchainSettingsPluginSource( - pluginID: plugin.descriptor.id.rawValue, - name: plugin.manifest.displayName, - manifest: plugin.descriptor.normalizedManifest, - isEnabled: enabled.contains(plugin.descriptor.id), + pluginID: plugin.package.descriptor.id.rawValue, + name: plugin.package.manifest.displayName, + manifest: plugin.package.descriptor.normalizedManifest, + isEnabled: plugin.isEnabled, // Short tool IDs need an official catalog entry installed through a verified channel. - verifiedOfficial: OfficialPluginCatalog.trustPolicy(for: plugin.descriptor.id) != nil - && [.bundled, .marketplace].contains(plugin.installation.origin) + verifiedOfficial: OfficialPluginCatalog.trustPolicy(for: plugin.package.descriptor.id) != nil + && [.bundled, .marketplace].contains(plugin.package.installation.origin) ) } } @@ -1363,20 +1383,18 @@ final class MacServiceContainer { discover: { request in await toolchainDiscovery.discover(request) } ) services.toolchainInputWatcher = MacToolchainInputWatcher() - services.toolchainPreferences = MacToolchainPreferencesStore(core: rustCore, storage: fileStorage, - immutableRoots: { [pluginPackageStore] in - [Bundle.main.bundleURL] + pluginPackageStore.runtimeReadOnlyDirectories + installedPlugins.map(\.packageURL) - }) - services.projectGeneratorSources = { [pluginManager] in - // Enablement is read on every call, so a plugin enabled from the - // window shows its entries and satisfies requirements at once. - let enabled = Set(pluginManager.snapshots.filter(\.isEnabled).map(\.id)) - return installedPlugins.map { plugin in + services.toolchainPreferences = toolchainPreferences + services.projectToolInstallations = { tools in + await MacProjectToolInstallations(discover: { await toolchainDiscovery.discover($0) }) + .installations(of: tools, sources: toolchainSources()) + } + services.projectGeneratorSources = { + pluginsInEffect().map { plugin in ProjectGeneratorPluginSource( - pluginID: plugin.descriptor.id, - manifest: plugin.descriptor.normalizedManifest, - packageURL: plugin.packageURL, - isEnabled: enabled.contains(plugin.descriptor.id) + pluginID: plugin.package.descriptor.id, + manifest: plugin.package.descriptor.normalizedManifest, + packageURL: plugin.package.packageURL, + isEnabled: plugin.isEnabled ) } } diff --git a/macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolInstallations.swift b/macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolInstallations.swift new file mode 100644 index 0000000000..69d01222bb --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolInstallations.swift @@ -0,0 +1,65 @@ +import Foundation + +// Note: 新建项目向导与项目生成器见 .agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md + +/// Finds installations of tools for the New Project window, where there is no +/// project yet to resolve them against. +/// +/// It runs the toolchain discovery the settings pages use, with an empty +/// scratch directory standing in for the workspace. Discovery needs a real +/// directory because Core refuses homes inside it; an empty one matches +/// nothing, so only machine-wide installations are found. The directory lives +/// in the temporary directory and is removed afterwards, so nothing is written +/// to a plugin package, the app bundle, or a user workspace. +struct MacProjectToolInstallations: Sendable { + private let discover: @Sendable (MacToolchainDiscovery.Request) async -> MacToolchainDiscovery.Result + private let environment: @Sendable () -> [String: String] + private let scratchParent: URL + + init( + discover: @escaping @Sendable (MacToolchainDiscovery.Request) async -> MacToolchainDiscovery.Result, + environment: @escaping @Sendable () -> [String: String] = { + MacLoginShellEnvironment.shared.merged(ProcessInfo.processInfo.environment) + }, + scratchParent: URL = FileManager.default.temporaryDirectory + ) { + self.discover = discover + self.environment = environment + self.scratchParent = scratchParent + } + + /// The installations of each tool, best first. A tool no enabled plugin + /// defines, or one with nothing installed, maps to an empty list. + func installations( + of tools: [String], sources: [ToolchainSettingsPluginSource] + ) async -> [String: [ProjectToolInstallation]] { + let enabled = sources.filter(\.isEnabled) + guard !tools.isEmpty, !enabled.isEmpty, let scratch = makeScratchDirectory() else { return [:] } + defer { try? FileManager.default.removeItem(at: scratch) } + + let request = MacToolchainDiscovery.Request( + workspace: scratch, sources: enabled, environment: environment() + ) + let result = await discover(request) + + var found: [String: [ProjectToolInstallation]] = [:] + for tool in tools { + found[tool] = (result.detections[tool]?.installations ?? []).map { + ProjectToolInstallation(home: $0.home, executable: $0.executable, version: $0.version) + } + } + return found + } + + private func makeScratchDirectory() -> URL? { + let directory = scratchParent.appendingPathComponent( + "lithe-new-project-tools-\(UUID().uuidString)", isDirectory: true + ) + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + return directory + } catch { + return nil + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolPins.swift b/macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolPins.swift new file mode 100644 index 0000000000..d7cd1abdcc --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolPins.swift @@ -0,0 +1,29 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +// Note: 新建项目向导与项目生成器见 .agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md + +/// Records the installation a `tool` field chose in the new project's tool +/// pins, the same document the Toolchains settings page edits. +/// +/// Nothing new is stored: the pin is the workspace-level default of that tool in +/// `.lithe/run/local.json`, so the project resolves the tool the way the user +/// picked it from the first open, and the settings page shows and changes it. +@MainActor +enum MacProjectToolPins { + /// Writes `pin` into `project`. It reads the document first and saves + /// against that revision, so a file somebody else changed in between is + /// reported as a conflict and never overwritten. + static func save( + _ pin: ProjectGenerationPlan.ToolPin, + in project: URL, + using store: any ToolchainPreferencesStoring + ) async throws { + let snapshot = try await store.load(workspace: project) + // `pinAs: "project"` is the only pin kind a generator can ask for, and + // it is the workspace default: directory "." with no consumer. + let address = ToolchainPreferenceAddress(directory: ".", consumer: nil, tool: pin.tool) + _ = try await store.save(.object(["home": .string(pin.home)]), at: address, expecting: snapshot) + } +} diff --git a/macos/Sources/Lithe/Services/ProjectGeneration/ProjectGenerationService.swift b/macos/Sources/Lithe/Services/ProjectGeneration/ProjectGenerationService.swift index 0f3b72809f..dd52e62476 100644 --- a/macos/Sources/Lithe/Services/ProjectGeneration/ProjectGenerationService.swift +++ b/macos/Sources/Lithe/Services/ProjectGeneration/ProjectGenerationService.swift @@ -12,6 +12,10 @@ struct CreatedProject: Equatable, Sendable { /// Set when the project exists but its Git repository could not be /// created; the project is still opened. let gitWarning: String? + /// Set when the project exists but the tool installations its generator + /// chose could not be saved as the project's pins; the project is still + /// opened and resolves those tools as it would without a pin. + var toolPinWarning: String? = nil } /// Runs what Rust Core planned for the New Project window. Every step @@ -24,21 +28,26 @@ final class ProjectGenerationService: Sendable { private let platform: any ProjectGenerationPlatform private let initializeGitRepository: @Sendable (URL) -> String? private let recordJavaHome: @Sendable (URL, String) async throws -> Void + private let saveToolPin: @Sendable (URL, ProjectGenerationPlan.ToolPin) async throws -> Void /// - Parameters: /// - initializeGitRepository: Creates a repository at the URL and /// returns an error message, or nil on success. /// - recordJavaHome: Stores a JDK as the new project's runtime. + /// - saveToolPin: Stores the installation a `tool` field chose in the + /// project at the URL, where every later tool resolution reads it. init( planner: any ProjectGeneratorPlanning, platform: any ProjectGenerationPlatform, initializeGitRepository: @escaping @Sendable (URL) -> String?, - recordJavaHome: @escaping @Sendable (URL, String) async throws -> Void + recordJavaHome: @escaping @Sendable (URL, String) async throws -> Void, + saveToolPin: @escaping @Sendable (URL, ProjectGenerationPlan.ToolPin) async throws -> Void = { _, _ in } ) { self.planner = planner self.platform = platform self.initializeGitRepository = initializeGitRepository self.recordJavaHome = recordJavaHome + self.saveToolPin = saveToolPin } /// The host's own empty project: a directory, optionally with Git. @@ -100,13 +109,31 @@ final class ProjectGenerationService: Sendable { for runtime in plan.runtimes where runtime.runtime == "java" { try await recordJavaHome(destination, runtime.home) } + let toolPinWarning = await pinTools(of: plan, in: destination) return CreatedProject( url: destination, openFiles: plan.openFiles.map { destination.appendingPathComponent($0) }, - gitWarning: createGitRepository ? initializeGitRepository(destination) : nil + gitWarning: createGitRepository ? initializeGitRepository(destination) : nil, + toolPinWarning: toolPinWarning ) } + /// Records the chosen tool installations in the new project. The project is + /// complete by now, so a failure is reported next to it instead of undoing + /// it; the user can pin the tool later in the project's settings. + /// - Returns: The first failure's message, or nil when every pin was saved. + private func pinTools(of plan: ProjectGenerationPlan, in destination: URL) async -> String? { + var firstFailure: String? + for pin in plan.toolPins { + do { + try await saveToolPin(destination, pin) + } catch { + firstFailure = firstFailure ?? error.localizedDescription + } + } + return firstFailure + } + private func run( _ step: ProjectGenerationPlan.Step, in staging: URL, @@ -152,6 +179,7 @@ final class ProjectGenerationService: Sendable { case .invalidFormat: L10n.string("The value is not in the expected format.") case .unsupportedValue: L10n.string("Choose one of the offered values.") case .runtimeTooOld: L10n.string("This runtime is older than the generator requires.") + case .toolTooOld: L10n.string("This installation is older than the generator requires.") case .versionTooHigh: L10n.string("This version is newer than the selected runtime supports.") } } diff --git a/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift b/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift index faa286e974..565226d645 100644 --- a/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift +++ b/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift @@ -347,6 +347,8 @@ struct NewProjectView: View { ) { feature.setValue(.array($0.map(ToolingJSONValue.string)), for: field) } case .runtime: runtimeControl(field) + case .tool: + toolControl(field) case .boolean: EmptyView() } @@ -381,6 +383,68 @@ struct NewProjectView: View { } } + /// Picks one installation of the tool the field names. Core marks the one + /// the form uses; when none is found the field says why and what to do. + @ViewBuilder private func toolControl(_ field: ProjectGeneratorField) -> some View { + let choices = feature.toolChoices(for: field) + if !choices.isEmpty { + LitheSettingsSelect( + selection: Binding( + get: { feature.value(of: field).stringValue }, + set: { feature.setValue(.string($0), for: field) } + ), + options: choices.map(\.home), + width: 420, + accessibilityLabel: field.title, + title: { home in + choices.first(where: { $0.home == home }).map(Self.toolChoiceTitle) ?? home + }, + localizesTitles: false + ) + } else if feature.isListingTools { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text(LocalizedStringKey("Looking for installed tools…")) + .foregroundStyle(LitheTheme.secondaryText) + } + } else { + toolMissing(field) + } + } + + /// No installation was found. Without a plugin that defines the tool, the + /// notice offers to enable or find one; otherwise the tool is just absent. + @ViewBuilder private func toolMissing(_ field: ProjectGeneratorField) -> some View { + switch feature.toolNoticeAction(for: field) { + case .enable(let pluginID, let name): + pluginNotice( + Text(String(format: L10n.string("%@ is needed for this project but %@ is disabled."), field.title, name)), + actionTitle: "Enable", + pluginID: pluginID + ) + case .findInMarketplace(let query): + HStack(spacing: 8) { + Image(systemName: "info.circle") + .foregroundStyle(LitheTheme.secondaryText) + Text(String(format: L10n.string("No installed plugin supports %@ yet."), field.title)) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 8) + Button(LocalizedStringKey("Find in Marketplace")) { feature.openMarketplace(searching: query) } + .buttonStyle(LitheActionButtonStyle()) + .fixedSize() + } + .modifier(NoticeChrome()) + case nil: + Text(LocalizedStringKey("No installation of this tool was found.")) + .foregroundStyle(LitheTheme.secondaryText) + } + } + + private static func toolChoiceTitle(_ choice: ProjectGeneratorFormState.ToolChoice) -> String { + let title = "\(choice.version) — \((choice.home as NSString).abbreviatingWithTildeInPath)" + return choice.meetsMinimum ? title : String(format: L10n.string("%@ (older than required)"), title) + } + @ViewBuilder private var progress: some View { if let error = feature.errorMessage { notice(error, systemImage: "exclamationmark.triangle.fill", color: LitheTheme.error) diff --git a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift index 9131612bac..9c873e18b1 100644 --- a/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift +++ b/macos/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -527,7 +527,7 @@ public struct PluginManifest: Equatable, Codable, Sendable { "executionProviders": [1], "languageServers": [1, 2], "languages": [1, 2], - "projectGenerators": [1], + "projectGenerators": [1, 2], "serverProcess": [1], "settings": [1, 2], "toolDefinitions": [1], diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 0bb6ed87a3..2ad6ffded2 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -4937,6 +4937,7 @@ "native.logMessage": "Log message", "native.logs": "Logs", "native.lookingForGit": "Looking for Git…", + "native.lookingForInstalledTools": "Looking for installed tools…", "native.low": "Low", "native.lowEffortAndASmallOutputLimitAreRecommendedForFastCommitMessageGeneration": "Low effort and a small output limit are recommended for fast commit-message generation.", "native.lowRecommended": "Low (recommended)", @@ -5169,6 +5170,7 @@ "native.noGitRepositoryIsOpen": "No Git repository is open", "native.noHistoryRecordedYet": "No history recorded yet", "native.noImplementationsFound": "No implementations found", + "native.noInstallationOfThisToolWasFound": "No installation of this tool was found.", "native.noJDKFoundSetJAVAHOMEInstallAJDKOrChooseAJDKDirectory": "No JDK found. Set JAVA_HOME, install a JDK, or choose a JDK directory.", "native.noJDKIsConfiguredForThisProject": "No JDK is configured for this project.", "native.noJVMPerformanceSamplesReceivedFor10Seconds": "No JVM performance samples received for 10 seconds.", @@ -5288,6 +5290,7 @@ "native.official": "Official", "native.olderCommitsAreNotListed": "Older commits are not listed.", "native.olderCommitsAreOutsideTheLoadedHistory": "Older commits are outside the loaded history", + "native.olderThanRequired": "{arg1} (older than required)", "native.on": "On", "native.oneArgumentPerLineArgumentsKeepSpacesWithoutQuoting": "One argument per line. Arguments keep spaces without quoting.", "native.oneFile": "One file: {arg1}", @@ -6309,6 +6312,7 @@ "native.thePrimaryWorktreeCannotBeLocked": "The primary worktree cannot be locked.", "native.thePrimaryWorktreeCannotBeRemoved": "The primary worktree cannot be removed.", "native.theProjectModelIsNotLoaded": "The project model is not loaded.", + "native.theProjectWasCreatedButTheToolYouChoseCouldNotBeSavedForIt": "The project was created, but the tool you chose could not be saved for it: {arg1}", "native.thePublishedUpdateManifestIsInvalidAndCannotBeTrustedOpenTheReleasePageAndDowna6815c0bf9": "The published update manifest is invalid and cannot be trusted. Open the Release page and download the update manually.", "native.thePullRequestWillReturnToTheOpenListAndCanReceiveNewReviews": "The pull request will return to the open list and can receive new reviews.", "native.theRebaseWasAbortedInspectTheBranchAndWorkingFilesBeforeContinuingYourWork": "The rebase was aborted. Inspect the branch and working files before continuing your work.", @@ -6380,6 +6384,7 @@ "native.thisFileIsOutsideTheCurrentProject": "This file is outside the current project.", "native.thisFileWasDeletedOutsideLitheYourEditorContentIsPreserved": "This file was deleted outside Lithe. Your editor content is preserved.", "native.thisFolderAlreadyBelongsToAGitRepository": "This folder already belongs to a Git repository", + "native.thisInstallationIsOlderThanTheGeneratorRequires": "This installation is older than the generator requires.", "native.thisMac": "This Mac", "native.thisMacOnly": "This Mac only", "native.thisPage": "This page", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index c8e8fce707..7419e8b8b8 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -4937,6 +4937,7 @@ "native.logMessage": "日志消息", "native.logs": "日志", "native.lookingForGit": "正在查找 Git…", + "native.lookingForInstalledTools": "正在查找已安装的工具…", "native.low": "低", "native.lowEffortAndASmallOutputLimitAreRecommendedForFastCommitMessageGeneration": "为了快速生成提交信息,建议使用低推理强度和较小的输出限制。", "native.lowRecommended": "低(推荐)", @@ -5169,6 +5170,7 @@ "native.noGitRepositoryIsOpen": "未打开 Git 仓库", "native.noHistoryRecordedYet": "还没有历史记录", "native.noImplementationsFound": "没有找到实现", + "native.noInstallationOfThisToolWasFound": "未找到此工具的安装。", "native.noJDKFoundSetJAVAHOMEInstallAJDKOrChooseAJDKDirectory": "未找到 JDK。请设置 JAVA_HOME、安装 JDK,或选择 JDK 目录。", "native.noJDKIsConfiguredForThisProject": "此项目没有配置 JDK。", "native.noJVMPerformanceSamplesReceivedFor10Seconds": "10 秒内未收到 JVM 性能采样。", @@ -5288,6 +5290,7 @@ "native.official": "官方", "native.olderCommitsAreNotListed": "更早的提交未列出。", "native.olderCommitsAreOutsideTheLoadedHistory": "更早的提交不在当前加载的历史范围内", + "native.olderThanRequired": "{arg1}(低于要求的版本)", "native.on": "开启", "native.oneArgumentPerLineArgumentsKeepSpacesWithoutQuoting": "每行一个参数。参数中的空格无需额外引号。", "native.oneFile": "单个文件:{arg1}", @@ -6309,6 +6312,7 @@ "native.thePrimaryWorktreeCannotBeLocked": "主工作区不能锁定。", "native.thePrimaryWorktreeCannotBeRemoved": "主工作区不能删除。", "native.theProjectModelIsNotLoaded": "项目模型尚未加载。", + "native.theProjectWasCreatedButTheToolYouChoseCouldNotBeSavedForIt": "项目已创建,但无法为它保存你选择的工具:{arg1}", "native.thePublishedUpdateManifestIsInvalidAndCannotBeTrustedOpenTheReleasePageAndDowna6815c0bf9": "已发布的更新清单无效,无法信任。请打开 Release 页面手动下载更新。", "native.thePullRequestWillReturnToTheOpenListAndCanReceiveNewReviews": "该拉取请求将回到打开列表,并可继续接收审查。", "native.theRebaseWasAbortedInspectTheBranchAndWorkingFilesBeforeContinuingYourWork": "变基已中止。继续工作前请检查分支和工作文件。", @@ -6380,6 +6384,7 @@ "native.thisFileIsOutsideTheCurrentProject": "此文件不在当前项目中。", "native.thisFileWasDeletedOutsideLitheYourEditorContentIsPreserved": "该文件已被外部删除,编辑器内容已保留。", "native.thisFolderAlreadyBelongsToAGitRepository": "此目录已属于一个 Git 仓库。", + "native.thisInstallationIsOlderThanTheGeneratorRequires": "此安装版本低于模板要求。", "native.thisMac": "本机", "native.thisMacOnly": "仅本机", "native.thisPage": "此页面", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 30c54a1c33..9a2e1fb4d7 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -44285,6 +44285,17 @@ "arguments": [] } }, + { + "id": "native.lookingForInstalledTools", + "description": "Native UI: Looking for installed tools…", + "platforms": [ + "macos" + ], + "macos": { + "key": "Looking for installed tools…", + "arguments": [] + } + }, { "id": "native.low", "description": "Native UI: Low", @@ -46885,6 +46896,17 @@ "arguments": [] } }, + { + "id": "native.noInstallationOfThisToolWasFound", + "description": "Native UI: No installation of this tool was found.", + "platforms": [ + "macos" + ], + "macos": { + "key": "No installation of this tool was found.", + "arguments": [] + } + }, { "id": "native.noJDKFoundSetJAVAHOMEInstallAJDKOrChooseAJDKDirectory", "description": "Native UI: No JDK found. Set JAVA_HOME, install a JDK, or choose a JDK directory.", @@ -48218,6 +48240,22 @@ "arguments": [] } }, + { + "id": "native.olderThanRequired", + "description": "Native UI: %@ (older than required)", + "platforms": [ + "macos" + ], + "macos": { + "key": "%@ (older than required)", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, { "id": "native.on", "description": "Native UI: On", @@ -59815,6 +59853,22 @@ "arguments": [] } }, + { + "id": "native.theProjectWasCreatedButTheToolYouChoseCouldNotBeSavedForIt", + "description": "Native UI: The project was created, but the tool you chose could not be saved for it: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "The project was created, but the tool you chose could not be saved for it: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, { "id": "native.thePublishedUpdateManifestIsInvalidAndCannotBeTrustedOpenTheReleasePageAndDowna6815c0bf9", "description": "Native UI: The published update manifest is invalid and cannot be trusted. Open the Release page and download the update manually.", @@ -60590,6 +60644,17 @@ "arguments": [] } }, + { + "id": "native.thisInstallationIsOlderThanTheGeneratorRequires", + "description": "Native UI: This installation is older than the generator requires.", + "platforms": [ + "macos" + ], + "macos": { + "key": "This installation is older than the generator requires.", + "arguments": [] + } + }, { "id": "native.thisMac", "description": "Native UI: This Mac", From 54cbbb74b6e309a2774dabeb70eca6f207187b95 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 4 Nov 2026 22:41:35 +0800 Subject: [PATCH 05/11] =?UTF-8?q?feat(plugins):=20=E5=8F=AA=E5=90=AB?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E7=94=9F=E6=88=90=E5=99=A8=E6=88=96=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=AE=9A=E4=B9=89=E7=9A=84=E6=8F=92=E4=BB=B6=E5=85=8D?= =?UTF-8?q?=E9=87=8D=E5=90=AF=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - canActivateWithoutRestart 放行 projectGeneratorsExtension 和 toolchainsExtension:生成器清单和工具定义是宿主每次用到时才读取的数据,不依赖启动时构建的模块图 - 含语言服务器、构建等其他贡献或后端的插件(例如 Java Support)判断不变,仍然重启后生效 Refs #1495 --- .../Plugins/DeclarativePluginContracts.swift | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift index b39158a566..5437d5c478 100644 --- a/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift +++ b/macos/Sources/LitheCoreContracts/Plugins/DeclarativePluginContracts.swift @@ -898,15 +898,22 @@ public struct DeclarativePluginDescriptor: Equatable, Sendable { } /// Whether the plugin can take effect in a running session. That holds when - /// debug adapters and language grammars are its only contributions: the - /// host reads adapters on demand when a debug session starts and hands - /// grammars to open editors when the registry changes, so the module graph, + /// debug adapters, language grammars, project generators and tool + /// definitions are its only contributions: the host reads adapters on demand + /// when a debug session starts, hands grammars to open editors when the + /// registry changes, lists generators when the New Project window loads, and + /// reads tool definitions each time a tool is resolved, so the module graph, /// the language-server and run/test modules, backend processes and UI /// composition built at launch do not change. Anything else waits for the /// next launch. public var canActivateWithoutRestart: Bool { let modules = pluginManifest.modules - let live: Set = [.debugAdaptersExtension(id.rawValue), .grammarsExtension(id.rawValue)] + let live: Set = [ + .debugAdaptersExtension(id.rawValue), + .grammarsExtension(id.rawValue), + .projectGeneratorsExtension(id.rawValue), + .toolchainsExtension(id.rawValue), + ] return !modules.isEmpty && !declaresBackend && modules.allSatisfy { live.contains($0.manifest.id) && $0.contributions.isEmpty } From bddb3749ac75767343b7cc12676508041fbc25b8 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 4 Nov 2026 22:41:41 +0800 Subject: [PATCH 06/11] =?UTF-8?q?test(macos):=20=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=AD=97=E6=AE=B5=E3=80=81=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=20pin=20=E9=80=82=E9=85=8D=E5=99=A8=E5=92=8C=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=99=A8=E6=8F=92=E4=BB=B6=E5=85=8D=E9=87=8D=E5=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProjectGenerationTests:工具字段的目录候选、表单版本检查、计划里的工具 pin 与命令展开、创建后写 pin、pin 写失败的提示、没有工具定义时的市场提示、插件变化后重新载入 - ProjectToolAdaptersTests:临时目录里的工具发现与清理,工具 pin 写入设置页同一个存储且不覆盖已有 pin - PluginLiveActivationTests、ToolchainPluginAdmissionTests:生成器和工具定义插件满足免重启谓词,安装与卸载即时生效;工具链 provider 仍需重启 Refs #1495 --- .../PluginLiveActivationTests.swift | 99 +++++- .../LitheTests/ProjectGenerationTests.swift | 324 +++++++++++++++++- .../LitheTests/ProjectToolAdaptersTests.swift | 153 +++++++++ .../ToolchainPluginAdmissionTests.swift | 6 +- 4 files changed, 571 insertions(+), 11 deletions(-) create mode 100644 macos/Tests/LitheTests/ProjectToolAdaptersTests.swift diff --git a/macos/Tests/LitheTests/PluginLiveActivationTests.swift b/macos/Tests/LitheTests/PluginLiveActivationTests.swift index 4344c93a6f..b86ac96425 100644 --- a/macos/Tests/LitheTests/PluginLiveActivationTests.swift +++ b/macos/Tests/LitheTests/PluginLiveActivationTests.swift @@ -68,12 +68,29 @@ struct PluginLiveActivationTests { @Test(arguments: [ "language-packaged-server", "language-system-command", "build-provider", - "vcs-provider", "hello-lithe", "project-generator" + "vcs-provider", "hello-lithe" ]) func pluginsWithOtherContributionsStillNeedARestart(_ fixture: String) throws { - let url = DeclarativePluginTestSupport.repositoryRoot - .appendingPathComponent("shared/contracts/plugins/v1/fixtures/\(fixture).plugin.json") - let descriptor = try DeclarativePluginTestSupport.operations.validate(manifest: Data(contentsOf: url)) + let descriptor = try Self.fixtureDescriptor(fixture) + #expect(!descriptor.canActivateWithoutRestart) + } + + /// Generators are listed when the New Project window loads, and tool + /// definitions are read each time a tool is resolved, so neither needs the + /// module graph built at launch. + @Test(arguments: ["project-generator", "project-generator-tool-field", "tool-definition"]) + func generatorsAndToolDefinitionsActivateWithoutARestart(_ fixture: String) throws { + let descriptor = try Self.fixtureDescriptor(fixture) + #expect(!descriptor.declaresBackend) + #expect(descriptor.canActivateWithoutRestart) + } + + /// A toolchain provider is a backend process, which only starts with the + /// module graph. + @Test + func aToolchainProviderStillNeedsARestart() throws { + let descriptor = try Self.fixtureDescriptor("toolchain-provider") + #expect(descriptor.declaresBackend) #expect(!descriptor.canActivateWithoutRestart) } @@ -396,6 +413,39 @@ struct PluginLiveActivationTests { #expect(snapshot.isEnabled) } + @Test + func installingAGeneratorPluginActivatesItAtOnce() async throws { + let fixture = try LiveFixture(acceptance: .startupLoader) + defer { fixture.cleanUp() } + let manager = fixture.makeManager() + let source = try fixture.writeSource(Self.generatorManifest(), name: "generator") + + let arrived = try await fixture.awaitChange { try manager.installPackage(at: source) } + + #expect(arrived) + // The New Project window lists it from this registry, with no restart. + let entry = try #require(fixture.registry.snapshot.entries[Self.generatorPluginID]) + #expect(entry.isEnabled) + let snapshot = try #require(manager.snapshots.first { $0.id == Self.generatorPluginID }) + #expect(snapshot.installationStatus == .installed) + #expect(!snapshot.requiresRestart) + #expect(!snapshot.hasChangesPendingRestart) + } + + @Test + func uninstallingAGeneratorPluginRemovesItFromTheRegistryAtOnce() async throws { + let fixture = try LiveFixture(acceptance: .startupLoader) + defer { fixture.cleanUp() } + let manager = fixture.makeManager() + let source = try fixture.writeSource(Self.generatorManifest(), name: "generator") + _ = try await fixture.awaitChange { try manager.installPackage(at: source) } + + let removed = try await fixture.awaitChange { try await manager.uninstall(Self.generatorPluginID) } + + #expect(removed) + #expect(fixture.registry.snapshot.entries[Self.generatorPluginID] == nil) + } + @Test func installingAnyOtherPluginStillWaitsForARestart() throws { let fixture = try LiveFixture(acceptance: .startupLoader) @@ -492,6 +542,47 @@ struct PluginLiveActivationTests { // MARK: - Fixtures + private static let generatorPluginID = PluginID("dev.example.generator") + + private static func fixtureDescriptor(_ name: String) throws -> DeclarativePluginDescriptor { + let url = DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/\(name).plugin.json") + return try DeclarativePluginTestSupport.operations.validate(manifest: Data(contentsOf: url)) + } + + /// A generator-only plugin: one command step, no templates to ship. + private static func generatorManifest() -> [String: Any] { + [ + "schemaVersion": 2, + "id": generatorPluginID.rawValue, + "displayName": "Example Generator", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": ["projectGenerators": 2], + "vendor": [ + "id": "dev.example", + "displayName": "Example", + "signatureRequirement": PluginSignatureRequirement.publisherPackage.rawValue + ], + "permissions": ["project.generate"], + "activationEvents": [], + "projectGenerators": [[ + "id": "app", + "displayName": "App", + "section": "generator", + "fields": [ + ["id": "sdk", "type": "tool", "title": "SDK", "tool": "dev.example.generator:sdk", "pinAs": "project"] + ], + "steps": [[ + "kind": "command", + "command": "example-init", + "arguments": ["--sdk", "${field.sdk.executable}"], + "mayUseNetwork": false + ]] + ]] + ] + } + private static func debugManifest( id: String = pluginID.rawValue, version: PluginVersion = PluginVersion(major: 0, minor: 1, patch: 0), diff --git a/macos/Tests/LitheTests/ProjectGenerationTests.swift b/macos/Tests/LitheTests/ProjectGenerationTests.swift index da9c43d3f2..7b3b4a3364 100644 --- a/macos/Tests/LitheTests/ProjectGenerationTests.swift +++ b/macos/Tests/LitheTests/ProjectGenerationTests.swift @@ -1,3 +1,4 @@ +import Combine import Foundation import LitheCoreContracts import LitheModuleAPI @@ -8,6 +9,18 @@ import Testing @MainActor struct ProjectGenerationTests { private static let javaSupportDirectory = "JavaSupport" + /// The published projectGenerators 2 fixture: `python-app` asks for a + /// `python` tool of at least 3.11, `node-app` for an optional `node` tool. + private static let toolFieldPluginID = PluginID("dev.lithe.fixture.project-generator-tool-field") + /// The published fixture that defines a tool, for tests where a generator + /// points its tool field at a tool another plugin defines. + private static let toolDefinitionPluginID = PluginID("dev.lithe.fixture.java-support") + private static let python310 = ProjectToolInstallation( + home: "/opt/python-3.10", executable: "/opt/python-3.10/bin/python3", version: "3.10.14" + ) + private static let python312 = ProjectToolInstallation( + home: "/opt/python-3.12", executable: "/opt/python-3.12/bin/python3", version: "3.12.4" + ) /// Generator-only copy of Java Support's entries, so window tests can /// keep the template plugin and the language provider apart the way a /// community template plugin would be. @@ -319,6 +332,217 @@ struct ProjectGenerationTests { #expect(feature.value(of: artifact) == .string("billing-api")) } + // MARK: Tool fields: Rust Core + + @Test + func catalogNamesThePluginsThatDefineEachTool() throws { + let catalog = try Self.planner.catalog([try Self.toolFieldSource(enabled: true)]) + #expect(catalog.generators.map(\.generator.id) == ["python-app", "node-app"]) + // Nothing installed defines either tool. + #expect(catalog.generators[0].tools == [ProjectToolProviders(fieldID: "python", tool: "python", providers: [])]) + #expect(catalog.generators[1].tools == [ProjectToolProviders(fieldID: "node", tool: "node", providers: [])]) + } + + @Test + func toolFieldListsTheInstallationsAndFlagsOldOnes() throws { + let target = try Self.toolFieldTarget("python-app") + var input = ProjectGeneratorFormInput(projectName: "demo") + input.tools["python"] = [Self.python310, Self.python312] + + let form = try Self.planner.form(target, input: input) + let python = try #require(form.field("python")) + #expect(python.installations.map(\.home) == [Self.python310.home, Self.python312.home]) + #expect(python.installations.map(\.meetsMinimum) == [false, true]) + // Without a choice the field holds the newest installation. + #expect(python.value == .string(Self.python312.home)) + #expect(form.valid) + + input.values["python"] = .string(Self.python310.home) + let old = try Self.planner.form(target, input: input) + #expect(old.field("python")?.problem == .toolTooOld) + #expect(!old.valid) + } + + @Test + func aToolFieldWithoutInstallationsIsRequiredUnlessOptional() throws { + let input = ProjectGeneratorFormInput(projectName: "demo") + + let required = try Self.planner.form(try Self.toolFieldTarget("python-app"), input: input) + #expect(required.field("python")?.problem == .required) + #expect(required.field("python")?.installations == []) + #expect(!required.valid) + + let optional = try Self.planner.form(try Self.toolFieldTarget("node-app"), input: input) + #expect(optional.field("node")?.problem == nil) + #expect(optional.valid) + } + + @Test + func theCommandsGetTheChosenInstallationAndItIsPinned() throws { + var input = ProjectGeneratorFormInput(projectName: "demo") + input.tools["python"] = [Self.python312] + + let plan = try Self.planner.plan(try Self.toolFieldTarget("python-app"), input: input) + + #expect(plan.toolPins == [ + ProjectGenerationPlan.ToolPin( + field: "python", tool: "python", pinAs: "project", + home: Self.python312.home, executable: Self.python312.executable + ) + ]) + let commands = plan.steps.compactMap { step -> ProjectGenerationPlan.Command? in + if case .command(let command) = step { return command } + return nil + } + let command = try #require(commands.first) + #expect(command.arguments == ["venv", "--python", Self.python312.executable]) + #expect(command.environment["PYTHON_HOME"] == Self.python312.home) + } + + @Test + func anOptionalToolWithoutInstallationIsNotPinned() throws { + let plan = try Self.planner.plan( + try Self.toolFieldTarget("node-app"), input: ProjectGeneratorFormInput(projectName: "demo") + ) + #expect(plan.toolPins.isEmpty) + } + + // MARK: Tool fields: creating projects + + @Test + func savesTheChosenToolInstallationInTheNewProject() async throws { + let directory = try TemporaryDirectory() + defer { directory.remove() } + let pins = ToolPinRecorder() + let runner = ScaffoldingRunner(creates: nil) + let service = Self.service(runner: runner, saveToolPin: { root, pin in + await pins.record(root: root, pin: pin) + }) + var input = ProjectGeneratorFormInput(projectName: "demo") + input.tools["python"] = [Self.python312] + + let project = try await service.create( + try Self.toolFieldTarget("python-app"), input: input, in: directory.url, createGitRepository: false + ) + + #expect(project.toolPinWarning == nil) + let saved = await pins.saved + #expect(saved.map(\.root) == [project.url.path]) + #expect(saved.map(\.pin.tool) == ["python"]) + #expect(saved.map(\.pin.home) == [Self.python312.home]) + // The step that uses the tool ran with the chosen installation. + let request = try #require(runner.requests.first) + #expect(request.arguments == ["venv", "--python", Self.python312.executable]) + #expect(request.environment["PYTHON_HOME"] == Self.python312.home) + } + + @Test + func aFailedPinIsReportedWithoutUndoingTheProject() async throws { + let directory = try TemporaryDirectory() + defer { directory.remove() } + let service = Self.service(saveToolPin: { _, _ in + throw ProjectGenerationError(message: "local.json is locked") + }) + var input = ProjectGeneratorFormInput(projectName: "demo") + input.tools["python"] = [Self.python312] + + let project = try await service.create( + try Self.toolFieldTarget("python-app"), input: input, in: directory.url, createGitRepository: false + ) + + #expect(project.toolPinWarning == "local.json is locked") + #expect(FileManager.default.fileExists(atPath: project.url.appendingPathComponent("README.md").path)) + } + + // MARK: Tool fields: window + + @Test + func aToolFieldOffersWhatTheHostFound() async throws { + let requests = ToolListingRequests() + let feature = NewProjectFeatureModel(environment: try Self.environment( + plugins: PluginStates(enabled: [Self.toolFieldPluginID]), + sources: [try Self.toolFieldSource(enabled: true)], + toolInstallations: { tools in + requests.record(tools) + return ["python": [Self.python310, Self.python312]] + } + )) + await feature.load() + let python = try #require(feature.selectedEntry?.generator.fields.first { $0.id == "python" }) + await Self.waitUntil("the python installations") { feature.toolChoices(for: python).count == 2 } + + #expect(requests.requested.first == ["python"]) + #expect(feature.toolChoices(for: python).map(\.meetsMinimum) == [false, true]) + #expect(feature.value(of: python) == .string(Self.python312.home)) + #expect(feature.canCreate) + + // An installation below the minimum can be picked, and blocks creating. + feature.setValue(.string(Self.python310.home), for: python) + #expect(feature.problem(of: python) == .toolTooOld) + #expect(!feature.canCreate) + } + + @Test + func aToolNobodyDefinesSendsTheUserToTheMarketplace() async throws { + let feature = NewProjectFeatureModel(environment: try Self.environment( + plugins: PluginStates(enabled: [Self.toolFieldPluginID]), + sources: [try Self.toolFieldSource(enabled: true)] + )) + await feature.load() + let python = try #require(feature.selectedEntry?.generator.fields.first { $0.id == "python" }) + await Self.waitUntil("the tool lookup") { !feature.isListingTools } + + #expect(feature.toolChoices(for: python).isEmpty) + #expect(feature.toolNoticeAction(for: python) == .findInMarketplace("python")) + #expect(!feature.canCreate) + } + + @Test + func aDisabledPluginThatDefinesTheToolIsOfferedForEnabling() async throws { + let plugins = PluginStates(enabled: [Self.toolFieldPluginID]) + let feature = NewProjectFeatureModel(environment: try Self.environment( + plugins: plugins, + sources: [try Self.toolFieldSource(tool: "java", enabled: true), try Self.toolDefinitionSource()] + )) + await feature.load() + let field = try #require(feature.selectedEntry?.generator.fields.first { $0.id == "python" }) + await Self.waitUntil("the tool lookup") { !feature.isListingTools } + #expect(feature.toolNoticeAction(for: field) + == .enable(Self.toolDefinitionPluginID, name: "Tool definition fixture")) + + // Once the defining plugin is on, the tool is merely not installed. + plugins.enabled.insert(Self.toolDefinitionPluginID) + feature.reloadCatalog() + await Self.waitUntil("the tool lookup") { !feature.isListingTools } + #expect(feature.toolNoticeAction(for: field) == nil) + } + + @Test + func aPluginChangeReloadsTheEntriesAndLooksForToolsAgain() async throws { + let plugins = PluginStates(enabled: []) + let changes = PassthroughSubject() + let requests = ToolListingRequests() + let feature = NewProjectFeatureModel(environment: try Self.environment( + plugins: plugins, + sources: [try Self.toolFieldSource(enabled: true)], + toolInstallations: { tools in + requests.record(tools) + return [:] + }, + pluginChanges: changes.eraseToAnyPublisher() + )) + await feature.load() + await Self.waitUntil("the first tool lookup") { requests.requested.count == 1 } + #expect(feature.entries.first?.pluginEnabled == false) + + plugins.enabled.insert(Self.toolFieldPluginID) + changes.send() + await Self.mainQueueTurn() + + #expect(feature.entries.first?.pluginEnabled == true) + await Self.waitUntil("the second tool lookup") { requests.requested.count == 2 } + } + // MARK: Helpers private static var planner: RustProjectGeneratorOperations { @@ -398,26 +622,93 @@ struct ProjectGenerationTests { private static func platform(runner: any BuildCommandRunning = ScaffoldingRunner(creates: nil)) -> MacProjectGenerationPlatform { MacProjectGenerationPlatform( environment: { $0.merging(["PATH": "/usr/bin:/bin"]) { override, _ in override } }, - executable: { $0 == "mvn" ? URL(fileURLWithPath: "/usr/local/bin/mvn") : nil }, + // The scaffolding tools the tests' generators run by name. + executable: { ["mvn", "uv"].contains($0) ? URL(fileURLWithPath: "/usr/local/bin/\($0)") : nil }, runner: runner ) } private static func service( runner: any BuildCommandRunning = ScaffoldingRunner(creates: nil), - recorder: JavaHomeRecorder = JavaHomeRecorder() + recorder: JavaHomeRecorder = JavaHomeRecorder(), + saveToolPin: @escaping @Sendable (URL, ProjectGenerationPlan.ToolPin) async throws -> Void = { _, _ in } ) -> ProjectGenerationService { ProjectGenerationService( planner: planner, platform: platform(runner: runner), initializeGitRepository: { _ in nil }, - recordJavaHome: { root, home in await recorder.record(root: root, home: home) } + recordJavaHome: { root, home in await recorder.record(root: root, home: home) }, + saveToolPin: saveToolPin + ) + } + + // MARK: Tool field fixtures + + private static var fixturesURL: URL { + DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures", isDirectory: true) + } + + /// The tool-field fixture's `plugin.json`. `tool` points its `python` field + /// at another tool, to test fields whose tool a different plugin defines. + private static func toolFieldManifest(tool: String? = nil) throws -> Data { + let text = try String( + contentsOf: fixturesURL.appendingPathComponent("project-generator-tool-field.plugin.json"), + encoding: .utf8 + ) + guard let tool else { return Data(text.utf8) } + return Data(text.replacingOccurrences(of: "\"tool\": \"python\"", with: "\"tool\": \"\(tool)\"").utf8) + } + + private static func toolFieldSource(tool: String? = nil, enabled: Bool) throws -> ProjectGeneratorPluginSource { + ProjectGeneratorPluginSource( + pluginID: toolFieldPluginID, manifest: try toolFieldManifest(tool: tool), + packageURL: fixturesURL, isEnabled: enabled ) } + private static func toolFieldTarget(_ generatorID: String) throws -> ProjectGeneratorTarget { + ProjectGeneratorTarget( + pluginID: toolFieldPluginID, generatorID: generatorID, + manifest: try toolFieldManifest(), packageURL: fixturesURL + ) + } + + /// A disabled plugin that only defines a tool. + private static func toolDefinitionSource() throws -> ProjectGeneratorPluginSource { + ProjectGeneratorPluginSource( + pluginID: toolDefinitionPluginID, + manifest: try Data(contentsOf: fixturesURL.appendingPathComponent("tool-definition.plugin.json")), + packageURL: fixturesURL, isEnabled: false + ) + } + + /// Waits for `condition` with a two second deadline and records an issue + /// naming what never happened, instead of leaving a stuck test to the CI + /// timeout. + private static func waitUntil(_ what: String, _ condition: @MainActor () -> Bool) async { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + while clock.now < deadline { + if condition() { return } + await Task.yield() + } + if !condition() { Issue.record("Timed out after two seconds waiting for \(what).") } + } + + /// Returns after the work already queued on the main queue, such as a + /// Combine `receive(on: .main)` delivery sent just before. + private static func mainQueueTurn() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + } + private static func environment( plugins: PluginStates, - sources: [ProjectGeneratorPluginSource]? = nil + sources: [ProjectGeneratorPluginSource]? = nil, + toolInstallations: @escaping @MainActor ([String]) async -> [String: [ProjectToolInstallation]] = { _ in [:] }, + pluginChanges: AnyPublisher = Empty().eraseToAnyPublisher() ) throws -> NewProjectEnvironment { let split = try splitJavaSupport() let installed = sources ?? [split.templates, split.language] @@ -436,7 +727,9 @@ struct ProjectGenerationTests { setPluginEnabled: { _ = plugins.enabled.insert($0) }, openMarketplace: { _ in }, openProject: { _ in }, - store: InMemoryStore() + store: InMemoryStore(), + toolInstallations: toolInstallations, + pluginChanges: pluginChanges ) } @@ -507,6 +800,27 @@ private actor JavaHomeRecorder { } } +private struct SavedToolPin: Equatable, Sendable { + let root: String + let pin: ProjectGenerationPlan.ToolPin +} + +private actor ToolPinRecorder { + private(set) var saved: [SavedToolPin] = [] + + func record(root: URL, pin: ProjectGenerationPlan.ToolPin) { + saved.append(SavedToolPin(root: root.path, pin: pin)) + } +} + +/// The tools the window asked the host to look up, one entry per request. +@MainActor +private final class ToolListingRequests { + private(set) var requested: [[String]] = [] + + func record(_ tools: [String]) { requested.append(tools) } +} + @MainActor private final class PluginStates { var enabled: Set diff --git a/macos/Tests/LitheTests/ProjectToolAdaptersTests.swift b/macos/Tests/LitheTests/ProjectToolAdaptersTests.swift new file mode 100644 index 0000000000..54f4f0949d --- /dev/null +++ b/macos/Tests/LitheTests/ProjectToolAdaptersTests.swift @@ -0,0 +1,153 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +/// The two macOS adapters behind a New Project `tool` field: finding the +/// installations to offer, and recording the chosen one in the new project. +@Suite("New project tool adapters") +@MainActor +struct ProjectToolAdaptersTests { + // The discovery stand-in below runs off the main actor and reads these. + nonisolated private static let python = ProjectToolInstallation( + home: "/opt/python-3.12", executable: "/opt/python-3.12/bin/python3", version: "3.12.4" + ) + + // MARK: Finding installations + + @Test + func discoversInAnEmptyScratchDirectoryAndRemovesIt() async throws { + let parent = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: parent) } + let log = DiscoveryLog() + let finder = MacProjectToolInstallations( + discover: { request in + await log.record(request) + return Self.detection(of: "python", Self.python) + }, + environment: { ["PATH": "/usr/bin"] }, + scratchParent: parent + ) + + let found = await finder.installations(of: ["python", "node"], sources: [Self.source(enabled: true)]) + + // A tool with nothing installed is listed with no installations. + #expect(found == ["python": [Self.python], "node": []]) + let request = try #require(await log.requests.first) + #expect(request.environment == ["PATH": "/usr/bin"]) + #expect(request.sources.map(\.pluginID) == ["dev.example.tools"]) + // Discovery ran in a new, empty directory below the given parent, ... + #expect(request.workspace.deletingLastPathComponent().standardizedFileURL == parent.standardizedFileURL) + #expect(await log.scratchDirectoriesWereEmpty == [true]) + // ... and nothing of it is left behind. + #expect(try FileManager.default.contentsOfDirectory(atPath: parent.path).isEmpty) + } + + @Test + func doesNotRunDiscoveryWithoutEnabledPluginsOrTools() async throws { + let parent = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: parent) } + let log = DiscoveryLog() + let finder = MacProjectToolInstallations( + discover: { request in + await log.record(request) + return MacToolchainDiscovery.Result() + }, + scratchParent: parent + ) + + let withoutEnabledPlugin = await finder.installations(of: ["python"], sources: [Self.source(enabled: false)]) + let withoutTools = await finder.installations(of: [], sources: [Self.source(enabled: true)]) + + #expect(withoutEnabledPlugin.isEmpty) + #expect(withoutTools.isEmpty) + #expect(await log.requests.isEmpty) + #expect(try FileManager.default.contentsOfDirectory(atPath: parent.path).isEmpty) + } + + // MARK: Pinning + + @Test + func pinsTheChosenInstallationAsTheProjectDefault() async throws { + let project = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: project) } + let store = Self.store() + let pin = Self.pin(tool: "dev.example.tools:python", Self.python) + + try await MacProjectToolPins.save(pin, in: project, using: store) + + let snapshot = try await store.load(workspace: project) + let address = ToolchainPreferenceAddress(directory: ".", consumer: nil, tool: pin.tool) + #expect(try store.inspect(address, snapshot: snapshot).fixed == .installation(home: Self.python.home)) + // It is the document the Toolchains settings page edits, not a new store. + #expect(FileManager.default.fileExists(atPath: project.appendingPathComponent(".lithe/run/local.json").path)) + } + + @Test + func pinningAnotherToolKeepsThePinsAlreadySaved() async throws { + let project = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: project) } + let store = Self.store() + let node = ProjectToolInstallation(home: "/opt/node-22", executable: "/opt/node-22/bin/node", version: "22.11.0") + let pins = [Self.pin(tool: "dev.example.tools:python", Self.python), Self.pin(tool: "dev.example.tools:node", node)] + + for pin in pins { try await MacProjectToolPins.save(pin, in: project, using: store) } + + let snapshot = try await store.load(workspace: project) + for (pin, installation) in zip(pins, [Self.python, node]) { + let address = ToolchainPreferenceAddress(directory: ".", consumer: nil, tool: pin.tool) + #expect(try store.inspect(address, snapshot: snapshot).fixed == .installation(home: installation.home)) + } + } + + // MARK: Helpers + + private static func source(enabled: Bool) -> ToolchainSettingsPluginSource { + ToolchainSettingsPluginSource( + pluginID: "dev.example.tools", name: "Example tools", manifest: Data("{}".utf8), isEnabled: enabled + ) + } + + nonisolated private static func detection( + of tool: String, _ installation: ProjectToolInstallation + ) -> MacToolchainDiscovery.Result { + MacToolchainDiscovery.Result(detections: [ + tool: .init(installations: [ + .init( + home: installation.home, executable: installation.executable, + version: installation.version, provider: "host:system" + ) + ]) + ]) + } + + private static func pin(tool: String, _ installation: ProjectToolInstallation) -> ProjectGenerationPlan.ToolPin { + ProjectGenerationPlan.ToolPin( + field: "sdk", tool: tool, pinAs: "project", home: installation.home, executable: installation.executable + ) + } + + private static func store() -> MacToolchainPreferencesStore { + MacToolchainPreferencesStore(core: RustCoreBridge(), storage: MacFileStorage(), immutableRoots: { [] }) + } + + private static func temporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-project-tool-adapters-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } +} + +/// Stands in for toolchain discovery: remembers each request and whether the +/// directory it was asked to look in was empty at that moment. +private actor DiscoveryLog { + private(set) var requests: [MacToolchainDiscovery.Request] = [] + private(set) var scratchDirectoriesWereEmpty: [Bool] = [] + + func record(_ request: MacToolchainDiscovery.Request) { + requests.append(request) + let contents = try? FileManager.default.contentsOfDirectory(atPath: request.workspace.path) + scratchDirectoriesWereEmpty.append(contents?.isEmpty == true) + } +} diff --git a/macos/Tests/LitheTests/ToolchainPluginAdmissionTests.swift b/macos/Tests/LitheTests/ToolchainPluginAdmissionTests.swift index c107df753a..cec3cb321a 100644 --- a/macos/Tests/LitheTests/ToolchainPluginAdmissionTests.swift +++ b/macos/Tests/LitheTests/ToolchainPluginAdmissionTests.swift @@ -22,12 +22,14 @@ struct ToolchainPluginAdmissionTests { #expect(review.programs.map(\.path) == ["bin/mise-provider"]) } - @Test func standaloneDefinitionHasEnablementAndRequiresRestart() throws { + /// Definitions are data the host reads each time a tool is resolved, so a + /// plugin that only carries them applies without a restart. + @Test func standaloneDefinitionHasEnablementAndActivatesWithoutARestart() throws { let descriptor = try checkAdmission(fixture("tool-definition.plugin.json")) #expect(!descriptor.declaresBackend) #expect(descriptor.pluginManifest.modules.map(\.manifest.id) == [.toolchainsExtension(descriptor.id.rawValue)]) #expect(descriptor.pluginManifest.modules.allSatisfy { $0.manifest.defaultState == .disabled }) - #expect(!descriptor.canActivateWithoutRestart) + #expect(descriptor.canActivateWithoutRestart) let registry = try ValidatedPluginCatalog(manifests: [descriptor.pluginManifest], hostVersion: BuiltInPluginCatalog.hostVersion) #expect(registry.manifests.count == 1) From 97ab2ccf632c1c3dfd69139385423828212b4cb8 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 4 Nov 2026 22:41:47 +0800 Subject: [PATCH 07/11] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E7=94=9F=E6=88=90=E5=99=A8=E5=B7=A5=E5=85=B7=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E4=B8=8E=E7=94=9F=E6=88=90=E5=99=A8=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=85=8D=E9=87=8D=E5=90=AF=EF=BC=88API-33=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 计划:API-33 状态改为 projectGenerators 2 增加通用工具字段,能力版本列表加入 projectGenerators 2 - 跨平台功能矩阵:补充工具字段、免重启和对应验证步骤,Windows 不变 - Agent 笔记:新建项目向导增加工具字段一节(职责划分、pin 失败不回滚、被否方案);热激活笔记更新谓词范围和已知边界 - rust-core-api:说明工具候选由宿主提供、Core 只做选择与校验 Refs #1495 --- .../2026-10-26-plugin-live-activation.md | 8 +-- .../2026-10-23-plugin-project-generators.md | 49 +++++++++++++++++-- docs/development/macos-plugin-api-plan.md | 6 +-- shared/contracts/rust-core-api.md | 7 +++ .../features/plugin-project-generators.json | 8 ++- 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-26-plugin-live-activation.md b/.agents/notes/implemented/architecture/2026-10-26-plugin-live-activation.md index 698d234faa..a0b504e5ae 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-plugin-live-activation.md +++ b/.agents/notes/implemented/architecture/2026-10-26-plugin-live-activation.md @@ -4,7 +4,7 @@ ## 先说结论 -安装、更新、回退、卸载、启用、停用一个插件时,只有“它的每一项贡献在宿主里都有运行期实现”的插件才免重启生效。判断由宿主后端决定,写在 `PluginDescriptor.canActivateWithoutRestart`,不是 `plugin.json` 里的字段,所以插件契约没有变化,也不需要同步 lithe-plugin-docs。这条规则目前落地了两类:第一阶段的“纯调试适配器插件”(只贡献 `debugAdapters`,以及它声明的语言和设置,没有后端、没有其他模块),和第二阶段的“纯语法高亮插件”(只贡献带 `grammar` 或编辑配置的语言,见 [独立插件编辑器 API](2026-10-31-plugin-editor-extension-apis.md))。两者也可以出现在同一个插件里。 +安装、更新、回退、卸载、启用、停用一个插件时,只有“它的每一项贡献在宿主里都有运行期实现”的插件才免重启生效。判断由宿主后端决定,写在 `PluginDescriptor.canActivateWithoutRestart`,不是 `plugin.json` 里的字段,所以插件契约没有变化,也不需要同步 lithe-plugin-docs。这条规则目前落地了四类:第一阶段的“纯调试适配器插件”(只贡献 `debugAdapters`,以及它声明的语言和设置,没有后端、没有其他模块),第二阶段的“纯语法高亮插件”(只贡献带 `grammar` 或编辑配置的语言,见 [独立插件编辑器 API](2026-10-31-plugin-editor-extension-apis.md)),以及“纯项目生成器插件”和“纯工具定义插件”(见[插件注入的新建项目向导](../feature/2026-10-23-plugin-project-generators.md)的工具字段一节)。这几类也可以出现在同一个插件里。 满足条件的插件不进模块图,而是登记在一个**内存实时注册表**(`MacLivePluginRegistry`)里。注册表用后台线程扫描已安装目录,主线程只读取扫描结果快照。其他插件的路径完全不变:仍然写成 `.updateStaged`,等重启才生效。 @@ -23,7 +23,7 @@ ### 1. 由后端能力决定,不是清单字段 -`canActivateWithoutRestart` 现在的定义是:没有 `backend` 声明、模块列表非空,且每个模块都是 `debugAdaptersExtension(<插件 id>)` 或 `grammarsExtension(<插件 id>)`,并且没有其他贡献。语法模块只承载启用状态,它的运行期登记、撤回和查找由 `MacPluginEditorLanguages` 完成(编辑器每次变化都收到完整的语言列表,撤回就是从列表里拿掉)。 +`canActivateWithoutRestart` 现在的定义是:没有 `backend` 声明、模块列表非空,且每个模块都是 `debugAdaptersExtension`、`grammarsExtension`、`projectGeneratorsExtension` 或 `toolchainsExtension`(参数都是插件 id)之一,并且没有其他贡献。语法模块只承载启用状态,它的运行期登记、撤回和查找由 `MacPluginEditorLanguages` 完成(编辑器每次变化都收到完整的语言列表,撤回就是从列表里拿掉)。生成器和工具定义同样只是数据:`MacServiceContainer` 把启动时登记的插件和实时注册表里的插件合并成“当前生效的插件”,新建项目的生成器列表、工具链发现和工具 pin 的只读目录检查每次调用都读它;新建项目窗口监听 `pluginRegistrationChanges`,插件变化时重新载入条目。 - 正确做法:新增一种贡献(例如语言服务器)的热激活时,先实现它的运行期登记和撤回,再在这个谓词里放行。 - 错误做法:在 `plugin.json` 里加 `restartRequired: false` 让插件作者声明。作者不了解宿主哪些贡献有运行期实现;声明错了,用户看到的是“装上了但没生效”。 @@ -97,7 +97,9 @@ 5. 传给 Core 的 `registrationGeneration` 是准备调试适配器时注册表的 `generation`,Core 只比较是否相等,不解释数值。 6. `stopSessions(ofPlugin:)` 只停该插件拥有的会话。会话记录的所有者是创建时选中的插件;同一个语言下另一个插件的会话继续运行。 7. debug-only 声明里的文件扩展名会并入语言;扩展名集合变化会让该语言的服务器按原有规则重启。 -8. 目前仍然需要重启的贡献:语言服务器、运行/测试、编辑器动作后端(因为它依赖 `backend`)、构建系统、VCS、项目生成器、界面贡献、`backend`、网页窗口。后续阶段按“先有运行期登记和撤回,再放进谓词”的顺序逐类加入,每类单独验证。 +8. 目前仍然需要重启的贡献:语言服务器、运行/测试、编辑器动作后端(因为它依赖 `backend`)、构建系统、VCS、界面贡献、`backend`、网页窗口。后续阶段按“先有运行期登记和撤回,再放进谓词”的顺序逐类加入,每类单独验证。 +9. 设置里的工具链页面不会在热激活工具定义插件时自动刷新:它在打开时读取一次,重新打开才看到新增或移除的定义。新建项目窗口已经会刷新。 +10. 只含生成器或工具定义的插件属于热激活范围,安装、更新、卸载它们不再设置“需要重启”标记;同时含语言服务器等其他贡献的插件(例如 Java Support)仍然不满足谓词。 ## 验证 diff --git a/.agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md b/.agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md index 6cf3880699..b5e3dd4956 100644 --- a/.agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md +++ b/.agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md @@ -10,6 +10,8 @@ 界面复用 #1395 设置窗口的原生 SwiftUI 组件。为此把设置侧栏行、插件字段控件和文件夹选择器抽成了共享组件,设置窗口和克隆仓库窗口改用它们。 +后来(计划 API-33)新增 `projectGenerators: 2`,字段多了一种**工具字段**(`type: "tool"`):生成器只写一个工具定义 ID 和最低版本,宿主用已有的工具解析列出本机安装,用户的选择写进新项目的工具 pin(项目自己记住“用哪个安装”的那份设置)。这样 Python、Node、JDK 等任何工具都能被生成器选择,宿主和 Core 里不出现任何语言名。版本 1 的 `runtime`(只能选 Java)保持原样,等 Java 逻辑迁出宿主后再移除。 + ## 问题 - **入口什么都没做。** 欢迎页和项目切换器的「New Project…」调用 `chooseProject(title: "New Project")`,与「打开」只差一个标题。 @@ -56,7 +58,7 @@ 1. Core 用 `project.generationPlan` 校验表单,展开所有文件、argv 和 URL。 2. 宿主在目标父目录下建 `..lithe-creating-<随机>` 临时目录,按步骤执行。压缩包由 Core 的 `project.extractArchive` 解压:拒绝目录外路径、符号链接、覆盖已有文件,并限制实际写出的字节数。 3. 全部成功才原子移动到最终位置;目标已存在就拒绝。失败或取消时删除临时目录。 -4. 所选 JDK 通过 `ProjectRuntimeService.presetJavaHome` 写成新项目的 JDK;勾选 Git 时用 `git.initialize` 建仓库;`openFiles` 写进新项目的工作区会话,项目在哪个窗口打开都会恢复这些标签。 +4. 所选 JDK(版本 1 的 `runtime` 字段)通过 `ProjectRuntimeService.presetJavaHome` 写成新项目的 JDK;声明了 `pinAs` 的工具字段按计划里的 `toolPins` 写成新项目的工具 pin(见下文第 7 节);勾选 Git 时用 `git.initialize` 建仓库;`openFiles` 写进新项目的工作区会话,项目在哪个窗口打开都会恢复这些标签。 压缩包边下载边计数,超过 256 MiB 立即取消,不先占满磁盘;服务器拒绝请求时最多读 64 KiB 的错误说明。 @@ -80,6 +82,36 @@ 左栏固定宽度,不用每帧 `GeometryReader`,符合[可调整面板性能边界](../architecture/2026-09-13-resizable-ui-performance-boundaries.md)。插件提供的标题和说明走 `LocalizedStringKey`,与插件设置一样可以在宿主的 `Localizable.strings` 里翻译。 +### 7. 工具字段(`projectGenerators` 2) + +字段写法:`{"id": "sdk", "type": "tool", "tool": "python", "minimumVersion": "3.11", "pinAs": "project"}`。`tool` 是工具定义 ID(已验证的官方定义用短 ID,其余是 `<插件 id>:<定义 id>`),没有封闭列表;没人声明的工具就是没有安装可选。 + +谁做什么: + +| 部分 | 负责方 | 做什么 | +| --- | --- | --- | +| 字段校验、默认选择、版本比较、`${field.}` 与 `${field..executable}` 展开、`toolPins` | Rust Core | 在已有的 `project.generatorForm` 和 `project.generationPlan` 里;选择规则:用户的选择还在候选里就保留,否则取版本最新的,同版本保持宿主给的顺序;没有候选报 `required`(可选字段除外),低于 `minimumVersion` 报 `toolTooOld` | +| 列出候选安装 | macOS 宿主(I/O) | `MacProjectToolInstallations` 复用设置页的工具发现;新建项目没有工作区,所以在一个空的临时目录里发现,用完删除 | +| 保存 pin | macOS 宿主(I/O) | `MacProjectToolPins` 通过设置页同一个 `ToolchainPreferencesStoring` 写 `.lithe/run/local.json`,不新增存储 | +| 找不到工具时的提示 | Core 给出提供它的插件,宿主按已有方式显示 | 有定义但插件停用显示“启用”,没有定义显示“在插件市场中查找” | + +宿主不判断“这是哪种语言”,也不判断版本够不够:它把发现的安装交给 Core,按 Core 返回的 `installations[].meetsMinimum` 画下拉并把太旧的灰掉。 + +pin 写失败不会让项目创建失败:项目已经生成并移动到位,回滚没有意义。创建结果带一个 `toolPinWarning`,窗口提示“项目已创建,但无法保存你选择的工具”,用户可以到设置的工具页再选一次。 + +**热激活。** 只含生成器、或只含工具定义的插件原来要重启才生效。生成器清单和工具定义都是宿主每次用到时才读的数据,所以它们加入了 `canActivateWithoutRestart`(见[插件热激活](../architecture/2026-10-26-plugin-live-activation.md))。新建项目窗口监听插件变化,自动重新列出条目和工具候选;窗口打开时安装工具定义插件,下拉随即多出新的安装。含语言服务器、构建、界面等其他贡献的插件(例如 Java Support)仍然要重启。 + +#### 正确做法 + +- Python 生成器想让用户选解释器:写 `{"type": "tool", "tool": "python", "minimumVersion": "3.11", "pinAs": "project"}`,步骤里用 `${field.sdk.executable}` 调 `venv`。 +- 生成器只需要路径、不想改项目的工具设置:不写 `pinAs`,选择只用于步骤。 + +#### 不要这样做 + +- 在生成器里自带发现命令或固定路径。发现归工具定义和 provider,生成器只引用工具 ID。 +- 在宿主里按工具名判断版本、默认值或提示。这些是 Core 的职责,宿主只执行 I/O 和画界面。 +- 在同一个生成器里同时用 `runtime` 和 `tool`:Core 拒绝这样的清单。`runtime` 只属于版本 1。 + ### 正确做法 - 新增一个 Cargo 生成器:在 Cargo 插件里声明一条 `command` 步骤调用 `cargo init --vcs none`,`requirements` 写 `{"buildSystemID": "cargo"}`,`requires` 加 `projectGenerators: 1`。宿主不用改。 @@ -110,6 +142,14 @@ 最有吸引力的地方:宿主几乎不用改。没有采用:插件界面原生优先,网页只作为可选方式;名称、位置、校验和提交需要宿主统一控制。 +### 给 `runtime` 字段继续增加取值(`python`、`node` ……) + +最有吸引力的地方:改动最小,Java 已经这样做。没有采用:`runtime` 取值是 Core 里的封闭列表,每支持一种工具都要改 Core 和宿主,社区插件无法自己带新工具;Core 和宿主里会出现一份又一份语言词汇。工具字段直接引用工具定义 ID,工具的发现和版本规则本来就在工具定义里。 + +### 生成器自带发现命令 + +最有吸引力的地方:生成器作者完全自主。没有采用:发现一个工具的方式(PATH、版本管理器、固定目录)已经由工具定义和 provider 描述并被设置页使用;生成器再写一份会让新建项目看到的安装和设置页不一致,选了也无法写成项目的工具 pin。 + ### 宿主自己解压(`ditto`) 最有吸引力的地方:少一个 Core 命令。没有采用:压缩包来自用户可修改的服务器,属于不可信输入;放在 Core 里,路径穿越、符号链接、覆盖和大小上限的检查只写一次,Windows 接入时直接复用。 @@ -120,6 +160,9 @@ - 收益:设置窗口、克隆窗口和向导共用侧栏行、字段控件和文件夹选择器,样式不会各自漂移。 - 代价:Spring Boot 的版本和依赖列表写在插件里,会随时间过时,要靠插件发版更新;动态列表要等 Provider 能力。 - 代价:Java Support 的 `requires` 多了 `projectGenerators: 1`,不支持生成器的宿主装不了这一版;停用 Java Support 时模板一起停用。 +- 收益:任何带工具定义的工具都能被生成器选择,新增工具不需要改 Core 和宿主;选择直接成为项目的工具 pin,建完就能用同一个安装构建运行。 +- 代价:使用工具字段的生成器要求 `projectGenerators: 2`,不支持它的宿主装不了这个插件;版本 1 的 `runtime` 字段和 Java 专用路径还在,等 Java 逻辑迁出宿主后再删。 +- 代价:只含生成器或工具定义的插件热激活后,设置里的工具页不会自动刷新,要重新打开才看到新增的工具定义。 - 暂不支持:JavaFX、Quarkus、Micronaut、Jakarta EE 等其他 Java 生成器;Windows 宿主(不声明 `projectGenerators`,需要它的插件在 Windows 市场中隐藏)。 ## 验证 @@ -127,7 +170,7 @@ ```bash cargo test --manifest-path rust/Cargo.toml -p lithe-core project_generators ./scripts/architecture/verify-shared-contracts.sh -./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'ProjectGenerationTests|PluginManagerTests' +./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'ProjectGenerationTests|ProjectToolAdaptersTests|PluginManagerTests|PluginLiveActivationTests' ./scripts/plugins/verify-official-plugins.sh ./scripts/platform-feature-matrix/verify-platform-feature-matrix.sh ``` @@ -142,6 +185,6 @@ cargo test --manifest-path rust/Cargo.toml -p lithe-core project_generators - `macos/Sources/Lithe/Views/Workspace/NewProjectView.swift` - `macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift` - `macos/Sources/Lithe/Services/ProjectGeneration/` -- `macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/` +- `macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/`(含 `MacProjectToolInstallations.swift`、`MacProjectToolPins.swift`) - `macos/Sources/Lithe/Views/Components/LitheSidebarList.swift` - `macos/Sources/Lithe/Views/Components/PluginFieldControls.swift` diff --git a/docs/development/macos-plugin-api-plan.md b/docs/development/macos-plugin-api-plan.md index fd7a307578..624b6791da 100644 --- a/docs/development/macos-plugin-api-plan.md +++ b/docs/development/macos-plugin-api-plan.md @@ -55,7 +55,7 @@ Windows 不在本轮,平台矩阵的 partial/pending 保持真实。 | API-30 | 既有能力 | 已有 debugAdapters 1/2 与 DAP 宿主;本 PR 不重建调试协议 | | API-31 | 既有能力 | 已有 vcsProviders:1;不包含内置 Git 公共读取面 | | API-32 | 未做 | 通用 ServiceProvider 仍为提案 | -| API-33 | 既有能力 | 已有 projectGenerators:1;新 hooks 不在本轮 | +| API-33 | 部分完成 | projectGenerators 2 增加通用工具字段(`type:"tool"`,工具来自 `toolDefinitions`,选择写入项目的工具 pin);项目生成器与工具定义插件免重启生效;新 hooks 不在本轮 | | API-34 | 未做 | 插件公共 IDE/AI/MCP 授权面未开放;独立 broker 不能直接转授 | | API-35 | 未做 | 跨插件依赖/导出、远程 Provider 剩余接续未完成 | | API-36 | 未做 | WASM/WASI 非本轮前置,未引入运行时 | @@ -157,7 +157,7 @@ Windows 不在本轮,平台矩阵的 partial/pending 保持真实。 | 内置 Git | Git 在 `LitheGitModule`,按 2026-10-22 评审不接入统一 VCS 层;IDE broker 的 13 个工具没有 Git 工具 [S15/S21] | API-31 只覆盖插件提供的 VCS,插件没有读取内置 Git 状态的公共入口 | | Agent 侧 | Skills、Hooks、MCP 是用户管理的 Codex/Claude 原生配置,宿主只读取并编辑用户选定的来源 [S22] | 插件没有贡献来源;不能让插件绕过用户确认写入这些配置 | -Mac 当前声明的能力版本为:`backend:1`、`buildModelInputs:1`、`buildProviders:1–6`、`buildSystems:1/2`、`debugAdapters:1/2`、`executionProviders:1`、`executableResolution:1`、`languageServers:1/2`、`languages:1`、`packageTrust:1`、`projectGenerators:1`、`serverProcess:1`、`settings:1/2`、`ui:1/2/3`、`vcsProviders:1`。Core 能校验某版本不等于 Mac 会执行它:Core 的 `serverProcess` 声明 1/2,而 Mac 只声明 1,不能因此给 Mac 标记支持 `serverProcess:2`。以上以 `PluginTypes.swift` 的 `hostCapabilities` 为准,与 Core 的 `SUPPORTED_CAPABILITIES` 逐项比对过,唯一差异是 `serverProcess`。 +Mac 当前声明的能力版本为:`backend:1`、`buildModelInputs:1`、`buildProviders:1–6`、`buildSystems:1/2`、`debugAdapters:1/2`、`executionProviders:1`、`executableResolution:1`、`languageServers:1/2`、`languages:1`、`packageTrust:1`、`projectGenerators:1/2`、`serverProcess:1`、`settings:1/2`、`ui:1/2/3`、`vcsProviders:1`。Core 能校验某版本不等于 Mac 会执行它:Core 的 `serverProcess` 声明 1/2,而 Mac 只声明 1,不能因此给 Mac 标记支持 `serverProcess:2`。以上以 `PluginTypes.swift` 的 `hostCapabilities` 为准,与 Core 的 `SUPPORTED_CAPABILITIES` 逐项比对过,唯一差异是 `serverProcess`。 当前 Web bridge 的方法白名单为 `build.instances/refresh/runTask/runTests/cancel/dependencies/setOptions`、`workspace.openFile`、`ui.getState/setState`、`functions.list/invoke/cancel`、`dialog.openFile/saveFile`。后两个选择文件方法已在 Mac bridge v2 实现;盘点不能只依据较早 README 的列表。网页调用权限也不自动转授给后端进程。 @@ -244,7 +244,7 @@ Mac 当前声明的能力版本为:`backend:1`、`buildModelInputs:1`、`build | API-30 P/C/E | Debug Adapter 注册、配置和会话事件;已有 DAP 宿主 [S14] | 进程/连接授权、launch 与 attach 所有权、终止和断开区别;复用断点/调试 UI。高级内存/反汇编按独立提案,不阻塞基础 API | M4 | | API-31 P/C | VCS 仓库/状态/diff/提交等领域能力;已有 vcsProviders 1 [S14],只覆盖插件提供的 VCS,内置 Git 见 API-42 | 读取与修改仓库分权;危险操作有明确确认,结果有仓库/操作身份。插件私有函数不能代替宿主可理解的 VCS 协议 | M4 | | API-32 P/C/E | Services 的实例/状态/动作/日志;数据库已有自身路径,通用 ServiceProvider 仍为提案 [S17] | 插件拥有的连接/进程与用户外部服务分开;停用只清理前者,不默认停用户数据库/容器。服务接入现有 Services,不再画一套工作台 | 后续 | -| API-33 P/C | 项目生成器发现、参数、预览与生成;已有 projectGenerators 1 [S14] | 写目标目录需确认,禁止路径逃逸/无提示覆盖;只生成选择的项目,不下载未声明程序。先补现有文档,再评估新增 hooks | M4 | +| API-33 P/C | 项目生成器发现、参数、预览与生成;已有 projectGenerators 1,projectGenerators 2 增加通用工具字段 [S14] | 写目标目录需确认,禁止路径逃逸/无提示覆盖;只生成选择的项目,不下载未声明程序。工具字段只通过工具定义的通用解析列出候选,选择写入项目已有的工具 pin,生成器不能自带工具发现。版本 1 的 `runtime` 字段保持不变,待 Java 逻辑迁出后再移除。先补现有文档,再评估新增 hooks | M4 | | API-34 H/P | 受限 IDE/AI/MCP 能力;已有单独 IDE broker [S15] | 项目 opt-in 和 read/configure/execute 分权不变;不转交 broker token、全量 dispatcher 或 Agent 历史。是否开放、哪些调用方可用需独立威胁建模 | 后续 | | API-35 H/P/C | 插件依赖、显式导出服务;远程 Provider 适配的剩余部分(原生 Java 集成、Agent、SSH 调试)[S17/S20] | 跨插件调用需要新授权/版本/循环依赖规则。远程 authority 基线已移到 API-40(M0),本项只在其上扩展;未实现的环境继续明确拒绝,不静默本地执行 | 后续 | | API-36 运行时 | 可选 WASM Component/WASI 宿主;当前能力表无 WASM [S1] | 复用相同公共 API/权限;独立验证 WIT 版本、fuel/内存/时间预算、取消、沙盒可达性、签名、工具链和分发。不得预挂载整个 HOME/工作区或绕过主进程隔离 | 后续,非 M0–M4 前置 | diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 924553fd08..82f543a05a 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -4834,6 +4834,13 @@ answers. Results use the `plugins.declarative.*` envelope: `catalog`, `form`, `plan`, `name`, and `extracted`. Commands that take a manifest revalidate it and answer `plugin.project.unavailable` for an undeclared generator. +A `projectGenerators` 2 generator picks tool installations with `tool` fields. +The host lists the installations of each field's tool and sends them as the +form's `tools`; Core chooses among them, checks `minimumVersion`, expands +`${field..executable}`, and returns `toolPins` in the plan. Core lists no +installations itself, so the commands touch neither the file system nor a +process for tools. + `project.generationPlan` reads template files below `packagePath` (bounded, no symbolic links). `project.extractArchive` is the only command that writes: it extracts into an existing staging directory the host created and never diff --git a/shared/platform-feature-matrix/features/plugin-project-generators.json b/shared/platform-feature-matrix/features/plugin-project-generators.json index 996c82277a..da9fc0085e 100644 --- a/shared/platform-feature-matrix/features/plugin-project-generators.json +++ b/shared/platform-feature-matrix/features/plugin-project-generators.json @@ -2,18 +2,22 @@ "id": "plugin-project-generators", "area": "工作区", "group": "项目", - "capability": "新建项目窗口按 IDEA 的布局列出“新建项目”和“生成器”两组条目:空项目由宿主提供,其余条目由插件通过 projectGenerators 1 注入(随 Lithe 内置的 Java Support 直接提供 Java、Maven Archetype、Spring Boot)。名称、位置、创建 Git 仓库由宿主绘制;字段、派生默认值、显示条件和生成计划由 Rust Core 计算;先在目标目录旁的临时目录生成,完成后整体移动到位,失败或取消不留残留。条目需要的语言或构建系统插件未启用时提示一键启用,未安装时跳转插件市场搜索;所选 JDK 写入新项目的运行时设置,模板指定的文件随项目一起打开", + "capability": "新建项目窗口按 IDEA 的布局列出“新建项目”和“生成器”两组条目:空项目由宿主提供,其余条目由插件通过 projectGenerators 1 注入(随 Lithe 内置的 Java Support 直接提供 Java、Maven Archetype、Spring Boot)。名称、位置、创建 Git 仓库由宿主绘制;字段、派生默认值、显示条件和生成计划由 Rust Core 计算;先在目标目录旁的临时目录生成,完成后整体移动到位,失败或取消不留残留。条目需要的语言或构建系统插件未启用时提示一键启用,未安装时跳转插件市场搜索;所选 JDK 写入新项目的运行时设置,模板指定的文件随项目一起打开。projectGenerators 2 增加通用的工具字段(type:tool):生成器只写工具定义 ID 和最低版本,宿主通过通用工具解析列出已安装的候选,所选工具写入新项目的工具 pin(.lithe/run/local.json),步骤可引用 ${field.} 与 ${field..executable};找不到工具时提示启用或到插件市场查找,低于最低版本的安装不可创建。只含生成器或工具定义的插件安装、更新、卸载后免重启,新建项目窗口随之刷新条目", "macos": { "evidence": [ "rust/lithe-core/src/project_generators/", "shared/contracts/plugins/v1/fixtures/project-generator.plugin.json", "shared/contracts/plugins/v1/fixtures/project-plans.json", + "shared/contracts/plugins/v1/fixtures/project-generator-tool-field.plugin.json", "Plugins/mac/Official/JavaSupport/plugin.json", "macos/Sources/Lithe/Views/Workspace/NewProjectView.swift", "macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift", "macos/Sources/Lithe/Services/ProjectGeneration/ProjectGenerationService.swift", "macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectGenerationPlatform.swift", + "macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolInstallations.swift", + "macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolPins.swift", "macos/Tests/LitheTests/ProjectGenerationTests.swift", + "macos/Tests/LitheTests/ProjectToolAdaptersTests.swift", "rust/lithe-core/src/project_generators/archive.rs" ], "implementationStatus": "implemented", @@ -27,6 +31,6 @@ "verificationStatus": "not-applicable" }, "owner": "Plugin Platform", - "verification": "macOS:从欢迎页和项目切换器点“新建项目”,确认左栏有“新建项目”(Java、空项目)和“生成器”(Maven Archetype、Spring Boot)两组且可搜索。Java 选 Maven、默认 JDK 创建,确认生成 pom.xml(maven.compiler.release 等于所选 JDK 主版本)、.gitignore 和 src/main/java/org/example/Main.java,项目打开后 Main.java 已打开、Build 工具窗口识别 Maven 项目。勾选“创建 Git 仓库”时项目根目录有 .git。Spring Boot 选几个依赖创建,确认从 start.spring.io 下载并解压、mvnw 可执行;把服务器 URL 改成不可达地址时报错且目标目录下没有残留的 .*.lithe-creating-* 临时目录。Maven Archetype 在已安装 mvn 时生成 quickstart 项目,未安装时提示找不到 mvn。停用 Java Support 后,三个条目变灰、创建按钮不可用,只出现一条“启用 Java Support”,点击后条目恢复可用;未安装 Maven 插件时出现“在插件市场中查找”并跳到市场搜索 maven。目标目录已存在时拒绝创建且不改动原目录。Windows:宿主不声明 projectGenerators,需要它的插件在市场中隐藏。", + "verification": "macOS:从欢迎页和项目切换器点“新建项目”,确认左栏有“新建项目”(Java、空项目)和“生成器”(Maven Archetype、Spring Boot)两组且可搜索。Java 选 Maven、默认 JDK 创建,确认生成 pom.xml(maven.compiler.release 等于所选 JDK 主版本)、.gitignore 和 src/main/java/org/example/Main.java,项目打开后 Main.java 已打开、Build 工具窗口识别 Maven 项目。勾选“创建 Git 仓库”时项目根目录有 .git。Spring Boot 选几个依赖创建,确认从 start.spring.io 下载并解压、mvnw 可执行;把服务器 URL 改成不可达地址时报错且目标目录下没有残留的 .*.lithe-creating-* 临时目录。Maven Archetype 在已安装 mvn 时生成 quickstart 项目,未安装时提示找不到 mvn。停用 Java Support 后,三个条目变灰、创建按钮不可用,只出现一条“启用 Java Support”,点击后条目恢复可用;未安装 Maven 插件时出现“在插件市场中查找”并跳到市场搜索 maven。目标目录已存在时拒绝创建且不改动原目录。工具字段:用 shared/contracts/plugins/v1/fixtures/project-generator-tool-field.plugin.json 和一个定义了同名工具的插件做本地开发安装,新建项目里该生成器的工具下拉列出本机已发现的安装(含版本),选低于最低版本的安装时创建按钮不可用并提示,选满足版本的安装创建后新项目的 .lithe/run/local.json 里有该工具的 pin,设置里的工具链页面显示同一个固定值;没有任何插件定义该工具时出现“在插件市场中查找”,插件已安装未启用时出现“启用”。在新建项目窗口保持打开时安装或卸载只含生成器的插件,条目立即出现或消失且无需重启。Windows:宿主不声明 projectGenerators,需要它的插件在市场中隐藏。", "notes": "#1478 拆分(Core):生成器归档解压先预检全部条目(条目数、展开字节、目录深度 64 层、路径总字节 16 MiB),只经过真实目录(不跟随符号链接,Windows 还拒绝 reparse point),按 32 KiB 分块复制并在块间检查取消;违反约定报 plugin.project.archiveInvalid,取消或超时作为 Core 错误返回且不再写入。Windows 额外拒绝设备名、ADS 和非法字符。macOS 的 project.extractArchive 没有取消作用域,只受预检和上限影响。" } From 6f89834f1eeaaee9af28166ced1ced93f603dac3 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 4 Nov 2026 22:45:52 +0800 Subject: [PATCH 08/11] =?UTF-8?q?feat(macos):=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E9=93=BE=E8=AE=BE=E7=BD=AE=E9=A1=B5=E9=9A=8F=E5=85=8D=E9=87=8D?= =?UTF-8?q?=E5=90=AF=E6=8F=92=E4=BB=B6=E5=8F=98=E5=8C=96=E9=87=8D=E8=AF=BB?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=EF=BC=88API-33=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工具定义插件现在可以免重启生效,已打开的工具链设置页也应立即看到新增或移除的工具定义。 AppModel 已有的 pluginRegistrationChanges 订阅额外通知 ToolchainSettingsFeatureModel, 后者复用文件变化的合并重载路径;页面加载前不触发读取。同步更新热激活与新建项目向导 Note。 --- .../2026-10-26-plugin-live-activation.md | 2 +- .../2026-10-23-plugin-project-generators.md | 2 +- .../Features/ToolchainSettingsFeatureModel.swift | 6 ++++++ .../Sources/Lithe/Models/AppModel/AppModel.swift | 6 ++++-- .../ToolchainSettingsFeatureTests.swift | 16 ++++++++++++++++ 5 files changed, 28 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-26-plugin-live-activation.md b/.agents/notes/implemented/architecture/2026-10-26-plugin-live-activation.md index a0b504e5ae..2241824d26 100644 --- a/.agents/notes/implemented/architecture/2026-10-26-plugin-live-activation.md +++ b/.agents/notes/implemented/architecture/2026-10-26-plugin-live-activation.md @@ -98,7 +98,7 @@ 6. `stopSessions(ofPlugin:)` 只停该插件拥有的会话。会话记录的所有者是创建时选中的插件;同一个语言下另一个插件的会话继续运行。 7. debug-only 声明里的文件扩展名会并入语言;扩展名集合变化会让该语言的服务器按原有规则重启。 8. 目前仍然需要重启的贡献:语言服务器、运行/测试、编辑器动作后端(因为它依赖 `backend`)、构建系统、VCS、界面贡献、`backend`、网页窗口。后续阶段按“先有运行期登记和撤回,再放进谓词”的顺序逐类加入,每类单独验证。 -9. 设置里的工具链页面不会在热激活工具定义插件时自动刷新:它在打开时读取一次,重新打开才看到新增或移除的定义。新建项目窗口已经会刷新。 +9. 设置里的工具链页面和新建项目窗口都监听 `pluginRegistrationChanges`:工具定义插件热激活后,已打开的工具链页面按文件变化同样的合并规则重读一次快照,页面还没加载过时什么都不做。 10. 只含生成器或工具定义的插件属于热激活范围,安装、更新、卸载它们不再设置“需要重启”标记;同时含语言服务器等其他贡献的插件(例如 Java Support)仍然不满足谓词。 ## 验证 diff --git a/.agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md b/.agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md index b5e3dd4956..6e7bffce30 100644 --- a/.agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md +++ b/.agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md @@ -162,7 +162,7 @@ pin 写失败不会让项目创建失败:项目已经生成并移动到位, - 代价:Java Support 的 `requires` 多了 `projectGenerators: 1`,不支持生成器的宿主装不了这一版;停用 Java Support 时模板一起停用。 - 收益:任何带工具定义的工具都能被生成器选择,新增工具不需要改 Core 和宿主;选择直接成为项目的工具 pin,建完就能用同一个安装构建运行。 - 代价:使用工具字段的生成器要求 `projectGenerators: 2`,不支持它的宿主装不了这个插件;版本 1 的 `runtime` 字段和 Java 专用路径还在,等 Java 逻辑迁出宿主后再删。 -- 代价:只含生成器或工具定义的插件热激活后,设置里的工具页不会自动刷新,要重新打开才看到新增的工具定义。 +- 代价:只含生成器或工具定义的插件热激活后,已打开的工具链设置页要重读整份快照(和工作区版本文件变化走同一条合并重载路径),而不是只增删变化的那个工具。 - 暂不支持:JavaFX、Quarkus、Micronaut、Jakarta EE 等其他 Java 生成器;Windows 宿主(不声明 `projectGenerators`,需要它的插件在 Windows 市场中隐藏)。 ## 验证 diff --git a/macos/Sources/Lithe/Application/Features/ToolchainSettingsFeatureModel.swift b/macos/Sources/Lithe/Application/Features/ToolchainSettingsFeatureModel.swift index 778d337d25..825e968b58 100644 --- a/macos/Sources/Lithe/Application/Features/ToolchainSettingsFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/ToolchainSettingsFeatureModel.swift @@ -91,6 +91,12 @@ final class ToolchainSettingsFeatureModel: ObservableObject { if touched { scheduleReload() } } + /// A plugin that applies without a restart can add or remove tool definitions, + /// so an open page rereads its snapshot. Nothing runs before the page first loads one. + func pluginsChanged() { + scheduleReload() + } + /// Bursts of file events collapse into one reload; the latest reload replaces earlier ones. private func scheduleReload() { guard snapshot != nil else { return } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 4ef06dfd33..bf141c3273 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -614,10 +614,12 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { guard let self, let root = self.workspaceURL else { return } self.searchFeatureIfActive?.invalidateIndex(at: root, visibilityRules: self.settings.searchEverywhereVisibilityRules.searchRules) } - // The language catalog follows plugins that apply without a restart, so - // a debugger installed now is offered now. + // The language catalog and the toolchain page follow plugins that apply + // without a restart, so a debugger or tool definition installed now is + // offered now. livePluginObservation = services.pluginRegistrationChanges?.sink { [weak self] in self?.languageToolingFeature.refreshCatalog() + self?.toolchainSettingsFeature.pluginsChanged() } suggestDatabasePluginIfNeeded() detectedAIConfigurations = loadAIConfigurations() diff --git a/macos/Tests/LitheTests/ToolchainSettingsFeatureTests.swift b/macos/Tests/LitheTests/ToolchainSettingsFeatureTests.swift index bfa02c7a09..fe9d677ce9 100644 --- a/macos/Tests/LitheTests/ToolchainSettingsFeatureTests.swift +++ b/macos/Tests/LitheTests/ToolchainSettingsFeatureTests.swift @@ -201,6 +201,22 @@ struct ToolchainSettingsFeatureTests { #expect(loader.workspaces == [root, root]) } + /// A live plugin change rereads an open page, and does nothing before the page loads. + @Test + func pluginChangesReloadAnOpenPageOnly() async { + let loader = SettingsSnapshotLoader(snapshots: [snapshot(), snapshot()]) + let model = ToolchainSettingsFeatureModel(loader: loader, reloadDelay: .zero) + defer { model.close() } + model.pluginsChanged() + #expect(loader.workspaces.isEmpty) + await model.load(workspace: root) + let reloaded = AsyncGate() + loader.beforeReturn = { index in if index == 1 { reloaded.open() } } + model.pluginsChanged() + #expect(await reloaded.wait(timeout: .seconds(5))) + #expect(loader.workspaces == [root, root]) + } + /// Inputs outside the workspace are watched per snapshot; closing ends the observation. @Test func providerInputsOutsideTheWorkspaceAreWatchedUntilClose() async { From 1a11ca4ece4a29dd3fb310cda9f5f9d3bd311356 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 5 Nov 2026 09:11:12 +0800 Subject: [PATCH 09/11] =?UTF-8?q?fix(project-generators):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=B7=A5=E5=85=B7=E5=AD=97=E6=AE=B5=E8=AF=84=E5=AE=A1?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Core 解析工具字段时,固定版本已卸载或列表为空回退到可用安装,并补测试 - 查找已安装工具失败时记录日志、在新建项目窗口显示原因和重试按钮,不再当成没有安装 - 插件变化后清空已缓存的工具列表;未选工具时保留空选项,选中后不再出现空行 - 临时目录创建/清理失败写日志,不再静默吞掉 - 契约 README 与 fixtures、Agent Note、i18n 词条同步更新 --- .../2026-10-23-plugin-project-generators.md | 8 +- macos/Resources/en.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../Application/Composition/AppServices.swift | 2 +- .../Features/NewProjectFeatureModel.swift | 72 ++++++++++-- .../Models/AppModel/AppModel+NewProject.swift | 2 +- .../Platform/MacOS/MacServiceContainer.swift | 6 +- .../MacProjectToolInstallations.swift | 31 ++++-- .../Components/LitheSettingsControls.swift | 8 +- .../Views/Workspace/NewProjectView.swift | 32 +++++- .../LitheTests/ProjectGenerationTests.swift | 102 ++++++++++++++++- .../LitheTests/ProjectToolAdaptersTests.swift | 87 ++++++++++++++- .../lithe-core/src/project_generators/form.rs | 105 ++++++++++++++++-- shared/contracts/plugins/v1/README.md | 11 +- .../project-generator-tool-field.plugin.json | 3 +- .../plugins/v1/fixtures/project-plans.json | 103 +++++++++++++++++ shared/i18n/locales/en-US.json | 1 + shared/i18n/locales/zh-CN.json | 1 + shared/i18n/messages.json | 16 +++ 19 files changed, 544 insertions(+), 50 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md b/.agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md index 6e7bffce30..738fe5d3e6 100644 --- a/.agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md +++ b/.agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md @@ -90,16 +90,16 @@ | 部分 | 负责方 | 做什么 | | --- | --- | --- | -| 字段校验、默认选择、版本比较、`${field.}` 与 `${field..executable}` 展开、`toolPins` | Rust Core | 在已有的 `project.generatorForm` 和 `project.generationPlan` 里;选择规则:用户的选择还在候选里就保留,否则取版本最新的,同版本保持宿主给的顺序;没有候选报 `required`(可选字段除外),低于 `minimumVersion` 报 `toolTooOld` | -| 列出候选安装 | macOS 宿主(I/O) | `MacProjectToolInstallations` 复用设置页的工具发现;新建项目没有工作区,所以在一个空的临时目录里发现,用完删除 | +| 字段校验、默认选择、版本比较、`${field.}` 与 `${field..executable}` 展开、`toolPins` | Rust Core | 在已有的 `project.generatorForm` 和 `project.generationPlan` 里;选择规则:用户的选择还在候选里就保留,否则取版本最新的,同版本保持宿主给的顺序;没有候选报 `required`(可选字段除外),低于 `minimumVersion` 报 `toolTooOld`。版本按“发行号”比较,末尾的 0 不算(`3.11`、`3.11.0`、`3.11.0.0` 相同),和工具链解析一致。可选字段没有够新的候选时保持为空、不写 pin,和“完全没装”一样,机器上有旧版本不会反而挡住创建;用户明确选了旧版本仍报 `toolTooOld` | +| 列出候选安装 | macOS 宿主(I/O) | `MacProjectToolInstallations` 复用设置页的工具发现;新建项目没有工作区,所以在一个空的临时目录里发现,用完删除。临时目录建不出来时记日志并抛错,窗口显示“无法查找已安装的工具”和“重试”,而不是“没有安装”;删不掉只记日志,列出的结果照常使用 | | 保存 pin | macOS 宿主(I/O) | `MacProjectToolPins` 通过设置页同一个 `ToolchainPreferencesStoring` 写 `.lithe/run/local.json`,不新增存储 | | 找不到工具时的提示 | Core 给出提供它的插件,宿主按已有方式显示 | 有定义但插件停用显示“启用”,没有定义显示“在插件市场中查找” | -宿主不判断“这是哪种语言”,也不判断版本够不够:它把发现的安装交给 Core,按 Core 返回的 `installations[].meetsMinimum` 画下拉并把太旧的灰掉。 +宿主不判断“这是哪种语言”,也不判断版本够不够:它把发现的安装交给 Core,按 Core 返回的 `installations[].meetsMinimum` 画下拉:太旧的仍然列出(让用户看到为什么没用它),但灰掉且不能选;Core 把可选字段留空时,下拉第一项显示“无”。 pin 写失败不会让项目创建失败:项目已经生成并移动到位,回滚没有意义。创建结果带一个 `toolPinWarning`,窗口提示“项目已创建,但无法保存你选择的工具”,用户可以到设置的工具页再选一次。 -**热激活。** 只含生成器、或只含工具定义的插件原来要重启才生效。生成器清单和工具定义都是宿主每次用到时才读的数据,所以它们加入了 `canActivateWithoutRestart`(见[插件热激活](../architecture/2026-10-26-plugin-live-activation.md))。新建项目窗口监听插件变化,自动重新列出条目和工具候选;窗口打开时安装工具定义插件,下拉随即多出新的安装。含语言服务器、构建、界面等其他贡献的插件(例如 Java Support)仍然要重启。 +**热激活。** 只含生成器、或只含工具定义的插件原来要重启才生效。生成器清单和工具定义都是宿主每次用到时才读的数据,所以它们加入了 `canActivateWithoutRestart`(见[插件热激活](../architecture/2026-10-26-plugin-live-activation.md))。新建项目窗口监听插件变化,自动重新列出条目和工具候选;窗口打开时安装工具定义插件,下拉随即多出新的安装。插件变化会丢掉所有条目已列出的工具候选,不只是当前条目的:之后切到别的条目会重新查找,不会沿用已停用定义找到的安装;工具重新列出之前“创建”不可用。含语言服务器、构建、界面等其他贡献的插件(例如 Java Support)仍然要重启。 #### 正确做法 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 1fff4bc1d7..814575f47f 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -1076,6 +1076,7 @@ "Could not load service dependencies" = "Could not load service dependencies"; "Could not load the changed files." = "Could not load the changed files."; "Could not load this image" = "Could not load this image"; +"Could not look for installed tools: %@" = "Could not look for installed tools: %@"; "Could not move the editor tab. Its content could not be synchronized." = "Could not move the editor tab. Its content could not be synchronized."; "Could not open %@: %@" = "Could not open %@: %@"; "Could not open %@: %@ %@" = "Could not open %@: %@ %@"; @@ -6228,6 +6229,7 @@ "native.couldNotLoadServiceDependencies" = "Could not load service dependencies"; "native.couldNotLoadTheChangedFiles" = "Could not load the changed files."; "native.couldNotLoadThisImage" = "Could not load this image"; +"native.couldNotLookForInstalledTools" = "Could not look for installed tools: %1$@"; "native.couldNotMoveTheEditorTabItsContentCouldNotBeSynchronized" = "Could not move the editor tab. Its content could not be synchronized."; "native.couldNotOpenHTMLInBrowser" = "Could not open HTML in browser"; "native.couldNotOpenTheRemoteFolder" = "Could not open the remote folder: %1$@"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 648f23b2cf..e13eea6cd2 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1076,6 +1076,7 @@ "Could not load service dependencies" = "无法加载服务依赖"; "Could not load the changed files." = "无法加载更改的文件。"; "Could not load this image" = "无法加载此图片"; +"Could not look for installed tools: %@" = "无法查找已安装的工具:%@"; "Could not move the editor tab. Its content could not be synchronized." = "无法移动编辑标签,未能同步其内容。"; "Could not open %@: %@" = "无法打开 %@:%@"; "Could not open %@: %@ %@" = "无法打开 %@:%@ %@"; @@ -6228,6 +6229,7 @@ "native.couldNotLoadServiceDependencies" = "无法加载服务依赖"; "native.couldNotLoadTheChangedFiles" = "无法加载更改的文件。"; "native.couldNotLoadThisImage" = "无法加载此图片"; +"native.couldNotLookForInstalledTools" = "无法查找已安装的工具:%1$@"; "native.couldNotMoveTheEditorTabItsContentCouldNotBeSynchronized" = "无法移动编辑标签,未能同步其内容。"; "native.couldNotOpenHTMLInBrowser" = "无法在浏览器中打开 HTML"; "native.couldNotOpenTheRemoteFolder" = "无法打开远程文件夹:%1$@"; diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index ee490679e2..d18f17e944 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -47,7 +47,7 @@ final class AppServices { /// Installations of the tools a generator's `tool` fields pick, by tool /// definition ID, found without a workspace; `nil` when the platform /// cannot discover tools. - var projectToolInstallations: (@MainActor ([String]) async -> [String: [ProjectToolInstallation]])? + var projectToolInstallations: (@MainActor ([String]) async throws -> [String: [ProjectToolInstallation]])? /// Read-only plugin toolchain projection; absent until platform composition. var toolchainSettingsLoader: (any ToolchainSettingsLoading)? var toolchainPreferences: (any ToolchainPreferencesStoring)? diff --git a/macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift b/macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift index be5ea9aa46..ece5d7ddba 100644 --- a/macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/NewProjectFeatureModel.swift @@ -21,8 +21,9 @@ struct NewProjectEnvironment { let store: any KeyValueStore /// Installations of the tools `tool` fields pick, by tool definition ID and /// best first. A new project has no workspace yet, so this is the - /// machine-wide discovery; a tool nothing defines is simply absent. - var toolInstallations: @MainActor ([String]) async -> [String: [ProjectToolInstallation]] = { _ in [:] } + /// machine-wide discovery; a tool nothing defines is simply absent. Throws + /// when discovery could not run, which is not the same as nothing found. + var toolInstallations: @MainActor ([String]) async throws -> [String: [ProjectToolInstallation]] = { _ in [:] } /// Fires after the installed plugins or their enablement changed, so the /// entry list follows an install, update, or removal without reopening. var pluginChanges: AnyPublisher = Empty().eraseToAnyPublisher() @@ -96,10 +97,14 @@ final class NewProjectFeatureModel: ObservableObject { /// True from the moment tool installations are requested until they arrive, /// so an empty list reads as "none installed" rather than "still looking". @Published private(set) var isListingTools = false + /// Why the installations of a tool could not be looked up, by tool + /// definition ID. Such a tool has no listing, so Create stays disabled. + @Published private(set) var toolListingFailures: [String: String] = [:] private let environment: NewProjectEnvironment /// Installations found per tool definition ID. A listing replaces the - /// entries it asked for; the others keep their last answer. + /// entries it asked for; the others keep their last answer until a plugin + /// change drops them all. A tool with no entry has not been listed yet. private var listedTools: [String: [ProjectToolInstallation]] = [:] private var toolListing: Task? private var pluginChangeObservation: AnyCancellable? @@ -151,9 +156,13 @@ final class NewProjectFeatureModel: ObservableObject { if case .generator(let id) = selection, !entries.contains(where: { $0.id == id }) { selection = .empty } + // A plugin change may add or remove the definition of any tool, not + // only the selected entry's, so every listing is dropped: an entry + // selected later is looked up again instead of offering a home the + // removed definition found. + listedTools = [:] + toolListingFailures = [:] evaluate() - // A plugin change may add or remove the definition of a tool, so the - // installations are looked up again rather than trusted. listToolInstallations() } @@ -249,7 +258,10 @@ final class NewProjectFeatureModel: ObservableObject { guard !isCreating, nameProblem == nil, formError == nil, !location.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } guard let entry = selectedEntry else { return selection == .empty } + // Until its tools are listed the form has judged an empty list, so an + // optional tool would read as "none" and be left out of the project. return entry.pluginEnabled && formState?.valid == true + && toolIDs(of: entry).allSatisfy { listedTools[$0] != nil } } private var parentURL: URL { @@ -345,12 +357,28 @@ final class NewProjectFeatureModel: ObservableObject { formState?.field(field.id)?.installations ?? [] } + /// The homes a tool field's picker lists. Core leaves an optional field + /// empty when every installation is too old, so "" comes first then and + /// the picker shows that rather than a blank control. + func toolOptions(for field: ProjectGeneratorField) -> [String] { + let homes = toolChoices(for: field).map(\.home) + guard case .string(let chosen) = value(of: field), !chosen.isEmpty else { return [""] + homes } + return homes + } + + /// Whether the picker lets the user choose `home`: "no tool" and the + /// installations Core says are new enough. An older one stays listed so the + /// user sees why it is not used. + func isToolChoosable(_ home: String, for field: ProjectGeneratorField) -> Bool { + home.isEmpty || toolChoices(for: field).first { $0.home == home }?.meetsMinimum == true + } + /// What to offer when a tool field found no installation because no enabled /// plugin defines its tool: enable the plugin that does, or look for one. /// Nil while listing, when installations exist, and when the defining /// plugin is already enabled (the tool is then just not installed). func toolNoticeAction(for field: ProjectGeneratorField) -> RequirementNotice.Action? { - guard !isListingTools, toolChoices(for: field).isEmpty, + guard !isListingTools, toolListingError(for: field) == nil, toolChoices(for: field).isEmpty, let tool = selectedEntry?.tools.first(where: { $0.fieldID == field.id }), !tool.providers.contains(where: \.enabled) else { return nil } if let provider = tool.providers.first { @@ -371,6 +399,18 @@ final class NewProjectFeatureModel: ObservableObject { .filter { seen.insert($0).inserted } } + /// Why the installations of the field's tool could not be looked up, or + /// nil when they were (or are still being) looked up. + func toolListingError(for field: ProjectGeneratorField) -> String? { + guard !isListingTools else { return nil } + return field.tool.flatMap { toolListingFailures[$0] } + } + + /// Looks the selected generator's tools up again after a failure. + func retryToolListing() { + listToolInstallations() + } + /// Looks up the installations of the selected generator's tools and /// re-evaluates the form with them. A newer request replaces an older one. private func listToolInstallations() { @@ -383,9 +423,23 @@ final class NewProjectFeatureModel: ObservableObject { isListingTools = true toolListing = Task { [weak self] in guard let self else { return } - let found = await environment.toolInstallations(tools) - guard !Task.isCancelled else { return } - for tool in tools { listedTools[tool] = found[tool] ?? [] } + do { + let found = try await environment.toolInstallations(tools) + guard !Task.isCancelled else { return } + for tool in tools { + listedTools[tool] = found[tool] ?? [] + toolListingFailures[tool] = nil + } + } catch { + guard !Task.isCancelled else { return } + // The tools stay unlisted, so the field says the lookup failed + // instead of "not installed", and Create waits for a retry. + // A tool listed before keeps that listing. + let message = String( + format: L10n.string("Could not look for installed tools: %@"), error.localizedDescription + ) + for tool in tools where listedTools[tool] == nil { toolListingFailures[tool] = message } + } isListingTools = false evaluate() } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+NewProject.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+NewProject.swift index 1c2b97ca1c..16be16d282 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+NewProject.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+NewProject.swift @@ -33,7 +33,7 @@ extension AppModel { openMarketplace: { [weak self] query in self?.showPluginMarketplace(searching: query) }, openProject: { [weak self] project in self?.openCreatedProject(project) }, store: services.store, - toolInstallations: { [weak self] tools in await self?.services.projectToolInstallations?(tools) ?? [:] }, + toolInstallations: { [weak self] tools in try await self?.services.projectToolInstallations?(tools) ?? [:] }, pluginChanges: services.pluginRegistrationChanges ?? Empty().eraseToAnyPublisher() )) } diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 76eceb0d55..42fa02bb1a 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -1385,8 +1385,10 @@ final class MacServiceContainer { services.toolchainInputWatcher = MacToolchainInputWatcher() services.toolchainPreferences = toolchainPreferences services.projectToolInstallations = { tools in - await MacProjectToolInstallations(discover: { await toolchainDiscovery.discover($0) }) - .installations(of: tools, sources: toolchainSources()) + try await MacProjectToolInstallations( + discover: { await toolchainDiscovery.discover($0) }, + report: { logSink?.emit($0) } + ).installations(of: tools, sources: toolchainSources()) } services.projectGeneratorSources = { pluginsInEffect().map { plugin in diff --git a/macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolInstallations.swift b/macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolInstallations.swift index 69d01222bb..e993f0366f 100644 --- a/macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolInstallations.swift +++ b/macos/Sources/Lithe/Platform/MacOS/ProjectGeneration/MacProjectToolInstallations.swift @@ -1,4 +1,5 @@ import Foundation +import LitheModuleAPI // Note: 新建项目向导与项目生成器见 .agents/notes/implemented/feature/2026-10-23-plugin-project-generators.md @@ -15,27 +16,34 @@ struct MacProjectToolInstallations: Sendable { private let discover: @Sendable (MacToolchainDiscovery.Request) async -> MacToolchainDiscovery.Result private let environment: @Sendable () -> [String: String] private let scratchParent: URL + private let report: @Sendable (any LogEvent) -> Void init( discover: @escaping @Sendable (MacToolchainDiscovery.Request) async -> MacToolchainDiscovery.Result, environment: @escaping @Sendable () -> [String: String] = { MacLoginShellEnvironment.shared.merged(ProcessInfo.processInfo.environment) }, - scratchParent: URL = FileManager.default.temporaryDirectory + scratchParent: URL = FileManager.default.temporaryDirectory, + report: @escaping @Sendable (any LogEvent) -> Void = { _ in } ) { self.discover = discover self.environment = environment self.scratchParent = scratchParent + self.report = report } /// The installations of each tool, best first. A tool no enabled plugin /// defines, or one with nothing installed, maps to an empty list. + /// + /// Throws when discovery could not run because the scratch directory could + /// not be created, so the window does not mistake that for "not installed". func installations( of tools: [String], sources: [ToolchainSettingsPluginSource] - ) async -> [String: [ProjectToolInstallation]] { + ) async throws -> [String: [ProjectToolInstallation]] { let enabled = sources.filter(\.isEnabled) - guard !tools.isEmpty, !enabled.isEmpty, let scratch = makeScratchDirectory() else { return [:] } - defer { try? FileManager.default.removeItem(at: scratch) } + guard !tools.isEmpty, !enabled.isEmpty else { return [:] } + let scratch = try makeScratchDirectory() + defer { removeScratchDirectory(scratch) } let request = MacToolchainDiscovery.Request( workspace: scratch, sources: enabled, environment: environment() @@ -51,15 +59,24 @@ struct MacProjectToolInstallations: Sendable { return found } - private func makeScratchDirectory() -> URL? { + private func makeScratchDirectory() throws -> URL { let directory = scratchParent.appendingPathComponent( "lithe-new-project-tools-\(UUID().uuidString)", isDirectory: true ) do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) - return directory } catch { - return nil + report(AppStorageMaintenanceFailed(error: error, stage: "new-project-tools-scratch-create")) + throw error } + return directory + } + + /// Discovery only reads the directory, so one left behind is empty and the + /// listing it served is still right: the failure is recorded, not raised. + private func removeScratchDirectory(_ directory: URL) { + do { try FileManager.default.removeItem(at: directory) } + catch let error as CocoaError where error.code == .fileNoSuchFile { /* Already gone; nothing is left behind. */ } + catch { report(AppStorageMaintenanceFailed(error: error, stage: "new-project-tools-scratch-remove")) } } } diff --git a/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift b/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift index 1ecf4f4f4a..fa8ef01c6e 100644 --- a/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift +++ b/macos/Sources/Lithe/Views/Components/LitheSettingsControls.swift @@ -388,6 +388,8 @@ struct LitheSettingsSelect: View { private let expandsToFitOptions: Bool private let isAvailable: (Value) -> Bool private let onUnavailableSelection: ((Value) -> Void)? + /// Why an unavailable option cannot be chosen, shown when hovering it. + private let unavailableHelp: LocalizedStringKey /// When set, the popup shows a search field above the rows and filters the /// options as the user types. Existing callers leave it nil and keep the /// plain, unfiltered list. @@ -408,6 +410,7 @@ struct LitheSettingsSelect: View { expandsToFitOptions: Bool = false, isAvailable: @escaping (Value) -> Bool = { _ in true }, onUnavailableSelection: ((Value) -> Void)? = nil, + unavailableHelp: LocalizedStringKey = "Shell is not available at this path", searchPrompt: LocalizedStringKey? = nil, searchText: ((Value) -> String)? = nil ) { @@ -420,6 +423,7 @@ struct LitheSettingsSelect: View { self.expandsToFitOptions = expandsToFitOptions self.isAvailable = isAvailable self.onUnavailableSelection = onUnavailableSelection + self.unavailableHelp = unavailableHelp self.searchPrompt = searchPrompt self.searchText = searchText ?? title } @@ -494,6 +498,7 @@ struct LitheSettingsSelect: View { localizesTitles: localizesTitles, expandsToFitOptions: expandsToFitOptions, isAvailable: isAvailable, + unavailableHelp: unavailableHelp, searchPrompt: searchPrompt ) let popupHeight = searchPrompt == nil @@ -694,6 +699,7 @@ private struct LitheSettingsSelectPopupContent: View { let localizesTitles: Bool let expandsToFitOptions: Bool let isAvailable: (Value) -> Bool + let unavailableHelp: LocalizedStringKey let searchPrompt: LocalizedStringKey? var body: some View { @@ -773,7 +779,7 @@ private struct LitheSettingsSelectPopupContent: View { if hovering { state.highlightedIndex = row } } .id(row) - .help(isAvailable(option) ? "" : "Shell is not available at this path") + .help(isAvailable(option) ? "" : unavailableHelp) } } .padding(SettingsSelectMetrics.popupPadding) diff --git a/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift b/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift index 565226d645..6f2f5a71b4 100644 --- a/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift +++ b/macos/Sources/Lithe/Views/Workspace/NewProjectView.swift @@ -384,22 +384,28 @@ struct NewProjectView: View { } /// Picks one installation of the tool the field names. Core marks the one - /// the form uses; when none is found the field says why and what to do. + /// the form uses and which are new enough; an older one is listed but + /// cannot be chosen. When none is found the field says why and what to do. @ViewBuilder private func toolControl(_ field: ProjectGeneratorField) -> some View { let choices = feature.toolChoices(for: field) - if !choices.isEmpty { + if let error = feature.toolListingError(for: field) { + toolListingFailure(error) + } else if !choices.isEmpty { LitheSettingsSelect( selection: Binding( get: { feature.value(of: field).stringValue }, set: { feature.setValue(.string($0), for: field) } ), - options: choices.map(\.home), + options: feature.toolOptions(for: field), width: 420, accessibilityLabel: field.title, title: { home in - choices.first(where: { $0.home == home }).map(Self.toolChoiceTitle) ?? home + if home.isEmpty { return L10n.string("None") } + return choices.first(where: { $0.home == home }).map(Self.toolChoiceTitle) ?? home }, - localizesTitles: false + localizesTitles: false, + isAvailable: { feature.isToolChoosable($0, for: field) }, + unavailableHelp: "This installation is older than the generator requires." ) } else if feature.isListingTools { HStack(spacing: 6) { @@ -440,6 +446,22 @@ struct NewProjectView: View { } } + /// Discovery could not run, which is not the same as nothing installed. + private func toolListingFailure(_ message: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(LitheTheme.error) + Text(verbatim: message) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + Spacer(minLength: 8) + Button(LocalizedStringKey("Try Again")) { feature.retryToolListing() } + .buttonStyle(LitheActionButtonStyle()) + .fixedSize() + } + .modifier(NoticeChrome()) + } + private static func toolChoiceTitle(_ choice: ProjectGeneratorFormState.ToolChoice) -> String { let title = "\(choice.version) — \((choice.home as NSString).abbreviatingWithTildeInPath)" return choice.meetsMinimum ? title : String(format: L10n.string("%@ (older than required)"), title) diff --git a/macos/Tests/LitheTests/ProjectGenerationTests.swift b/macos/Tests/LitheTests/ProjectGenerationTests.swift index 7b3b4a3364..c369da50a0 100644 --- a/macos/Tests/LitheTests/ProjectGenerationTests.swift +++ b/macos/Tests/LitheTests/ProjectGenerationTests.swift @@ -475,13 +475,41 @@ struct ProjectGenerationTests { #expect(feature.toolChoices(for: python).map(\.meetsMinimum) == [false, true]) #expect(feature.value(of: python) == .string(Self.python312.home)) #expect(feature.canCreate) + // Both are listed; only the one new enough can be chosen. + #expect(feature.toolOptions(for: python) == [Self.python310.home, Self.python312.home]) + #expect(!feature.isToolChoosable(Self.python310.home, for: python)) + #expect(feature.isToolChoosable(Self.python312.home, for: python)) - // An installation below the minimum can be picked, and blocks creating. + // The window does not offer an installation below the minimum; one set + // anyway is still judged by Core and blocks creating. feature.setValue(.string(Self.python310.home), for: python) #expect(feature.problem(of: python) == .toolTooOld) #expect(!feature.canCreate) } + @Test + func anOptionalToolWithOnlyOldInstallationsOffersNone() async throws { + let node16 = ProjectToolInstallation(home: "/opt/node-16", executable: "/opt/node-16/bin/node", version: "16.20.2") + let feature = NewProjectFeatureModel(environment: try Self.environment( + plugins: PluginStates(enabled: [Self.toolFieldPluginID]), + sources: [try Self.toolFieldSource(enabled: true)], + toolInstallations: { _ in ["node": [node16]] } + )) + await feature.load() + let entry = try #require(feature.entries.first { $0.generator.id == "node-app" }) + feature.selection = .generator(entry.id) + let node = try #require(entry.generator.fields.first { $0.id == "node" }) + await Self.waitUntil("the node installations") { feature.toolChoices(for: node).count == 1 } + + // Node.js is optional and 16 is below 18, so the project goes without it. + #expect(feature.value(of: node) == .string("")) + #expect(feature.toolOptions(for: node) == ["", node16.home]) + #expect(feature.isToolChoosable("", for: node)) + #expect(!feature.isToolChoosable(node16.home, for: node)) + #expect(feature.problem(of: node) == nil) + #expect(feature.canCreate) + } + @Test func aToolNobodyDefinesSendsTheUserToTheMarketplace() async throws { let feature = NewProjectFeatureModel(environment: try Self.environment( @@ -543,6 +571,71 @@ struct ProjectGenerationTests { await Self.waitUntil("the second tool lookup") { requests.requested.count == 2 } } + @Test + func aPluginChangeDropsTheToolsListedForOtherEntries() async throws { + let plugins = PluginStates(enabled: [Self.toolFieldPluginID, Self.toolDefinitionPluginID]) + let changes = PassthroughSubject() + let requests = ToolListingRequests() + let feature = NewProjectFeatureModel(environment: try Self.environment( + plugins: plugins, + sources: [try Self.toolFieldSource(tool: "java", enabled: true), try Self.toolDefinitionSource()], + toolInstallations: { tools in + requests.record(tools) + // Like discovery, only an enabled definition finds anything. + let defined = plugins.enabled.contains(Self.toolDefinitionPluginID) + return Dictionary(uniqueKeysWithValues: tools.map { ($0, defined ? [Self.python312] : []) }) + }, + pluginChanges: changes.eraseToAnyPublisher() + )) + await feature.load() + let entry = try #require(feature.selectedEntry) + let field = try #require(entry.generator.fields.first { $0.id == "python" }) + await Self.waitUntil("the first tool lookup") { feature.toolChoices(for: field).count == 1 } + + // The definition goes away while another entry is selected. + feature.selection = .empty + plugins.enabled.remove(Self.toolDefinitionPluginID) + changes.send() + await Self.mainQueueTurn() + feature.selection = .generator(entry.id) + + // The installation the removed definition found is not offered again. + #expect(!feature.canCreate) + await Self.waitUntil("the tools to be looked up again") { + requests.requested.count == 2 && !feature.isListingTools + } + #expect(feature.toolChoices(for: field).isEmpty) + #expect(feature.toolNoticeAction(for: field) == .enable(Self.toolDefinitionPluginID, name: "Tool definition fixture")) + #expect(!feature.canCreate) + } + + @Test + func aFailedToolLookupIsShownAndCanBeRetried() async throws { + let lookup = ToolLookupOutcome() + let feature = NewProjectFeatureModel(environment: try Self.environment( + plugins: PluginStates(enabled: [Self.toolFieldPluginID]), + sources: [try Self.toolFieldSource(enabled: true)], + toolInstallations: { _ in + if lookup.fails { throw CocoaError(.fileWriteNoPermission) } + return ["python": [Self.python312]] + } + )) + await feature.load() + let python = try #require(feature.selectedEntry?.generator.fields.first { $0.id == "python" }) + await Self.waitUntil("the failed tool lookup") { !feature.isListingTools } + + // A lookup that did not run is not "nothing installed". + #expect(feature.toolListingError(for: python) != nil) + #expect(feature.toolNoticeAction(for: python) == nil) + #expect(!feature.canCreate) + + lookup.fails = false + feature.retryToolListing() + await Self.waitUntil("the retried tool lookup") { feature.toolChoices(for: python).count == 1 } + #expect(feature.toolListingError(for: python) == nil) + #expect(feature.canCreate) + } + // MARK: Helpers private static var planner: RustProjectGeneratorOperations { @@ -707,7 +800,7 @@ struct ProjectGenerationTests { private static func environment( plugins: PluginStates, sources: [ProjectGeneratorPluginSource]? = nil, - toolInstallations: @escaping @MainActor ([String]) async -> [String: [ProjectToolInstallation]] = { _ in [:] }, + toolInstallations: @escaping @MainActor ([String]) async throws -> [String: [ProjectToolInstallation]] = { _ in [:] }, pluginChanges: AnyPublisher = Empty().eraseToAnyPublisher() ) throws -> NewProjectEnvironment { let split = try splitJavaSupport() @@ -828,6 +921,11 @@ private final class PluginStates { init(enabled: Set) { self.enabled = enabled } } +/// Whether the stand-in tool lookup fails, switched by the test. +private final class ToolLookupOutcome { + var fails = true +} + /// Stands in for a scaffolding tool: records the request and creates one /// file in its working directory, as `mvn archetype:generate` creates the /// project in a subdirectory. diff --git a/macos/Tests/LitheTests/ProjectToolAdaptersTests.swift b/macos/Tests/LitheTests/ProjectToolAdaptersTests.swift index 54f4f0949d..6628fbfed8 100644 --- a/macos/Tests/LitheTests/ProjectToolAdaptersTests.swift +++ b/macos/Tests/LitheTests/ProjectToolAdaptersTests.swift @@ -1,5 +1,6 @@ import Foundation import LitheCoreContracts +import LitheModuleAPI import Testing @testable import Lithe @@ -29,7 +30,7 @@ struct ProjectToolAdaptersTests { scratchParent: parent ) - let found = await finder.installations(of: ["python", "node"], sources: [Self.source(enabled: true)]) + let found = try await finder.installations(of: ["python", "node"], sources: [Self.source(enabled: true)]) // A tool with nothing installed is listed with no installations. #expect(found == ["python": [Self.python], "node": []]) @@ -56,8 +57,8 @@ struct ProjectToolAdaptersTests { scratchParent: parent ) - let withoutEnabledPlugin = await finder.installations(of: ["python"], sources: [Self.source(enabled: false)]) - let withoutTools = await finder.installations(of: [], sources: [Self.source(enabled: true)]) + let withoutEnabledPlugin = try await finder.installations(of: ["python"], sources: [Self.source(enabled: false)]) + let withoutTools = try await finder.installations(of: [], sources: [Self.source(enabled: true)]) #expect(withoutEnabledPlugin.isEmpty) #expect(withoutTools.isEmpty) @@ -65,6 +66,63 @@ struct ProjectToolAdaptersTests { #expect(try FileManager.default.contentsOfDirectory(atPath: parent.path).isEmpty) } + @Test + func reportsAndThrowsWhenTheScratchDirectoryCannotBeCreated() async throws { + let parent = try Self.temporaryDirectory() + defer { try? FileManager.default.removeItem(at: parent) } + let log = DiscoveryLog() + let events = TypedLogRecorder() + let finder = MacProjectToolInstallations( + discover: { request in + await log.record(request) + return Self.detection(of: "python", Self.python) + }, + // The parent does not exist, so the scratch directory cannot be made. + scratchParent: parent.appendingPathComponent("missing", isDirectory: true), + report: { events.emit($0) } + ) + + // Not running discovery is not the same as finding nothing installed. + await #expect(throws: (any Error).self) { + try await finder.installations(of: ["python"], sources: [Self.source(enabled: true)]) + } + #expect(await log.requests.isEmpty) + #expect(Self.stages(events) == ["new-project-tools-scratch-create"]) + } + + @Test + func reportsAScratchDirectoryItCouldNotRemove() async throws { + let parent = try Self.temporaryDirectory() + let locked = LockedValue(nil) + defer { + // Make the directory removable again before cleaning up. + if let directory = locked.value { + try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: directory.path) + } + try? FileManager.default.removeItem(at: parent) + } + let events = TypedLogRecorder() + let finder = MacProjectToolInstallations( + discover: { request in + // A read-only directory holding a file cannot be emptied. + let directory = request.workspace.appendingPathComponent("locked", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + FileManager.default.createFile(atPath: directory.appendingPathComponent("file").path, contents: Data()) + try? FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: directory.path) + locked.value = directory + return Self.detection(of: "python", Self.python) + }, + scratchParent: parent, + report: { events.emit($0) } + ) + + let found = try await finder.installations(of: ["python"], sources: [Self.source(enabled: true)]) + + // The listing is still right; the leftover directory is recorded. + #expect(found == ["python": [Self.python]]) + #expect(Self.stages(events) == ["new-project-tools-scratch-remove"]) + } + // MARK: Pinning @Test @@ -131,6 +189,16 @@ struct ProjectToolAdaptersTests { MacToolchainPreferencesStore(core: RustCoreBridge(), storage: MacFileStorage(), immutableRoots: { [] }) } + /// The `stage` of each storage event the adapter reported, in order. + private static func stages(_ recorder: TypedLogRecorder) -> [String] { + recorder.events.compactMap { event in + guard event is AppStorageMaintenanceFailed, case .token(let stage)? = event.fields["stage"] else { + return nil + } + return stage + } + } + private static func temporaryDirectory() throws -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent("lithe-project-tool-adapters-\(UUID().uuidString)", isDirectory: true) @@ -139,6 +207,19 @@ struct ProjectToolAdaptersTests { } } +/// A value the discovery stand-in sets off the main actor and the test reads. +private final class LockedValue: @unchecked Sendable { + private let lock = NSLock() + private var stored: Value + + init(_ value: Value) { stored = value } + + var value: Value { + get { lock.withLock { stored } } + set { lock.withLock { stored = newValue } } + } +} + /// Stands in for toolchain discovery: remembers each request and whether the /// directory it was asked to look in was empty at that moment. private actor DiscoveryLog { diff --git a/rust/lithe-core/src/project_generators/form.rs b/rust/lithe-core/src/project_generators/form.rs index 4d4043e6d0..438e281374 100644 --- a/rust/lithe-core/src/project_generators/form.rs +++ b/rust/lithe-core/src/project_generators/form.rs @@ -287,9 +287,10 @@ fn resolve( } FIELD_RUNTIME => match input.runtimes.get(&field.id) { Some(selection) if !selection.home.trim().is_empty() => { - let too_old = field.minimum_version.as_deref().is_some_and(|minimum| { - version_components(&selection.version) < version_components(minimum) - }); + let too_old = field + .minimum_version + .as_deref() + .is_some_and(|minimum| release(&selection.version) < release(minimum)); ( json!(selection.home), FieldValue::Runtime { @@ -316,6 +317,11 @@ fn resolve( /// A tool field holds the installation the user chose, or else the newest /// one the host found. A choice the host no longer lists (the tool was /// uninstalled meanwhile) falls back to the newest, and the field shows it. +/// +/// An optional field whose newest installation is below the minimum stays +/// empty, as if the tool were not installed: an old copy on the machine must +/// not block a project that can be created without the tool. Choosing that +/// copy explicitly is still reported. fn resolve_tool( field: &GeneratorField, input: &FormInput, @@ -326,7 +332,9 @@ fn resolve_tool( .get(&field.id) .and_then(Value::as_str) .and_then(|home| found.iter().find(|installation| installation.home == home)); - match chosen.or_else(|| newest(found)) { + let fallback = + newest(found).filter(|installation| !field.optional || meets_minimum(field, installation)); + match chosen.or(fallback) { Some(installation) => ( json!(installation.home), FieldValue::Tool { @@ -361,13 +369,25 @@ fn newest(found: &[ToolInstallation]) -> Option<&ToolInstallation> { found .iter() .rev() - .max_by_key(|installation| version_components(&installation.version)) + .max_by_key(|installation| release(&installation.version)) } fn meets_minimum(field: &GeneratorField, installation: &ToolInstallation) -> bool { - field.minimum_version.as_deref().is_none_or(|minimum| { - version_components(&installation.version) >= version_components(minimum) - }) + field + .minimum_version + .as_deref() + .is_none_or(|minimum| release(&installation.version) >= release(minimum)) +} + +/// Version numbers without trailing zeros, so `3.11`, `3.11.0` and +/// `3.11.0.0` compare as the same release, as toolchain resolution treats +/// them. Plain vector comparison would put `3.11` below `3.11.0`. +fn release(version: &str) -> Vec { + let mut numbers = version_components(version); + while numbers.last() == Some(&0) { + numbers.pop(); + } + numbers } fn tool_choices(field: &GeneratorField, input: &FormInput) -> Vec { @@ -404,8 +424,7 @@ fn version_too_high( if bound.is_empty() || value.is_empty() { return None; } - (version_components(&value) > version_components(&bound)) - .then_some(FieldProblem::VersionTooHigh) + (release(&value) > release(&bound)).then_some(FieldProblem::VersionTooHigh) } fn text_problem(field: &GeneratorField, text: &str) -> Option { @@ -688,6 +707,72 @@ mod tests { assert_eq!(meets, [false, true]); } + /// `3.11` and `3.11.0` are one release: neither falls below a minimum + /// written the other way, and equal releases keep the host's order. + #[test] + fn trailing_zeros_do_not_change_a_release() { + let tool = generator(json!([{ + "id": "sdk", "type": "tool", "title": "Python", "tool": "python", + "minimumVersion": "3.11.0", "pinAs": "project" + }])); + let form = with_tools(input("demo", json!({}), &[]), &[("/py/3.11", "3.11")]); + let state = evaluate(&tool, &form); + assert_eq!(field(&state, "sdk").problem, None); + assert!(field(&state, "sdk").installations[0].meets_minimum); + + let form = with_tools( + input("demo", json!({}), &[]), + &[("/first", "3.12"), ("/second", "3.12.0.0")], + ); + let state = evaluate(&tool_generator(false), &form); + assert_eq!(field(&state, "sdk").value, json!("/first")); + + let runtime = generator(json!([ + {"id": "jdk", "type": "runtime", "title": "JDK", "runtime": "java", "minimumVersion": "17.0"} + ])); + let mut form = input("demo", json!({}), &[]); + form.runtimes.insert( + "jdk".into(), + RuntimeSelection { + home: "/jdk".into(), + version: "17".into(), + }, + ); + assert_eq!(field(&evaluate(&runtime, &form), "jdk").problem, None); + } + + /// An old copy of an optional tool must not block a project that could be + /// created without the tool: the field stays empty and leaves no pin, the + /// same as when nothing is installed. Choosing the old copy is reported. + #[test] + fn an_optional_tool_with_only_old_installations_stays_empty() { + let form = with_tools(input("demo", json!({}), &[]), &[("/py/3.10", "3.10.14")]); + let state = evaluate(&tool_generator(true), &form); + assert_eq!(field(&state, "sdk").problem, None); + assert_eq!(field(&state, "sdk").value, json!("")); + assert!(state.is_valid()); + assert_eq!( + state.values["sdk"], + FieldValue::Tool { + home: String::new(), + executable: String::new(), + } + ); + assert!(!field(&state, "sdk").installations[0].meets_minimum); + + let mut cleared = form.clone(); + cleared.values.insert("sdk".into(), json!("")); + assert_eq!( + field(&evaluate(&tool_generator(true), &cleared), "sdk").value, + json!("") + ); + + let mut chosen = form; + chosen.values.insert("sdk".into(), json!("/py/3.10")); + let state = evaluate(&tool_generator(true), &chosen); + assert_eq!(field(&state, "sdk").problem, Some(FieldProblem::ToolTooOld)); + } + #[test] fn a_tool_field_without_candidates_is_required_unless_optional() { let form = input("demo", json!({}), &[]); diff --git a/shared/contracts/plugins/v1/README.md b/shared/contracts/plugins/v1/README.md index 481dbe2e37..7a7d855c95 100644 --- a/shared/contracts/plugins/v1/README.md +++ b/shared/contracts/plugins/v1/README.md @@ -2094,14 +2094,17 @@ tool settings, exactly as the tool's own settings page does. | `tool` | Required. A tool definition ID: the short ID of a verified official definition, otherwise `:`. There is no closed list; a tool nobody declares has no installations. | | `minimumVersion` | Optional, one to three dotted numbers. An installation below it is not selectable. | | `pinAs` | Optional. `project` saves the selection as the created project's own tool choice (the same per-directory pin the tool's settings page edits), so the project builds and runs with the installation picked here. Without it the selection only feeds the steps. | -| `optional` | Optional. Without a usable installation an optional field stays empty and leaves no pin; otherwise the form reports `required`. | +| `optional` | Optional. Without a usable installation an optional field stays empty and leaves no pin; otherwise the form reports `required`. An installation below `minimumVersion` is not usable. | A tool field has no `default`; the host chooses the initial value. Core picks, among the installations the host listed, the user's choice if it is still listed and otherwise the newest version (the host's first among equal -versions). An installation below `minimumVersion` is reported as -`toolTooOld` and is marked in the field's `installations`, so the host can -show it disabled. +versions). Versions compare as releases, so trailing zeros do not count: +`3.11`, `3.11.0`, and `3.11.0.0` are equal. An installation below +`minimumVersion` is marked in the field's `installations`, so the host can +show it disabled. When the selection is below `minimumVersion` the form +reports `toolTooOld`, except that an optional field the user has not set +stays empty rather than picking an installation that is too old. A generator with `projectGenerators` 2 must not declare a `runtime` field, and a generator with `projectGenerators` 1 must not declare a `tool` field diff --git a/shared/contracts/plugins/v1/fixtures/project-generator-tool-field.plugin.json b/shared/contracts/plugins/v1/fixtures/project-generator-tool-field.plugin.json index 774ac793a6..107b72fde8 100644 --- a/shared/contracts/plugins/v1/fixtures/project-generator-tool-field.plugin.json +++ b/shared/contracts/plugins/v1/fixtures/project-generator-tool-field.plugin.json @@ -31,7 +31,8 @@ "displayName": "Node App", "section": "generator", "fields": [ - {"id": "node", "type": "tool", "title": "Node.js", "tool": "node", "pinAs": "project", "optional": true} + {"id": "node", "type": "tool", "title": "Node.js", "tool": "node", + "minimumVersion": "18.0.0", "pinAs": "project", "optional": true} ], "steps": [ {"kind": "command", "command": "npm", "mayUseNetwork": true, diff --git a/shared/contracts/plugins/v1/fixtures/project-plans.json b/shared/contracts/plugins/v1/fixtures/project-plans.json index 7cdfa228e3..cf3d314860 100644 --- a/shared/contracts/plugins/v1/fixtures/project-plans.json +++ b/shared/contracts/plugins/v1/fixtures/project-plans.json @@ -597,6 +597,109 @@ } } }, + { + "name": "tool form treats a release with fewer trailing zeros as the same release", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generatorForm", + "generatorID": "node-app", + "form": { + "projectName": "demo", + "tools": { + "node": [ + {"home": "/opt/node-18", "executable": "/opt/node-18/bin/node", "version": "18"}, + {"home": "/opt/node-18.0", "executable": "/opt/node-18.0/bin/node", "version": "18.0.0"} + ] + } + }, + "expected": { + "status": "ok", + "form": { + "projectName": {"problem": null}, + "fields": [ + { + "id": "node", + "visible": true, + "value": "/opt/node-18", + "installations": [ + {"home": "/opt/node-18", "executable": "/opt/node-18/bin/node", "version": "18", "meetsMinimum": true}, + {"home": "/opt/node-18.0", "executable": "/opt/node-18.0/bin/node", "version": "18.0.0", "meetsMinimum": true} + ] + } + ], + "activeRequirements": [], + "valid": true + } + } + }, + { + "name": "tool form leaves an optional tool with only old installations empty", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generatorForm", + "generatorID": "node-app", + "form": { + "projectName": "demo", + "tools": { + "node": [ + {"home": "/opt/node-16", "executable": "/opt/node-16/bin/node", "version": "16.20.2"} + ] + } + }, + "expected": { + "status": "ok", + "form": { + "projectName": {"problem": null}, + "fields": [ + { + "id": "node", + "visible": true, + "value": "", + "installations": [ + {"home": "/opt/node-16", "executable": "/opt/node-16/bin/node", "version": "16.20.2", "meetsMinimum": false} + ] + } + ], + "activeRequirements": [], + "valid": true + } + } + }, + { + "name": "plan leaves an optional tool with only old installations unpinned", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generationPlan", + "generatorID": "node-app", + "form": { + "projectName": "demo", + "tools": { + "node": [ + {"home": "/opt/node-16", "executable": "/opt/node-16/bin/node", "version": "16.20.2"} + ] + } + }, + "expected": { + "status": "ok", + "plan": {"projectName": "demo", "steps": [], "openFiles": [], "runtimes": [], "toolPins": [], "usesNetwork": false} + } + }, + { + "name": "plan refuses an old optional tool the user chose", + "manifest": "project-generator-tool-field.plugin.json", + "operation": "generationPlan", + "generatorID": "node-app", + "form": { + "projectName": "demo", + "values": {"node": "/opt/node-16"}, + "tools": { + "node": [ + {"home": "/opt/node-16", "executable": "/opt/node-16/bin/node", "version": "16.20.2"} + ] + } + }, + "expected": { + "status": "rejected", + "error": {"code": "plugin.project.invalidInput", "message": "The value of node is not accepted (toolTooOld).", "field": "values.node"} + } + }, { "name": "name accepted", "operation": "checkName", diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 2ad6ffded2..f0b5cc0d7f 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -4047,6 +4047,7 @@ "native.couldNotLoadSavedChanges": "Could not load saved changes", "native.couldNotLoadServiceDependencies": "Could not load service dependencies", "native.couldNotLoadTheChangedFiles": "Could not load the changed files.", + "native.couldNotLookForInstalledTools": "Could not look for installed tools: {arg1}", "native.couldNotMoveTheEditorTabItsContentCouldNotBeSynchronized": "Could not move the editor tab. Its content could not be synchronized.", "native.couldNotOpenHTMLInBrowser": "Could not open HTML in browser", "native.couldNotRead": "Could not read {arg1}: {arg2}", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 7419e8b8b8..2b2c907f6a 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -4047,6 +4047,7 @@ "native.couldNotLoadSavedChanges": "无法加载已保存的更改", "native.couldNotLoadServiceDependencies": "无法加载服务依赖", "native.couldNotLoadTheChangedFiles": "无法加载更改的文件。", + "native.couldNotLookForInstalledTools": "无法查找已安装的工具:{arg1}", "native.couldNotMoveTheEditorTabItsContentCouldNotBeSynchronized": "无法移动编辑标签,未能同步其内容。", "native.couldNotOpenHTMLInBrowser": "无法在浏览器中打开 HTML", "native.couldNotRead": "无法读取 {arg1}:{arg2}", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 9a2e1fb4d7..3d1b0e982d 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -33337,6 +33337,22 @@ "arguments": [] } }, + { + "id": "native.couldNotLookForInstalledTools", + "description": "Native UI: Could not look for installed tools: %@", + "platforms": [ + "macos" + ], + "macos": { + "key": "Could not look for installed tools: %@", + "arguments": [ + "arg1" + ] + }, + "parameters": { + "arg1": "string" + } + }, { "id": "native.couldNotMoveTheEditorTabItsContentCouldNotBeSynchronized", "description": "Native UI: Could not move the editor tab. Its content could not be synchronized.", From e7bf03c922c50c0f426e859fa96359e88240584e Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 5 Nov 2026 12:53:47 +0800 Subject: [PATCH 10/11] =?UTF-8?q?fix(contracts):=20=E5=90=88=E5=B9=B6=20fi?= =?UTF-8?q?xtures=20=E5=88=86=E7=9B=AE=E5=BD=95=E5=90=8E=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=20project-plans=20=E8=B7=AF=E5=BE=84=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=20rustfmt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/lithe-core/src/tests/build_systems.rs | 145 ++++++++++++------ .../src/tests/declarative_plugins.rs | 42 +++-- .../src/tests/marketplace_plugins.rs | 3 +- .../src/tests/plugin_editor_resources.rs | 4 +- .../architecture/verify-shared-contracts.sh | 2 +- 5 files changed, 129 insertions(+), 67 deletions(-) diff --git a/rust/lithe-core/src/tests/build_systems.rs b/rust/lithe-core/src/tests/build_systems.rs index 28169c8aeb..a536d64134 100644 --- a/rust/lithe-core/src/tests/build_systems.rs +++ b/rust/lithe-core/src/tests/build_systems.rs @@ -30,9 +30,12 @@ macro_rules! contract_file { const CARGO_FIXTURE: &str = contract_file!("fixtures/build-systems/build-static-file.plugin.json"); const GO_FIXTURE: &str = contract_file!("fixtures/build-systems/build-command-stdout.plugin.json"); -const GRADLE_FIXTURE: &str = contract_file!("fixtures/build-systems/build-command-output-file.plugin.json"); -const MAVEN_FIXTURE: &str = contract_file!("fixtures/build-systems/build-host-integration.plugin.json"); -const GRADLE_PROVIDER_FIXTURE: &str = contract_file!("fixtures/build-providers/build-provider.plugin.json"); +const GRADLE_FIXTURE: &str = + contract_file!("fixtures/build-systems/build-command-output-file.plugin.json"); +const MAVEN_FIXTURE: &str = + contract_file!("fixtures/build-systems/build-host-integration.plugin.json"); +const GRADLE_PROVIDER_FIXTURE: &str = + contract_file!("fixtures/build-providers/build-provider.plugin.json"); const MIX_PROVIDER_FIXTURE: &str = contract_file!("fixtures/build-providers/build-provider-language-server.plugin.json"); const BUILD_PLANS: &str = contract_file!("fixtures/build-systems/build-plans.json"); @@ -49,7 +52,9 @@ fn fixture(name: &str) -> Value { "build-provider-reports.plugin.json" => { contract_file!("fixtures/build-providers/build-provider-reports.plugin.json") } - "build-toolchains.plugin.json" => contract_file!("fixtures/build-systems/build-toolchains.plugin.json"), + "build-toolchains.plugin.json" => { + contract_file!("fixtures/build-systems/build-toolchains.plugin.json") + } "build-command-stdout.plugin.json" => GO_FIXTURE, "build-command-output-file.plugin.json" => GRADLE_FIXTURE, "build-host-integration.plugin.json" => MAVEN_FIXTURE, @@ -478,8 +483,10 @@ fn provider_test_coverage_is_bounded_canonical_and_does_not_change_legacy_result fn canonical_repeated_cases_sum_only_complete_safe_durations() { let workspace = TemporaryWorkspace::new("canonical-case-duration"); let manifest = fixture("build-provider-reports.plugin.json"); - let fixture: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-test-durations.json")).unwrap(); + let fixture: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-test-durations.json" + )) + .unwrap(); for case in fixture["cases"].as_array().unwrap() { let left = &case["values"][0]; let right = &case["values"][1]; @@ -1959,8 +1966,10 @@ fn task_categories_and_option_placements_are_display_only() { /// launch settings. No filesystem access or per-tool branch belongs in Core. #[test] fn explicit_build_tool_path_requires_supported_global_file_setting() { - let manifest: Value = - serde_json::from_str(contract_file!("fixtures/build-systems/build-tool-path.plugin.json")).unwrap(); + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-systems/build-tool-path.plugin.json" + )) + .unwrap(); let validated = validate(manifest.clone()); assert_eq!(validated["status"], "ok", "{validated}"); let invoke = |settings: Value| { @@ -2027,8 +2036,10 @@ fn explicit_build_tool_path_requires_supported_global_file_setting() { #[test] fn executable_candidates_order_and_constraints_are_shared_and_deterministic() { - let base: Value = - serde_json::from_str(contract_file!("fixtures/build-systems/build-tool-path.plugin.json")).unwrap(); + let base: Value = serde_json::from_str(contract_file!( + "fixtures/build-systems/build-tool-path.plugin.json" + )) + .unwrap(); let invoke = |candidates: Value| { let mut manifest = base.clone(); manifest["buildSystems"][0]["invocation"]["executables"] = candidates; @@ -2080,8 +2091,10 @@ fn executable_candidates_order_and_constraints_are_shared_and_deterministic() { #[test] fn published_build_v2_paths_adapt_to_candidates_and_preserve_invalid_values() { - let mut manifest: Value = - serde_json::from_str(contract_file!("fixtures/build-systems/build-tool-path.plugin.json")).unwrap(); + let mut manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-systems/build-tool-path.plugin.json" + )) + .unwrap(); manifest["requires"] .as_object_mut() .unwrap() @@ -2116,8 +2129,10 @@ fn published_build_v2_paths_adapt_to_candidates_and_preserve_invalid_values() { #[test] fn executable_resolution_is_opt_in_and_custom_runtimes_share_the_same_plan() { - let mut manifest: Value = - serde_json::from_str(contract_file!("fixtures/build-systems/build-tool-path.plugin.json")).unwrap(); + let mut manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-systems/build-tool-path.plugin.json" + )) + .unwrap(); let old_host = validate_command( json!({"manifest":manifest,"hostCapabilities":{"buildSystems":[1,2],"settings":[1,2]}}), ) @@ -2283,10 +2298,14 @@ fn preparation_tasks_are_declared_versioned_and_unambiguous() { /// no test selector is interpolated into a host command. #[test] fn provider_tests_preserve_filters_and_report_ownership() { - let manifest: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-provider-tests.plugin.json")).unwrap(); - let fixture: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-test-protocol.json")).unwrap(); + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-provider-tests.plugin.json" + )) + .unwrap(); + let fixture: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-test-protocol.json" + )) + .unwrap(); assert_eq!(validate(manifest.clone())["status"], "ok"); let plan = build_test_plan_command( json!({"manifest": manifest, "buildSystemID":"gradle", "request":fixture["request"]}), @@ -2339,8 +2358,10 @@ fn provider_tests_preserve_filters_and_report_ownership() { /// every test or fall back to host-owned business logic. #[test] fn provider_tests_require_version_three_and_an_owned_process() { - let manifest: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-provider-tests.plugin.json")).unwrap(); + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-provider-tests.plugin.json" + )) + .unwrap(); for version in [1, 2] { let mut old = manifest.clone(); old["requires"]["buildProviders"] = json!(version); @@ -2383,10 +2404,14 @@ fn provider_tests_require_version_three_and_an_owned_process() { /// identity and rejecting malformed lists instead of silently hiding tests. #[test] fn provider_test_discovery_is_bounded_deterministic_and_runnable() { - let manifest: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-provider-tests.plugin.json")).unwrap(); - let fixture: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-test-discovery.json")).unwrap(); + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-provider-tests.plugin.json" + )) + .unwrap(); + let fixture: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-test-discovery.json" + )) + .unwrap(); let plan = build_test_discovery_plan_command(json!({"manifest":manifest,"buildSystemID":"gradle", "request":{"root":".","refresh":true}})) @@ -2449,8 +2474,10 @@ fn provider_run_preparation_selects_targets_and_validates_launch_data() { "fixtures/build-providers/build-provider-run-preparation.plugin.json" )) .unwrap(); - let fixture: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-run-preparation.json")).unwrap(); + let fixture: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-run-preparation.json" + )) + .unwrap(); assert_eq!(validate(manifest.clone())["status"], "ok"); let plan = |request: Value| { build_prepare_run_plan_command( @@ -2557,10 +2584,14 @@ fn provider_debug_preparation_validates_native_target_and_same_plugin_adapter() "fixtures/build-providers/build-provider-debug-preparation.plugin.json" )) .unwrap(); - let fixture: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-debug-preparation.json")).unwrap(); - let run: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-run-preparation.json")).unwrap(); + let fixture: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-debug-preparation.json" + )) + .unwrap(); + let run: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-run-preparation.json" + )) + .unwrap(); assert_eq!(validate(manifest.clone())["status"], "ok"); let planned = build_prepare_debug_plan_command( json!({"manifest":manifest,"buildSystemID":"gradle","request":run["request"]}), @@ -2669,10 +2700,14 @@ fn provider_debug_target_bindings_keep_exact_arguments_and_require_bounded_facts #[test] fn model_inputs_project_public_facts_and_preserve_configuration_identity() { use crate::build::build_resolve_model_inputs_command; - let producer: Value = - serde_json::from_str(contract_file!("fixtures/build-systems/build-model-producer.plugin.json")).unwrap(); - let consumer: Value = - serde_json::from_str(contract_file!("fixtures/build-systems/build-model-consumer.plugin.json")).unwrap(); + let producer: Value = serde_json::from_str(contract_file!( + "fixtures/build-systems/build-model-producer.plugin.json" + )) + .unwrap(); + let consumer: Value = serde_json::from_str(contract_file!( + "fixtures/build-systems/build-model-consumer.plugin.json" + )) + .unwrap(); assert_eq!( validate(producer.clone())["status"], "ok", @@ -2746,10 +2781,14 @@ fn model_inputs_project_public_facts_and_preserve_configuration_identity() { #[test] fn model_inputs_reject_invalid_manifests_models_and_snapshot_smuggling() { use crate::build::build_resolve_model_inputs_command; - let producer: Value = - serde_json::from_str(contract_file!("fixtures/build-systems/build-model-producer.plugin.json")).unwrap(); - let consumer: Value = - serde_json::from_str(contract_file!("fixtures/build-systems/build-model-consumer.plugin.json")).unwrap(); + let producer: Value = serde_json::from_str(contract_file!( + "fixtures/build-systems/build-model-producer.plugin.json" + )) + .unwrap(); + let consumer: Value = serde_json::from_str(contract_file!( + "fixtures/build-systems/build-model-consumer.plugin.json" + )) + .unwrap(); for change in ["oldWire", "noCapability", "reexport", "unknownOption"] { let mut manifest = consumer.clone(); match change { @@ -2843,15 +2882,18 @@ fn provider_run_options_preserve_argv_and_validate_declared_execution_and_cwd() "fixtures/build-providers/build-provider-run-options.plugin.json" )) .unwrap(); - let fixture: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-run-options.json")).unwrap(); + let fixture: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-run-options.json" + )) + .unwrap(); let workspace = TemporaryWorkspace::new("provider-run-options"); workspace.write("app/Main.java", "class Main {}"); workspace.write("tools/readme.txt", "working directory"); - let mut request: Value = - serde_json::from_str::(contract_file!("fixtures/build-providers/build-run-preparation.json")) - .unwrap()["request"] - .clone(); + let mut request: Value = serde_json::from_str::(contract_file!( + "fixtures/build-providers/build-run-preparation.json" + )) + .unwrap()["request"] + .clone(); request["root"] = json!("app"); request["run"] = json!("service"); request["workspaceRoot"] = json!(workspace.root()); @@ -2938,10 +2980,11 @@ fn provider_connection_debug_options_require_version_eleven_and_a_declared_regis let workspace = TemporaryWorkspace::new("provider-connection-debug"); workspace.write("app/Main.java", "class Main {}"); workspace.write("tools/readme.txt", "cwd"); - let mut request: Value = - serde_json::from_str::(contract_file!("fixtures/build-providers/build-run-preparation.json")) - .unwrap()["request"] - .clone(); + let mut request: Value = serde_json::from_str::(contract_file!( + "fixtures/build-providers/build-run-preparation.json" + )) + .unwrap()["request"] + .clone(); request["root"] = json!("app"); request["run"] = json!("service"); request["workspaceRoot"] = json!(workspace.root()); @@ -3007,8 +3050,10 @@ fn provider_run_options_refuse_explicit_cwd_symlinks_outside_workspace() { "fixtures/build-providers/build-provider-run-options.plugin.json" )) .unwrap(); - let mut fixture: Value = - serde_json::from_str(contract_file!("fixtures/build-providers/build-run-options.json")).unwrap(); + let mut fixture: Value = serde_json::from_str(contract_file!( + "fixtures/build-providers/build-run-options.json" + )) + .unwrap(); fixture["launchOptions"]["workingDirectory"] = json!("escape"); fixture["result"]["workingDirectory"] = json!("escape"); let result = build_decode_run_preparation_command(json!({"manifest":manifest,"buildSystemID":"gradle", diff --git a/rust/lithe-core/src/tests/declarative_plugins.rs b/rust/lithe-core/src/tests/declarative_plugins.rs index 944ddf64a1..23f7eb6952 100644 --- a/rust/lithe-core/src/tests/declarative_plugins.rs +++ b/rust/lithe-core/src/tests/declarative_plugins.rs @@ -19,25 +19,35 @@ const PHP_FIXTURE: &str = contract_file!("fixtures/languages/language-packaged-s const HELLO_FIXTURE: &str = contract_file!("fixtures/manifest/hello-lithe.plugin.json"); const WEB_PANEL_FIXTURE: &str = contract_file!("fixtures/ui/web-panel.plugin.json"); const GO_FIXTURE: &str = contract_file!("fixtures/languages/language-system-command.plugin.json"); -const JAVA_FIXTURE: &str = contract_file!("fixtures/languages/language-host-integration.plugin.json"); +const JAVA_FIXTURE: &str = + contract_file!("fixtures/languages/language-host-integration.plugin.json"); const NODE_RUNTIME_FIXTURE: &str = contract_file!("fixtures/languages/node-runtime.plugin.json"); -const PUBLISHER_BUNDLED_FIXTURE: &str = contract_file!("fixtures/package-trust/publisher-bundled.plugin.json"); -const CARGO_BUILD_FIXTURE: &str = contract_file!("fixtures/build-systems/build-static-file.plugin.json"); -const GO_BUILD_FIXTURE: &str = contract_file!("fixtures/build-systems/build-command-stdout.plugin.json"); -const GRADLE_BUILD_FIXTURE: &str = contract_file!("fixtures/build-systems/build-command-output-file.plugin.json"); -const MAVEN_BUILD_FIXTURE: &str = contract_file!("fixtures/build-systems/build-host-integration.plugin.json"); -const GRADLE_PROVIDER_FIXTURE: &str = contract_file!("fixtures/build-providers/build-provider.plugin.json"); +const PUBLISHER_BUNDLED_FIXTURE: &str = + contract_file!("fixtures/package-trust/publisher-bundled.plugin.json"); +const CARGO_BUILD_FIXTURE: &str = + contract_file!("fixtures/build-systems/build-static-file.plugin.json"); +const GO_BUILD_FIXTURE: &str = + contract_file!("fixtures/build-systems/build-command-stdout.plugin.json"); +const GRADLE_BUILD_FIXTURE: &str = + contract_file!("fixtures/build-systems/build-command-output-file.plugin.json"); +const MAVEN_BUILD_FIXTURE: &str = + contract_file!("fixtures/build-systems/build-host-integration.plugin.json"); +const GRADLE_PROVIDER_FIXTURE: &str = + contract_file!("fixtures/build-providers/build-provider.plugin.json"); const MIX_PROVIDER_FIXTURE: &str = contract_file!("fixtures/build-providers/build-provider-language-server.plugin.json"); const SETTINGS_V2_FIXTURE: &str = contract_file!("fixtures/settings/settings-v2.plugin.json"); const BACKEND_FIXTURE: &str = contract_file!("fixtures/backend/backend.plugin.json"); -const WORKSPACE_BACKEND_FIXTURE: &str = contract_file!("fixtures/backend/backend-workspace.plugin.json"); -const DEBUG_ADAPTER_FIXTURE: &str = contract_file!("fixtures/debug-adapters/debug-adapter.plugin.json"); +const WORKSPACE_BACKEND_FIXTURE: &str = + contract_file!("fixtures/backend/backend-workspace.plugin.json"); +const DEBUG_ADAPTER_FIXTURE: &str = + contract_file!("fixtures/debug-adapters/debug-adapter.plugin.json"); const DEBUG_ADAPTER_LANGUAGE_SERVER_FIXTURE: &str = contract_file!("fixtures/debug-adapters/debug-adapter-language-server.plugin.json"); const DEBUG_ADAPTER_REMOTE_ATTACH_FIXTURE: &str = contract_file!("fixtures/debug-adapters/debug-adapter-remote-attach.plugin.json"); -const DEBUG_ADAPTER_REJECTIONS: &str = contract_file!("fixtures/debug-adapters/debug-adapter-rejections.json"); +const DEBUG_ADAPTER_REJECTIONS: &str = + contract_file!("fixtures/debug-adapters/debug-adapter-rejections.json"); const EDITOR_ACTIONS_FIXTURE: &str = contract_file!("fixtures/editor/editor-actions.plugin.json"); const GRAMMAR_FIXTURE: &str = contract_file!("fixtures/languages/language-grammar.plugin.json"); const PROJECT_GENERATOR_FIXTURE: &str = @@ -2103,8 +2113,10 @@ fn debug_start_arguments_refuse_stale_registrations_and_missing_target_facts() { /// only their own methods rather than an entire growing service namespace. #[test] fn pure_backend_commands_require_ui_4_and_grant_methods_individually() { - let manifest: Value = - serde_json::from_str(contract_file!("fixtures/backend/command-backend.plugin.json")).unwrap(); + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/backend/command-backend.plugin.json" + )) + .unwrap(); assert_eq!(validate(manifest.clone())["status"], "ok"); let mut old = manifest.clone(); old["requires"]["ui"] = json!(3); @@ -2124,8 +2136,10 @@ fn pure_backend_commands_require_ui_4_and_grant_methods_individually() { #[test] fn terminal_permission_grants_only_owned_terminal_methods_and_requires_backend_two() { - let manifest: Value = - serde_json::from_str(contract_file!("fixtures/backend/terminal-backend.plugin.json")).unwrap(); + let manifest: Value = serde_json::from_str(contract_file!( + "fixtures/backend/terminal-backend.plugin.json" + )) + .unwrap(); assert_eq!(validate(manifest.clone())["status"], "ok"); let plan = language_server_launch_plan_command(json!({ "manifest": manifest, "backend": true, diff --git a/rust/lithe-core/src/tests/marketplace_plugins.rs b/rust/lithe-core/src/tests/marketplace_plugins.rs index 05cd5a1dbc..b92ec4e983 100644 --- a/rust/lithe-core/src/tests/marketplace_plugins.rs +++ b/rust/lithe-core/src/tests/marketplace_plugins.rs @@ -20,7 +20,8 @@ const SOURCE_URLS: &str = contract_file!("fixtures/marketplace/source-urls.json" const INSTALL_REVIEWS: &str = contract_file!("fixtures/marketplace/install-reviews.json"); const PHP_FIXTURE: &str = contract_file!("fixtures/languages/language-packaged-server.plugin.json"); const GO_FIXTURE: &str = contract_file!("fixtures/languages/language-system-command.plugin.json"); -const MIX_FIXTURE: &str = contract_file!("fixtures/build-providers/build-provider-language-server.plugin.json"); +const MIX_FIXTURE: &str = + contract_file!("fixtures/build-providers/build-provider-language-server.plugin.json"); const GRADLE_FIXTURE: &str = contract_file!("fixtures/build-providers/build-provider.plugin.json"); const CARGO_FIXTURE: &str = contract_file!("fixtures/build-systems/build-static-file.plugin.json"); const OFFICIAL_INDEX: &str = include_str!(concat!( diff --git a/rust/lithe-core/src/tests/plugin_editor_resources.rs b/rust/lithe-core/src/tests/plugin_editor_resources.rs index 45d84a9504..5e1e1c2c20 100644 --- a/rust/lithe-core/src/tests/plugin_editor_resources.rs +++ b/rust/lithe-core/src/tests/plugin_editor_resources.rs @@ -195,7 +195,9 @@ fn configuration_only_contribution_decodes_without_grammar() { .unwrap() .remove("grammar"); // A backend is a complete tool plugin, with no language-server ownership. - let tool = manifest(contract_file!("fixtures/backend/backend-workspace.plugin.json")); + let tool = manifest(contract_file!( + "fixtures/backend/backend-workspace.plugin.json" + )); plugin["backend"] = tool["backend"].clone(); plugin["requires"]["backend"] = json!(2); plugin["requires"]["serverProcess"] = json!(1); diff --git a/scripts/architecture/verify-shared-contracts.sh b/scripts/architecture/verify-shared-contracts.sh index d5cdaa34f5..68bb2d84f9 100755 --- a/scripts/architecture/verify-shared-contracts.sh +++ b/scripts/architecture/verify-shared-contracts.sh @@ -215,7 +215,7 @@ done end end # A project plan case runs against the version 1 generator fixture unless it names another. - JSON.parse(File.read(File.join(root, "fixtures", "project-plans.json"))).fetch("cases").each do |item| + JSON.parse(File.read(File.join(fixtures, "project-generators", "project-plans.json"))).fetch("cases").each do |item| next unless item.key?("manifest") abort "project plan case #{item.fetch("name")} uses an unknown manifest" unless fixture_names.include?(item.fetch("manifest")) end From 5c008f045f62f453602ddf41ca09db6f91098400 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 5 Nov 2026 13:11:18 +0800 Subject: [PATCH 11/11] =?UTF-8?q?test(project-generators):=20Swift=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=94=B9=E8=AF=BB=E5=88=86=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E5=90=8E=E7=9A=84=20fixtures=EF=BC=8C=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=86=99=E6=AD=BB=E5=B7=A5=E5=85=B7=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProjectGenerationTests 的工具字段与工具定义 fixtures 改从 project-generators/ 与 toolchains/ 读取 - 工具字段测试从定义 fixture 读取工具与插件 ID,运行时测试改用 KNOWN_RUNTIMES,语言名闸门回到基线 --- .../LitheTests/ProjectGenerationTests.swift | 21 ++++++++++++------- .../lithe-core/src/project_generators/form.rs | 11 ++++++---- .../src/project_generators/manifest.rs | 2 +- .../src/tests/project_generators.rs | 20 ++++++++++++------ 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/macos/Tests/LitheTests/ProjectGenerationTests.swift b/macos/Tests/LitheTests/ProjectGenerationTests.swift index c369da50a0..f1e2ebe9da 100644 --- a/macos/Tests/LitheTests/ProjectGenerationTests.swift +++ b/macos/Tests/LitheTests/ProjectGenerationTests.swift @@ -737,16 +737,23 @@ struct ProjectGenerationTests { // MARK: Tool field fixtures - private static var fixturesURL: URL { + /// The project-generator fixtures. The tool-field manifest's + /// `templates/tool-field` source resolves against this directory. + private static var generatorFixturesURL: URL { DeclarativePluginTestSupport.repositoryRoot - .appendingPathComponent("shared/contracts/plugins/v1/fixtures", isDirectory: true) + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/project-generators", isDirectory: true) + } + + private static var toolchainFixturesURL: URL { + DeclarativePluginTestSupport.repositoryRoot + .appendingPathComponent("shared/contracts/plugins/v1/fixtures/toolchains", isDirectory: true) } /// The tool-field fixture's `plugin.json`. `tool` points its `python` field /// at another tool, to test fields whose tool a different plugin defines. private static func toolFieldManifest(tool: String? = nil) throws -> Data { let text = try String( - contentsOf: fixturesURL.appendingPathComponent("project-generator-tool-field.plugin.json"), + contentsOf: generatorFixturesURL.appendingPathComponent("project-generator-tool-field.plugin.json"), encoding: .utf8 ) guard let tool else { return Data(text.utf8) } @@ -756,14 +763,14 @@ struct ProjectGenerationTests { private static func toolFieldSource(tool: String? = nil, enabled: Bool) throws -> ProjectGeneratorPluginSource { ProjectGeneratorPluginSource( pluginID: toolFieldPluginID, manifest: try toolFieldManifest(tool: tool), - packageURL: fixturesURL, isEnabled: enabled + packageURL: generatorFixturesURL, isEnabled: enabled ) } private static func toolFieldTarget(_ generatorID: String) throws -> ProjectGeneratorTarget { ProjectGeneratorTarget( pluginID: toolFieldPluginID, generatorID: generatorID, - manifest: try toolFieldManifest(), packageURL: fixturesURL + manifest: try toolFieldManifest(), packageURL: generatorFixturesURL ) } @@ -771,8 +778,8 @@ struct ProjectGenerationTests { private static func toolDefinitionSource() throws -> ProjectGeneratorPluginSource { ProjectGeneratorPluginSource( pluginID: toolDefinitionPluginID, - manifest: try Data(contentsOf: fixturesURL.appendingPathComponent("tool-definition.plugin.json")), - packageURL: fixturesURL, isEnabled: false + manifest: try Data(contentsOf: toolchainFixturesURL.appendingPathComponent("tool-definition.plugin.json")), + packageURL: toolchainFixturesURL, isEnabled: false ) } diff --git a/rust/lithe-core/src/project_generators/form.rs b/rust/lithe-core/src/project_generators/form.rs index 438e281374..d8cb79554f 100644 --- a/rust/lithe-core/src/project_generators/form.rs +++ b/rust/lithe-core/src/project_generators/form.rs @@ -463,6 +463,7 @@ pub(crate) fn project_name_problem(name: &str) -> Option { #[cfg(test)] mod tests { + use super::super::manifest::KNOWN_RUNTIMES; use super::*; fn generator(fields: Value) -> ProjectGeneratorDeclaration { @@ -727,18 +728,20 @@ mod tests { let state = evaluate(&tool_generator(false), &form); assert_eq!(field(&state, "sdk").value, json!("/first")); + // Runtime fields compare the same way; the runtime is whichever one the + // manifest accepts, so the test names none itself. let runtime = generator(json!([ - {"id": "jdk", "type": "runtime", "title": "JDK", "runtime": "java", "minimumVersion": "17.0"} + {"id": "sdk", "type": "runtime", "title": "SDK", "runtime": KNOWN_RUNTIMES[0], "minimumVersion": "17.0"} ])); let mut form = input("demo", json!({}), &[]); form.runtimes.insert( - "jdk".into(), + "sdk".into(), RuntimeSelection { - home: "/jdk".into(), + home: "/sdk".into(), version: "17".into(), }, ); - assert_eq!(field(&evaluate(&runtime, &form), "jdk").problem, None); + assert_eq!(field(&evaluate(&runtime, &form), "sdk").problem, None); } /// An old copy of an optional tool must not block a project that could be diff --git a/rust/lithe-core/src/project_generators/manifest.rs b/rust/lithe-core/src/project_generators/manifest.rs index ea43cdc166..24f688caf2 100644 --- a/rust/lithe-core/src/project_generators/manifest.rs +++ b/rust/lithe-core/src/project_generators/manifest.rs @@ -86,7 +86,7 @@ pub(crate) const FIELD_RUNTIME: &str = "runtime"; pub(crate) const FIELD_TOOL: &str = "tool"; /// Host-managed runtimes a runtime field may select. The host discovers /// installations; Core only checks the version. -const KNOWN_RUNTIMES: &[&str] = &["java"]; +pub(super) const KNOWN_RUNTIMES: &[&str] = &["java"]; /// The `requires.projectGenerators` version that introduces tool fields and /// retires runtime fields. A generator declared with version 2 selects every /// tool through its definition; version 1 keeps the runtime field. diff --git a/rust/lithe-core/src/tests/project_generators.rs b/rust/lithe-core/src/tests/project_generators.rs index 0fa6dffd36..fb59f1eed2 100644 --- a/rust/lithe-core/src/tests/project_generators.rs +++ b/rust/lithe-core/src/tests/project_generators.rs @@ -174,9 +174,17 @@ fn catalog_skips_plugins_the_host_cannot_run() { /// nobody declares has no provider, which is how an unknown ID shows up. #[test] fn catalog_names_the_plugins_that_define_each_tool() { + // Read the names from the definition fixture, so the test follows it and + // the host keeps no tool name of its own. + let definition = json(TOOL_DEFINITION_FIXTURE); + let definer = definition["id"].as_str().unwrap().to_string(); + let tool = definition["toolDefinitions"][0]["id"] + .as_str() + .unwrap() + .to_string(); let mut generators = json(TOOL_FIELD_FIXTURE); - // The definition fixture declares `java`, so point the first field at it. - generators["projectGenerators"][0]["fields"][0]["tool"] = json!("java"); + // Point the first field at the tool the definition fixture declares. + generators["projectGenerators"][0]["fields"][0]["tool"] = json!(tool); let result = project_generator_catalog_command(json!({ "plugins": [ {"manifest": generators, "enabled": true}, @@ -191,9 +199,9 @@ fn catalog_names_the_plugins_that_define_each_tool() { assert_eq!( entries[0]["tools"], json!([{ - "fieldID": "python", "tool": "java", + "fieldID": "python", "tool": tool, "providers": [{ - "pluginID": "dev.lithe.fixture.java-support", + "pluginID": definer, "pluginName": "Tool definition fixture", "enabled": false }] @@ -206,8 +214,8 @@ fn catalog_names_the_plugins_that_define_each_tool() { // A third-party definition is addressed as `:`; that spelling // names the same declaration, but another plugin's prefix does not. for (tool, provider_count) in [ - ("dev.lithe.fixture.java-support:java", 1), - ("dev.lithe.fixture.other:java", 0), + (format!("{definer}:{tool}"), 1), + (format!("dev.lithe.fixture.other:{tool}"), 0), ] { let mut generators = json(TOOL_FIELD_FIXTURE); generators["projectGenerators"][0]["fields"][0]["tool"] = json!(tool);