diff --git a/crates/aqua-registry/src/lib.rs b/crates/aqua-registry/src/lib.rs index e38e064f07..79ada578d5 100644 --- a/crates/aqua-registry/src/lib.rs +++ b/crates/aqua-registry/src/lib.rs @@ -13,7 +13,8 @@ pub use registry::{ NoOpCacheStore, package_ids, }; pub use types::{ - AquaChecksum, AquaChecksumType, AquaMinisignType, AquaPackage, AquaPackageType, RegistryYaml, + AquaChecksum, AquaChecksumType, AquaMinisignType, AquaPackage, AquaPackageType, AquaVar, + RegistryYaml, }; use thiserror::Error; diff --git a/crates/aqua-registry/src/template.rs b/crates/aqua-registry/src/template.rs index 40417aec54..8f6b1abb6d 100644 --- a/crates/aqua-registry/src/template.rs +++ b/crates/aqua-registry/src/template.rs @@ -144,26 +144,22 @@ fn lex(code: &str) -> Result>> { } } } else if code.starts_with(".") { - // Check if this is a property access (after ) or identifier) - let next_char = code.chars().nth(1); - if next_char.is_some_and(|c| c.is_alphabetic()) { - // This could be .Key or .Property - let end = code[1..] - .chars() - .enumerate() - .find(|(_, c)| !c.is_alphanumeric() && *c != '_') - .map(|(i, _)| i + 1) - .unwrap_or(code.len()); - - // If preceded by RParen, it's a property access + if let Some(first_end) = dotted_identifier_end(code) { + // This could be .Key or .Property, and can also include chained fields + // like .Vars.channel. if tokens.last().is_some_and(|t| t.is_r_paren()) { tokens.push(Token::Dot); - tokens.push(Token::Ident(&code[1..end])); + tokens.push(Token::Ident(&code[1..first_end])); } else { - // Otherwise it's a key reference - tokens.push(Token::Key(&code[1..end])); + tokens.push(Token::Key(&code[1..first_end])); + } + code = &code[first_end..]; + + while let Some(end) = dotted_identifier_end(code) { + tokens.push(Token::Dot); + tokens.push(Token::Ident(&code[1..end])); + code = &code[end..]; } - code = &code[end..]; } else { tokens.push(Token::Dot); code = &code[1..]; @@ -190,6 +186,22 @@ fn lex(code: &str) -> Result>> { Ok(tokens) } +fn dotted_identifier_end(code: &str) -> Option { + let rest = code.strip_prefix('.')?; + let first = rest.chars().next()?; + if !first.is_alphabetic() { + return None; + } + Some(1 + identifier_len(rest)) +} + +fn identifier_len(code: &str) -> usize { + code.char_indices() + .find(|(_, c)| !c.is_alphanumeric() && *c != '_') + .map(|(i, _)| i) + .unwrap_or(code.len()) +} + /// Parse tokens into an AST fn parse_tokens(tokens: &[Token]) -> Result { let mut tokens = tokens.iter().peekable(); @@ -220,7 +232,7 @@ fn parse_primary(tokens: &mut std::iter::Peekable>) -> R let token = tokens.next().wrap_err("unexpected end of expression")?; - let mut expr = match token { + let expr = match token { Token::Key(k) => Expr::Var(k.to_string()), Token::String(s) => Expr::Literal(s.to_string()), Token::LParen => { @@ -256,19 +268,7 @@ fn parse_primary(tokens: &mut std::iter::Peekable>) -> R _ => bail!("unexpected token: {token:?}"), }; - // Handle property access: expr.Property - while matches!(tokens.peek(), Some(Token::Dot)) { - tokens.next(); // consume dot - skip_whitespace(tokens); - - if let Some(Token::Ident(prop)) = tokens.next() { - expr = Expr::PropertyAccess(Box::new(expr), prop.to_string()); - } else { - bail!("expected identifier after dot"); - } - } - - Ok(expr) + parse_property_chain(tokens, expr) } /// Parse a function argument @@ -284,23 +284,11 @@ fn parse_arg(tokens: &mut std::iter::Peekable>) -> Resul if !matches!(tokens.next(), Some(Token::RParen)) { bail!("expected closing parenthesis"); } - - // Check for property access after paren - let mut result = expr; - while matches!(tokens.peek(), Some(Token::Dot)) { - tokens.next(); // consume dot - skip_whitespace(tokens); - if let Some(Token::Ident(prop)) = tokens.next() { - result = Expr::PropertyAccess(Box::new(result), prop.to_string()); - } else { - bail!("expected identifier after dot"); - } - } - Ok(result) + parse_property_chain(tokens, expr) } Some(Token::Key(k)) => { tokens.next(); - Ok(Expr::Var(k.to_string())) + parse_property_chain(tokens, Expr::Var(k.to_string())) } Some(Token::String(s)) => { tokens.next(); @@ -310,6 +298,24 @@ fn parse_arg(tokens: &mut std::iter::Peekable>) -> Resul } } +fn parse_property_chain( + tokens: &mut std::iter::Peekable>, + mut expr: Expr, +) -> Result { + while matches!(tokens.peek(), Some(Token::Dot)) { + tokens.next(); // consume dot + skip_whitespace(tokens); + + if let Some(Token::Ident(prop)) = tokens.next() { + expr = Expr::PropertyAccess(Box::new(expr), prop.to_string()); + } else { + bail!("expected identifier after dot"); + } + } + + Ok(expr) +} + fn skip_whitespace(tokens: &mut std::iter::Peekable>) { while matches!(tokens.peek(), Some(Token::Whitespace(_))) { tokens.next(); @@ -442,6 +448,12 @@ impl<'a> Evaluator<'a> { /// Evaluate property access fn eval_property(&self, expr: &Expr, prop: &str) -> Result> { + if let Expr::Var(name) = expr { + let key = format!("{name}.{prop}"); + if let Some(value) = self.ctx.get(&key) { + return Ok(Box::new(StringValue(value.clone())) as Box); + } + } let value = self.eval_value(expr)?; let prop_value = value.get_property(prop)?; Ok(Box::new(StringValue(prop_value)) as Box) @@ -683,4 +695,18 @@ mod tests { let ctx = hashmap(vec![("AssetWithoutExt", "gradle-8.14.3-bin")]); assert_eq!(render(tmpl, &ctx).unwrap(), "gradle-8.14.3/bin/gradle"); } + + #[test] + fn test_render_vars_property_access() { + let tmpl = "{{.Vars.channel}}"; + let ctx = hashmap(vec![("Vars.channel", "stable")]); + assert_eq!(render(tmpl, &ctx).unwrap(), "stable"); + } + + #[test] + fn test_render_vars_property_in_function_arg() { + let tmpl = "{{title .Vars.channel}}"; + let ctx = hashmap(vec![("Vars.channel", "stable")]); + assert_eq!(render(tmpl, &ctx).unwrap(), "Stable"); + } } diff --git a/crates/aqua-registry/src/types.rs b/crates/aqua-registry/src/types.rs index d38d63508b..f7d4da76c9 100644 --- a/crates/aqua-registry/src/types.rs +++ b/crates/aqua-registry/src/types.rs @@ -38,6 +38,7 @@ pub struct AquaPackage { pub complete_windows_ext: bool, pub supported_envs: Vec, pub files: Vec, + pub vars: Vec, pub replacements: HashMap, pub version_prefix: Option, version_filter: Option, @@ -54,6 +55,8 @@ pub struct AquaPackage { pub no_asset: bool, pub error_message: Option, pub path: Option, + #[serde(skip)] + var_values: HashMap, } /// Override configuration for specific OS/architecture combinations @@ -65,6 +68,15 @@ struct AquaOverride { goarch: Option, } +/// Variable definition for Aqua templates +#[derive(Debug, Deserialize, Clone, Default)] +pub struct AquaVar { + pub name: String, + pub default: Option, + #[serde(default)] + pub required: bool, +} + /// File definition within a package #[derive(Debug, Deserialize, Clone)] pub struct AquaFile { @@ -195,6 +207,7 @@ impl Default for AquaPackage { complete_windows_ext: true, supported_envs: Vec::new(), files: Vec::new(), + vars: Vec::new(), replacements: HashMap::new(), version_prefix: None, version_filter: None, @@ -210,6 +223,7 @@ impl Default for AquaPackage { no_asset: false, error_message: None, path: None, + var_values: HashMap::new(), } } } @@ -234,6 +248,13 @@ impl AquaPackage { self } + /// Apply user-provided variable values used by aqua `vars` templates. + pub fn with_var_values(mut self, var_values: HashMap) -> Result { + self.var_values = var_values; + self.validate_vars()?; + Ok(self) + } + fn version_override(&self, versions: &[&str]) -> &AquaPackage { let expressions = versions .iter() @@ -401,11 +422,45 @@ impl AquaPackage { ctx.insert("GOARCH".to_string(), replace(actual_arch)); ctx.insert("Arch".to_string(), replace(actual_arch)); ctx.insert("Format".to_string(), replace(&self.format)); + ctx.extend(self.vars_ctx()?); ctx.extend(overrides.clone()); crate::template::render(s, &ctx) } + fn vars_ctx(&self) -> Result> { + self.validate_vars()?; + let mut ctx = HashMap::new(); + for var in &self.vars { + if let Some(value) = self.var_value(var)? { + ctx.insert(format!("Vars.{}", var.name), value); + } + } + Ok(ctx) + } + + fn validate_vars(&self) -> Result<()> { + for var in &self.vars { + if var.name.is_empty() { + return Err(eyre!("aqua var name is empty")); + } + if var.required && self.var_value(var)?.is_none() { + return Err(eyre!("required aqua var not set: {}", var.name)); + } + } + Ok(()) + } + + fn var_value(&self, var: &AquaVar) -> Result> { + if let Some(value) = self.var_values.get(&var.name) { + return Ok(Some(value.clone())); + } + var.default + .as_ref() + .map(|value| yaml_var_to_string(&var.name, value)) + .transpose() + } + /// Set up version filter expression if configured pub fn setup_version_filter(&mut self) -> Result<()> { if let Some(version_filter) = &self.version_filter { @@ -473,6 +528,31 @@ impl AquaPackage { } } +fn yaml_var_to_string(name: &str, value: &serde_yaml::Value) -> Result { + match value { + serde_yaml::Value::String(s) => Ok(s.clone()), + serde_yaml::Value::Null => Ok(String::new()), + serde_yaml::Value::Tagged(tagged) => yaml_var_to_string(name, &tagged.value), + value => Err(eyre!( + "aqua var `{}` must be a string, got {}", + name, + yaml_value_kind(value) + )), + } +} + +fn yaml_value_kind(value: &serde_yaml::Value) -> &'static str { + match value { + serde_yaml::Value::String(_) => "string", + serde_yaml::Value::Number(_) => "number", + serde_yaml::Value::Bool(_) => "boolean", + serde_yaml::Value::Sequence(_) => "array", + serde_yaml::Value::Mapping(_) => "object", + serde_yaml::Value::Null => "null", + serde_yaml::Value::Tagged(tagged) => yaml_value_kind(&tagged.value), + } +} + /// splits a version number into an optional prefix and the remaining version string fn split_version_prefix(version: &str) -> (String, String) { version @@ -561,6 +641,9 @@ fn apply_override(mut orig: AquaPackage, avo: &AquaPackage) -> AquaPackage { if !avo.files.is_empty() { orig.files = avo.files.clone(); } + if !avo.vars.is_empty() { + orig.vars = avo.vars.clone(); + } orig.replacements.extend(avo.replacements.clone()); if let Some(avo_version_prefix) = avo.version_prefix.clone() { orig.version_prefix = Some(avo_version_prefix); @@ -904,6 +987,10 @@ impl AquaGithubArtifactAttestations { mod tests { use super::*; + fn default_str(value: &str) -> Option { + Some(serde_yaml::Value::String(value.to_string())) + } + #[test] fn test_aqua_file_src_gradle() { // Test the gradle package src template: {{.AssetWithoutExt | trimSuffix "-bin"}}/bin/gradle @@ -1030,4 +1117,145 @@ mod tests { ); } } + + #[test] + fn test_vars_default_value() { + let pkg = AquaPackage { + asset: "tool-{{.Vars.channel}}-{{.Version}}.tar.gz".to_string(), + vars: vec![AquaVar { + name: "channel".to_string(), + default: default_str("stable"), + required: false, + }], + ..Default::default() + }; + let asset = pkg.asset("1.0.0", "linux", "amd64").unwrap(); + assert_eq!(asset, "tool-stable-1.0.0.tar.gz"); + } + + #[test] + fn test_vars_override_value() { + let mut var_values = HashMap::new(); + var_values.insert("channel".to_string(), "beta".to_string()); + let pkg = AquaPackage { + asset: "tool-{{.Vars.channel}}-{{.Version}}.tar.gz".to_string(), + vars: vec![AquaVar { + name: "channel".to_string(), + default: default_str("stable"), + required: false, + }], + ..Default::default() + } + .with_var_values(var_values) + .unwrap(); + let asset = pkg.asset("1.0.0", "linux", "amd64").unwrap(); + assert_eq!(asset, "tool-beta-1.0.0.tar.gz"); + } + + #[test] + fn test_vars_default_scalar_value() { + let pkg = AquaPackage { + asset: "tool-go{{.Vars.go_version}}-{{.Version}}.tar.gz".to_string(), + vars: vec![AquaVar { + name: "go_version".to_string(), + default: Some(serde_yaml::from_str(r#""1.24""#).unwrap()), + required: false, + }], + ..Default::default() + }; + let asset = pkg.asset("1.0.0", "linux", "amd64").unwrap(); + assert_eq!(asset, "tool-go1.24-1.0.0.tar.gz"); + } + + #[test] + fn test_vars_default_array_errors() { + let pkg = AquaPackage { + asset: "tool-{{.Vars.channels}}-{{.Version}}.tar.gz".to_string(), + vars: vec![AquaVar { + name: "channels".to_string(), + default: Some(serde_yaml::from_str("[stable, beta]").unwrap()), + required: true, + }], + ..Default::default() + }; + let err = pkg.asset("1.0.0", "linux", "amd64").unwrap_err(); + assert!( + err.to_string() + .contains("aqua var `channels` must be a string, got array"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_vars_default_object_errors() { + let pkg = AquaPackage { + asset: "tool-{{.Vars.config}}-{{.Version}}.tar.gz".to_string(), + vars: vec![AquaVar { + name: "config".to_string(), + default: Some(serde_yaml::from_str("{channel: stable, flavor: beta}").unwrap()), + required: true, + }], + ..Default::default() + }; + let err = pkg.asset("1.0.0", "linux", "amd64").unwrap_err(); + assert!( + err.to_string() + .contains("aqua var `config` must be a string, got object"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_vars_required_missing() { + let pkg = AquaPackage { + asset: "tool-{{.Vars.channel}}-{{.Version}}.tar.gz".to_string(), + vars: vec![AquaVar { + name: "channel".to_string(), + default: None, + required: true, + }], + ..Default::default() + }; + let err = pkg.asset("1.0.0", "linux", "amd64").unwrap_err(); + assert!( + err.to_string() + .contains("required aqua var not set: channel"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_vars_required_missing_with_var_values() { + let pkg = AquaPackage { + vars: vec![AquaVar { + name: "go_version".to_string(), + default: None, + required: true, + }], + ..Default::default() + }; + let err = pkg.with_var_values(HashMap::new()).unwrap_err(); + assert!( + err.to_string() + .contains("required aqua var not set: go_version"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_vars_empty_name() { + let pkg = AquaPackage { + vars: vec![AquaVar { + name: String::new(), + default: None, + required: false, + }], + ..Default::default() + }; + let err = pkg.asset("1.0.0", "linux", "amd64").unwrap_err(); + assert!( + err.to_string().contains("aqua var name is empty"), + "unexpected error: {err}" + ); + } } diff --git a/docs/dev-tools/backends/aqua.md b/docs/dev-tools/backends/aqua.md index 540bf51a52..6f0633a028 100644 --- a/docs/dev-tools/backends/aqua.md +++ b/docs/dev-tools/backends/aqua.md @@ -64,6 +64,20 @@ When enabled: - A `.mise-bins` subdirectory is created with symlinks to the exposed binaries - Bundled dependencies and other extra executables, such as Python in `aws-cli`, are not added to PATH +### `vars` + +Some aqua registry entries define template variables (for example `{{.Vars.channel}}`). +Set them via tool options using either top-level keys or a nested `vars` table: + +```toml +[tools] +"aqua:flutter/flutter" = { version = "3.32.8", channel = "stable" } +"aqua:scenarigo/scenarigo" = { version = "0.21.0", vars = { go_version = "1.24" } } +``` + +Vars with defaults are filled automatically. Vars marked as required in the aqua registry must be set +unless the registry also provides a default. + ## Settings