Create starlark rule crate

Bug: 528225104
Change-Id: I949721873d6fe3ad7af42b8ff98bd00c6a6a6964
Reviewed-on: https://gn-review.googlesource.com/c/gn/+/24201
Reviewed-by: Takuto Ikuta <tikuta@google.com>
Reviewed-by: Philipp Wollermann <philwo@google.com>
Commit-Queue: Matt Stark <msta@google.com>
diff --git a/src/gn/starlark/Cargo.lock b/src/gn/starlark/Cargo.lock
index 8d80e3f..0be2db9 100644
--- a/src/gn/starlark/Cargo.lock
+++ b/src/gn/starlark/Cargo.lock
@@ -1267,6 +1267,22 @@
 checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
 
 [[package]]
+name = "rule"
+version = "0.1.0"
+dependencies = [
+ "allocative",
+ "attr",
+ "depset",
+ "providers",
+ "starlark",
+ "starlark_derive",
+ "strum",
+ "testutils",
+ "thiserror",
+ "types",
+]
+
+[[package]]
 name = "rustc_version"
 version = "0.4.1"
 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 dfc681c..abcc8e2 100644
--- a/src/gn/starlark/Cargo.toml
+++ b/src/gn/starlark/Cargo.toml
@@ -19,6 +19,7 @@
     "crates/ffi",
     "crates/loader",
     "crates/providers",
+    "crates/rule",
     "crates/testutils",
     "crates/types",
 ]
diff --git a/src/gn/starlark/crates/attr/Cargo.toml b/src/gn/starlark/crates/attr/Cargo.toml
index 56b9433..e5a8ce5 100644
--- a/src/gn/starlark/crates/attr/Cargo.toml
+++ b/src/gn/starlark/crates/attr/Cargo.toml
@@ -10,12 +10,12 @@
 doctest = false
 
 [dependencies]
+allocative = { workspace = true }
+either = { workspace = true }
 starlark = { workspace = true }
 starlark_derive = { workspace = true }
 thiserror = { workspace = true }
-allocative = { workspace = true }
 types = { path = "../types" }
-either = { workspace = true }
 
 [dev-dependencies]
 testutils = { path = "../testutils" }
diff --git a/src/gn/starlark/crates/attr/src/ctx.rs b/src/gn/starlark/crates/attr/src/ctx.rs
index 269da67..afca193 100644
--- a/src/gn/starlark/crates/attr/src/ctx.rs
+++ b/src/gn/starlark/crates/attr/src/ctx.rs
@@ -2,6 +2,7 @@
 // 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::{
     collections::SmallMap,
     values::{
@@ -10,7 +11,7 @@
         FrozenHeap, FrozenValue, FrozenValueTyped, Heap, Value,
     },
 };
-use types::LabelRef;
+use types::{LabelRef, OutputType};
 
 use crate::{
     schema::{AllowFilesSchema, AttrKind, AttrSchema},
@@ -34,6 +35,7 @@
 ///   "foo": attr.label_list(...),
 ///   "bar": attr.string(...),
 /// }
+#[derive(Debug, Allocative)]
 pub struct CtxAttrSchema {
     attrs: SmallMap<String, AttrSchema>,
     attr: FrozenValueTyped<'static, FrozenRecordType>,
@@ -43,8 +45,16 @@
 
 impl CtxAttrSchema {
     /// Creates a new `CtxAttrSchema`.
-    pub fn new(attrs: SmallMap<String, AttrSchema>, heap: &FrozenHeap) -> Self {
-        let mut attrs_fields = SmallMap::with_capacity(attrs.len());
+    pub fn new(
+        attrs: SmallMap<String, AttrSchema>,
+        builtin: Option<OutputType>,
+        heap: &FrozenHeap,
+    ) -> Self {
+        let (builtin_files, builtin_attrs) =
+            builtin.map(|b| b.attrs()).unwrap_or_default();
+        let mut attrs_fields = SmallMap::with_capacity(
+            attrs.len() + builtin_files.len() + builtin_attrs.len(),
+        );
         let mut file_fields = SmallMap::new();
         let mut files_fields = SmallMap::new();
 
@@ -52,6 +62,14 @@
             FieldGen::new(starlark::values::typing::TypeCompiled::any(), None)
         };
 
+        for name in builtin_files {
+            attrs_fields.insert(name.to_string(), any());
+            files_fields.insert(name.to_string(), any());
+        }
+        for name in builtin_attrs {
+            attrs_fields.insert(name.to_string(), any());
+        }
+
         for (name, attr) in &attrs {
             attrs_fields.insert(name.clone(), any());
 
@@ -93,10 +111,18 @@
         fields: &[Attr],
         session: &S,
         current_toolchain: &LabelRef,
+        builtin: Option<OutputType>,
+        mut ctx_attr: Vec<Value<'v>>,
         heap: &Heap<'v>,
     ) -> starlark::Result<CtxAttr<'v>> {
-        let mut ctx_attr = Vec::with_capacity(self.attr.len());
+        let (builtin_files, builtin_attrs) =
+            builtin.map(|b| b.attrs()).unwrap_or_default();
+        debug_assert!(builtin_files.len() + builtin_attrs.len() == ctx_attr.len());
+        ctx_attr.reserve_exact(self.attr.len() - ctx_attr.len());
         let mut ctx_files = Vec::with_capacity(self.files.len());
+        for &attr_val in ctx_attr.iter().take(builtin_files.len()) {
+            ctx_files.push(attr_val);
+        }
         let mut ctx_file = Vec::with_capacity(self.file.len());
 
         debug_assert!(fields.len() == self.attrs.len());
@@ -134,6 +160,10 @@
             )),
         })
     }
