diff --git a/Cargo.lock b/Cargo.lock index bd7f99a8..86b72aa2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,6 +130,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -194,7 +203,7 @@ dependencies = [ "anstyle", "clap_lex", "strsim", - "terminal_size", + "terminal_size 0.4.2", ] [[package]] @@ -230,7 +239,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.1", "windows-sys 0.59.0", ] @@ -561,7 +570,7 @@ dependencies = [ "serde", "serde_json", "syn 2.0.104", - "textwrap", + "textwrap 0.16.2", "thiserror", "typed-builder", ] @@ -620,6 +629,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "humantime" version = "2.2.0" @@ -690,6 +705,23 @@ dependencies = [ "libc", ] +[[package]] +name = "is-terminal" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -799,6 +831,38 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "miette" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e" +dependencies = [ + "backtrace", + "backtrace-ext", + "is-terminal", + "miette-derive", + "once_cell", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size 0.1.17", + "textwrap 0.15.2", + "thiserror", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "minijinja" version = "2.11.0" @@ -882,6 +946,7 @@ dependencies = [ "insta", "itertools 0.12.1", "itoa", + "miette", "minijinja", "mockable", "mockall", @@ -891,6 +956,7 @@ dependencies = [ "serde", "serde_json", "serde_json_canonicalizer", + "serde_spanned", "serde_yml", "serial_test", "sha2", @@ -979,6 +1045,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + [[package]] name = "parking_lot" version = "0.12.4" @@ -1345,6 +1417,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "serde_spanned" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +dependencies = [ + "serde", +] + [[package]] name = "serde_yml" version = "0.0.12" @@ -1446,6 +1527,34 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84231692eb0d4d41e4cdd0cabfdd2e6cd9e255e65f80c9aa7c98dd502b4233d" +dependencies = [ + "is-terminal", +] + +[[package]] +name = "supports-unicode" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f850c19edd184a205e883199a261ed44471c81e39bd95b1357f5febbef00e77a" +dependencies = [ + "is-terminal", +] + [[package]] name = "syn" version = "1.0.109" @@ -1514,6 +1623,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "terminal_size" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "terminal_size" version = "0.4.2" @@ -1539,6 +1658,17 @@ dependencies = [ "tempfile", ] +[[package]] +name = "textwrap" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7b3e525a49ec206798b40326a44121291b530c963cfb01018f63e135bac543d" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width 0.1.14", +] + [[package]] name = "textwrap" version = "0.16.2" @@ -1547,7 +1677,7 @@ checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ "smawk", "unicode-linebreak", - "unicode-width", + "unicode-width 0.2.1", ] [[package]] @@ -1700,6 +1830,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 0a4bd03b..a7cda91b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ minijinja = "2.11.0" semver = { version = "1", features = ["serde"] } anyhow = "1" thiserror = "1" +miette = { version = "5", features = ["fancy"] } +serde_spanned = "1" sha2 = "0.10" itoa = "1" itertools = "0.12" diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 40421add..15e58c9d 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -1269,12 +1269,13 @@ three fundamental questions: Found a tab character, which is not allowed. Hint: Use spaces for indentation instead."). -### 7.2 Crate Selection and Strategy: `anyhow` and `thiserror` +### 7.2 Crate Selection and Strategy: `anyhow`, `thiserror`, and `miette` -To implement this philosophy, Netsuke will adopt a hybrid error handling -strategy using the `anyhow` and `thiserror` crates. This is a common and highly -effective pattern in the Rust ecosystem for creating robust applications and -libraries.[^27] +To implement this philosophy, Netsuke adopts a hybrid error handling strategy +using the `anyhow`, `thiserror`, and `miette` crates. This is a common and +highly effective pattern in the Rust ecosystem for creating robust applications +and libraries.[^27] `miette` renders user-facing diagnostics, underlining spans +from `serde_spanned`. - `thiserror`: This crate will be used *within* Netsuke's internal library modules (e.g., `parser`, `ir`, `ninja_gen`) to define specific, structured @@ -1316,6 +1317,9 @@ pub enum IrGenError { `.with_context()` methods for adding high-level, human-readable context to errors as they bubble up the call stack.[^31] +- `miette`: Presents human-friendly diagnostics, leveraging spans from + `serde_spanned` to highlight exact error locations. + ### 7.3 Error Handling Flow The flow of an error from its origin to the user follows a clear path of @@ -1563,15 +1567,15 @@ goal. This table serves as a quick-reference guide to the core third-party crates 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_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. | -| Logging | tracing | Structured, levelled diagnostic output for debugging and insight. | -| Versioning | semver | The standard library for parsing and evaluating Semantic Versioning strings, essential for the `netsuke_version` field. | +| Component | Recommended Crate | Rationale | +| -------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| CLI Parsing | clap | The Rust standard for powerful, derive-based CLI development. | +| 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 + miette + serde_spanned | An idiomatic and powerful combination for creating rich, contextual, and user-friendly error reports with precise source spans. | +| Logging | tracing | Structured, levelled diagnostic output for debugging and insight. | +| Versioning | semver | The standard library for parsing and evaluating Semantic Versioning strings, essential for the `netsuke_version` field. | ### 9.3 Future Enhancements diff --git a/src/manifest.rs b/src/manifest.rs index 7f2bfd00..462c0c43 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -6,13 +6,81 @@ use crate::ast::{NetsukeManifest, Recipe, StringOrList, Target, Vars}; use anyhow::{Context, Result, anyhow}; +use miette::{Diagnostic, NamedSource, SourceSpan}; use minijinja::{Environment, UndefinedBehavior, context, value::Value}; +use serde_spanned::Spanned; +use serde_yml::{Error as YamlError, Location}; use serde_yml::{Mapping as YamlMapping, Value as YamlValue}; +use std::fmt::Write; use std::{fs, path::Path}; +use thiserror::Error; -const ERR_INITIAL_YAML_PARSE: &str = "initial YAML parse error"; const ERR_MANIFEST_PARSE: &str = "manifest parse error"; +// Compute a narrow highlight span from a location. +fn to_span(src: &str, loc: Location) -> SourceSpan { + let at = loc.index(); + let end = if src.as_bytes().get(at).is_some_and(|b| *b != b'\n') { + at + 1 + } else { + at + }; + let span = Spanned::new(at..end, ()); + SourceSpan::new(span.span().start.into(), span.span().len().into()) +} + +#[derive(Debug, Error, Diagnostic)] +#[error("{message}")] +struct YamlDiagnostic { + #[source_code] + src: NamedSource, + #[label("{label}")] + span: Option, + #[help] + help: Option, + #[source] + source: Option, + #[diagnostic(code(netsuke.yaml.parse))] + message: String, + label: String, +} + +fn hint_for(err_str: &str, src: &str) -> Option { + let lower = err_str.to_lowercase(); + if src.contains('\t') { + Some("Use spaces for indentation; tabs are invalid in YAML.".into()) + } else if lower.contains("did not find expected '-'") { + Some("Start list items with '-' and ensure proper indentation.".into()) + } else if lower.contains("expected ':'") { + Some("Ensure each key is followed by ':' separating key and value.".into()) + } else { + None + } +} + +fn map_yaml_error(err: &YamlError, src: &str) -> anyhow::Error { + let (line, col, span) = err.location().map_or((1, 1, None), |l| { + (l.line(), l.column(), Some(to_span(src, l))) + }); + let err_str = err.to_string(); + let hint = hint_for(&err_str, src); + let mut message = format!("YAML parse error at line {line}, column {col}: {err_str}"); + if let Some(h) = &hint { + write!(message, " Hint: {h}").expect("string write"); + } + + let diag = YamlDiagnostic { + src: NamedSource::new("manifest.yml", src.to_string()), + span, + help: hint, + source: Some(anyhow::Error::msg(err_str.clone())), + message, + label: "parse error here".into(), + }; + + anyhow::Error::new(diag) +} + /// Parse a manifest string using Jinja for value templating. /// /// The input YAML must be valid on its own. Jinja expressions are evaluated @@ -22,7 +90,7 @@ const ERR_MANIFEST_PARSE: &str = "manifest parse error"; /// /// Returns an error if YAML parsing or Jinja evaluation fails. pub fn from_str(yaml: &str) -> Result { - let mut doc: YamlValue = serde_yml::from_str(yaml).context(ERR_INITIAL_YAML_PARSE)?; + let mut doc: YamlValue = serde_yml::from_str(yaml).map_err(|e| map_yaml_error(&e, yaml))?; let mut env = Environment::new(); env.set_undefined_behavior(UndefinedBehavior::Strict); diff --git a/tests/yaml_error_tests.rs b/tests/yaml_error_tests.rs new file mode 100644 index 00000000..5200d5c2 --- /dev/null +++ b/tests/yaml_error_tests.rs @@ -0,0 +1,26 @@ +use netsuke::manifest; + +#[test] +fn reports_line_and_column_with_tab_hint() { + let yaml = "targets:\n\t- name: test\n"; + let err = manifest::from_str(yaml).expect_err("parse should fail"); + let msg = err.to_string(); + assert!(msg.contains("line 2, column 1"), "missing location: {msg}"); + assert!( + msg.contains("Use spaces for indentation"), + "missing hint: {msg}" + ); +} + +#[test] +fn suggests_colon_when_missing() { + let yaml = "targets:\n - name: hi\n command echo\n"; + let err = manifest::from_str(yaml).expect_err("parse should fail"); + let msg = err.to_string(); + assert!(msg.contains("line 3"), "missing line info: {msg}"); + assert!(msg.contains("expected ':'"), "missing error detail: {msg}"); + assert!( + msg.contains("Ensure each key is followed by ':'"), + "missing suggestion: {msg}" + ); +}