From 6893c7f8ee57a103641ddbca71310c9e07337aa2 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 19 Jul 2025 03:24:46 +0100 Subject: [PATCH 1/6] Add manifest AST structures and tests --- Cargo.lock | 43 ++++++++++++++++ Cargo.toml | 2 + docs/netsuke-design.md | 7 +++ docs/roadmap.md | 3 +- src/ast.rs | 87 +++++++++++++++++++++++++++++++++ src/lib.rs | 1 + tests/ast_tests.rs | 26 ++++++++++ tests/cucumber.rs | 2 + tests/data/minimal.yml | 6 +++ tests/features/manifest.feature | 6 +++ tests/steps/manifest_steps.rs | 45 +++++++++++++++++ tests/steps/mod.rs | 1 + 12 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 src/ast.rs create mode 100644 tests/ast_tests.rs create mode 100644 tests/data/minimal.yml create mode 100644 tests/features/manifest.feature create mode 100644 tests/steps/manifest_steps.rs diff --git a/Cargo.lock b/Cargo.lock index 5af9ffc0..1cd1536e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -316,6 +316,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.13" @@ -474,6 +480,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "hashbrown" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" + [[package]] name = "heck" version = "0.4.1" @@ -508,6 +520,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "inflections" version = "1.1.1" @@ -641,6 +663,8 @@ dependencies = [ "clap", "cucumber", "rstest", + "serde", + "serde_yaml", "tokio", ] @@ -932,6 +956,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "slab" version = "0.4.10" @@ -1110,6 +1147,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "utf8parse" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 5c743751..6d8216d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,8 @@ edition = "2024" [dependencies] clap = { version = "4.5.0", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" [lints.clippy] pedantic = { level = "warn", priority = -1 } diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 1ed690ae..4eed46fa 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -550,6 +550,13 @@ This two-pass mechanism cleanly separates the concerns of templating and data structure parsing. It allows each library to do what it does best without interference, ensuring a robust and predictable ingestion pipeline. +### 3.4 Design Decisions + +The AST structures are implemented in `src/ast.rs` and derive `Deserialize`. +Unknown fields are rejected to surface user errors early. `StringOrList` +provides a default `Empty` variant so optional lists are trivial to represent. +This keeps YAML manifests concise while ensuring forward compatibility. + ## Section 4: Dynamic Builds with the Jinja Templating Engine To provide the dynamic capabilities and logical expressiveness that make a diff --git a/docs/roadmap.md b/docs/roadmap.md index 25280772..f793b217 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,8 +16,9 @@ compilation pipeline from parsing to execution. global options (--file, --directory, --jobs), as defined in the design document. *(done)* - - [ ] Define the core Abstract Syntax Tree (AST) data structures + - [x] Define the core Abstract Syntax Tree (AST) data structures (NetsukeManifest, Rule, Target, StringOrList, Recipe) in `src/ast.rs`. + *(done)* - [ ] Annotate AST structs with #[derive(Deserialize)] and #[serde(deny_unknown_fields)] diff --git a/src/ast.rs b/src/ast.rs new file mode 100644 index 00000000..f56fa75f --- /dev/null +++ b/src/ast.rs @@ -0,0 +1,87 @@ +//! Netsuke manifest Abstract Syntax Tree structures. +//! +//! This module defines the data structures used to represent a parsed +//! `Netsukefile`. They mirror the YAML schema described in the design +//! document and are deserialised with `serde_yaml`. + +use serde::Deserialize; +use std::collections::HashMap; + +/// Top-level manifest structure. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NetsukeManifest { + pub netsuke_version: String, + + #[serde(default)] + pub vars: HashMap, + + #[serde(default)] + pub rules: Vec, + + #[serde(default)] + pub actions: Vec, + + pub targets: Vec, + + #[serde(default)] + pub defaults: Vec, +} + +/// A reusable command template. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Rule { + pub name: String, + pub recipe: Recipe, + pub description: Option, + pub deps: Option, +} + +/// Execution style for rules and targets. +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum Recipe { + #[serde(alias = "command")] + Command { command: String }, + #[serde(alias = "script")] + Script { script: String }, + #[serde(alias = "rule")] + Rule { rule: StringOrList }, +} + +/// A single build target. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Target { + pub name: StringOrList, + pub recipe: Recipe, + + #[serde(default)] + pub sources: StringOrList, + + #[serde(default)] + pub deps: StringOrList, + + #[serde(default)] + pub order_only_deps: StringOrList, + + #[serde(default)] + pub vars: HashMap, + + #[serde(default)] + pub phony: bool, + + #[serde(default)] + pub always: bool, +} + +/// A helper for fields that accept either a string or list of strings. +#[derive(Debug, Deserialize, Default)] +#[serde(untagged)] +pub enum StringOrList { + #[default] + Empty, + String(String), + List(Vec), +} diff --git a/src/lib.rs b/src/lib.rs index f0c8a4a9..50e14883 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,5 +3,6 @@ //! Currently this library only exposes the command line interface //! definitions used by the binary and tests. +pub mod ast; pub mod cli; pub mod runner; diff --git a/tests/ast_tests.rs b/tests/ast_tests.rs new file mode 100644 index 00000000..f00a5764 --- /dev/null +++ b/tests/ast_tests.rs @@ -0,0 +1,26 @@ +//! Unit tests for Netsuke manifest AST deserialisation. + +use netsuke::ast::*; +use rstest::rstest; + +#[rstest] +fn parse_minimal_manifest() { + let yaml = r#" + netsuke_version: "1.0" + targets: + - name: hello + recipe: + kind: command + command: "echo hi" + "#; + + let manifest: NetsukeManifest = serde_yaml::from_str(yaml).expect("parse"); + + assert_eq!(manifest.netsuke_version, "1.0"); + let first = manifest.targets.first().expect("target"); + if let StringOrList::String(name) = &first.name { + assert_eq!(name, "hello"); + } else { + panic!("target name should be String"); + } +} diff --git a/tests/cucumber.rs b/tests/cucumber.rs index 4b32c084..ffdfc9b7 100644 --- a/tests/cucumber.rs +++ b/tests/cucumber.rs @@ -4,6 +4,8 @@ use cucumber::World; pub struct CliWorld { pub cli: Option, pub cli_error: Option, + pub manifest: Option, + pub manifest_error: Option, } mod steps; diff --git a/tests/data/minimal.yml b/tests/data/minimal.yml new file mode 100644 index 00000000..e9e91bf6 --- /dev/null +++ b/tests/data/minimal.yml @@ -0,0 +1,6 @@ +netsuke_version: "1.0" +targets: + - name: hello + recipe: + kind: command + command: "echo hi" diff --git a/tests/features/manifest.feature b/tests/features/manifest.feature new file mode 100644 index 00000000..5d6c2dd0 --- /dev/null +++ b/tests/features/manifest.feature @@ -0,0 +1,6 @@ +Feature: Manifest parsing + + Scenario: Parse minimal manifest + When the manifest file "tests/data/minimal.yml" is parsed + Then the manifest version is "1.0" + And the first target name is "hello" diff --git a/tests/steps/manifest_steps.rs b/tests/steps/manifest_steps.rs new file mode 100644 index 00000000..cd7ae7e3 --- /dev/null +++ b/tests/steps/manifest_steps.rs @@ -0,0 +1,45 @@ +//! Step definitions for manifest parsing scenarios. + +use crate::CliWorld; +use cucumber::{then, when}; +use netsuke::ast::{NetsukeManifest, StringOrList}; +use std::fs; + +#[when(expr = "the manifest file {string} is parsed")] +fn parse_manifest(world: &mut CliWorld, path: String) { + let yaml = fs::read_to_string(path).expect("read manifest"); + match serde_yaml::from_str::(&yaml) { + Ok(manifest) => { + world.manifest = Some(manifest); + world.manifest_error = None; + } + Err(e) => { + world.manifest = None; + world.manifest_error = Some(e.to_string()); + } + } +} + +#[expect( + clippy::needless_pass_by_value, + reason = "Cucumber requires owned String arguments" +)] +#[then(expr = "the manifest version is {string}")] +fn manifest_version(world: &mut CliWorld, version: String) { + let manifest = world.manifest.as_ref().expect("manifest"); + assert_eq!(manifest.netsuke_version, version); +} + +#[expect( + clippy::needless_pass_by_value, + reason = "Cucumber requires owned String arguments" +)] +#[then(expr = "the first target name is {string}")] +fn first_target_name(world: &mut CliWorld, name: String) { + let manifest = world.manifest.as_ref().expect("manifest"); + let first = manifest.targets.first().expect("targets"); + match &first.name { + StringOrList::String(value) => assert_eq!(value, &name), + other => panic!("unexpected variant {other:?}"), + } +} diff --git a/tests/steps/mod.rs b/tests/steps/mod.rs index 90e6389d..f434c7a1 100644 --- a/tests/steps/mod.rs +++ b/tests/steps/mod.rs @@ -1 +1,2 @@ mod cli_steps; +mod manifest_steps; From cef80aed4ecd1dd92e0961b0067c157d6a7a9d1e Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 19 Jul 2025 03:41:23 +0100 Subject: [PATCH 2/6] Update manifest diagram --- docs/netsuke-design.md | 86 ++++++++++++++++++------------------------ 1 file changed, 37 insertions(+), 49 deletions(-) diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 4eed46fa..357d8fe8 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -184,63 +184,51 @@ level keys. invoked without any specific targets on the command line. This maps directly to Ninja's `default` target statement.[^3] -The class diagram below summarizes the structure of a `Netsukefile` and the +The E-R diagram below summarises the structure of a `Netsukefile` and the relationships between its components. ```mermaid -classDiagram - class NetsukeManifest { - +String netsuke_version - +HashMap vars - +Vec rules - +Vec actions - +Vec targets - +Vec defaults +erDiagram + NETSUKE_MANIFEST { + string netsuke_version + map vars + list rules + list actions + list targets + list defaults } - class Target { - +StringOrList name - +Recipe recipe - +StringOrList sources - +StringOrList deps - +StringOrList order_only_deps - +HashMap vars - +bool phony - +bool always + RULE { + string name + Recipe recipe + string description + string deps } - class Rule { - +String name - +Recipe recipe - +String description - +String deps + TARGET { + StringOrList name + Recipe recipe + StringOrList sources + StringOrList deps + StringOrList order_only_deps + map vars + bool phony + bool always } - class Recipe { - <> - Command - Script - Rule + RECIPE { + enum kind + string command + string script + StringOrList rule } - class StringOrList { - <> - Empty - String - List + STRING_OR_LIST { + enum value } - class Value { - <> - Null - Bool - Number - String - Sequence - Mapping - Tagged - } - NetsukeManifest "1" o-- "*" Target - NetsukeManifest "1" o-- "*" Rule - NetsukeManifest "1" o-- "*" Value - Target "1" -- "1" Recipe - Target "1" -- "1" StringOrList - Rule "1" -- "1" Recipe + NETSUKE_MANIFEST ||--o{ RULE : contains + NETSUKE_MANIFEST ||--o{ TARGET : has_actions + NETSUKE_MANIFEST ||--o{ TARGET : has_targets + RULE }o--|| RECIPE : uses + TARGET }o--|| RECIPE : uses + TARGET }o--|| STRING_OR_LIST : uses + RECIPE }o--|| STRING_OR_LIST : uses ``` ### 2.3 Defining `rules` From bd2565682585b324e6084ee7e2f0434064b4d57f Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 19 Jul 2025 03:54:20 +0100 Subject: [PATCH 3/6] Refine manifest AST and tests --- Cargo.lock | 4 + Cargo.toml | 1 + docs/netsuke-design.md | 23 ++-- docs/roadmap.md | 2 +- src/ast.rs | 10 +- tests/ast_tests.rs | 188 +++++++++++++++++++++++++++++++- tests/data/minimal.yml | 2 +- tests/features/manifest.feature | 2 +- tests/steps/manifest_steps.rs | 2 +- 9 files changed, 214 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1cd1536e..98615129 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -663,6 +663,7 @@ dependencies = [ "clap", "cucumber", "rstest", + "semver", "serde", "serde_yaml", "tokio", @@ -923,6 +924,9 @@ name = "semver" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] [[package]] name = "serde" diff --git a/Cargo.toml b/Cargo.toml index 6d8216d6..ec471a32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" clap = { version = "4.5.0", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_yaml = "0.9" +semver = { version = "1", features = ["serde"] } [lints.clippy] pedantic = { level = "warn", priority = -1 } diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 357d8fe8..4e409e35 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -157,7 +157,7 @@ A `Netsukefile` file is a YAML mapping containing a set of well-defined top- level keys. - `netsuke_version`: A mandatory string that specifies the version of the - Netsuke schema the manifest conforms to (e.g., `"1.0"`). This allows for + Netsuke schema the manifest conforms to (e.g., `"1.0.0"`). This allows for future evolution of the schema while maintaining backward compatibility. This version string should be parsed and validated using the `semver` crate.[^4] @@ -177,7 +177,7 @@ level keys. the sources it depends on, and the rule used to produce it. This corresponds to a Ninja `build` statement.[^3] -- `actions`: A secondary list of build targets. Any target placed here is +- `steps`: A secondary list of build targets. Any target placed here is treated as `{ phony: true, always: false }` by default. - `defaults`: An optional list of target names to be built when Netsuke is @@ -193,7 +193,7 @@ erDiagram string netsuke_version map vars list rules - list actions + list steps list targets list defaults } @@ -223,7 +223,7 @@ erDiagram enum value } NETSUKE_MANIFEST ||--o{ RULE : contains - NETSUKE_MANIFEST ||--o{ TARGET : has_actions + NETSUKE_MANIFEST ||--o{ TARGET : has_steps NETSUKE_MANIFEST ||--o{ TARGET : has_targets RULE }o--|| RECIPE : uses TARGET }o--|| RECIPE : uses @@ -274,7 +274,7 @@ Each entry in the `rules` list is a mapping that defines a reusable action. ### 2.4 Defining `targets` Each entry in `targets` defines a build edge; placing a target in the optional -`actions` list instead marks it as `phony: true` with `always` left `false`. +`steps` list instead marks it as `phony: true` with `always` left `false`. - `name`: The primary output file or files for this build step. This can be a single string or a list of strings. @@ -412,16 +412,16 @@ use std::collections::HashMap; /// Represents the top-level structure of a Netsukefile file. #[serde(deny_unknown_fields)] pub struct NetsukeManifest { - pub netsuke_version: String, + pub netsuke_version: Version, #[serde(default)] - pub vars: HashMap, + pub vars: HashMap, #[serde(default)] pub rules: Vec, #[serde(default)] - pub actions: Vec, + pub steps: Vec, pub targets: Vec, @@ -435,7 +435,7 @@ pub struct Rule { pub name: String, pub recipe: Recipe, pub description: Option, - pub deps: Option, + pub deps: Option, // Additional fields like 'pool' or 'restat' can be added here // to map to more advanced Ninja features. } @@ -543,7 +543,10 @@ interference, ensuring a robust and predictable ingestion pipeline. The AST structures are implemented in `src/ast.rs` and derive `Deserialize`. Unknown fields are rejected to surface user errors early. `StringOrList` provides a default `Empty` variant so optional lists are trivial to represent. -This keeps YAML manifests concise while ensuring forward compatibility. +The manifest version is parsed using the `semver` crate to validate that it +follows semantic versioning rules. Global and target variable maps now share +the `HashMap` type for consistency. This keeps YAML manifests +concise while ensuring forward compatibility. ## Section 4: Dynamic Builds with the Jinja Templating Engine diff --git a/docs/roadmap.md b/docs/roadmap.md index f793b217..a3d571a4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -29,7 +29,7 @@ compilation pipeline from parsing to execution. - [ ] Support `phony` and `always` boolean flags on targets. - - [ ] Parse the optional actions list, treating each entry as a target with + - [ ] Parse the optional steps list, treating each entry as a target with phony: true by default. - [ ] Implement the YAML parsing logic to deserialize a static Netsukefile diff --git a/src/ast.rs b/src/ast.rs index f56fa75f..cd104358 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -4,6 +4,7 @@ //! `Netsukefile`. They mirror the YAML schema described in the design //! document and are deserialised with `serde_yaml`. +use semver::Version; use serde::Deserialize; use std::collections::HashMap; @@ -11,16 +12,17 @@ use std::collections::HashMap; #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct NetsukeManifest { - pub netsuke_version: String, + pub netsuke_version: Version, #[serde(default)] - pub vars: HashMap, + pub vars: HashMap, #[serde(default)] pub rules: Vec, + /// Optional top-level steps executed before normal targets. #[serde(default)] - pub actions: Vec, + pub steps: Vec, pub targets: Vec, @@ -35,7 +37,7 @@ pub struct Rule { pub name: String, pub recipe: Recipe, pub description: Option, - pub deps: Option, + pub deps: Option, } /// Execution style for rules and targets. diff --git a/tests/ast_tests.rs b/tests/ast_tests.rs index f00a5764..25b06eab 100644 --- a/tests/ast_tests.rs +++ b/tests/ast_tests.rs @@ -2,11 +2,12 @@ use netsuke::ast::*; use rstest::rstest; +use semver::Version; #[rstest] fn parse_minimal_manifest() { let yaml = r#" - netsuke_version: "1.0" + netsuke_version: "1.0.0" targets: - name: hello recipe: @@ -16,7 +17,10 @@ fn parse_minimal_manifest() { let manifest: NetsukeManifest = serde_yaml::from_str(yaml).expect("parse"); - assert_eq!(manifest.netsuke_version, "1.0"); + assert_eq!( + manifest.netsuke_version, + Version::parse("1.0.0").expect("ver") + ); let first = manifest.targets.first().expect("target"); if let StringOrList::String(name) = &first.name { assert_eq!(name, "hello"); @@ -24,3 +28,183 @@ fn parse_minimal_manifest() { panic!("target name should be String"); } } + +#[test] +fn missing_required_fields() { + let yaml = r#" + targets: + - name: hello + recipe: + kind: command + command: "echo hi" + "#; + assert!(serde_yaml::from_str::(yaml).is_err()); + + let yaml = r#" + netsuke_version: "1.0.0" + "#; + assert!(serde_yaml::from_str::(yaml).is_err()); + + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - recipe: + kind: command + command: "echo hi" + "#; + assert!(serde_yaml::from_str::(yaml).is_err()); +} + +#[test] +fn unknown_fields() { + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: hello + recipe: + kind: command + command: "echo hi" + extra: 42 + "#; + assert!(serde_yaml::from_str::(yaml).is_err()); + + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: hello + recipe: + kind: command + command: "echo hi" + unexpected: true + "#; + assert!(serde_yaml::from_str::(yaml).is_err()); +} + +#[test] +fn empty_lists_and_maps() { + let yaml = r#" + netsuke_version: "1.0.0" + targets: [] + "#; + let manifest = serde_yaml::from_str::(yaml).expect("parse"); + assert!(manifest.targets.is_empty()); + + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: hello + recipe: {} + "#; + assert!(serde_yaml::from_str::(yaml).is_err()); +} + +#[test] +fn string_or_list_variants() { + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: hello + recipe: + kind: command + command: "echo hi" + "#; + let manifest = serde_yaml::from_str::(yaml).expect("parse"); + let first = manifest.targets.first().expect("target"); + if let StringOrList::String(name) = &first.name { + assert_eq!(name, "hello"); + } else { + panic!("Expected String variant"); + } + + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: + - hello + - world + recipe: + kind: command + command: "echo hi" + "#; + let manifest = serde_yaml::from_str::(yaml).expect("parse"); + let first = manifest.targets.first().expect("target"); + if let StringOrList::List(names) = &first.name { + assert_eq!(names, &vec!["hello".to_string(), "world".to_string()]); + } else { + panic!("Expected List variant"); + } + + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: [] + recipe: + kind: command + command: "echo hi" + "#; + let manifest = serde_yaml::from_str::(yaml).expect("parse"); + let first = manifest.targets.first().expect("target"); + if let StringOrList::List(names) = &first.name { + assert!(names.is_empty()); + } else { + panic!("Expected List variant"); + } +} + +#[test] +fn optional_fields() { + let yaml = r#" + netsuke_version: "1.0.0" + rules: + - name: compile + recipe: + kind: command + command: cc + description: "Compile" + deps: hello + targets: + - name: hello + recipe: + kind: rule + rule: compile + "#; + let manifest = serde_yaml::from_str::(yaml).expect("parse"); + let rule = manifest.rules.first().expect("rule"); + assert_eq!(rule.description.as_deref(), Some("Compile")); + if let Some(StringOrList::String(dep)) = &rule.deps { + assert_eq!(dep, "hello"); + } else { + panic!("deps should be string"); + } + + let yaml = r#" + netsuke_version: "1.0.0" + rules: + - name: compile + recipe: + kind: command + command: cc + targets: + - name: hello + recipe: + kind: rule + rule: compile + "#; + let manifest = serde_yaml::from_str::(yaml).expect("parse"); + let rule = manifest.rules.first().expect("rule"); + assert!(rule.description.is_none()); + assert!(rule.deps.is_none()); +} + +#[test] +fn invalid_enum_variants() { + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: hello + recipe: + kind: not_a_kind + command: "echo hi" + "#; + assert!(serde_yaml::from_str::(yaml).is_err()); +} diff --git a/tests/data/minimal.yml b/tests/data/minimal.yml index e9e91bf6..d31ce051 100644 --- a/tests/data/minimal.yml +++ b/tests/data/minimal.yml @@ -1,4 +1,4 @@ -netsuke_version: "1.0" +netsuke_version: "1.0.0" targets: - name: hello recipe: diff --git a/tests/features/manifest.feature b/tests/features/manifest.feature index 5d6c2dd0..431fae80 100644 --- a/tests/features/manifest.feature +++ b/tests/features/manifest.feature @@ -2,5 +2,5 @@ Feature: Manifest parsing Scenario: Parse minimal manifest When the manifest file "tests/data/minimal.yml" is parsed - Then the manifest version is "1.0" + Then the manifest version is "1.0.0" And the first target name is "hello" diff --git a/tests/steps/manifest_steps.rs b/tests/steps/manifest_steps.rs index cd7ae7e3..6432da17 100644 --- a/tests/steps/manifest_steps.rs +++ b/tests/steps/manifest_steps.rs @@ -27,7 +27,7 @@ fn parse_manifest(world: &mut CliWorld, path: String) { #[then(expr = "the manifest version is {string}")] fn manifest_version(world: &mut CliWorld, version: String) { let manifest = world.manifest.as_ref().expect("manifest"); - assert_eq!(manifest.netsuke_version, version); + assert_eq!(manifest.netsuke_version.to_string(), version); } #[expect( From 62da840e3159af2d2723d0253791205be442c35f Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 19 Jul 2025 09:44:16 +0100 Subject: [PATCH 4/6] Switch to serde_yml and enhance documentation --- Cargo.toml | 2 +- docs/netsuke-design.md | 28 +++++------ docs/roadmap.md | 4 +- src/ast.rs | 87 +++++++++++++++++++++++++++++++++-- tests/ast_tests.rs | 56 ++++++++++++---------- tests/steps/manifest_steps.rs | 13 ++++-- 6 files changed, 142 insertions(+), 48 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ec471a32..4295923f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" [dependencies] clap = { version = "4.5.0", features = ["derive"] } serde = { version = "1", features = ["derive"] } -serde_yaml = "0.9" +serde_yml = "0.11" semver = { version = "1", features = ["serde"] } [lints.clippy] diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 4e409e35..e82a3029 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -184,7 +184,7 @@ level keys. invoked without any specific targets on the command line. This maps directly to Ninja's `default` target statement.[^3] -The E-R diagram below summarises the structure of a `Netsukefile` and the +The E-R diagram below summarizes the structure of a `Netsukefile` and the relationships between its components. ```mermaid @@ -373,12 +373,12 @@ critical step is to parse this string and deserialize it into a structured, in- memory representation. The choice of libraries and the definition of the target data structures are crucial for the robustness and maintainability of Netsuke. -### 3.1 Crate Selection: `serde_yaml` +### 3.1 Crate Selection: `serde_yml` -For YAML parsing and deserialization, the recommended crate is `serde_yaml`. +For YAML parsing and deserialization, the recommended crate is `serde_yml`. This choice is based on its deep and direct integration with the `serde` framework, the de-facto standard for serialization and deserialization in the -Rust ecosystem. Using `serde_yaml` allows `serde`'s powerful derive macros to +Rust ecosystem. Using `serde_yml` allows `serde`'s powerful derive macros to automatically generate the deserialization logic for Rust structs. This approach is idiomatic, highly efficient, and significantly reduces the amount of boilerplate code that needs to be written and maintained. A simple `#` @@ -390,12 +390,12 @@ highly experimental stage (version 0.0.0)[^11]. Building a core component of Netsuke on a nascent or unreleased library would introduce significant and unnecessary project risk. -`serde_yaml` is mature, widely adopted, and battle-tested, making it the -prudent choice for production-quality software. +`serde_yml` is mature, widely adopted, and battle-tested, making it the prudent +choice for production-quality software. ### 3.2 Core Data Structures (`ast.rs`) -The Rust structs that `serde_yaml` will deserialize into form the Abstract +The Rust structs that `serde_yml` will deserialize into form the Abstract Syntax Tree (AST) of the build manifest. These structs must precisely mirror the YAML schema defined in Section 2. They will be defined in a dedicated module, `src/ast.rs`, and annotated with `#` to enable automatic @@ -496,7 +496,7 @@ as a simple string and multiple as a list, enhancing user-friendliness.* The integration of a templating engine like Jinja fundamentally shapes the parsing pipeline, mandating a two-pass approach. It is impossible to parse the -user's `Netsukefile` file with `serde_yaml` in a single step. +user's `Netsukefile` file with `serde_yml` in a single step. Consider a manifest containing Jinja syntax: @@ -511,7 +511,7 @@ targets: The value of `sources`, `{{ glob('src/*.c') }}`, is not a valid YAML string from the perspective of a strict parser. Attempting to deserialize this -directly with `serde_yaml` would result in a parsing error. +directly with `serde_yml` would result in a parsing error. Therefore, the process must be sequential: @@ -531,7 +531,7 @@ YAML ``` 1. **Second Pass (YAML Deserialization):** This new, rendered string, which is - now pure and valid YAML, is then passed to `serde_yaml`. The parser can now + now pure and valid YAML, is then passed to `serde_yml`. The parser can now successfully deserialize this text into the `NetsukeManifest` Rust struct. This two-pass mechanism cleanly separates the concerns of templating and data @@ -542,7 +542,7 @@ interference, ensuring a robust and predictable ingestion pipeline. The AST structures are implemented in `src/ast.rs` and derive `Deserialize`. Unknown fields are rejected to surface user errors early. `StringOrList` -provides a default `Empty` variant so optional lists are trivial to represent. +provides a default `Empty` variant, so optional lists are trivial to represent. The manifest version is parsed using the `semver` crate to validate that it follows semantic versioning rules. Global and target variable maps now share the `HashMap` type for consistency. This keeps YAML manifests @@ -1302,7 +1302,7 @@ goal. 1. Implement the initial `clap` CLI structure for the `build` command. - 1. Implement the YAML parser using `serde_yaml` and the AST data structures + 1. Implement the YAML parser using `serde_yml` and the AST data structures (`ast.rs`). 1. Implement the AST-to-IR transformation logic, including basic validation @@ -1327,7 +1327,7 @@ goal. 1. Integrate the `minijinja` crate into the build pipeline. 1. Implement the two-pass parsing mechanism: first render the manifest with - `minijinja`, then parse the result with `serde_yaml`. + `minijinja`, then parse the result with `serde_yml`. 1. Populate the initial Jinja context with the global `vars` from the manifest. @@ -1370,7 +1370,7 @@ selected for this project and the rationale for their inclusion. | Component | Recommended Crate | Rationale | | -------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------- | | CLI Parsing | clap | The Rust standard for powerful, derive-based CLI development. | -| YAML Parsing | serde_yaml | Mature, stable, and provides seamless integration with the serde framework. | +| YAML Parsing | serde_yml | Mature, stable, and provides seamless integration with the serde framework. | | Templating | minijinja | High compatibility with Jinja2, minimal dependencies, and supports runtime template loading. | | Shell Quoting | shell-quote | A critical security component; provides robust, shell-specific escaping for command arguments. | | Error Handling | anyhow + thiserror | An idiomatic and powerful combination for creating rich, contextual, and user-friendly error reports. | diff --git a/docs/roadmap.md b/docs/roadmap.md index a3d571a4..2cc9aabd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -22,7 +22,7 @@ compilation pipeline from parsing to execution. - [ ] Annotate AST structs with #[derive(Deserialize)] and #[serde(deny_unknown_fields)] - to enable serde_yaml parsing. + to enable serde_yml parsing. - [ ] Implement parsing for the netsuke_version field and validate it using the semver crate. @@ -80,7 +80,7 @@ configurations with variables, control flow, and custom functions. - [ ] Implement the two-pass parsing mechanism: the first pass renders the manifest as a Jinja template, and the second pass parses the resulting pure - YAML string with serde_yaml. + YAML string with serde_yml. - [ ] Create a minijinja::Environment and populate its initial context with the global vars defined in the manifest. diff --git a/src/ast.rs b/src/ast.rs index cd104358..539decbc 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -2,88 +2,169 @@ //! //! This module defines the data structures used to represent a parsed //! `Netsukefile`. They mirror the YAML schema described in the design -//! document and are deserialised with `serde_yaml`. +//! document and are deserialised with `serde_yml`. +//! +//! The following example shows how to parse a minimal manifest string: +//! +//! ```rust +//! use netsuke::ast::NetsukeManifest; +//! +//! let yaml = r#"netsuke_version: \"1.0.0\"\ntargets:\n - name: hello\n recipe:\n kind: command\n command: \"echo hi\""#; +//! let manifest: NetsukeManifest = serde_yml::from_str(yaml).expect("parse"); +//! if let StringOrList::String(name) = &manifest.targets[0].name { +//! assert_eq!(name, "hello"); +//! } +//! ``` use semver::Version; use serde::Deserialize; use std::collections::HashMap; -/// Top-level manifest structure. +/// Top-level manifest structure parsed from a `Netsukefile`. +/// +/// Each field mirrors a key in the YAML manifest. Optional collections default +/// to empty to simplify deserialisation. +/// +/// ```yaml +/// netsuke_version: "1.0.0" +/// steps: [] +/// targets: +/// - name: hello +/// recipe: +/// kind: command +/// command: echo hi +/// ``` +/// +/// ```rust +/// use netsuke::ast::NetsukeManifest; +/// # fn main() -> Result<(), Box> { +/// let yaml = "netsuke_version: 1.0.0\ntargets:\n - name: hello\n recipe:\n kind: command\n command: echo hi"; +/// let manifest: NetsukeManifest = serde_yml::from_str(yaml)?; +/// assert_eq!(manifest.targets.len(), 1); +/// # Ok(()) } +/// ``` #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct NetsukeManifest { + /// Semantic version of the manifest format. pub netsuke_version: Version, #[serde(default)] + /// Global key/value pairs available to recipes. pub vars: HashMap, #[serde(default)] + /// Named rule templates that can be referenced by targets. pub rules: Vec, /// Optional top-level steps executed before normal targets. #[serde(default)] pub steps: Vec, + /// Primary build targets. pub targets: Vec, #[serde(default)] + /// Names of targets built when no command line target is supplied. pub defaults: Vec, } /// A reusable command template. +/// +/// A rule encapsulates a snippet of work that can be referenced by multiple +/// targets. It may define a command line, a script block, or delegate to another +/// named rule. Dependencies are expressed as a whitespace separated list. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct Rule { + /// Unique identifier used by targets to reference this rule. pub name: String, + /// The action executed when the rule is invoked. pub recipe: Recipe, + /// Optional human-friendly summary. pub description: Option, + /// Space separated prerequisites for the rule. pub deps: Option, } /// Execution style for rules and targets. +/// +/// 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)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum Recipe { + /// A single shell command. #[serde(alias = "command")] Command { command: String }, + /// An embedded multi-line script. #[serde(alias = "script")] Script { script: String }, + /// Invoke another named rule. #[serde(alias = "rule")] Rule { rule: StringOrList }, } /// A single build target. +/// +/// Targets describe the files produced by a rule and their dependencies. +/// `phony` targets are always considered out of date, while `always` targets are +/// regenerated even if their inputs are unchanged. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct Target { + /// Output file or files. pub name: StringOrList, + /// How the target should be built. pub recipe: Recipe, #[serde(default)] + /// Input files consumed by the recipe. pub sources: StringOrList, #[serde(default)] + /// Normal prerequisites that must be built first. pub deps: StringOrList, #[serde(default)] + /// Dependencies that do not cause a rebuild when changed. pub order_only_deps: StringOrList, #[serde(default)] + /// Target-scoped variables available during command execution. pub vars: HashMap, #[serde(default)] + /// Declares that the target does not correspond to a real file. pub phony: bool, #[serde(default)] + /// Force the recipe to run even if the outputs are up to date. pub always: bool, } -/// A helper for fields that accept either a string or list of strings. +/// A helper for fields that accept either a single string or a list of +/// strings. +/// +/// It mirrors YAML syntax where a scalar or sequence is allowed. Empty values +/// deserialize to `StringOrList::Empty`. +/// +/// ```yaml +/// # Scalar +/// name: hello +/// # Sequence +/// name: +/// - hello +/// - world +/// ``` #[derive(Debug, Deserialize, Default)] #[serde(untagged)] pub enum StringOrList { + /// No value provided. #[default] Empty, + /// A single string item. String(String), + /// A list of string items. List(Vec), } diff --git a/tests/ast_tests.rs b/tests/ast_tests.rs index 25b06eab..9de3374c 100644 --- a/tests/ast_tests.rs +++ b/tests/ast_tests.rs @@ -6,26 +6,32 @@ use semver::Version; #[rstest] fn parse_minimal_manifest() { - let yaml = r#" - netsuke_version: "1.0.0" - targets: - - name: hello - recipe: - kind: command - command: "echo hi" - "#; + let yaml = r#"netsuke_version: "1.0.0" +targets: + - name: hello + recipe: + kind: command + command: "echo hi""#; - let manifest: NetsukeManifest = serde_yaml::from_str(yaml).expect("parse"); + let manifest: NetsukeManifest = serde_yml::from_str(yaml).expect("parse"); assert_eq!( manifest.netsuke_version, Version::parse("1.0.0").expect("ver") ); let first = manifest.targets.first().expect("target"); - if let StringOrList::String(name) = &first.name { - assert_eq!(name, "hello"); + let StringOrList::String(name) = &first.name else { + panic!( + "Expected target name to be StringOrList::String, got: {:?}", + first.name + ); + }; + assert_eq!(name, "hello"); + + if let Recipe::Command { command } = &first.recipe { + assert_eq!(command, "echo hi"); } else { - panic!("target name should be String"); + panic!("Expected command recipe, got: {:?}", first.recipe); } } @@ -38,12 +44,12 @@ fn missing_required_fields() { kind: command command: "echo hi" "#; - assert!(serde_yaml::from_str::(yaml).is_err()); + assert!(serde_yml::from_str::(yaml).is_err()); let yaml = r#" netsuke_version: "1.0.0" "#; - assert!(serde_yaml::from_str::(yaml).is_err()); + assert!(serde_yml::from_str::(yaml).is_err()); let yaml = r#" netsuke_version: "1.0.0" @@ -52,7 +58,7 @@ fn missing_required_fields() { kind: command command: "echo hi" "#; - assert!(serde_yaml::from_str::(yaml).is_err()); + assert!(serde_yml::from_str::(yaml).is_err()); } #[test] @@ -66,7 +72,7 @@ fn unknown_fields() { command: "echo hi" extra: 42 "#; - assert!(serde_yaml::from_str::(yaml).is_err()); + assert!(serde_yml::from_str::(yaml).is_err()); let yaml = r#" netsuke_version: "1.0.0" @@ -77,7 +83,7 @@ fn unknown_fields() { command: "echo hi" unexpected: true "#; - assert!(serde_yaml::from_str::(yaml).is_err()); + assert!(serde_yml::from_str::(yaml).is_err()); } #[test] @@ -86,7 +92,7 @@ fn empty_lists_and_maps() { netsuke_version: "1.0.0" targets: [] "#; - let manifest = serde_yaml::from_str::(yaml).expect("parse"); + let manifest = serde_yml::from_str::(yaml).expect("parse"); assert!(manifest.targets.is_empty()); let yaml = r#" @@ -95,7 +101,7 @@ fn empty_lists_and_maps() { - name: hello recipe: {} "#; - assert!(serde_yaml::from_str::(yaml).is_err()); + assert!(serde_yml::from_str::(yaml).is_err()); } #[test] @@ -108,7 +114,7 @@ fn string_or_list_variants() { kind: command command: "echo hi" "#; - let manifest = serde_yaml::from_str::(yaml).expect("parse"); + let manifest = serde_yml::from_str::(yaml).expect("parse"); let first = manifest.targets.first().expect("target"); if let StringOrList::String(name) = &first.name { assert_eq!(name, "hello"); @@ -126,7 +132,7 @@ fn string_or_list_variants() { kind: command command: "echo hi" "#; - let manifest = serde_yaml::from_str::(yaml).expect("parse"); + let manifest = serde_yml::from_str::(yaml).expect("parse"); let first = manifest.targets.first().expect("target"); if let StringOrList::List(names) = &first.name { assert_eq!(names, &vec!["hello".to_string(), "world".to_string()]); @@ -142,7 +148,7 @@ fn string_or_list_variants() { kind: command command: "echo hi" "#; - let manifest = serde_yaml::from_str::(yaml).expect("parse"); + let manifest = serde_yml::from_str::(yaml).expect("parse"); let first = manifest.targets.first().expect("target"); if let StringOrList::List(names) = &first.name { assert!(names.is_empty()); @@ -168,7 +174,7 @@ fn optional_fields() { kind: rule rule: compile "#; - let manifest = serde_yaml::from_str::(yaml).expect("parse"); + let manifest = serde_yml::from_str::(yaml).expect("parse"); let rule = manifest.rules.first().expect("rule"); assert_eq!(rule.description.as_deref(), Some("Compile")); if let Some(StringOrList::String(dep)) = &rule.deps { @@ -190,7 +196,7 @@ fn optional_fields() { kind: rule rule: compile "#; - let manifest = serde_yaml::from_str::(yaml).expect("parse"); + let manifest = serde_yml::from_str::(yaml).expect("parse"); let rule = manifest.rules.first().expect("rule"); assert!(rule.description.is_none()); assert!(rule.deps.is_none()); @@ -206,5 +212,5 @@ fn invalid_enum_variants() { kind: not_a_kind command: "echo hi" "#; - assert!(serde_yaml::from_str::(yaml).is_err()); + assert!(serde_yml::from_str::(yaml).is_err()); } diff --git a/tests/steps/manifest_steps.rs b/tests/steps/manifest_steps.rs index 6432da17..1976f800 100644 --- a/tests/steps/manifest_steps.rs +++ b/tests/steps/manifest_steps.rs @@ -7,8 +7,15 @@ use std::fs; #[when(expr = "the manifest file {string} is parsed")] fn parse_manifest(world: &mut CliWorld, path: String) { - let yaml = fs::read_to_string(path).expect("read manifest"); - match serde_yaml::from_str::(&yaml) { + let yaml = match fs::read_to_string(&path) { + Ok(content) => content, + Err(e) => { + world.manifest = None; + world.manifest_error = Some(format!("Failed to read {path}: {e}")); + return; + } + }; + match serde_yml::from_str::(&yaml) { Ok(manifest) => { world.manifest = Some(manifest); world.manifest_error = None; @@ -40,6 +47,6 @@ fn first_target_name(world: &mut CliWorld, name: String) { let first = manifest.targets.first().expect("targets"); match &first.name { StringOrList::String(value) => assert_eq!(value, &name), - other => panic!("unexpected variant {other:?}"), + other => panic!("Expected StringOrList::String, got: {other:?}"), } } From c66df400d87f9517a348a4972923fa415df90f6b Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 19 Jul 2025 10:19:03 +0100 Subject: [PATCH 5/6] Fix doc comment ordering and update YAML parser --- Cargo.lock | 38 ++++++++++----- Cargo.toml | 2 +- README.md | 89 +++++++++++++++++++---------------- src/ast.rs | 18 +++---- tests/steps/manifest_steps.rs | 4 ++ 5 files changed, 88 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 98615129..60aa3eb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -606,6 +606,16 @@ version = "0.2.174" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -665,7 +675,7 @@ dependencies = [ "rstest", "semver", "serde", - "serde_yaml", + "serde_yml", "tokio", ] @@ -950,9 +960,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.141" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" dependencies = [ "itoa", "memchr", @@ -961,16 +971,18 @@ dependencies = [ ] [[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" +name = "serde_yml" +version = "0.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" dependencies = [ "indexmap", "itoa", + "libyml", + "memchr", "ryu", "serde", - "unsafe-libyaml", + "version_check", ] [[package]] @@ -1151,18 +1163,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 4295923f..b515e0eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" [dependencies] clap = { version = "4.5.0", features = ["derive"] } serde = { version = "1", features = ["derive"] } -serde_yml = "0.11" +serde_yml = "0.0.12" semver = { version = "1", features = ["serde"] } [lints.clippy] diff --git a/README.md b/README.md index 0058001a..5a6cddf4 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,27 @@ # 🧵 Netsuke -A modern, declarative build system compiler. +A modern, declarative build system compiler. YAML + Jinja in, Ninja out. Nothing more. Nothing less. ## What is Netsuke? -**Netsuke** is a friendly build system that compiles structured manifests into a Ninja build graph. -It’s not a shell-script runner, a meta-task framework, or a domain-specific CI layer. It’s `make`, if `make` hadn’t been invented in 1977. +**Netsuke** is a friendly build system that compiles structured manifests into +a Ninja build graph. +It’s not a shell-script runner, a meta-task framework, or a domain-specific CI +layer. It’s `make`, if `make` hadn’t been invented in 1977. ### Key properties - **Declarative**: Targets, rules, and dependencies described explicitly. -- **Dynamic when needed**: Jinja templating for loops, macros, conditionals, file globbing. -- **Static where required**: Always compiles to a reproducible, fully static dependency graph. -- **Unopinionated**: No magic for C, Rust, Python, JavaScript, or any other blessed language. +- **Dynamic when needed**: Jinja templating for loops, macros, conditionals, + file globbing. +- **Static where required**: Always compiles to a reproducible, fully static + dependency graph. +- **Unopinionated**: No magic for C, Rust, Python, JavaScript, or any other + blessed language. - **Safe**: All variable interpolation is securely shell-escaped by default. -- **Fast**: Builds executed by [Ninja](https://ninja-build.org/), the fastest graph executor we know of. +- **Fast**: Builds executed by [Ninja](https://ninja-build.org/), the fastest + graph executor we know of. ## Quick Example @@ -44,10 +50,8 @@ targets: sources: "{{ glob('src/*.c') | map('basename') | map('with_suffix', '.o') }}" ```` -Yes, it’s just YAML. -Yes, that’s a Jinja `foreach`. -No, you don’t need to define `.PHONY` or remember what `$@` means. -This is 2025. You deserve better. +Yes, it’s just YAML. Yes, that’s a Jinja `foreach`. No, you don’t need to +define `.PHONY` or remember what `$@` means. This is 2025. You deserve better. ## Key Concepts @@ -55,9 +59,9 @@ This is 2025. You deserve better. Rules are reusable command templates. Each one has exactly one of: -* `command:` — a single shell string -* `script:` — a multi-line block -* (or) can be declared inline on a target +- `command:` — a single shell string +- `script:` — a multi-line block +- (or) can be declared inline on a target ```yaml rules: @@ -78,9 +82,9 @@ Targets are things you want to build. Targets can also define: -* `deps`: explicit dependencies -* `order_only_deps`: e.g. `mkdir -p build` -* `vars`: per-target variables +- `deps`: explicit dependencies +- `order_only_deps`: e.g. `mkdir -p build` +- `vars`: per-target variables You may also use `command:` or `script:` instead of referencing a `rule`. @@ -95,7 +99,8 @@ Phony targets behave like Make’s `.PHONY`: command: rm -rf build ``` -For cleaner structure, you may also define phony targets under an `actions:` block: +For cleaner structure, you may also define phony targets under an `actions:` +block: ```yaml actions: @@ -107,14 +112,15 @@ All `actions` are treated as `{ phony: true, always: false }` by default. ## 🧠 Templating -Netsuke uses [MiniJinja](https://docs.rs/minijinja) to render your manifest before parsing. +Netsuke uses [MiniJinja](https://docs.rs/minijinja) to render your manifest +before parsing. You can: -* Glob files: `{{ glob('src/**/*.c') }}` -* Read environment vars: `{{ env('CC') }}` -* Use filters: `{{ path | basename | with_suffix('.o') }}` -* Define reusable macros: +- Glob files: `{{ glob('src/**/*.c') }}` +- Read environment vars: `{{ env('CC') }}` +- Use filters: `{{ path | basename | with_suffix('.o') }}` +- Define reusable macros: ```yaml macros: @@ -127,8 +133,9 @@ Templating happens **before** parsing, so any valid output must be valid YAML. ## šŸ” Safety -Shell commands are automatically escaped. -Interpolation into `command:` or `script:` will never yield a command injection vulnerability unless you explicitly ask for `| raw`. +Shell commands are automatically escaped. Interpolation into `command:` or +`script:` will never yield a command injection vulnerability unless you +explicitly ask for `| raw`. ```yaml command: "echo {{ dangerous_value }}" # Safe @@ -143,34 +150,36 @@ netsuke clean netsuke graph ``` -* `netsuke` alone builds the `defaults:` targets from your manifest -* `netsuke graph` emits a Graphviz `.dot` of the build DAG -* `netsuke clean` runs `ninja -t clean` +- `netsuke` alone builds the `defaults:` targets from your manifest +- `netsuke graph` emits a Graphviz `.dot` of the build DAG +- `netsuke clean` runs `ninja -t clean` You can also pass: -* `--file` to use an alternate manifest -* `--directory` to run in a different working dir -* `-j N` to control parallelism (passed through to Ninja) +- `--file` to use an alternate manifest +- `--directory` to run in a different working dir +- `-j N` to control parallelism (passed through to Ninja) ## 🚧 Status -Netsuke is **under active development**. -It’s not finished, but it’s buildable, usable, and increasingly delightful. +Netsuke is **under active development**. It’s not finished, but it’s buildable, +usable, and increasingly delightful. Coming soon: -* `graph --html` for interactive DAGs -* Extensible plugin system for filters/functions -* Toolchain presets (`cargo`, `node`, etc.) +- `graph --html` for interactive DAGs +- Extensible plugin system for filters/functions +- Toolchain presets (`cargo`, `node`, etc.) ## Why ā€œNetsukeā€? -A **netsuke** is a small carved object used to fasten things securely to a belt. -It’s not the sword. It’s not the pouch. It’s the thing that connects them. +A **netsuke** is a small carved object used to fasten things securely to a +belt. It’s not the sword. It’s not the pouch. It’s the thing that connects them. -That’s what this is: a tidy connector between your intent and the tool that gets it done. +That’s what this is: a tidy connector between your intent and the tool that +gets it done. ## License -[ISC](https://opensource.org/licenses/ISC) — because you don't need a legal thesis to use a build tool. +[ISC](https://opensource.org/licenses/ISC) — because you don't need a legal +thesis to use a build tool. diff --git a/src/ast.rs b/src/ast.rs index 539decbc..b3fa16dc 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -49,12 +49,12 @@ pub struct NetsukeManifest { /// Semantic version of the manifest format. pub netsuke_version: Version, - #[serde(default)] /// Global key/value pairs available to recipes. + #[serde(default)] pub vars: HashMap, - #[serde(default)] /// Named rule templates that can be referenced by targets. + #[serde(default)] pub rules: Vec, /// Optional top-level steps executed before normal targets. @@ -64,8 +64,8 @@ pub struct NetsukeManifest { /// Primary build targets. pub targets: Vec, - #[serde(default)] /// Names of targets built when no command line target is supplied. + #[serde(default)] pub defaults: Vec, } @@ -118,28 +118,28 @@ pub struct Target { /// How the target should be built. pub recipe: Recipe, - #[serde(default)] /// Input files consumed by the recipe. + #[serde(default)] pub sources: StringOrList, - #[serde(default)] /// Normal prerequisites that must be built first. + #[serde(default)] pub deps: StringOrList, - #[serde(default)] /// Dependencies that do not cause a rebuild when changed. + #[serde(default)] pub order_only_deps: StringOrList, - #[serde(default)] /// Target-scoped variables available during command execution. + #[serde(default)] pub vars: HashMap, - #[serde(default)] /// Declares that the target does not correspond to a real file. + #[serde(default)] pub phony: bool, - #[serde(default)] /// Force the recipe to run even if the outputs are up to date. + #[serde(default)] pub always: bool, } diff --git a/tests/steps/manifest_steps.rs b/tests/steps/manifest_steps.rs index 1976f800..ce78e5a1 100644 --- a/tests/steps/manifest_steps.rs +++ b/tests/steps/manifest_steps.rs @@ -5,6 +5,10 @@ use cucumber::{then, when}; use netsuke::ast::{NetsukeManifest, StringOrList}; use std::fs; +#[expect( + clippy::needless_pass_by_value, + reason = "Cucumber requires owned String arguments", +)] #[when(expr = "the manifest file {string} is parsed")] fn parse_manifest(world: &mut CliWorld, path: String) { let yaml = match fs::read_to_string(&path) { From ede8aa85f0f82119477b735e85ccf9bf273686f6 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 19 Jul 2025 11:11:07 +0100 Subject: [PATCH 6/6] Standardise deps field and sync docs --- docs/netsuke-design.md | 5 +++-- src/ast.rs | 9 ++++++--- tests/ast_tests.rs | 9 ++++----- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index e82a3029..7ee9b542 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -201,7 +201,7 @@ erDiagram string name Recipe recipe string description - string deps + StringOrList deps } TARGET { StringOrList name @@ -435,7 +435,8 @@ pub struct Rule { pub name: String, pub recipe: Recipe, pub description: Option, - pub deps: Option, + #[serde(default)] + pub deps: StringOrList, // Additional fields like 'pool' or 'restat' can be added here // to map to more advanced Ninja features. } diff --git a/src/ast.rs b/src/ast.rs index b3fa16dc..70f3141a 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -8,6 +8,7 @@ //! //! ```rust //! use netsuke::ast::NetsukeManifest; +//! use netsuke::ast::StringOrList; //! //! let yaml = r#"netsuke_version: \"1.0.0\"\ntargets:\n - name: hello\n recipe:\n kind: command\n command: \"echo hi\""#; //! let manifest: NetsukeManifest = serde_yml::from_str(yaml).expect("parse"); @@ -73,7 +74,8 @@ pub struct NetsukeManifest { /// /// A rule encapsulates a snippet of work that can be referenced by multiple /// targets. It may define a command line, a script block, or delegate to another -/// named rule. Dependencies are expressed as a whitespace separated list. +/// named rule. Dependencies may be specified as either a single string or a +/// list of strings. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct Rule { @@ -83,8 +85,9 @@ pub struct Rule { pub recipe: Recipe, /// Optional human-friendly summary. pub description: Option, - /// Space separated prerequisites for the rule. - pub deps: Option, + /// Prerequisites for the rule. Empty by default. + #[serde(default)] + pub deps: StringOrList, } /// Execution style for rules and targets. diff --git a/tests/ast_tests.rs b/tests/ast_tests.rs index 9de3374c..45d5e19d 100644 --- a/tests/ast_tests.rs +++ b/tests/ast_tests.rs @@ -177,10 +177,9 @@ fn optional_fields() { let manifest = serde_yml::from_str::(yaml).expect("parse"); let rule = manifest.rules.first().expect("rule"); assert_eq!(rule.description.as_deref(), Some("Compile")); - if let Some(StringOrList::String(dep)) = &rule.deps { - assert_eq!(dep, "hello"); - } else { - panic!("deps should be string"); + match &rule.deps { + StringOrList::String(dep) => assert_eq!(dep, "hello"), + other => panic!("deps should be String, got: {other:?}"), } let yaml = r#" @@ -199,7 +198,7 @@ fn optional_fields() { let manifest = serde_yml::from_str::(yaml).expect("parse"); let rule = manifest.rules.first().expect("rule"); assert!(rule.description.is_none()); - assert!(rule.deps.is_none()); + assert!(matches!(rule.deps, StringOrList::Empty)); } #[test]