+
+    pub fn attrs(&self) -> &SmallMap<String, AttrSchema> {
+        &self.attrs
+    }
 }
 
 #[cfg(test)]
@@ -183,12 +213,15 @@
 
             let ctx = CtxAttrSchema::new(
                 schema.into_iter().map(|(k, v)| (k, (*v).clone())).collect(),
+                None,
                 eval.frozen_heap(),
             )
             .create_ctx_fields(
                 &fields,
                 &context.session,
                 &context.current_toolchain.as_ref(),
+                None,
+                Vec::new(),
                 &eval.heap(),
             )?;
 
diff --git a/src/gn/starlark/crates/attr/src/errors.rs b/src/gn/starlark/crates/attr/src/errors.rs
index f6f2e8e..9c60497 100644
--- a/src/gn/starlark/crates/attr/src/errors.rs
+++ b/src/gn/starlark/crates/attr/src/errors.rs
@@ -9,7 +9,7 @@
 
 /// Errors returned by target attribute validation and coercion.
 #[derive(thiserror::Error, Debug, Clone)]
-pub enum Error {
+pub(crate) enum Error {
     #[error("value is mandatory")]
     MandatoryAttribute,
     #[error("value cannot be empty")]
@@ -40,6 +40,10 @@
         "target `{target}` does not produce any outputs matching allowed extensions: {allowed:?}"
     )]
     NoMatchingOutputs { target: Label, allowed: Vec<String> },
+    #[error("attribute {0}: only attr.label and attr.label_list types may be overridden")]
+    OnlyLabelTypesMayBeOverridden(String),
+    #[error("attribute {0}: types of parent and child's attributes mismatch")]
+    AttributeTypeMismatch(String),
 }
 
 impl From<Error> for starlark::Error {
diff --git a/src/gn/starlark/crates/attr/src/schema.rs b/src/gn/starlark/crates/attr/src/schema.rs
index 642f99e..ab51e76 100644
--- a/src/gn/starlark/crates/attr/src/schema.rs
+++ b/src/gn/starlark/crates/attr/src/schema.rs
@@ -9,10 +9,11 @@
 
 use allocative::Allocative;
 use starlark::{
+    eval::ParametersSpecParam,
     starlark_simple_value,
     values::{
-        none::NoneOr, Freeze, FreezeResult, Freezer, Heap, ProvidesStaticType, StarlarkValue,
-        Trace, Value,
+        none::NoneOr, Freeze, FreezeResult, Freezer, FrozenValue, Heap, ProvidesStaticType,
+        StarlarkValue, Trace, Value,
     },
 };
 use starlark_derive::{starlark_value, NoSerialize};
@@ -184,9 +185,19 @@
         Ok(heap.alloc(schema))
     }
 
-    /// Returns the allowed files schema for this attribute.
-    pub fn allow_files(&self) -> &AllowFilesSchema {
-        &self.allow_files
+    /// Verifies if a child attribute schema can override this attribute schema.
+    pub fn check_override(&self, name: &str, child: &Self) -> starlark::Result<()> {
+        if !matches!(self.kind, AttrKind::Label | AttrKind::LabelList) {
+            return Err(crate::Error::OnlyLabelTypesMayBeOverridden(name.to_owned()).into());
+        }
+        if self.kind != child.kind
+            || self.disallow_empty != child.disallow_empty
+            || self.allow_files != child.allow_files
+            || self.cfg != child.cfg
+        {
+            return Err(crate::Error::AttributeTypeMismatch(name.to_owned()).into());
+        }
+        Ok(())
     }
 
     /// Returns the default value of this attribute, if any.
@@ -202,6 +213,16 @@
             AllowFilesSchema::None => None,
         }
     }
+
+    // Converts the attribute to a parameter spec.
+    // Note that we intentionally do not use Defaulted(T) as it only supports
+    // Defaulted(starlark::Value), while we want Defaulted(Attr).
+    pub fn as_param_spec(&self) -> ParametersSpecParam<FrozenValue> {
+        match &self.default {
+            None => ParametersSpecParam::Required,
+            Some(_) => ParametersSpecParam::Optional,
+        }
+    }
 }
 
 #[cfg(test)]
