diff --git a/Cargo.lock b/Cargo.lock index 5af9ffc0..60aa3eb5 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" @@ -584,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" @@ -641,6 +673,9 @@ dependencies = [ "clap", "cucumber", "rstest", + "semver", + "serde", + "serde_yml", "tokio", ] @@ -899,6 +934,9 @@ name = "semver" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] [[package]] name = "serde" @@ -922,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", @@ -932,6 +970,21 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + [[package]] name = "slab" version = "0.4.10" @@ -1116,6 +1169,12 @@ 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 5c743751..b515e0eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,9 @@ edition = "2024" [dependencies] clap = { version = "4.5.0", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_yml = "0.0.12" +semver = { version = "1", features = ["serde"] } [lints.clippy] pedantic = { level = "warn", priority = -1 } 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/docs/netsuke-design.md b/docs/netsuke-design.md index 1ed690ae..7ee9b542 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,70 +177,58 @@ 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 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 summarizes 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 steps + 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 + StringOrList 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_steps + 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` @@ -286,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. @@ -385,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 `#` @@ -402,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 @@ -424,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, @@ -447,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. } @@ -508,7 +497,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: @@ -523,7 +512,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: @@ -543,13 +532,23 @@ 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 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. +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 To provide the dynamic capabilities and logical expressiveness that make a @@ -1304,7 +1303,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 @@ -1329,7 +1328,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. @@ -1372,7 +1371,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 25280772..2cc9aabd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,19 +16,20 @@ 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)] - to enable serde_yaml parsing. + to enable serde_yml parsing. - [ ] Implement parsing for the netsuke_version field and validate it using the semver crate. - [ ] 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 @@ -79,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 new file mode 100644 index 00000000..70f3141a --- /dev/null +++ b/src/ast.rs @@ -0,0 +1,173 @@ +//! 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_yml`. +//! +//! The following example shows how to parse a minimal manifest string: +//! +//! ```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"); +//! 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 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, + + /// Global key/value pairs available to recipes. + #[serde(default)] + pub vars: HashMap, + + /// Named rule templates that can be referenced by targets. + #[serde(default)] + pub rules: Vec, + + /// Optional top-level steps executed before normal targets. + #[serde(default)] + pub steps: Vec, + + /// Primary build targets. + pub targets: Vec, + + /// Names of targets built when no command line target is supplied. + #[serde(default)] + 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 may be specified as either a single string or a +/// list of strings. +#[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, + /// Prerequisites for the rule. Empty by default. + #[serde(default)] + pub deps: StringOrList, +} + +/// 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, + + /// Input files consumed by the recipe. + #[serde(default)] + pub sources: StringOrList, + + /// Normal prerequisites that must be built first. + #[serde(default)] + pub deps: StringOrList, + + /// Dependencies that do not cause a rebuild when changed. + #[serde(default)] + pub order_only_deps: StringOrList, + + /// Target-scoped variables available during command execution. + #[serde(default)] + pub vars: HashMap, + + /// Declares that the target does not correspond to a real file. + #[serde(default)] + pub phony: bool, + + /// Force the recipe to run even if the outputs are up to date. + #[serde(default)] + pub always: bool, +} + +/// 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/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..45d5e19d --- /dev/null +++ b/tests/ast_tests.rs @@ -0,0 +1,215 @@ +//! Unit tests for Netsuke manifest AST deserialisation. + +use netsuke::ast::*; +use rstest::rstest; +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 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"); + 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!("Expected command recipe, got: {:?}", first.recipe); + } +} + +#[test] +fn missing_required_fields() { + let yaml = r#" + targets: + - name: hello + recipe: + kind: command + command: "echo hi" + "#; + assert!(serde_yml::from_str::(yaml).is_err()); + + let yaml = r#" + netsuke_version: "1.0.0" + "#; + assert!(serde_yml::from_str::(yaml).is_err()); + + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - recipe: + kind: command + command: "echo hi" + "#; + assert!(serde_yml::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_yml::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_yml::from_str::(yaml).is_err()); +} + +#[test] +fn empty_lists_and_maps() { + let yaml = r#" + netsuke_version: "1.0.0" + targets: [] + "#; + let manifest = serde_yml::from_str::(yaml).expect("parse"); + assert!(manifest.targets.is_empty()); + + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: hello + recipe: {} + "#; + assert!(serde_yml::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_yml::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_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()]); + } else { + panic!("Expected List variant"); + } + + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: [] + recipe: + kind: command + command: "echo hi" + "#; + 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()); + } 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_yml::from_str::(yaml).expect("parse"); + let rule = manifest.rules.first().expect("rule"); + assert_eq!(rule.description.as_deref(), Some("Compile")); + match &rule.deps { + StringOrList::String(dep) => assert_eq!(dep, "hello"), + other => panic!("deps should be String, got: {other:?}"), + } + + 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_yml::from_str::(yaml).expect("parse"); + let rule = manifest.rules.first().expect("rule"); + assert!(rule.description.is_none()); + assert!(matches!(rule.deps, StringOrList::Empty)); +} + +#[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_yml::from_str::(yaml).is_err()); +} 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..d31ce051 --- /dev/null +++ b/tests/data/minimal.yml @@ -0,0 +1,6 @@ +netsuke_version: "1.0.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..431fae80 --- /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.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..ce78e5a1 --- /dev/null +++ b/tests/steps/manifest_steps.rs @@ -0,0 +1,56 @@ +//! Step definitions for manifest parsing scenarios. + +use crate::CliWorld; +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) { + 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; + } + 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.to_string(), 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!("Expected StringOrList::String, got: {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;