diff --git a/AGENTS.md b/AGENTS.md index d6350ace..700c61cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,8 +9,8 @@ - **Clarity over cleverness.** Be concise, but favour explicit over terse or obscure idioms. Prefer code that's easy to follow. - **Use functions and composition.** Avoid repetition by extracting reusable - logic. Prefer generators or comprehensions, and declarative code to imperative - repetition when readable. + logic. Prefer generators or comprehensions, and declarative code to + imperative repetition when readable. - **Small, meaningful functions.** Functions must be small, clear in purpose, single responsibility, and obey command/query segregation. - **Clear commit messages.** Commit messages should be descriptive, explaining @@ -25,12 +25,13 @@ ("-ize" / "-yse" / "-our") spelling and grammar, with the exception of references to external APIs. - **Illustrate with clear examples.** Function documentation must include clear - examples demonstrating the usage and outcome of the function. Test documentation - should omit examples where the example serves only to reiterate the test logic. -- **Keep file size managable.** No single code file may be longer than 400 lines. - Long switch statements or dispatch tables should be broken up by feature and - constituents colocated with targets. Large blocks of test data should be moved - to external data files. + examples demonstrating the usage and outcome of the function. Test + documentation should omit examples where the example serves only to reiterate + the test logic. +- **Keep file size managable.** No single code file may be longer than 400 + lines. Long switch statements or dispatch tables should be broken up by + feature and constituents colocated with targets. Large blocks of test data + should be moved to external data files. ## Documentation Maintenance @@ -42,8 +43,8 @@ relevant file(s) in the `docs/` directory to reflect the latest state. **Ensure the documentation remains accurate and current.** - Documentation must use en-GB-oxendict ("-ize" / "-yse" / "-our") spelling - and grammar. (EXCEPTION: the naming of the "LICENSE" file, which - is to be left unchanged for community consistency.) + and grammar. (EXCEPTION: the naming of the "LICENSE" file, which is to be + left unchanged for community consistency.) ## Change Quality & Committing @@ -153,19 +154,19 @@ project: specified in `Cargo.toml` must use SemVer-compatible caret requirements (e.g., `some-crate = "1.2.3"`). This is Cargo's default and allows for safe, non-breaking updates to minor and patch versions while preventing breaking - changes from new major versions. This approach is critical for ensuring - build stability and reproducibility. + changes from new major versions. This approach is critical for ensuring build + stability and reproducibility. - **Prohibit unstable version specifiers.** The use of wildcard (`*`) or - open-ended inequality (`>=`) version requirements is strictly forbidden - as they introduce unacceptable risk and unpredictability. Tilde requirements + open-ended inequality (`>=`) version requirements is strictly forbidden, as + they introduce unacceptable risk and unpredictability. Tilde requirements (`~`) should only be used where a dependency must be locked to patch-level updates for a specific, documented reason. ### Error Handling - **Prefer semantic error enums**. Derive `std::error::Error` (via the - `thiserror` crate) for any condition the caller might inspect, retry, or - map to an HTTP status. + `thiserror` crate) for any condition the caller might inspect, retry, or map + to an HTTP status. - **Use an *opaque* error only at the app boundary**. Use `eyre::Report` for human-readable logs; these should not be exposed in public APIs. - **Never export the opaque type from a library**. Convert to domain enums at diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 09c52c01..d283de23 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -1044,6 +1044,18 @@ default my_app \`\`\` +### 5.5 Design Decisions + +The IR structures defined in `src/ir.rs` are minimal containers that mirror +Ninja's conceptual model while remaining backend-agnostic. `BuildGraph` +collects all `Action`s and `BuildEdge`s in hash maps keyed by stable strings and +`PathBuf`s so the graph can be deterministically traversed for snapshot tests. +Actions hold the parsed `Recipe` and +optional execution metadata. `BuildEdge` connects inputs to outputs using an +action identifier and carries the `phony` and `always` flags verbatim from the +manifest. No Ninja specific placeholders are stored in the IR to keep the +representation portable. + ## Section 6: Process Management and Secure Execution The final stage of a Netsuke build involves executing commands. While Netsuke diff --git a/docs/roadmap.md b/docs/roadmap.md index be11f2a4..fbda68f7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -37,8 +37,8 @@ compilation pipeline from parsing to execution. - [ ] **Intermediate Representation (IR) and Validation:** - - [ ] Define the IR data structures (BuildGraph, Action, BuildEdge) in - `src/ir.rs`, keeping it backend-agnostic as per the design. + - [x] Define the IR data structures (BuildGraph, Action, BuildEdge) in + `src/ir.rs`, keeping it backend-agnostic as per the design. *(done)* - [ ] Implement the ir::from_manifest transformation logic to convert the AST into the BuildGraph IR. diff --git a/src/ast.rs b/src/ast.rs index 55320c6e..42d5e500 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -106,7 +106,7 @@ pub struct Rule { /// /// The variant is selected using the `kind` field in the manifest. Each variant /// corresponds to a different way of specifying how a command should run. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone, PartialEq)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum Recipe { /// A single shell command. @@ -172,7 +172,7 @@ pub struct Target { /// - hello /// - world /// ``` -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize, Default, Clone, PartialEq)] #[serde(untagged)] pub enum StringOrList { /// No value provided. diff --git a/src/ir.rs b/src/ir.rs new file mode 100644 index 00000000..9680fbd9 --- /dev/null +++ b/src/ir.rs @@ -0,0 +1,70 @@ +//! Intermediate Representation structures. +//! +//! This module defines the backend-agnostic build graph used by Netsuke after +//! validation. The IR mirrors the conceptual model of Ninja without embedding +//! any Ninja-specific syntax. +//! +//! # Examples +//! +//! ``` +//! use netsuke::ir::{Action, BuildGraph, BuildEdge}; +//! use netsuke::ast::Recipe; +//! use std::path::PathBuf; +//! +//! let action = Action { +//! recipe: Recipe::Command { command: "echo hi".into() }, +//! description: None, +//! depfile: None, +//! deps_format: None, +//! pool: None, +//! restat: false, +//! }; +//! let mut graph = BuildGraph::default(); +//! graph.actions.insert("a".into(), action); +//! graph.default_targets.push(PathBuf::from("hello")); +//! ``` +// +use crate::ast::Recipe; +use std::collections::HashMap; +use std::path::PathBuf; + +/// The complete, static build graph. +#[derive(Debug, Default, Clone)] +pub struct BuildGraph { + /// All unique actions in the build keyed by a stable hash. + pub actions: HashMap, + /// All target files to be built keyed by output path. + pub targets: HashMap, + /// Targets built when no explicit target is requested. + pub default_targets: Vec, +} + +/// A reusable command analogous to a Ninja rule. +#[derive(Debug, Clone, PartialEq)] +pub struct Action { + pub recipe: Recipe, + pub description: Option, + pub depfile: Option, + pub deps_format: Option, + pub pool: Option, + pub restat: bool, +} + +/// A single build statement connecting inputs to outputs. +#[derive(Debug, Clone, PartialEq)] +pub struct BuildEdge { + /// Identifier of the [`Action`] used for this edge. + pub action_id: String, + /// Explicit inputs that trigger a rebuild when changed. + pub inputs: Vec, + /// Outputs explicitly generated by the command. + pub explicit_outputs: Vec, + /// Outputs implicitly generated by the command (Ninja `|`). + pub implicit_outputs: Vec, + /// Order-only dependencies that do not trigger rebuilds (Ninja `||`). + pub order_only_deps: Vec, + /// Always run the command even if the output exists. + pub phony: bool, + /// Run the command on every invocation regardless of timestamps. + pub always: bool, +} diff --git a/src/lib.rs b/src/lib.rs index fb2272ab..c15faeeb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,5 +5,6 @@ pub mod ast; pub mod cli; +pub mod ir; pub mod manifest; pub mod runner; diff --git a/tests/cucumber.rs b/tests/cucumber.rs index ffdfc9b7..90f57129 100644 --- a/tests/cucumber.rs +++ b/tests/cucumber.rs @@ -6,6 +6,7 @@ pub struct CliWorld { pub cli_error: Option, pub manifest: Option, pub manifest_error: Option, + pub build_graph: Option, } mod steps; diff --git a/tests/features/ir.feature b/tests/features/ir.feature new file mode 100644 index 00000000..a0295d02 --- /dev/null +++ b/tests/features/ir.feature @@ -0,0 +1,8 @@ +Feature: BuildGraph + + Scenario: New BuildGraph is empty + When a new BuildGraph is created + Then the graph has 0 actions + And the graph has 0 targets + And the graph has 0 default targets + diff --git a/tests/ir_tests.rs b/tests/ir_tests.rs new file mode 100644 index 00000000..2606da4a --- /dev/null +++ b/tests/ir_tests.rs @@ -0,0 +1,112 @@ +//! Unit tests for IR structures. + +use netsuke::ast::Recipe; +use netsuke::ir::{Action, BuildEdge, BuildGraph}; +use rstest::rstest; +use std::path::PathBuf; + +#[rstest] +fn build_graph_default_is_empty() { + let graph = BuildGraph::default(); + assert!(graph.actions.is_empty()); + assert!(graph.targets.is_empty()); + assert!(graph.default_targets.is_empty()); +} + +#[rstest] +fn create_action_and_edge() { + let action = Action { + recipe: Recipe::Command { + command: "echo".into(), + }, + description: Some("desc".into()), + depfile: Some("$out.d".into()), + deps_format: Some("gcc".into()), + pool: None, + restat: false, + }; + let edge = BuildEdge { + action_id: "id".into(), + inputs: vec![PathBuf::from("in")], + explicit_outputs: vec![PathBuf::from("out")], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: true, + }; + let mut graph = BuildGraph::default(); + graph.actions.insert("id".into(), action); + graph.targets.insert(PathBuf::from("out"), edge); + assert_eq!(graph.actions.len(), 1); + assert_eq!(graph.targets.len(), 1); +} + +#[test] +fn build_graph_duplicate_action_ids() { + let mut graph = BuildGraph::default(); + let action1 = Action { + recipe: Recipe::Command { + command: "one".into(), + }, + description: None, + depfile: None, + deps_format: None, + pool: None, + restat: false, + }; + let action2 = Action { + recipe: Recipe::Command { + command: "two".into(), + }, + description: None, + depfile: None, + deps_format: None, + pool: None, + restat: false, + }; + let prev = graph.actions.insert("a".into(), action1); + assert!(prev.is_none()); + let prev = graph.actions.insert("a".into(), action2); + assert!(prev.is_some()); + assert_eq!(graph.actions.len(), 1); + if let Recipe::Command { command } = &graph.actions.get("a").expect("action").recipe { + assert_eq!(command, "two"); + } else { + panic!("unexpected recipe type"); + } +} + +#[test] +fn build_graph_duplicate_targets() { + let mut graph = BuildGraph::default(); + let edge1 = BuildEdge { + action_id: "a".into(), + inputs: vec![PathBuf::from("in")], + explicit_outputs: vec![PathBuf::from("out")], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + }; + let edge2 = BuildEdge { + action_id: "a".into(), + inputs: vec![PathBuf::from("in")], + explicit_outputs: vec![PathBuf::from("out")], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: true, + }; + let prev = graph.targets.insert(PathBuf::from("out"), edge1); + assert!(prev.is_none()); + let prev = graph.targets.insert(PathBuf::from("out"), edge2); + assert!(prev.is_some()); + assert_eq!(graph.targets.len(), 1); + assert!( + graph + .targets + .get(&PathBuf::from("out")) + .expect("edge") + .always + ); +} diff --git a/tests/steps/ir_steps.rs b/tests/steps/ir_steps.rs new file mode 100644 index 00000000..ce309b5d --- /dev/null +++ b/tests/steps/ir_steps.rs @@ -0,0 +1,28 @@ +//! Step definitions for `BuildGraph` scenarios. + +use crate::CliWorld; +use cucumber::{then, when}; +use netsuke::ir::BuildGraph; + +#[when("a new BuildGraph is created")] +fn create_graph(world: &mut CliWorld) { + world.build_graph = Some(BuildGraph::default()); +} + +#[then(expr = "the graph has {int} actions")] +fn graph_actions(world: &mut CliWorld, count: usize) { + let g = world.build_graph.as_ref().expect("graph"); + assert_eq!(g.actions.len(), count); +} + +#[then(expr = "the graph has {int} targets")] +fn graph_targets(world: &mut CliWorld, count: usize) { + let g = world.build_graph.as_ref().expect("graph"); + assert_eq!(g.targets.len(), count); +} + +#[then(expr = "the graph has {int} default targets")] +fn graph_defaults(world: &mut CliWorld, count: usize) { + let g = world.build_graph.as_ref().expect("graph"); + assert_eq!(g.default_targets.len(), count); +} diff --git a/tests/steps/mod.rs b/tests/steps/mod.rs index f434c7a1..8524b752 100644 --- a/tests/steps/mod.rs +++ b/tests/steps/mod.rs @@ -1,2 +1,3 @@ mod cli_steps; +mod ir_steps; mod manifest_steps;