diff --git a/src/gn/starlark/crates/rule/Cargo.toml b/src/gn/starlark/crates/rule/Cargo.toml
new file mode 100644
index 0000000..bf568c7
--- /dev/null
+++ b/src/gn/starlark/crates/rule/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "rule"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+doctest = false
+
+[dependencies]
+allocative = { workspace = true }
+attr = { path = "../attr" }
+depset = { path = "../depset" }
+providers = { path = "../providers" }
+starlark = { workspace = true }
+starlark_derive = { workspace = true }
+strum = { workspace = true }
+thiserror = { workspace = true }
+types = { path = "../types" }
+
+[dev-dependencies]
+testutils = { path = "../testutils" }
diff --git a/src/gn/starlark/crates/rule/src/errors.rs b/src/gn/starlark/crates/rule/src/errors.rs
new file mode 100644
index 0000000..7fdd277
--- /dev/null
+++ b/src/gn/starlark/crates/rule/src/errors.rs
@@ -0,0 +1,26 @@
+// 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.
+
+/// Errors returned by the GN Starlark rule system.
+#[derive(thiserror::Error, Debug)]
+pub(crate) enum Error {
+    #[error("Rule must be assigned to a global variable to be used")]
+    RuleMustBeNamed,
+    #[error("Parent must be a rule")]
+    ParentMustBeARule,
+    #[error("Attribute '{0}' is reserved")]
+    ReservedAttribute(String),
+}
+
+impl From<Error> for starlark::Error {
+    fn from(err: Error) -> Self {
+        starlark::Error::new_other(err)
+    }
+}
+
+impl From<Error> for starlark::values::FreezeError {
+    fn from(err: Error) -> Self {
+        Self::new(err.to_string())
+    }
+}
diff --git a/src/gn/starlark/crates/rule/src/frozen_rule.rs b/src/gn/starlark/crates/rule/src/frozen_rule.rs
new file mode 100644
index 0000000..b69b980
--- /dev/null
+++ b/src/gn/starlark/crates/rule/src/frozen_rule.rs
@@ -0,0 +1,324 @@
+// 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::{fmt, marker::PhantomData};
+
+use allocative::Allocative;
+use attr::{traits::EvalContextAttrExt, Attr, CtxAttrSchema};
+use starlark::{
+    any::ProvidesStaticType,
+    collections::SmallMap,
+    eval::{Arguments, Evaluator, ParametersSpec},
+    values::{FrozenHeap, FrozenValue, StarlarkValue, Value},
+};
+use starlark_derive::{starlark_value, NoSerialize};
+use types::{EvaluatorContextExt, Scope, TargetRef};
+
+use crate::rule::{build_signature, OutputType};
+
+
+/// A frozen representation of a Starlark rule object.
+///
+/// Once a rule has been exported from a loaded Starlark module (e.g., from
+/// `.bzl` files), it is frozen into a `FrozenRule` and is ready to be invoked
+/// in target build files.
+#[derive(NoSerialize, Allocative)]
+pub struct FrozenRule<C: EvalContextAttrExt> {
+    pub(crate) schema: CtxAttrSchema,
+    pub(crate) builtin: Option<OutputType>,
+    pub(crate) implementation: FrozenValue,
+    pub(crate) name: String,
+    pub(crate) signature: ParametersSpec<FrozenValue>,
+    pub(crate) parent: Option<&'static FrozenRule<C>>,
+    pub(crate) _phantom: PhantomData<C>,
+}
+
+// Safety: FrozenRule does not automatically derive Send and Sync because of C.
+// But it only contains C inside PhantomData, so Send and Sync are actually safe.
+unsafe impl<C: EvalContextAttrExt> Send for FrozenRule<C> {}
+unsafe impl<C: EvalContextAttrExt> Sync for FrozenRule<C> {}
+
+unsafe impl<'v, C: EvalContextAttrExt> ProvidesStaticType<'v> for FrozenRule<C> {
+    type StaticType = FrozenRule<C>;
+}
+
+unsafe impl<'v, C: EvalContextAttrExt> starlark::values::Trace<'v> for FrozenRule<C> {
+    fn trace(&mut self, _tracer: &starlark::values::Tracer<'v>) {}
+}
+
+impl<C: EvalContextAttrExt> fmt::Debug for FrozenRule<C> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.debug_struct("FrozenRule")
+            .field("schema", &self.schema)
+            .field("builtin", &self.builtin)
+            .field("parent", &self.parent)
+            .finish()
+    }
+}
+
+impl<C: EvalContextAttrExt> FrozenRule<C> {
+    /// Creates a new `FrozenRule` for a built-in rule.
+    pub fn new_builtin(builtin: OutputType, frozen_heap: &FrozenHeap) -> Self {
+        let schema = CtxAttrSchema::new(SmallMap::new(), Some(builtin), frozen_heap);
+        let name: &str = builtin.into();
+        let signature = build_signature(name, &schema, true);
+        Self {
+            schema,
+            builtin: Some(builtin),
+            implementation: FrozenValue::new_none(),
+            name: name.to_owned(),
+            signature,
+            parent: None,
+            _phantom: PhantomData,
+        }
+    }
+}
+
+#[starlark_value(type = "rule")]
+impl<'v, C: EvalContextAttrExt> StarlarkValue<'v> for FrozenRule<C>
+where
+    Self: ProvidesStaticType<'v>,
+{
+    type Canonical = Self;
+
+    /// Invoking a rule generates a target.
+    /// Note: This is only used when calling my_rule(...) from *starlark*.
+    /// When calling from GN directly, we use custom logic to handle scoping
+    /// correctly.
+    fn invoke(
+        &self,
+        me: Value<'v>,
+        args: &Arguments<'v, '_>,
+        eval: &mut Evaluator<'v, '_, '_>,
+    ) -> starlark::Result<Value<'v>> {
+        let me = me.unpack_frozen().unwrap();
+        let signature = &self.signature;
+
+        signature.parser(args, eval, |param_parser, eval| {
+            let target_name: &str = param_parser.next()?;
+
+            let context = eval.context::<C>();
+            let scope = context.require_macro()?;
+            let package = context.current_package();
+            let path_resolver = context.path_resolver();
+
+            let attrs = self
+                .schema
+                .attrs()
+                .iter()
+                .map(|(_name, schema)| {
+                    let value_opt: Option<Value<'v>> = param_parser.next_opt()?;
+                    Attr::create(schema, value_opt, package, path_resolver)
+                })
+                .collect::<Result<Vec<_>, _>>()?;
+
+            let target = if let Some(builtin) = self.builtin {
+                // Collect all the arguments we don't recognise and pass them to the native
+                // implementation.
+                let kwargs: SmallMap<String, Value<'v>> = param_parser.next()?;
+                let child_scope = scope.copy_with(kwargs.iter().map(|(k, v)| (k.as_str(), *v)));
+                context.create_target(Some(builtin), target_name, &child_scope, me, attrs)?
+            } else {
+                context.create_target(None, target_name, scope, me, attrs)?
+            };
+            target.register_dependencies(context.session(), context.current_toolchain());
+
+            Ok(Value::new_none())
+        })
+    }
+}
+
+impl<C: EvalContextAttrExt> starlark::values::AllocFrozenValue for FrozenRule<C> {
+    #[inline]
+    fn alloc_frozen_value(
+        self,
+        heap: &starlark::values::FrozenHeap,
+    ) -> starlark::values::FrozenValue {
+        heap.alloc_simple(self)
+    }
+}
+
+impl<C: EvalContextAttrExt> fmt::Display for FrozenRule<C> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "<rule: {}>", &self.name)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::{
+        collections::{HashMap, HashSet},
+        sync::Mutex,
+    };
+
+    use attr::{Attr, LabelOrFile};
+    use starlark::environment::FrozenModule;
+    use testutils::FakeTarget;
+    use types::{Label, OutputType, PackageRef};
+
+    use crate::globals::tests::new_assert;
+
+    #[test]
+    fn test_pure_rule_inheritance() {
+        let mut assert = new_assert();
+        let native = assert.load_module("//rules:native.scl");
+        let pure = assert.load_module("//rules:pure.scl");
+
+        let rule = |module: &FrozenModule, name: &str| {
+            module.get(name).unwrap().value().unpack_frozen().unwrap()
+        };
+
+        assert.pass(
+            r#"
+load("//rules:pure.scl", "child_rule", "parent_rule")
+load("//rules:native.scl", "custom_shared_library", "static_library")
+
+custom_shared_library(
+    name = "shared_library",
+    mandatory = "mandatory_val",
+    optional = "optional_val",
+    unknown = "unknown",
+)
+
+static_library(
+   name = "static_library",
+   optional = "optional_val",
+   unknown = "unknown"
+)
+
+parent_rule(
+    name = "parent_defaulted",
+    parent_only = "p",
+)
+
+child_rule(
+    name = "child_defaulted",
+    parent_only = "p",
+    child_only = "c",
+)
+
+child_rule(
+    name = "child_override",
+    parent_only = "parent_val",
+    child_only = "child_val",
+    override = "//:custom_val",
+)
+"#,
+        );
+
+        let heap = starlark::values::FrozenHeap::new();
+        let mut unknown_attrs = HashMap::new();
+        unknown_attrs.insert(
+            "unknown".to_owned(),
+            starlark::values::Value::new_frozen(heap.alloc("unknown")),
+        );
+
+        let context = assert.context();
+        let targets_lock = context.session.targets.lock().unwrap();
+        let load = |name: &str| {
+            let label = Label::new(PackageRef::root().to_owned(), name.to_owned());
+            targets_lock
+                .get(&(label, context.session.default_toolchain.clone()))
+                .unwrap()
+                .get()
+        };
+
+        assert_eq!(
+            *load("shared_library"),
+            FakeTarget {
+                outputs: vec![],
+                attrs: vec![
+                    Attr::String("optional_val".to_owned()),
+                    Attr::String("mandatory_val".to_owned()),
+                ],
+                output_type: Some(OutputType::SharedLibrary),
+                rule: rule(&native, "custom_shared_library"),
+                cxx_attrs: unknown_attrs.clone(),
+                dependencies: Mutex::new(HashSet::new()),
+            }
+        );
+
+        assert_eq!(
+            *load("static_library"),
+            FakeTarget {
+                outputs: vec![],
+                attrs: vec![Attr::String("optional_val".to_owned())],
+                output_type: Some(OutputType::StaticLibrary),
+                rule: rule(&native, "static_library"),
+                cxx_attrs: unknown_attrs.clone(),
+                dependencies: Mutex::new(HashSet::new()),
+            }
+        );
+
+        let toolchain = Label::new(
+            PackageRef::root().to_owned(),
+            "default_toolchain".to_owned(),
+        );
+
+        assert_eq!(
+            *load("parent_defaulted"),
+            FakeTarget {
+                outputs: vec![],
+                attrs: vec![
+                    Attr::String("p".to_owned()),
+                    Attr::Label(Some(LabelOrFile::Label(Label::new(
+                        PackageRef::root().to_owned(),
+                        "parent".to_owned()
+                    )))),
+                ],
+                output_type: None,
+                rule: rule(&pure, "parent_rule"),
+                cxx_attrs: HashMap::new(),
+                dependencies: Mutex::new(HashSet::from([(
+                    Label::new(PackageRef::root().to_owned(), "parent".to_owned()),
+                    toolchain.clone(),
+                )])),
+            }
+        );
+
+        assert_eq!(
+            *load("child_defaulted"),
+            FakeTarget {
+                outputs: vec![],
+                attrs: vec![
+                    Attr::String("p".to_owned()),
+                    Attr::Label(Some(LabelOrFile::Label(Label::new(
+                        PackageRef::root().to_owned(),
+                        "child".to_owned()
+                    )))),
+                    Attr::String("c".to_owned()),
+                ],
+                output_type: None,
+                rule: rule(&pure, "child_rule"),
+                cxx_attrs: HashMap::new(),
+                dependencies: Mutex::new(HashSet::from([(
+                    Label::new(PackageRef::root().to_owned(), "child".to_owned()),
+                    toolchain.clone(),
+                )])),
+            }
+        );
+
+        assert_eq!(
+            *load("child_override"),
+            FakeTarget {
+                outputs: vec![],
+                attrs: vec![
+                    Attr::String("parent_val".to_owned()),
+                    Attr::Label(Some(LabelOrFile::Label(Label::new(
+                        PackageRef::root().to_owned(),
+                        "custom_val".to_owned()
+                    )))),
+                    Attr::String("child_val".to_owned()),
+                ],
+                output_type: None,
+                rule: rule(&pure, "child_rule"),
+                cxx_attrs: HashMap::new(),
+                dependencies: Mutex::new(HashSet::from([(
+                    Label::new(PackageRef::root().to_owned(), "custom_val".to_owned()),
+                    toolchain.clone(),
+                )])),
+            }
+        );
+    }
+}
diff --git a/src/gn/starlark/crates/rule/src/globals.rs b/src/gn/starlark/crates/rule/src/globals.rs
new file mode 100644
index 0000000..463e032
--- /dev/null
+++ b/src/gn/starlark/crates/rule/src/globals.rs
@@ -0,0 +1,154 @@
+// 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::traits::EvalContextAttrExt;
+use starlark::{
+    environment::{FrozenModule, Module},
+    values::FrozenHeapName,
+};
+use strum::IntoEnumIterator;
+
+use crate::{FrozenRule, OutputType};
+
+/// Registers and returns the built-in target rules as a frozen module.
+/// Usage:
+/// load("//builtins:rules.scl", "static_library")
+///
+/// static_library(
+///   name = "foo",
+///   sources = [...],
+///   ...
+/// )
+pub fn register_builtin_rules<C: EvalContextAttrExt>() -> FrozenModule {
+    Module::with_temp_heap(|module| {
+        for output_type in OutputType::iter() {
+            let name = output_type.to_string();
+            let frozen_rule = FrozenRule::<C>::new_builtin(output_type, module.frozen_heap());
+            let frozen_value = module.frozen_heap().alloc(frozen_rule);
+            module.set(&name, frozen_value.to_value());
+        }
+
+        module
+            .freeze_named(FrozenHeapName::User(Box::new(
+                "//builtins:rules.scl".to_owned(),
+            )))
+            .unwrap()
+    })
+}
+
+/// Registers standard target rules in the session.
+#[macro_export]
+macro_rules! register_rule_globals {
+    ($builder:expr, $ctx_type:ty) => {
+        #[starlark_derive::starlark_module]
+        fn register_rule_globals(builder: &mut starlark::environment::GlobalsBuilder) {
+            fn rule<'v>(
+                #[starlark(require = named)] implementation: starlark::values::Value<'v>,
+                #[starlark(require = named)] parent: Option<starlark::values::Value<'v>>,
+                #[starlark(require = named)] attrs: Option<
+                    starlark::collections::SmallMap<&str, &attr::AttrSchema>,
+                >,
+                eval: &mut starlark::eval::Evaluator<'v, '_, '_>,
+            ) -> starlark::Result<starlark::values::Value<'v>> {
+                // Rules can only be created while loading scl files, not in macros or rule
+                // implementations.
+                use types::{EvalContext, EvaluatorContextExt};
+                eval.context::<$ctx_type>().require_bzl()?;
+
+                let attrs = attrs.unwrap_or_default();
+                let attrs = attrs
+                    .into_iter()
+                    .map(|(k, v)| (k.to_string(), v.clone()))
+                    .collect();
+                let rule = <$crate::Rule<'v, $ctx_type>>::new(
+                    attrs,
+                    None,
+                    parent,
+                    implementation,
+                    eval.frozen_heap(),
+                )?;
+                Ok(eval.heap().alloc(rule))
+            }
+        }
+        register_rule_globals($builder);
+    };
+}
+
+#[cfg(test)]
+pub(crate) mod tests {
+    use starlark::values::list::UnpackList;
+    use testutils::FakeEvalContext;
+
+    pub(crate) fn make_attr_schema<'v>(
+        kind: attr::AttrKind,
+        args: attr::AttrSpecArgs<'v>,
+        eval: &mut starlark::eval::Evaluator<'v, '_, '_>,
+    ) -> starlark::Result<starlark::values::Value<'v>> {
+        attr::AttrSchema::create(
+            kind,
+            args,
+            types::PackageRef::root(),
+            &types::PathResolver::new_for_testing(),
+            &eval.heap(),
+        )
+    }
+
+    pub(crate) fn new_assert() -> testutils::Assert {
+        let mut assert = testutils::Assert::default();
+        assert.modify_globals(|builder| {
+            crate::register_rule_globals!(builder, FakeEvalContext);
+            builder.set("attr", attr::AttrModule { make_attr_schema });
+        });
+        let builtins = crate::register_builtin_rules::<FakeEvalContext>();
+        assert.module_add(builtins);
+        assert
+    }
+
+    #[test]
+    fn test_rule_creation() {
+        let mut a = new_assert();
+        let rule_value = a.pass(
+            r#"
+my_rule = rule(implementation = None)
+my_rule
+"#,
+        );
+        let my_rule = rule_value.value().unpack_frozen().unwrap();
+
+        a.modify_globals(move |b| {
+            b.set("my_rule", my_rule);
+        });
+
+        a.eq(
+            r#"
+my_rule2 = rule(implementation = None)
+[str(my_rule), str(my_rule2), str(rule(implementation = None))]
+"#,
+            UnpackList {
+                items: vec![
+                    "<rule: my_rule>".to_owned(),
+                    "<rule: my_rule2>".to_owned(),
+                    "<anonymous rule>".to_owned(),
+                ],
+            },
+        );
+
+        a.fail_to_freeze(
+            r#"
+x = [rule(implementation = None)]
+"#,
+            "Rule must be assigned to a global variable to be used",
+        );
+
+        a.fail(
+            "rule()",
+            "Missing named-only parameter `implementation` for call to `rule`",
+        );
+
+        a.fail(
+            r#"rule(implementation = lambda ctx: None, attrs = {"name": attr.string()})"#,
+            "Attribute 'name' is reserved",
+        );
+    }
+}
diff --git a/src/gn/starlark/crates/rule/src/lib.rs b/src/gn/starlark/crates/rule/src/lib.rs
new file mode 100644
index 0000000..52fb090
--- /dev/null
+++ b/src/gn/starlark/crates/rule/src/lib.rs
@@ -0,0 +1,10 @@
+pub mod errors;
+pub mod frozen_rule;
+pub mod globals;
+pub mod rule;
+
+pub use attr::AttrSchema;
+pub(crate) use errors::Error;
+pub use frozen_rule::FrozenRule;
+pub use globals::register_builtin_rules;
+pub use rule::{OutputType, Rule};
diff --git a/src/gn/starlark/crates/rule/src/rule.rs b/src/gn/starlark/crates/rule/src/rule.rs
new file mode 100644
index 0000000..4fba527
--- /dev/null
+++ b/src/gn/starlark/crates/rule/src/rule.rs
@@ -0,0 +1,250 @@
+// 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::OnceCell, fmt, marker::PhantomData};
+
+use allocative::Allocative;
+use attr::{traits::EvalContextAttrExt, AttrSchema, CtxAttrSchema};
+use starlark::{
+    any::ProvidesStaticType,
+    collections::SmallMap,
+    eval::{Arguments, Evaluator, ParametersSpec, ParametersSpecParam},
+    values::{
+        Freeze, FreezeResult, Freezer, FrozenHeap, FrozenValue, FrozenValueTyped, StarlarkValue,
+        Value, ValueLike,
+    },
+};
+use starlark_derive::{starlark_value, NoSerialize};
+pub use types::OutputType;
+use types::EvaluatorContextExt;
+
+use crate::frozen_rule::FrozenRule;
+
+/// Representation of a Starlark rule object.
+///
+/// Rules represent target definitions in GN (e.g., `executable`, `source_set`,
+/// or custom rules declared via `rule()`).
+///
+/// Note that a rule is unusable - it must be frozen before being used.
+///
+/// The generic parameter `C` represents the execution context. This is required
+/// because although we don't store it in the rule object itself, `invoke`
+/// requires a concrete execution context.
+#[derive(NoSerialize, Allocative)]
+pub struct Rule<'v, C: EvalContextAttrExt> {
+    schema: CtxAttrSchema,
+    // Filled after `export_as` is called.
+    // Contains the name of the rule, and the signature required to call it.
+    once_named: OnceCell<(String, ParametersSpec<FrozenValue>)>,
+    builtin: Option<OutputType>,
+    implementation: Value<'v>,
+    parent: Option<Value<'v>>,
+    _phantom: PhantomData<C>,
+}
+
+// Safety: Rule does not automatically derive Send and Sync because of C.
+// But it only contains C inside PhantomData, so Send and Sync are actually safe.
+unsafe impl<'v, C: EvalContextAttrExt> Send for Rule<'v, C> {}
+unsafe impl<'v, C: EvalContextAttrExt> Sync for Rule<'v, C> {}
+
+unsafe impl<'v, C: EvalContextAttrExt> ProvidesStaticType<'v> for Rule<'v, C> {
+    type StaticType = Rule<'static, C>;
+}
+
+unsafe impl<'v, C: EvalContextAttrExt> starlark::values::Trace<'v> for Rule<'v, C> {
+    fn trace(&mut self, tracer: &starlark::values::Tracer<'v>) {
+        self.implementation.trace(tracer);
+        self.parent.trace(tracer);
+    }
+}
+
+impl<'v, C: EvalContextAttrExt> fmt::Debug for Rule<'v, C> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        fmt::Display::fmt(self, f)
+    }
+}
+
+/// Some reserved attributes are currently in use (eg. name).
+/// Others are reserved for future use, so we disallow them in case we want to
+/// support them in the future.
+const RESERVED_ATTRS: &[&str] = &[
+    "deps",
+    "name",
+    "public",
+    "public_deps",
+    "sources",
+    "testonly",
+    "visibility",
+];
+
+fn merge_attrs(
+    parent_attrs: &SmallMap<String, AttrSchema>,
+    mut child_attrs: SmallMap<String, AttrSchema>,
+) -> Result<SmallMap<String, AttrSchema>, starlark::Error> {
+    let mut merged = SmallMap::with_capacity(parent_attrs.len() + child_attrs.len());
+
+    // Order matters. Parent attributes need to be added first. This allows the
+    // static_library builtin, for example, to declare attrs = `{"public": ...,
+    // "sources": ...}`, then it can simply set `values = [target.public(),
+    // target.sources(), starlark parameters]`
+    for (name, parent_schema) in parent_attrs {
+        if let Some(child_schema) = child_attrs.shift_remove(name) {
+            parent_schema.check_override(name, &child_schema)?;
+            merged.insert(name.clone(), child_schema);
+        } else {
+            merged.insert(name.clone(), parent_schema.clone());
+        }
+    }
+
+    for (name, child_schema) in child_attrs {
+        merged.insert(name, child_schema);
+    }
+
+    Ok(merged)
+}
+
+impl<'v, C: EvalContextAttrExt> Rule<'v, C> {
+    /// Creates a new `Rule` with the given attributes, implementation, and
+    /// parent.
+    pub fn new(
+        attrs: SmallMap<String, AttrSchema>,
+        builtin: Option<OutputType>,
+        parent: Option<Value<'v>>,
+        implementation: Value<'v>,
+        frozen_heap: &FrozenHeap,
+    ) -> Result<Self, starlark::Error> {
+        for name in attrs.keys() {
+            if RESERVED_ATTRS.contains(&name.as_str()) {
+                return Err(crate::Error::ReservedAttribute(name.clone()).into());
+            }
+        }
+
+        let mut builtin = builtin;
+        let parent_schema = match parent {
+            Some(parent_val) => {
+                if let Some(rule) = parent_val.downcast_ref::<FrozenRule<C>>() {
+                    if builtin.is_none() {
+                        builtin = rule.builtin;
+                    }
+                    Some(&rule.schema)
+                } else if let Some(rule) = parent_val.downcast_ref::<Rule<'v, C>>() {
+                    if builtin.is_none() {
+                        builtin = rule.builtin;
+                    }
+                    Some(&rule.schema)
+                } else {
+                    return Err(crate::Error::ParentMustBeARule.into());
+                }
+            },
+            None => None,
+        };
+
+        let attrs = match parent_schema {
+            Some(parent_schema) => merge_attrs(parent_schema.attrs(), attrs)?,
+            None => attrs,
+        };
+
+        let schema = CtxAttrSchema::new(attrs, builtin, frozen_heap);
+        Ok(Self {
+            schema,
+            once_named: Default::default(),
+            builtin,
+            implementation,
+            parent,
+            _phantom: PhantomData,
+        })
+    }
+}
+
+pub(crate) fn build_signature(
+    name: &str,
+    schema: &CtxAttrSchema,
+    is_builtin: bool,
+) -> ParametersSpec<FrozenValue> {
+    let named_only: Vec<_> = std::iter::once(("name", ParametersSpecParam::Required))
+        .chain(
+            schema
+                .attrs()
+                .iter()
+                .map(|(k, attr)| (k.as_str(), attr.as_param_spec())),
+        )
+        .collect();
+
+    ParametersSpec::new_parts(name, vec![], vec![], false, named_only, is_builtin)
+}
+
+#[starlark_value(type = "rule")]
+impl<'v, C: EvalContextAttrExt> StarlarkValue<'v> for Rule<'v, C>
+where
+    Self: ProvidesStaticType<'v>,
+{
+    type Canonical = FrozenRule<C>;
+
+    fn export_as(
+        &self,
+        variable_name: &str,
+        _eval: &mut Evaluator<'v, '_, '_>,
+    ) -> starlark::Result<()> {
+        let signature = build_signature(variable_name, &self.schema, self.builtin.is_some());
+        let _ = self.once_named.set((variable_name.to_owned(), signature));
+        Ok(())
+    }
+
+    fn invoke(
+        &self,
+        _me: Value<'v>,
+        _args: &Arguments<'v, '_>,
+        eval: &mut Evaluator<'v, '_, '_>,
+    ) -> starlark::Result<Value<'v>> {
+        eval.context::<C>().require_macro()?;
+        // Rules can only be created in bzl files, so if we are in a macro, the rule
+        // must already be frozen. In that case, we would be calling
+        // FrozenRule::invoke instead.
+        unreachable!();
+    }
+}
+
+impl<'v, C: EvalContextAttrExt> Freeze for Rule<'v, C> {
+    type Frozen = FrozenRule<C>;
+
+    fn freeze(self, freezer: &Freezer) -> FreezeResult<Self::Frozen> {
+        let (name, signature) = self
+            .once_named
+            .into_inner()
+            .ok_or(crate::Error::RuleMustBeNamed)?;
+        Ok(FrozenRule {
+            schema: self.schema,
+            builtin: self.builtin,
+            implementation: self.implementation.freeze(freezer)?,
+            name,
+            signature,
+            parent: match self.parent {
+                Some(p) => {
+                    let frozen_parent = p.freeze(freezer)?;
+                    let typed = FrozenValueTyped::<FrozenRule<C>>::new(frozen_parent).unwrap();
+                    Some(typed.as_ref())
+                },
+                None => None,
+            },
+            _phantom: PhantomData,
+        })
+    }
+}
+
+impl<'v, C: EvalContextAttrExt> starlark::values::AllocValue<'v> for Rule<'v, C> {
+    #[inline]
+    fn alloc_value(self, heap: starlark::values::Heap<'v>) -> starlark::values::Value<'v> {
+        heap.alloc_complex(self)
+    }
+}
+
+impl<'v, C: EvalContextAttrExt> fmt::Display for Rule<'v, C> {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        if let Some((name, _)) = self.once_named.get() {
+            write!(f, "<rule: {name}>")
+        } else {
+            write!(f, "<anonymous rule>")
+        }
+    }
+}
diff --git a/src/gn/starlark/crates/testutils/src/assert.rs b/src/gn/starlark/crates/testutils/src/assert.rs
index d10726a..3239ade 100644
--- a/src/gn/starlark/crates/testutils/src/assert.rs
+++ b/src/gn/starlark/crates/testutils/src/assert.rs
@@ -2,8 +2,11 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-use starlark::{environment::GlobalsBuilder, values::UnpackValue};
-use types::{EvaluatorContextExt, UnpackedOwnedValue};
+use starlark::{
+    environment::{FrozenModule, GlobalsBuilder},
+    values::UnpackValue,
+};
+use types::{EvaluatorContextExt, Label, PathResolver, UnpackedOwnedValue};
 
 use crate::{register_globals, FakeEvalContext};
 
