Create starlark args crate Bug: 528225104 Change-Id: I1ea2fa80c8b20d8af4426655b40b2a166a6a6964 Reviewed-on: https://gn-review.googlesource.com/c/gn/+/23980 Reviewed-by: Cole Faust <colefaust@google.com> Reviewed-by: Matt Stark <msta@google.com> Reviewed-by: Philipp Wollermann <philwo@chromium.org>
diff --git a/src/gn/starlark/Cargo.lock b/src/gn/starlark/Cargo.lock index 0ecb308..da068a6 100644 --- a/src/gn/starlark/Cargo.lock +++ b/src/gn/starlark/Cargo.lock
@@ -71,6 +71,20 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] +name = "args" +version = "0.1.0" +dependencies = [ + "allocative", + "depset", + "either", + "starlark", + "starlark_derive", + "testutils", + "thiserror", + "types", +] + +[[package]] name = "arrayref" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/src/gn/starlark/Cargo.toml b/src/gn/starlark/Cargo.toml index 18aa81e..ac485ff 100644 --- a/src/gn/starlark/Cargo.toml +++ b/src/gn/starlark/Cargo.toml
@@ -13,6 +13,7 @@ [workspace] members = [ ".", + "crates/args", "crates/attr", "crates/depset", "crates/ffi",
diff --git a/src/gn/starlark/crates/args/Cargo.toml b/src/gn/starlark/crates/args/Cargo.toml new file mode 100644 index 0000000..aee2b9d --- /dev/null +++ b/src/gn/starlark/crates/args/Cargo.toml
@@ -0,0 +1,22 @@ +[package] +name = "args" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[lib] +doctest = false + +[dependencies] +types = { path = "../types" } +depset = { path = "../depset" } +starlark = { workspace = true } +starlark_derive = { workspace = true } +allocative = { workspace = true } +thiserror = { workspace = true } +either = { workspace = true } + +[dev-dependencies] +testutils = { path = "../testutils" }
diff --git a/src/gn/starlark/crates/args/src/args.rs b/src/gn/starlark/crates/args/src/args.rs new file mode 100644 index 0000000..af95fa5 --- /dev/null +++ b/src/gn/starlark/crates/args/src/args.rs
@@ -0,0 +1,381 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +use std::{cell::RefCell, fmt, fmt::Display}; + +use allocative::Allocative; +use starlark::{ + environment::{Methods, MethodsBuilder, MethodsStatic}, + eval::Evaluator, + starlark_simple_value, + values::{ + Coerce, Freeze, FreezeResult, Freezer, FrozenValue, ProvidesStaticType, StarlarkValue, + Trace, Tracer, Value, ValueLike as _, + }, +}; +use starlark_derive::{starlark_module, starlark_value, NoSerialize}; + +use crate::{formatter::Formatter, Error}; + +/// Internal representation of individual arguments stored in `Args`. +#[derive(Debug, Clone, Trace, Coerce, ProvidesStaticType, NoSerialize, Allocative)] +#[repr(C)] +pub enum ArgValue<V> { + Scalar { + arg_name: Option<String>, + value: V, + format: Option<Formatter>, + }, + All { + flag: Option<String>, + values: V, + map_each: Option<V>, + format_each: Option<Formatter>, + before_each: Option<String>, + terminate_with: Option<String>, + omit_if_empty: bool, + uniquify: bool, + }, + Joined { + flag: Option<String>, + values: V, + join_with: String, + map_each: Option<V>, + format_each: Option<Formatter>, + format_joined: Option<Formatter>, + omit_if_empty: bool, + uniquify: bool, + }, +} + +impl ArgValue<FrozenValue> { + pub fn to_value<'v>(&self) -> ArgValue<Value<'v>> { + match self { + Self::Scalar { + arg_name, + value, + format, + } => ArgValue::Scalar { + arg_name: arg_name.clone(), + value: value.to_value(), + format: format.clone(), + }, + Self::All { + flag, + values, + map_each, + format_each, + before_each, + terminate_with, + omit_if_empty, + uniquify, + } => ArgValue::All { + flag: flag.clone(), + values: values.to_value(), + map_each: map_each.map(|m| m.to_value()), + format_each: format_each.clone(), + before_each: before_each.clone(), + terminate_with: terminate_with.clone(), + omit_if_empty: *omit_if_empty, + uniquify: *uniquify, + }, + Self::Joined { + flag, + values, + join_with, + map_each, + format_each, + format_joined, + omit_if_empty, + uniquify, + } => ArgValue::Joined { + flag: flag.clone(), + values: values.to_value(), + join_with: join_with.clone(), + map_each: map_each.map(|m| m.to_value()), + format_each: format_each.clone(), + format_joined: format_joined.clone(), + omit_if_empty: *omit_if_empty, + uniquify: *uniquify, + }, + } + } +} + +impl Freeze for ArgValue<Value<'_>> { + type Frozen = ArgValue<FrozenValue>; + + fn freeze(self, freezer: &Freezer) -> FreezeResult<Self::Frozen> { + match self { + ArgValue::Scalar { + arg_name, + value, + format, + } => Ok(ArgValue::Scalar { + arg_name, + value: value.freeze(freezer)?, + format: format.freeze(freezer)?, + }), + ArgValue::All { + flag, + values, + map_each, + format_each, + before_each, + terminate_with, + omit_if_empty, + uniquify, + } => Ok(ArgValue::All { + flag, + values: values.freeze(freezer)?, + map_each: map_each.map(|m| m.freeze(freezer)).transpose()?, + format_each: format_each.freeze(freezer)?, + before_each, + terminate_with, + omit_if_empty, + uniquify, + }), + ArgValue::Joined { + flag, + values, + join_with, + map_each, + format_each, + format_joined, + omit_if_empty, + uniquify, + } => Ok(ArgValue::Joined { + flag, + values: values.freeze(freezer)?, + join_with, + map_each: map_each.map(|m| m.freeze(freezer)).transpose()?, + format_each: format_each.freeze(freezer)?, + format_joined: format_joined.freeze(freezer)?, + omit_if_empty, + uniquify, + }), + } + } +} + +/// The mutable Starlark `Args` object used to construct command lines for +/// actions. +#[derive(Debug, Default, ProvidesStaticType, NoSerialize, Allocative)] +pub struct Args<'v> { + /// List of arguments added to the builder. + pub(crate) arguments: RefCell<Vec<ArgValue<Value<'v>>>>, +} + +/// The frozen Starlark `Args` object, which is read-only and thread-safe. +#[derive(Debug, Default, ProvidesStaticType, NoSerialize, Allocative)] +pub struct FrozenArgs { + /// List of frozen arguments. + pub(crate) arguments: Vec<ArgValue<FrozenValue>>, +} + +unsafe impl<'v> Trace<'v> for Args<'v> { + fn trace(&mut self, tracer: &Tracer<'v>) { + for arg in self.arguments.borrow_mut().iter_mut() { + arg.trace(tracer); + } + } +} + +starlark_simple_value!(FrozenArgs); + +impl<'v> starlark::values::AllocValue<'v> for Args<'v> { + #[inline] + fn alloc_value(self, heap: starlark::values::Heap<'v>) -> starlark::values::Value<'v> { + heap.alloc_complex(self) + } +} + +impl<'v> Display for Args<'v> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> fmt::Result { + write!(f, "args") + } +} + +impl Display for FrozenArgs { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> fmt::Result { + write!(f, "args") + } +} + +#[starlark_value(type = "Args")] +impl<'v> StarlarkValue<'v> for Args<'v> { + type Canonical = FrozenArgs; + + fn get_methods() -> Option<&'static Methods> { + static RES: MethodsStatic = MethodsStatic::new("Args", |builder| { + args_methods(builder); + }); + Some(RES.methods()) + } +} + +#[starlark_value(type = "Args")] +impl<'v> StarlarkValue<'v> for FrozenArgs { + type Canonical = Self; + + fn get_methods() -> Option<&'static Methods> { + static RES: MethodsStatic = MethodsStatic::new("Args", |builder| { + args_methods(builder); + }); + Some(RES.methods()) + } +} + +impl<'v> Freeze for Args<'v> { + type Frozen = FrozenArgs; + + fn freeze(self, freezer: &Freezer) -> FreezeResult<Self::Frozen> { + Ok(FrozenArgs { + arguments: self + .arguments + .into_inner() + .into_iter() + .map(|arg| arg.freeze(freezer)) + .collect::<Result<Vec<_>, _>>()?, + }) + } +} + +// Inline it to ensure that the error doesn't actually need to be passed as a +// function parameter. +#[inline] +fn arg_name_and_value<'v>( + arg_name_or_value: Value<'v>, + value: Option<Value<'v>>, + err: Error, +) -> starlark::Result<(Option<String>, Value<'v>)> { + if let Some(val) = value { + if let Some(arg_name) = arg_name_or_value.unpack_str() { + Ok((Some(arg_name.to_owned()), val)) + } else { + Err(starlark::Error::from(err)) + } + } else { + Ok((None, arg_name_or_value)) + } +} + +fn get_mutable_args<'v>( + this: Value<'v>, +) -> starlark::Result<std::cell::RefMut<'v, Vec<ArgValue<Value<'v>>>>> { + if let Some(args) = this.downcast_ref::<Args<'v>>() { + Ok(args.arguments.borrow_mut()) + } else if this.downcast_ref::<FrozenArgs>().is_some() { + Err(starlark::Error::new_other(Error::CannotMutateFrozenArgs)) + } else { + unreachable!(); + } +} + +/// Registers the Starlark methods of the `Args` class (`add`, `add_all`, +/// `add_joined`). +#[starlark_module] +pub fn args_methods(builder: &mut MethodsBuilder) { + fn add<'v>( + this: Value<'v>, + arg_name_or_value: Value<'v>, + value: Option<Value<'v>>, + #[starlark(require = named)] format: Option<Formatter>, + ) -> starlark::Result<Value<'v>> { + let mut args = get_mutable_args(this)?; + + let (arg_name, val) = + arg_name_and_value(arg_name_or_value, value, Error::ExpectedAddStringFlag)?; + + args.push(ArgValue::Scalar { + arg_name, + value: val, + format, + }); + Ok(this) + } + + fn add_all<'v>( + this: Value<'v>, + arg_name_or_values: Value<'v>, + values: Option<Value<'v>>, + #[starlark(require = named)] map_each: Option<Value<'v>>, + #[starlark(require = named)] format_each: Option<Formatter>, + #[starlark(require = named)] before_each: Option<&str>, + #[starlark(require = named)] terminate_with: Option<&str>, + #[starlark(require = named, default = true)] omit_if_empty: bool, + #[starlark(require = named, default = false)] uniquify: bool, + #[starlark(require = named)] allow_closure: Option<bool>, + ) -> starlark::Result<Value<'v>> { + // See a comment on the error message for more details on why this is needed. + if map_each.is_some() && allow_closure.is_none() { + return Err(Error::MapEachRequiresAllowClosure.into()); + } + + let mut args = get_mutable_args(this)?; + + let (flag, values) = + arg_name_and_value(arg_name_or_values, values, Error::ExpectedAddAllStringFlag)?; + + args.push(ArgValue::All { + flag, + values, + map_each, + format_each, + before_each: before_each.map(String::from), + terminate_with: terminate_with.map(String::from), + omit_if_empty, + uniquify, + }); + Ok(this) + } + + fn add_joined<'v>( + this: Value<'v>, + arg_name_or_values: Value<'v>, + values: Option<Value<'v>>, + #[starlark(require = named)] join_with: &str, + #[starlark(require = named)] map_each: Option<Value<'v>>, + #[starlark(require = named)] format_each: Option<Formatter>, + #[starlark(require = named)] format_joined: Option<Formatter>, + #[starlark(require = named, default = true)] omit_if_empty: bool, + #[starlark(require = named, default = false)] uniquify: bool, + #[starlark(require = named)] allow_closure: Option<bool>, + ) -> starlark::Result<Value<'v>> { + // See a comment on the error message for more details on why this is needed. + if map_each.is_some() && allow_closure.is_none() { + return Err(Error::MapEachRequiresAllowClosure.into()); + } + + let mut args = get_mutable_args(this)?; + + let (flag, values) = arg_name_and_value( + arg_name_or_values, + values, + Error::ExpectedAddJoinedStringFlag, + )?; + + args.push(ArgValue::Joined { + flag, + values, + join_with: join_with.to_owned(), + map_each, + format_each, + format_joined, + omit_if_empty, + uniquify, + }); + Ok(this) + } +} + +impl<'v> FrozenArgs { + /// Expands the stored arguments list into command-line arguments and input + /// files. + pub fn expand(&self, eval: &mut Evaluator<'v, '_, '_>) -> starlark::Result<Vec<String>> { + let mut command = Vec::new(); + crate::expand::expand_into(&mut command, self, eval)?; + Ok(command) + } +}
diff --git a/src/gn/starlark/crates/args/src/errors.rs b/src/gn/starlark/crates/args/src/errors.rs new file mode 100644 index 0000000..cc1c344 --- /dev/null +++ b/src/gn/starlark/crates/args/src/errors.rs
@@ -0,0 +1,35 @@ +/// Errors returned by action command line argument parsing and formatting. +#[derive(thiserror::Error, Debug)] +pub(crate) enum Error { + #[error("arguments must be a list, tuple, or depset")] + ArgumentsMustBeListTupleOrDepset, + #[error("map_each must return a list[str], str, or None")] + MapEachInvalidReturn, + #[error("Format string must contain exactly one '%s', got: {0}")] + InvalidFormatString(String), + #[error("trying to mutate a frozen Args value")] + CannotMutateFrozenArgs, + #[error("Expected first argument of add to be a string flag when value is specified")] + ExpectedAddStringFlag, + #[error("Expected first argument of add_all to be a string flag when values is specified")] + ExpectedAddAllStringFlag, + #[error("Expected first argument of add_joined to be a string flag when values is specified")] + ExpectedAddJoinedStringFlag, + // The starlark-rs API doesn't provide a way to check if something is a closure. + // So we provide a "user validates" approach instead. + #[error( + "map_each was specified without allow_closure.\n\ + For optimal performance, map_each should be a top-level function.\n\ + If you do this, set allow_closure = False.\n\ + Otherwise, set allow_closure = True" + )] + MapEachRequiresAllowClosure, + #[error("None is not allowed unless mapped by map_each")] + NoneNotAllowed, +} + +impl From<Error> for starlark::Error { + fn from(err: Error) -> Self { + Self::new_other(err) + } +}
diff --git a/src/gn/starlark/crates/args/src/expand.rs b/src/gn/starlark/crates/args/src/expand.rs new file mode 100644 index 0000000..c5968d7 --- /dev/null +++ b/src/gn/starlark/crates/args/src/expand.rs
@@ -0,0 +1,217 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +use depset::{Depset, FrozenDepset}; +use starlark::{ + eval::Evaluator, + values::{list::ListRef, tuple::TupleRef, Value, ValueLike}, +}; + +use crate::{ + args::{ArgValue, FrozenArgs}, + formatter::Formatter, + Error, +}; + +/// Helper to process and append a specific `FrozenArgs` object to the action's +/// command line. +pub fn expand_into<'v>( + command: &mut Vec<String>, + args_obj: &FrozenArgs, + eval: &mut Evaluator<'v, '_, '_>, +) -> starlark::Result<()> { + for arg in &args_obj.arguments { + match arg { + ArgValue::Scalar { + arg_name, + value, + format, + } => { + let value = (*value).to_value(); + if !value.is_none() { + handle_arg(arg_name.as_deref(), value, format.as_ref(), command)?; + } + }, + ArgValue::All { + flag, + values, + map_each, + format_each, + before_each, + terminate_with, + omit_if_empty, + uniquify, + } => { + let initial_len = command.len(); + if let Some(flag) = flag { + command.push(flag.clone()); + } + + let start_idx = command.len(); + handle_many_args( + (*values).to_value(), + map_each.as_ref(), + format_each.as_ref(), + before_each.as_ref().map(|x| x.as_str()), + command, + *uniquify, + eval, + )?; + + if command.len() > start_idx || !omit_if_empty { + if let Some(term) = terminate_with { + command.push(term.clone()); + } + } else if flag.is_some() { + command.truncate(initial_len); + } + }, + ArgValue::Joined { + flag, + values, + join_with, + map_each, + format_each, + format_joined, + omit_if_empty, + uniquify, + } => { + let mut dest = Vec::new(); + handle_many_args( + (*values).to_value(), + map_each.as_ref(), + format_each.as_ref(), + None, + &mut dest, + *uniquify, + eval, + )?; + + if !dest.is_empty() || !omit_if_empty { + if let Some(flag) = flag { + command.push(flag.clone()); + } + let joined = dest.join(join_with); + command.push(if let Some(fj) = format_joined { + fj.format(&joined) + } else { + joined + }); + } + }, + } + } + Ok(()) +} + +fn handle_arg( + arg_name: Option<&str>, + value: Value<'_>, + format: Option<&Formatter>, + dest: &mut Vec<String>, +) -> starlark::Result<()> { + if let Some(flag) = arg_name { + dest.push(flag.to_owned()); + } + + dest.push(if let Some(fmt) = format { + fmt.format(&value.to_str()) + } else { + value.to_str() + }); + Ok(()) +} + +fn for_each<'v, F>(value: Value<'v>, mut f: F) -> starlark::Result<()> +where + F: FnMut(Value<'v>) -> starlark::Result<()>, +{ + if let Some(l) = ListRef::from_value(value) { + for v in l.iter() { + f(v)?; + } + Ok(()) + } else if let Some(depset) = value.downcast_ref::<Depset>() { + for v in depset.iter() { + f(v)?; + } + Ok(()) + } else if let Some(depset) = value.downcast_ref::<FrozenDepset>() { + for v in depset.iter() { + f(v.to_value())?; + } + Ok(()) + } else if let Some(t) = TupleRef::from_value(value) { + for v in t.iter() { + f(v)?; + } + Ok(()) + } else { + Err(Error::ArgumentsMustBeListTupleOrDepset.into()) + } +} + +fn handle_many_args<'v, V: ValueLike<'v>>( + value: Value<'v>, + map_each: Option<&V>, + format_each: Option<&Formatter>, + before_each: Option<&str>, + dest: &mut Vec<String>, + uniquify: bool, + eval: &mut Evaluator<'v, '_, '_>, +) -> starlark::Result<()> { + let mut seen = starlark::collections::SmallSet::new(); + + let mut process_item = |item: Value<'v>| -> starlark::Result<()> { + if item.is_none() { + return Err(Error::NoneNotAllowed.into()); + } + let s = item.to_str(); + if uniquify && !seen.insert(s.clone()) { + return Ok(()); + } + + if let Some(before) = before_each { + dest.push(before.to_owned()); + } + dest.push(if let Some(format) = format_each { + format.format(&s) + } else { + s + }); + Ok(()) + }; + + for_each(value, |v| { + if let Some(map_each) = map_each { + let mapped = eval.eval_function((*map_each).to_value(), &[v], &[])?; + if let Some(l) = ListRef::from_value(mapped) { + for item in l.iter() { + if item.unpack_str().is_none() { + return Err(Error::MapEachInvalidReturn.into()); + } + process_item(item)?; + } + } else if let Some(t) = TupleRef::from_value(mapped) { + for item in t.iter() { + if item.unpack_str().is_none() { + return Err(Error::MapEachInvalidReturn.into()); + } + process_item(item)?; + } + } else if mapped.unpack_str().is_some() { + process_item(mapped)?; + } else if mapped.is_none() { + // skip + } else { + return Err(Error::MapEachInvalidReturn.into()); + } + } else { + process_item(v)?; + } + Ok(()) + })?; + + Ok(()) +}
diff --git a/src/gn/starlark/crates/args/src/formatter.rs b/src/gn/starlark/crates/args/src/formatter.rs new file mode 100644 index 0000000..7725b20 --- /dev/null +++ b/src/gn/starlark/crates/args/src/formatter.rs
@@ -0,0 +1,113 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +use allocative::Allocative; +use starlark::{ + typing::Ty, + values::{type_repr::StarlarkTypeRepr, Freeze, FreezeResult, Freezer}, +}; + +use crate::errors::Error; + +/// Helper to format argument values using a template containing `%s`. +#[derive(Debug, Clone, Allocative)] +pub struct Formatter { + before: String, + after: String, +} + +fn consume_partial(chars: &mut std::str::Chars<'_>, fmt: &str) -> starlark::Result<(String, bool)> { + let mut s = String::new(); + while let Some(c) = chars.next() { + if c == '%' { + match chars.next() { + Some('%') => s.push('%'), + Some('s') => return Ok((s, false)), + _ => return Err(Error::InvalidFormatString(fmt.to_owned()).into()), + } + } else { + s.push(c); + } + } + Ok((s, true)) +} + +impl Formatter { + /// Parses a format string and returns a `Formatter` if valid (must contain + /// exactly one `%s`). Literal percents may be escaped as `%%`. + pub fn new(fmt: &str) -> starlark::Result<Self> { + let mut chars = fmt.chars(); + let (before, eof) = consume_partial(&mut chars, fmt)?; + if eof { + return Err(Error::InvalidFormatString(fmt.to_owned()).into()); + } + let (after, eof) = consume_partial(&mut chars, fmt)?; + if !eof { + return Err(Error::InvalidFormatString(fmt.to_owned()).into()); + } + Ok(Self { before, after }) + } + + /// Formats the string by replacing `%s` with the input string. + pub fn format(&self, s: &str) -> String { + format!("{}{}{}", self.before, s, self.after) + } +} + +impl Freeze for Formatter { + type Frozen = Self; + + fn freeze(self, _freezer: &Freezer) -> FreezeResult<Self::Frozen> { + Ok(self) + } +} + +impl StarlarkTypeRepr for Formatter { + type Canonical = String; + + fn starlark_type_repr() -> Ty { + String::starlark_type_repr() + } +} + +#[cfg(test)] +mod tests { + use super::Formatter; + + // For consistency with bazel, test cases copied directly from: + // https://github.com/bazelbuild/bazel/blob/master/src/test/java/com/google/devtools/build/lib/actions/SingleStringArgFormatterTest.java + #[test] + fn test_valid_formats() { + let cases = [ + ("hello %s", "hello world"), + ("%s hello", "world hello"), + ("hello %s, hello", "hello world, hello"), + ("hello %%s %s", "hello %s world"), + ]; + for (fmt, expected) in cases { + let f = Formatter::new(fmt).unwrap(); + assert_eq!(f.format("world"), expected); + } + } + + #[test] + fn test_invalid_formats() { + let cases = [ + "hello", + "hello %%s", + "hello %s %s", + "%s hello %s", + "hello %", + "hello %f", + "hello %s %f", + "hello %s %", + ]; + for fmt in cases { + assert!( + Formatter::new(fmt).is_err(), + "Expected invalid format: {fmt}" + ); + } + } +}
diff --git a/src/gn/starlark/crates/args/src/lib.rs b/src/gn/starlark/crates/args/src/lib.rs new file mode 100644 index 0000000..739db6c --- /dev/null +++ b/src/gn/starlark/crates/args/src/lib.rs
@@ -0,0 +1,15 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +pub mod args; +pub mod errors; +pub mod expand; +pub mod formatter; +#[cfg(test)] +mod tests; +pub mod unpack; +pub use args::{args_methods, Args, FrozenArgs}; +pub(crate) use errors::Error; +pub use formatter::Formatter; +pub use unpack::FrozenArgsSequence;
diff --git a/src/gn/starlark/crates/args/src/tests.rs b/src/gn/starlark/crates/args/src/tests.rs new file mode 100644 index 0000000..19ce387 --- /dev/null +++ b/src/gn/starlark/crates/args/src/tests.rs
@@ -0,0 +1,279 @@ +// Copyright 2026 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +use starlark::{environment::GlobalsBuilder, eval::Evaluator, values::Value}; +use starlark_derive::starlark_module; + +use crate::args::Args; + +#[starlark_module] +pub(crate) fn register_args_test_globals(builder: &mut GlobalsBuilder) { + fn args<'v>(eval: &mut Evaluator<'v, '_, '_>) -> starlark::Result<Value<'v>> { + Ok(eval.heap().alloc(Args::default())) + } + + fn identity<'v>(x: Value<'v>) -> starlark::Result<Value<'v>> { + Ok(x) + } +} + +struct ArgsAssert { + assert: testutils::Assert, +} + +impl ArgsAssert { + fn new() -> Self { + let mut a = testutils::Assert::default(); + a.modify_globals(|builder| { + register_args_test_globals(builder); + depset::depset_globals!(builder, testutils::FakeEvalContext); + }); + Self { assert: a } + } + + #[track_caller] + fn assert_expands_to(&mut self, code: &str, expected: Result<&[&str], &str>) { + use starlark::values::UnpackValue as _; + + use crate::unpack::FrozenArgsSequence; + + let val = self.assert.pass(code); + + let res = starlark::environment::Module::with_temp_heap(|module| { + let mut eval = starlark::eval::Evaluator::new(&module); + let val_ref = eval.heap().access_owned_frozen_value(&val); + let seq = FrozenArgsSequence::unpack_value_err(val_ref).unwrap(); + seq.expand(&mut eval) + }); + + match expected { + Ok(expected_cmd) => { + assert_eq!(res.unwrap(), expected_cmd); + }, + Err(expected_err) => { + let err_str = res.unwrap_err().to_string(); + assert!( + err_str.contains(expected_err), + "Expected error containing: {expected_err}\nGot error: {err_str}" + ); + }, + } + } + + #[track_caller] + fn fail(&mut self, code: &str, expected_error: &str) { + self.assert.fail(code, expected_error); + } +} + +#[test] +fn test_combine_string_and_args() { + let mut a = ArgsAssert::new(); + a.assert_expands_to( + r#"["foo", args().add("bar"), "baz"]"#, + Ok(&["foo", "bar", "baz"]), + ); +} + +#[test] +fn test_args_add() { + let mut a = ArgsAssert::new(); + a.assert_expands_to(r#"[args().add("--foo")]"#, Ok(&["--foo"])); + + a.assert_expands_to(r#"[args().add(make_file("a/b.cc"))]"#, Ok(&["a/b.cc"])); + + a.assert_expands_to(r#"[args().add("--bar", "baz")]"#, Ok(&["--bar", "baz"])); + + a.assert_expands_to(r#"[args().add("--qux", None)]"#, Ok(&[])); + + a.assert_expands_to( + r#"[args().add("--val", 1, format="before %s after")]"#, + Ok(&["--val", "before 1 after"]), + ); + + a.fail( + r#"[args().add("--val", 1, format="no percent s")]"#, + "Format string must contain exactly one '%s'", + ); + + a.fail( + r#"[args().add("--val", 1, format="two %s %s")]"#, + "Format string must contain exactly one '%s'", + ); +} + +#[test] +fn test_args_add_all() { + let mut a = ArgsAssert::new(); + a.assert_expands_to(r#"[args().add_all([1, 2])]"#, Ok(&["1", "2"])); + + a.assert_expands_to( + r#"[args().add_all("--flag", ["3", "4"], before_each = "-b")]"#, + Ok(&["--flag", "-b", "3", "-b", "4"]), + ); + + a.assert_expands_to( + r#"[args().add_all(["x", "y"], before_each="-b", format_each=".%s.", terminate_with="--end")]"#, + Ok(&["-b", ".x.", "-b", ".y.", "--end"]), + ); + + a.assert_expands_to( + r#"[args().add_all([make_file("x.cc"), make_file("y.cc")])]"#, + Ok(&["x.cc", "y.cc"]), + ); + + a.assert_expands_to( + r#"[args().add_all(["a", None])]"#, + Err("None is not allowed unless mapped by map_each"), + ); +} + +#[test] +fn test_args_add_joined() { + let mut a = ArgsAssert::new(); + a.assert_expands_to( + r#"[args().add_joined([1, 2], join_with = ',')]"#, + Ok(&["1,2"]), + ); + + a.assert_expands_to( + r#"[args().add_joined("--flag", ["c", "d"], join_with=":")]"#, + Ok(&["--flag", "c:d"]), + ); + + a.assert_expands_to( + r#"[args().add_joined(depset(["a", "b"]), join_with = ",")]"#, + Ok(&["a,b"]), + ); + + a.assert_expands_to( + r#" +[ + args() + .add_joined( + [make_file("a/b.cc"), make_file("c/d.cc")], + join_with = ":", + ) +] +"#, + Ok(&["a/b.cc:c/d.cc"]), + ); + + a.assert_expands_to( + r#"[args().add_joined([1, 2], join_with=",", format_each=".%s.", format_joined="list=%s")]"#, + Ok(&["list=.1.,.2."]), + ); +} + +#[test] +fn test_args_omit_if_empty() { + let mut a = ArgsAssert::new(); + + a.assert_expands_to( + r#"[args().add_all("--flag", [], terminate_with = "after")]"#, + Ok(&[]), + ); + + a.assert_expands_to( + r#"[args().add_joined("--flag", [], join_with=",", omit_if_empty=False)]"#, + Ok(&["--flag", ""]), + ); + + a.assert_expands_to( + r#"[args().add_all("--flag", [None], map_each=identity, allow_closure=False, omit_if_empty=True)]"#, + Ok(&[]), + ); +} + +#[test] +fn test_args_map_each() { + let mut a = ArgsAssert::new(); + + a.assert_expands_to( + r#"[args().add_all(["abc", ["def", "ghi"], None], map_each=identity, allow_closure=False)]"#, + Ok(&["abc", "def", "ghi"]), + ); + + a.assert_expands_to( + r#"[args().add_all([[1]], map_each=identity, allow_closure=False)]"#, + Err("map_each must return a list[str], str, or None"), + ); + + a.assert_expands_to( + r#"[args().add_all([1], map_each=identity, allow_closure=False)]"#, + Err("map_each must return a list[str], str, or None"), + ); + + a.fail( + r#"[args().add_all([1], map_each=identity)]"#, + "map_each was specified without allow_closure", + ); + a.assert_expands_to( + r#"[args().add_all([1], map_each=fail, allow_closure=False)]"#, + Err("fail: 1"), + ); +} + +#[test] +fn test_args_uniquify() { + let mut a = ArgsAssert::new(); + + a.assert_expands_to( + r#"[args().add_all(["a", "b", "a", "c"])]"#, + Ok(&["a", "b", "a", "c"]), + ); + + a.assert_expands_to( + r#"[args().add_all(["a", "b", "a", "c"], uniquify=True)]"#, + Ok(&["a", "b", "c"]), + ); + + a.assert_expands_to( + r#"[args().add_joined(["a", "b", "a", "c"], join_with=",", uniquify=True)]"#, + Ok(&["a,b,c"]), + ); + + a.assert_expands_to( + r#"[args().add_all(["a", "b", "a", "c"], before_each="-b", uniquify=True)]"#, + Ok(&["-b", "a", "-b", "b", "-b", "c"]), + ); + + a.assert_expands_to( + r#" +def map_to_constant(x): + return "constant" + +[args().add_joined(["a", "b", "c"], map_each=map_to_constant, allow_closure=False, uniquify=True, join_with=",")] +"#, + Ok(&["constant"]), + ); +} + +#[test] +fn test_args_chaining() { + let mut a = ArgsAssert::new(); + a.assert_expands_to( + r#" +[ + args() + .add("--foo") + .add_all(["bar", "baz"]) + .add_joined(["x", "y"], join_with=":") +] +"#, + Ok(&["--foo", "bar", "baz", "x:y"]), + ); +} + +#[test] +fn test_mutate_frozen_args_fails() { + let mut a = ArgsAssert::new(); + a.assert.modify_globals(|builder| { + builder.set("frozen_args", crate::args::FrozenArgs::default()); + }); + a.fail( + "frozen_args.add('foo')", + "trying to mutate a frozen Args value", + ); +}
diff --git a/src/gn/starlark/crates/args/src/unpack.rs b/src/gn/starlark/crates/args/src/unpack.rs new file mode 100644 index 0000000..80b7ed7 --- /dev/null +++ b/src/gn/starlark/crates/args/src/unpack.rs
@@ -0,0 +1,51 @@ +use either::Either; +use starlark::{ + eval::Evaluator, + typing::Ty, + values::{list::UnpackList, type_repr::StarlarkTypeRepr, UnpackValue, Value, ValueTyped}, +}; + +use crate::{formatter::Formatter, FrozenArgs}; + +impl<'v> UnpackValue<'v> for Formatter { + type Error = starlark::Error; + + fn unpack_value_impl(value: Value<'v>) -> Result<Option<Self>, Self::Error> { + let s = <&'v str>::unpack_value_err(value)?; + Self::new(s).map(Some) + } +} + +pub struct FrozenArgsSequence<'v>(pub Vec<Either<&'v str, ValueTyped<'v, FrozenArgs>>>); + +impl<'v> StarlarkTypeRepr for FrozenArgsSequence<'v> { + type Canonical = starlark::values::Value<'v>; + + fn starlark_type_repr() -> Ty { + Ty::list(Ty::any()) + } +} + +impl<'v> UnpackValue<'v> for FrozenArgsSequence<'v> { + type Error = starlark::Error; + + fn unpack_value_impl(value: Value<'v>) -> Result<Option<Self>, Self::Error> { + let list = <UnpackList<Either<&'v str, ValueTyped<'v, FrozenArgs>>>>::unpack_value(value)?; + Ok(list.map(|l| FrozenArgsSequence(l.items))) + } +} + +impl<'v> FrozenArgsSequence<'v> { + pub fn expand(&self, eval: &mut Evaluator<'v, '_, '_>) -> starlark::Result<Vec<String>> { + let mut command = Vec::new(); + for item in &self.0 { + match item { + Either::Left(s) => command.push((*s).to_owned()), + Either::Right(args) => { + crate::expand::expand_into(&mut command, args.as_ref(), eval)?; + }, + } + } + Ok(command) + } +}