Create starlark testutils crate This CL introduces a mechanism to create a fake test object that supports EvalContext. This allows us to write tests for crates that depend on an EvalContext without touching any GN code. Bug: 528225104 Change-Id: I637f984205e5fb7910f42dc15f0a80d16a6a6964 Reviewed-on: https://gn-review.googlesource.com/c/gn/+/23540 Reviewed-by: Cole Faust <colefaust@google.com> Commit-Queue: Matt Stark <msta@google.com> Reviewed-by: Philipp Wollermann <philwo@google.com> Reviewed-by: Matt Stark <msta@google.com>
diff --git a/src/gn/starlark/Cargo.lock b/src/gn/starlark/Cargo.lock index f68f352..0444f96 100644 --- a/src/gn/starlark/Cargo.lock +++ b/src/gn/starlark/Cargo.lock
@@ -1602,6 +1602,17 @@ ] [[package]] +name = "testutils" +version = "0.1.0" +dependencies = [ + "allocative", + "attr", + "starlark", + "starlark_derive", + "types", +] + +[[package]] name = "textwrap" version = "0.11.0" 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 9d2a06d..dc9b615 100644 --- a/src/gn/starlark/Cargo.toml +++ b/src/gn/starlark/Cargo.toml
@@ -15,6 +15,7 @@ ".", "crates/attr", "crates/ffi", + "crates/testutils", "crates/types", ]
diff --git a/src/gn/starlark/crates/testutils/Cargo.toml b/src/gn/starlark/crates/testutils/Cargo.toml new file mode 100644 index 0000000..d307753 --- /dev/null +++ b/src/gn/starlark/crates/testutils/Cargo.toml
@@ -0,0 +1,15 @@ +[package] +name = "testutils" +version = "0.1.0" +edition = "2021" + +[lib] +test = false +doctest = false + +[dependencies] +allocative = { workspace = true } +attr = { path = "../attr" } +starlark = { workspace = true } +starlark_derive = { workspace = true } +types = { path = "../types" }
diff --git a/src/gn/starlark/crates/testutils/src/assert.rs b/src/gn/starlark/crates/testutils/src/assert.rs new file mode 100644 index 0000000..9bbb8f3 --- /dev/null +++ b/src/gn/starlark/crates/testutils/src/assert.rs
@@ -0,0 +1,120 @@ +// 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::values::UnpackValue; +use types::{EvaluatorContextExt, UnpackedOwnedValue}; + +use crate::FakeEvalContext; + +/// A simple wrapper around starlark::Assert that provides fake evaluation +/// contexts. +pub struct Assert { + assert: starlark::assert::Assert<'static>, + context: Box<FakeEvalContext>, +} + +impl Default for Assert { + fn default() -> Self { + Self::new(FakeEvalContext::default()) + } +} + +impl Assert { + /// Creates a new `Assert` helper instance with the given context. + pub fn new(context: FakeEvalContext) -> Self { + let mut assert = starlark::assert::Assert::new(); + // By default, starlark runs the code 3 times (with always GC, auto GC, and + // disabled GC) to check for GC bugs. Because the EvalContext can be + // mutated by the evaluator, running 3 times could cause state mutations + // to happen three times, messing with tests. Calling `always_gc()` + // forces the framework to run the code only once (specifically, + // under the "always GC" configuration), ensuring state is mutated only once. + assert.always_gc(); + let mut context = Box::new(context); + let context_ptr = &mut *context as *mut FakeEvalContext; + + assert.setup_eval(move |eval| { + // Safety: The context is owned by Assert, which outlives the evaluator run. + // Since all evaluation methods on Assert require `&mut self`, this guarantees + // exclusive access to `context` when the evaluator runs, so dereferencing + // this pointer is safe and does not alias. + let context_mut = unsafe { &mut *context_ptr }; + eval.set_context(context_mut); + }); + + Self { assert, context } + } + + /// Returns a read-only reference to the fake evaluation context. + pub fn context(&self) -> &FakeEvalContext { + &self.context + } + + /// Asserts that the result of evaluating code is equal to expected. + #[track_caller] + pub fn eq<T>(&mut self, code: &str, expected: T) + where + T: PartialEq + std::fmt::Debug + for<'v> UnpackValue<'v> + 'static, + { + assert_eq!(*self.eval::<T>(code), expected); + } + + /// Evaluates code and unpacks it to a given type. + #[track_caller] + pub fn eval<T>(&mut self, code: &str) -> UnpackedOwnedValue<T> + where + T: for<'v> UnpackValue<'v> + 'static, + { + let owned_val = self.assert.pass(code); + UnpackedOwnedValue::<T>::try_from(owned_val).unwrap() + } + + /// Asserts that the two pieces of code produce something equivalent. + #[track_caller] + pub fn equivalent(&mut self, lhs_code: &str, rhs_code: &str) { + let lhs_val = self.assert.pass(lhs_code); + let rhs_val = self.assert.pass(rhs_code); + assert_eq!(lhs_val.value(), rhs_val.value()); + } + + // We explicitly implement `pass`, `fail`, and `fails` with `&mut self` + // signatures despite `Deref` existing. The inherited methods on + // `starlark::assert::Assert` only take `&self`, which would bypass the + // borrow checker and allow running the evaluator while holding an active + // context borrow (leading to UB). Exposing them as `&mut self` methods on + // the wrapper statically prevents this. + + /// Evaluates code and returns the Starlark value. + #[track_caller] + pub fn pass(&mut self, code: &str) -> starlark::values::OwnedFrozenValue { + self.assert.pass(code) + } + + /// Asserts that the code fails to evaluate with the expected error. + #[track_caller] + pub fn fail(&mut self, code: &str, expected_error: &str) -> starlark::Error { + self.assert.fail(code, expected_error) + } + + /// Asserts that the code fails to evaluate with any of the expected errors. + #[track_caller] + pub fn fails(&mut self, code: &str, expected_errors: &[&str]) -> starlark::Error { + self.assert.fails(code, expected_errors) + } +} + +// We implement deref to get for free all the methods on starlark::Assert. +impl std::ops::Deref for Assert { + type Target = starlark::assert::Assert<'static>; + + fn deref(&self) -> &Self::Target { + &self.assert + } +} + +impl std::ops::DerefMut for Assert { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.assert + } +}
diff --git a/src/gn/starlark/crates/testutils/src/eval_context.rs b/src/gn/starlark/crates/testutils/src/eval_context.rs new file mode 100644 index 0000000..0bb9752 --- /dev/null +++ b/src/gn/starlark/crates/testutils/src/eval_context.rs
@@ -0,0 +1,112 @@ +// 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 attr::{Attr, EvalContext as AttrEvalContext, EvalContextAttrExt, Session as AttrSession}; +use starlark::{ + values::{FrozenValue, ProvidesStaticType}, + Result, +}; +use types::{CtxState, Label, LabelRef, Package, PackageRef, PathResolver, Session}; + +use crate::{FakeSession, FakeTarget, FakeTargetRef}; + +/// A simple implementation of the evaluation context used in Starlark unit +/// tests. +#[derive(allocative::Allocative)] +pub struct FakeEvalContext { + /// The current package being processed. + pub package: Package, + /// The current toolchain. + pub current_toolchain: Label, + /// The fake starlark session. + #[allocative(skip)] + pub session: FakeSession, + /// The fake path resolver. + #[allocative(skip)] + pub path_resolver: PathResolver, + /// The fake rule state. + #[allocative(skip)] + pub rule_state: CtxState<FakeTargetRef>, +} + +unsafe impl<'v> ProvidesStaticType<'v> for FakeEvalContext { + type StaticType = Self; +} + +impl Default for FakeEvalContext { + fn default() -> Self { + Self::new("//") + } +} + +impl FakeEvalContext { + /// Creates a new `FakeEvalContext` for a given package name. + pub fn new(package: &str) -> Self { + let session = FakeSession::new(); + let package_ref = PackageRef::new_for_testing(package).to_owned(); + Self { + package: package_ref, + current_toolchain: session.default_toolchain.clone(), + session, + path_resolver: PathResolver::new_for_testing(), + rule_state: CtxState::new(FakeTargetRef::default()), + } + } +} + +impl AttrEvalContext for FakeEvalContext { + type Session = FakeSession; + + fn session(&self) -> &Self::Session { + &self.session + } + + fn current_package(&self) -> &PackageRef { + &self.package + } + + fn path_resolver(&self) -> &PathResolver { + &self.path_resolver + } + + fn current_toolchain(&self) -> LabelRef<'_> { + self.current_toolchain.as_ref() + } + + fn require_macro(&self) -> Result<()> { + Ok(()) + } + + fn require_bzl(&self) -> Result<()> { + Ok(()) + } + + fn require_rule_impl(&self) -> Result<&CtxState<<Self::Session as Session>::TargetRef>> { + Ok(&self.rule_state) + } + + fn require_rule_impl_mut( + &mut self, + ) -> Result<&mut CtxState<<Self::Session as Session>::TargetRef>> { + Ok(&mut self.rule_state) + } +} + +impl EvalContextAttrExt for FakeEvalContext { + fn create_starlark_target( + &self, + target_name: &str, + _rule: FrozenValue, + attrs: Vec<Attr>, + ) -> Result<<Self::Session as AttrSession>::TargetRef> { + let label = Label::new(self.package.clone(), target_name.to_owned()); + let target = FakeTargetRef::new(FakeTarget { + outputs: vec![], + attrs, + ..Default::default() + }); + self.session.insert_target(label, target.clone()); + Ok(target) + } +}
diff --git a/src/gn/starlark/crates/testutils/src/lib.rs b/src/gn/starlark/crates/testutils/src/lib.rs new file mode 100644 index 0000000..52a4b4c --- /dev/null +++ b/src/gn/starlark/crates/testutils/src/lib.rs
@@ -0,0 +1,13 @@ +// 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 assert; +pub mod eval_context; +pub mod session; +pub mod target; + +pub use assert::Assert; +pub use eval_context::FakeEvalContext; +pub use session::FakeSession; +pub use target::{FakeTarget, FakeTargetRef};
diff --git a/src/gn/starlark/crates/testutils/src/session.rs b/src/gn/starlark/crates/testutils/src/session.rs new file mode 100644 index 0000000..26d918e --- /dev/null +++ b/src/gn/starlark/crates/testutils/src/session.rs
@@ -0,0 +1,74 @@ +// 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::{collections::HashMap, sync::Mutex}; + +use attr::Session; +use types::{Label, LabelRef, PackageRef}; + +use crate::FakeTargetRef; + +/// A fake implementation of the `Session` trait for testing. +pub struct FakeSession { + /// The preconfigured default toolchain label. + pub default_toolchain: Label, + /// A map of fake targets populated for testing, indexed by (label, + /// toolchain). + pub targets: Mutex<HashMap<(Label, Label), FakeTargetRef>>, +} + +impl Default for FakeSession { + fn default() -> Self { + Self::new() + } +} + +impl FakeSession { + /// Creates a new `FakeSession` instance with empty targets and a + /// preconfigured default toolchain. + pub fn new() -> Self { + Self { + default_toolchain: Label::new( + PackageRef::root().to_owned(), + "default_toolchain".to_owned(), + ), + targets: Mutex::new(HashMap::new()), + } + } + + /// Helper to insert a target under the default toolchain. + pub fn insert_target(&self, label: Label, target: FakeTargetRef) { + self.targets + .lock() + .unwrap() + .insert((label, self.default_toolchain.clone()), target); + } +} + +impl Session for FakeSession { + type TargetRef = FakeTargetRef; + + fn get_target(&self, label: LabelRef<'_>, current_toolchain: LabelRef<'_>) -> Self::TargetRef { + self.targets + .lock() + .unwrap() + .get(&(label.to_owned(), current_toolchain.to_owned())) + .cloned() + .unwrap_or_default() + } + + fn register_dependency<'a>( + &self, + source: Self::TargetRef, + target: LabelRef<'a>, + toolchain: LabelRef<'a>, + ) { + source + .get() + .dependencies + .lock() + .unwrap() + .insert((target.to_owned(), toolchain.to_owned())); + } +}
diff --git a/src/gn/starlark/crates/testutils/src/target.rs b/src/gn/starlark/crates/testutils/src/target.rs new file mode 100644 index 0000000..a60a968 --- /dev/null +++ b/src/gn/starlark/crates/testutils/src/target.rs
@@ -0,0 +1,90 @@ +// 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::{ + collections::HashSet, + sync::{Arc, Mutex}, +}; + +use allocative::Allocative; +use attr::Attr; +use starlark::{ + starlark_simple_value, + values::{ProvidesStaticType, StarlarkValue}, +}; +use starlark_derive::{starlark_value, NoSerialize}; +use types::{File, Label, TargetRef}; + +/// A fake target struct for testing. +#[derive(Debug, Allocative, Default)] +pub struct FakeTarget { + /// A list of fake files returned as outputs of the target. + pub outputs: Vec<File>, + /// A list of attributes. + pub attrs: Vec<Attr>, + /// Registered target dependencies. + #[allocative(skip)] + pub dependencies: Mutex<HashSet<(Label, Label)>>, +} + +/// A reference to a fake target. +#[derive(Debug, ProvidesStaticType, NoSerialize, Allocative, Clone)] +pub struct FakeTargetRef(#[allocative(skip)] Arc<FakeTarget>); + +impl Default for FakeTargetRef { + fn default() -> Self { + Self::new(FakeTarget::default()) + } +} + +impl FakeTargetRef { + /// Creates a new `FakeTargetRef` containing the given `FakeTarget`. + pub fn new(target: FakeTarget) -> Self { + Self(Arc::new(target)) + } + + /// Returns a shared reference to the underlying target. + pub fn get(&self) -> &FakeTarget { + &self.0 + } + + /// Returns the registered dependencies of this target. + pub fn registered_deps(&self) -> HashSet<(Label, Label)> { + self.get().dependencies.lock().unwrap().clone() + } +} + +impl PartialEq for FakeTargetRef { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} +impl Eq for FakeTargetRef {} + +impl std::hash::Hash for FakeTargetRef { + fn hash<H: std::hash::Hasher>(&self, state: &mut H) { + Arc::as_ptr(&self.0).hash(state); + } +} + +starlark_simple_value!(FakeTargetRef); + +impl std::fmt::Display for FakeTargetRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "FakeTargetRef") + } +} + +#[starlark_value(type = "Target")] +impl<'v> StarlarkValue<'v> for FakeTargetRef {} + +impl TargetRef for FakeTargetRef { + fn outputs(&self) -> Vec<File> { + self.get().outputs.clone() + } + + fn target_out_dir(&self, prefix: &str, suffix: &str, _separator: &str) -> String { + format!("{prefix}$TOOLCHAIN/{suffix}$LABEL") + } +}
diff --git a/src/gn/starlark/crates/types/src/eval_context.rs b/src/gn/starlark/crates/types/src/eval_context.rs index 65a6cda..f23ae6c 100644 --- a/src/gn/starlark/crates/types/src/eval_context.rs +++ b/src/gn/starlark/crates/types/src/eval_context.rs
@@ -61,6 +61,9 @@ /// Returns a mutable reference to the evaluation context. fn context_mut<C: EvalContext>(&mut self) -> &mut C; + + /// Sets the evaluation context on the evaluator. + fn set_context<C: EvalContext>(&mut self, context: &'a mut C); } impl<'v, 'a, 'e> EvaluatorContextExt<'v, 'a, 'e> for starlark::eval::Evaluator<'v, 'a, 'e> { @@ -81,4 +84,9 @@ debug_assert!(dyn_any.is::<C>(), "failed to downcast evaluator context"); unsafe { dyn_any.downcast_mut::<C>().unwrap_unchecked() } } + + #[inline] + fn set_context<C: EvalContext>(&mut self, context: &'a mut C) { + self.extra_mut = Some(context); + } }
diff --git a/src/gn/starlark/crates/types/src/lib.rs b/src/gn/starlark/crates/types/src/lib.rs index c43dd5f..40e565f 100644 --- a/src/gn/starlark/crates/types/src/lib.rs +++ b/src/gn/starlark/crates/types/src/lib.rs
@@ -13,6 +13,7 @@ pub mod path_resolver; pub mod session; pub mod target_ref; +pub mod unpacked_owned_value; pub mod util; pub use ctx_state::CtxState; @@ -26,3 +27,4 @@ pub use path_resolver::PathResolver; pub use session::Session; pub use target_ref::TargetRef; +pub use unpacked_owned_value::UnpackedOwnedValue;
diff --git a/src/gn/starlark/crates/types/src/unpacked_owned_value.rs b/src/gn/starlark/crates/types/src/unpacked_owned_value.rs new file mode 100644 index 0000000..5bb4ad6 --- /dev/null +++ b/src/gn/starlark/crates/types/src/unpacked_owned_value.rs
@@ -0,0 +1,42 @@ +// 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::ops::Deref; + +use starlark::values::{FrozenHeapRef, OwnedFrozenValue, UnpackValue}; + +/// A type-safe wrapper that holds an unpacked Starlark value and the +/// FrozenHeapRef that keeps the underlying memory alive. +pub struct UnpackedOwnedValue<T: 'static> { + value: T, + #[allow(dead_code)] // Keeps the heap alive. + heap: FrozenHeapRef, +} + +impl<T: 'static> Deref for UnpackedOwnedValue<T> { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.value + } +} + +impl<T> TryFrom<OwnedFrozenValue> for UnpackedOwnedValue<T> +where + T: for<'v> UnpackValue<'v> + 'static, +{ + type Error = starlark::Error; + + fn try_from(val: OwnedFrozenValue) -> Result<Self, Self::Error> { + let heap = val.owner().clone(); + // Safety: We extract the raw FrozenValue from OwnedFrozenValue. This is safe + // because we clone the heap ref and store it in UnpackedOwnedValue, + // ensuring the heap outlives T. + let value = unsafe { val.unchecked_frozen_value() }.to_value(); + Ok(Self { + value: T::unpack_value_err(value)?, + heap, + }) + } +}