@@ -66,6 +69,23 @@
         });
     }
 
+    pub fn module_add(&mut self, module: FrozenModule) {
+        let name = module.frozen_heap().name().unwrap().to_string();
+        self.assert.module_add(&name, module);
+    }
+
+    pub fn load_file(&self, label: &str) -> String {
+        let loader = PathResolver::new_for_testing();
+        let parsed = Label::parse(label, types::PackageRef::root()).unwrap();
+        let path = loader.absolute_path(parsed.package(), parsed.name());
+        std::fs::read_to_string(path).unwrap()
+    }
+
+    pub fn load_module(&mut self, label: &str) -> FrozenModule {
+        let content = self.load_file(label);
+        self.assert.module(label, &content)
+    }
+
     /// Returns a read-only reference to the fake evaluation context.
     pub fn context(&self) -> &FakeEvalContext {
         &self.context
@@ -110,8 +130,7 @@
         self.assert.pass(code)
     }
 
-    /// Asserts that freezing the evaluated module fails with the expected
-    /// error.
+    /// Asserts that freezing the evaluated module fails with the expected error.
     #[track_caller]
     pub fn fail_to_freeze(&mut self, code: &str, expected_error: &str) -> starlark::Error {
         self.assert.fail_to_freeze(code, expected_error)
diff --git a/src/gn/starlark/crates/testutils/src/target.rs b/src/gn/starlark/crates/testutils/src/target.rs
index 94dcb2e..544231f 100644
--- a/src/gn/starlark/crates/testutils/src/target.rs
+++ b/src/gn/starlark/crates/testutils/src/target.rs
@@ -15,7 +15,7 @@
     values::{FrozenValue, ProvidesStaticType, StarlarkValue, Value, ValueLike},
 };
 use starlark_derive::{starlark_value, NoSerialize};
