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)
+    }
+}