-use types::{File, IPromiseToImplementStarlarkEqAndHash, Label, OutputType, TargetRef};
+use types::{File, IPromiseToImplementStarlarkEqAndHash, Label, LabelRef, OutputType, Session, TargetRef};
 
 /// A fake target struct for testing.
 #[derive(Debug, Allocative, Default)]
@@ -129,4 +129,14 @@
     fn target_out_dir(&self, prefix: &str, suffix: &str, _separator: &str) -> String {
         format!("{prefix}$TOOLCHAIN/{suffix}$LABEL")
     }
+
+    fn register_dependencies<S: Session<TargetRef = Self>>(
+        &self,
+        session: &S,
+        toolchain: LabelRef<'_>,
+    ) {
+        for attr in &self.get().attrs {
+            attr.register_dependencies(session, self.clone(), toolchain);
+        }
+    }
 }
diff --git a/src/gn/starlark/crates/types/src/output_type.rs b/src/gn/starlark/crates/types/src/output_type.rs
index d3af62c..abb5f20 100644
--- a/src/gn/starlark/crates/types/src/output_type.rs
+++ b/src/gn/starlark/crates/types/src/output_type.rs
@@ -19,10 +19,27 @@
     SourceSet,
     Copy,
     Action,
-    ActionForEach,
+    ActionForeach,
     BundleData,
     CreateBundle,
     GeneratedFile,
     RustLibrary,
     RustProcMacro,
 }
+
+impl OutputType {
+    /// Returns (attr-and-files attributes, attr-only attributes).
+    ///
+    /// Any attributes that should fill ctx.files.* should go in the former.
+    /// Other attributes should go in the latter.
+    pub fn attrs(&self) -> (&'static [&'static str], &'static [&'static str]) {
+        match self {
+            Self::Executable
+            | Self::SharedLibrary
+            | Self::LoadableModule
+            | Self::StaticLibrary
+            | Self::SourceSet => (&["sources", "public"], &["deps", "public_deps"]),
+            _ => (&[], &[]),
+        }
+    }
+}
diff --git a/src/gn/starlark/crates/types/src/target_ref.rs b/src/gn/starlark/crates/types/src/target_ref.rs
index bdbcf40..8b6c06c 100644
--- a/src/gn/starlark/crates/types/src/target_ref.rs
+++ b/src/gn/starlark/crates/types/src/target_ref.rs
@@ -4,7 +4,7 @@
 
 use starlark::values::{AllocValue, StarlarkValue};
 
-use crate::File;
+use crate::{File, LabelRef, Session};
 
 /// Unfortunately while we could specify that Eq and Hash are implemented, there
 /// is no way to delegate starlark's equality and hash function to it
@@ -32,4 +32,11 @@
         label_prefix: &str,
         package_name_separator: &str,
     ) -> String;
+
+    /// Registers target dependencies contained within this target's attributes.
+    fn register_dependencies<S: Session<TargetRef = Self>>(
+        &self,
+        session: &S,
+        toolchain: LabelRef<'_>,
+    );
 }
diff --git a/src/gn/starlark/src/testdata/rules/native.scl b/src/gn/starlark/src/testdata/rules/native.scl
new file mode 100644
index 0000000..67d5259
--- /dev/null
+++ b/src/gn/starlark/src/testdata/rules/native.scl
@@ -0,0 +1,24 @@
+load("//builtins:rules.scl", "static_library", "shared_library")
+
+def _static_library_impl(ctx):
+  return []
+
+static_library = rule(
+  implementation = _static_library_impl,
+  parent = static_library,
+  attrs = {
+    "optional": attr.string(),
+  }
+)
+
+def _shared_library_impl(ctx):
+  return []
+
+custom_shared_library = rule(
+  implementation = _shared_library_impl,
+  parent = shared_library,
+  attrs = {
+    "optional": attr.string(),
+    "mandatory": attr.string(mandatory = True)
+  }
+)
\ No newline at end of file
diff --git a/src/gn/starlark/src/testdata/rules/pure.scl b/src/gn/starlark/src/testdata/rules/pure.scl
new file mode 100644
index 0000000..6be8bb6
--- /dev/null
+++ b/src/gn/starlark/src/testdata/rules/pure.scl
@@ -0,0 +1,22 @@
+def _parent_impl(ctx):
+  return []
+
+parent_rule = rule(
+  implementation = _parent_impl,
+  attrs = {
+    "parent_only": attr.string(mandatory = True),
+    "override": attr.label(default = "//:parent"),
+  }
+)
+
+def _child_impl(ctx):
+  return []
+
+child_rule = rule(
+  implementation = _child_impl,
+  parent = parent_rule,
+  attrs = {
+    "child_only": attr.string(mandatory = True),
+    "override": attr.label(default = "//:child"),
+  }
+)