diff --git a/CHANGELOG.md b/CHANGELOG.md index b46e3cd..577dc20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,14 +40,51 @@ mds lint --fix && mds fmt Dynamic message roles now use double braces: `@message {{role}}:` instead of `@message {role}:`. Bare-word roles (`@message system:`) are unchanged. +#### `LintDiagnostic` is now `#[non_exhaustive]`; use the constructor, not struct literals + +`LintDiagnostic` is marked `#[non_exhaustive]` so future minor releases can add +fields without a breaking change. External Rust crates can no longer construct it +via a struct literal. Use `LintDiagnostic::new(rule, severity, message)` to create +a diagnostic with required fields and all optional fields defaulting to `None`, then +chain the builder methods `with_help`, `with_span`, `with_file`, `with_fix_removals`, +and `with_fix_edits` to set optional fields. + +#### Nine additional public types are now `#[non_exhaustive]`; migrate struct literals to constructors + +The following types are marked `#[non_exhaustive]` so future minor releases can add +fields without a breaking change. External Rust crates can no longer construct them +via struct literals. Use the named constructor or builder listed for each: + +- **`LintResult`** — use `LintResult::new(diagnostics)` (defaults: `truncated=false`, `is_standalone=false`), + then chain `.truncated()` or `.standalone()` to override. +- **`SerializedError`** — not externally constructable by design; obtain via `MdsError::serialize()`. +- **`SerializedSpan`** — use `SerializedSpan::new(offset, length)`, then chain `.with_line(n)` and/or `.with_column(n)`. +- **`TextEdit`** — use `TextEdit::new(start, end, new_text)`. Previously this type was `pub` + inside a `pub(crate)` module and was thus unnameable from external crates; this PR re-exports + it at the crate root, making `mds::TextEdit` accessible for the first time. The `fix_edits` + field on `LintDiagnostic` (and the corresponding JSON field) was effectively unusable from + Rust until this change. +- **`FixLineSpan`** — use `FixLineSpan::single(offset)` for single-line removals, + `FixLineSpan::range_inclusive(from, to)` to remove through the line containing `to`, + or `FixLineSpan::range_exclusive(from, to)` to keep the line containing `to`. +- **`ByteEdit`** — use `ByteEdit::deletion(start, end, rule)` for pure deletions or + `ByteEdit::replacement(start, end, rule, text)` for in-place replacements. +- **`RejectedEdit`** — use `RejectedEdit::new(edit, reason)`. +- **`FixPlan`** — use `FixPlan::default()` for an empty plan; its fields are `pub`, so they + remain directly readable and writable from external crates. +- **`LintConfig`** — use `LintConfig::from_rules(rules)` or `LintConfig::default()` for no overrides. +- **`LintDiagnostic::sanitized_for_render()`** — a new method that returns a sanitized clone + suitable for miette render boundaries. `mds-cli`'s diagnostic render path now delegates to + this method instead of assembling sanitized copies itself, keeping the escape logic co-located + with the struct definition (PF-014). + #### New `fix_edits` field on `LintDiagnostic` `LintDiagnostic` gains an additive `fix_edits` field (null when not fixable; an array of `{start, end, new_text}` byte-span edit objects when fixable). This field is present across all binding surfaces: CLI JSON output, napi (`LintDiagnostic.fix_edits?: …`), WASM, and Python -(`LintDiagnostic.fix_edits: list[dict] | None`). Code that constructs -`LintDiagnostic` objects directly must add `fix_edits: null` or the typed field. +(`LintDiagnostic.fix_edits: list[dict] | None`). ### Security diff --git a/crates/mds-cli/src/build.rs b/crates/mds-cli/src/build.rs index f9c7133..318c6f4 100644 --- a/crates/mds-cli/src/build.rs +++ b/crates/mds-cli/src/build.rs @@ -54,7 +54,7 @@ pub(crate) struct LintCliConfig { impl LintCliConfig { /// Convert to the core `LintConfig` consumed by `mds::lint_*` functions. pub(crate) fn into_core_config(self) -> mds::LintConfig { - mds::LintConfig { rules: self.rules } + mds::LintConfig::from_rules(self.rules) } } @@ -1153,11 +1153,10 @@ pub(crate) fn run_build(args: BuildArgs) -> Result<()> { } let source_map_base = compute_source_map_base(Path::new("-"), &output, &out_dir, &None); - let opts = mds::CompileOptions { - source_map: use_source_map, - include_sources_content: use_embed_sources, - source_map_base, - }; + let opts = mds::CompileOptions::default() + .with_source_map(use_source_map) + .with_include_sources_content(use_embed_sources) + .with_source_map_base(source_map_base); let (source, cwd) = read_stdin()?; let result = mds::compile_str_with_deps_opts(&source, Some(&cwd), runtime_vars, opts) @@ -1272,11 +1271,10 @@ pub(crate) fn run_build(args: BuildArgs) -> Result<()> { } let source_map_base = compute_source_map_base(&input, &output, &out_dir, &config); - let opts = mds::CompileOptions { - source_map: use_source_map, - include_sources_content: use_embed_sources, - source_map_base, - }; + let opts = mds::CompileOptions::default() + .with_source_map(use_source_map) + .with_include_sources_content(use_embed_sources) + .with_source_map_base(source_map_base); let compiled = compile_to_content(&input, runtime_vars, quiet, opts)?; let output_path = resolve_output_path_for_kind( @@ -1482,11 +1480,10 @@ fn run_build_directory( .parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| PathBuf::from(".")); - let opts = mds::CompileOptions { - source_map, - include_sources_content: embed_sources, - source_map_base: Some(source_map_base), - }; + let opts = mds::CompileOptions::default() + .with_source_map(source_map) + .with_include_sources_content(embed_sources) + .with_source_map_base(Some(source_map_base)); // Compile (all reads go through mds-core which enforces MAX_FILE_SIZE — PF-004). match compile_to_content(file, runtime_vars.clone(), quiet, opts) { diff --git a/crates/mds-cli/src/lint.rs b/crates/mds-cli/src/lint.rs index 2ad8113..158da37 100644 --- a/crates/mds-cli/src/lint.rs +++ b/crates/mds-cli/src/lint.rs @@ -294,22 +294,10 @@ fn render_diag_human(diag: &mds::LintDiagnostic, quiet: bool, named_source: (&st if quiet && matches!(diag.severity, Severity::Info | Severity::Warn) { return; } - // Sanitize message and help at the input boundary. - // fix_removals/fix_edits are not read by miette's Diagnostic impl — set to None - // to avoid unnecessary allocations (architecture-3 / rust-1). - let sanitized = mds::LintDiagnostic { - rule: diag.rule.clone(), - severity: diag.severity, - message: mds::sanitize_control_chars(&diag.message).into_owned(), - help: diag - .help - .as_deref() - .map(|h| mds::sanitize_control_chars(h).into_owned()), - span: diag.span.clone(), - file: diag.file.clone(), - fix_removals: None, - fix_edits: None, - }; + // Sanitize message and help at the input boundary via the core method. + // fix_removals/fix_edits are set to None inside sanitized_for_render to avoid + // unnecessary allocations (architecture-3 / rust-1). + let sanitized = diag.sanitized_for_render(); let (filename, src) = named_source; let report = miette::Report::from(sanitized) .with_source_code(mds::named_source_for_render(filename, src)); @@ -1477,8 +1465,8 @@ mod tests { /// /// Math (identical to ISS-02): /// source = "line0\nline1\nline2\n" - /// Edit A: FixLineSpan { from: 0, to: 6, to_inclusive: true } → ByteEdit [0, 12) - /// Edit B: FixLineSpan { from: 6, to: 12, to_inclusive: true } → ByteEdit [6, 18) + /// Edit A: FixLineSpan::range_inclusive(0, 6) → ByteEdit [0, 12) + /// Edit B: FixLineSpan::range_inclusive(6, 12) → ByteEdit [6, 18) /// A.end=12 > B.start=6, B.end=18 > A.end=12 → partial overlap → overlap_rejected. /// /// `preview_fixes` must return `PreviewOutcome::Rejected` — the rejection must be @@ -1489,41 +1477,21 @@ mod tests { fn preview_fixes_surfaces_rejected_on_overlap() { let source = "line0\nline1\nline2\n"; // Edit A covers bytes [0, 12): line0 start through line1 end (inclusive). - let diag_a = LintDiagnostic { - rule: "duplicate-import".to_string(), - severity: Severity::Error, - message: "a".to_string(), - help: None, - span: None, - file: None, - fix_removals: Some(vec![FixLineSpan { - from: 0, // inside line0 - to: 6, // inside line1; extend_to_line_end(6) = 12 - to_inclusive: true, - }]), - fix_edits: None, - }; + let diag_a = LintDiagnostic::new("duplicate-import", Severity::Error, "a") + .with_fix_removals(vec![FixLineSpan::range_inclusive( + 0, // inside line0 + 6, // inside line1; extend_to_line_end(6) = 12 + )]); // Edit B covers bytes [6, 18): line1 start through line2 end (inclusive). // Partially overlaps A at [6, 12). - let diag_b = LintDiagnostic { - rule: "empty-block".to_string(), - severity: Severity::Warn, - message: "b".to_string(), - help: None, - span: None, - file: None, - fix_removals: Some(vec![FixLineSpan { - from: 6, // inside line1 - to: 12, // inside line2; extend_to_line_end(12) = 18 - to_inclusive: true, - }]), - fix_edits: None, - }; - let result = LintResult { - diagnostics: vec![diag_a, diag_b], - truncated: false, - is_standalone: false, - }; + let diag_b = + LintDiagnostic::new("empty-block", Severity::Warn, "b").with_fix_removals(vec![ + FixLineSpan::range_inclusive( + 6, // inside line1 + 12, // inside line2; extend_to_line_end(12) = 18 + ), + ]); + let result = LintResult::new(vec![diag_a, diag_b]); let outcome = preview_fixes( &result, diff --git a/crates/mds-cli/src/output.rs b/crates/mds-cli/src/output.rs index f825150..6bcd46d 100644 --- a/crates/mds-cli/src/output.rs +++ b/crates/mds-cli/src/output.rs @@ -462,7 +462,10 @@ pub(crate) fn atomic_write_file(path: &Path, content: &str) -> Result<()> { match std::fs::metadata(path) { Ok(m) => Some(m.permissions().mode()), Err(e) => { - eprint_error(miette::miette!("cannot get metadata for {}: {e}", path.display())); + eprint_error(miette::miette!( + "cannot get metadata for {}: {e}", + path.display() + )); None } } diff --git a/crates/mds-cli/tests/producer_discipline.rs b/crates/mds-cli/tests/producer_discipline.rs index d805839..1bc94ce 100644 --- a/crates/mds-cli/tests/producer_discipline.rs +++ b/crates/mds-cli/tests/producer_discipline.rs @@ -58,7 +58,7 @@ const ESCAPED_RLO: &str = "\\u202E"; /// `resolver.rs` branch that names the module in its warning. #[test] fn hostile_module_name_reaches_the_warning() { - use mds::{CompileOptions, Value}; + use mds::Value; let items: Vec = (0..100_000) .map(|_| Value::String("x".to_string())) @@ -83,10 +83,7 @@ fn hostile_module_name_reaches_the_warning() { modules, "entry.mds", Some(vars), - CompileOptions { - source_map: true, - ..Default::default() - }, + mds::CompileOptions::default().with_source_map(true), ) .expect("compilation must succeed even when the segment cap is hit"); diff --git a/crates/mds-core/src/error.rs b/crates/mds-core/src/error.rs index 029f1bd..f537b48 100644 --- a/crates/mds-core/src/error.rs +++ b/crates/mds-core/src/error.rs @@ -13,6 +13,11 @@ use crate::lint::{named_source_for_render, sanitize_control_chars, sanitize_cont /// matching `miette::SourceSpan`. `line` is 1-indexed; `column` is the /// 1-indexed character position (Unicode scalar values) from the start of the /// line — NOT a byte offset and NOT UTF-16 code units. +/// +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases. +/// Construct via [`SerializedSpan::new`] and the optional `with_line` / +/// `with_column` builders; do not construct via struct literal. +#[non_exhaustive] #[derive(Debug, Clone, PartialEq, serde::Serialize)] pub struct SerializedSpan { pub offset: usize, @@ -21,9 +26,71 @@ pub struct SerializedSpan { pub column: Option, } +impl SerializedSpan { + /// Construct a `SerializedSpan` with byte `offset` and `length`; line and + /// column default to `None`. + /// + /// This is the supported construction path for external crates — struct literals + /// are not available because this type is `#[non_exhaustive]`. + /// + /// # Examples + /// + /// ``` + /// use mds::SerializedSpan; + /// let span = SerializedSpan::new(10, 5); + /// assert_eq!(span.offset, 10); + /// assert_eq!(span.length, 5); + /// assert!(span.line.is_none()); + /// assert!(span.column.is_none()); + /// ``` + #[must_use] + pub fn new(offset: usize, length: usize) -> Self { + SerializedSpan { + offset, + length, + line: None, + column: None, + } + } + + /// Set the 1-indexed line number. + /// + /// # Examples + /// + /// ``` + /// use mds::SerializedSpan; + /// let span = SerializedSpan::new(10, 5).with_line(3); + /// assert_eq!(span.line, Some(3)); + /// ``` + #[must_use] + pub fn with_line(mut self, line: usize) -> Self { + self.line = Some(line); + self + } + + /// Set the 1-indexed character column. + /// + /// # Examples + /// + /// ``` + /// use mds::SerializedSpan; + /// let span = SerializedSpan::new(10, 5).with_column(7); + /// assert_eq!(span.column, Some(7)); + /// ``` + #[must_use] + pub fn with_column(mut self, column: usize) -> Self { + self.column = Some(column); + self + } +} + /// A serializable, `serde`-friendly representation of an [`MdsError`]. /// /// Suitable for embedding in JSON API responses or structured log output. +/// +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases. +/// Obtain values via [`MdsError::serialize`]; do not construct via struct literal. +#[non_exhaustive] #[derive(Debug, Clone, PartialEq, serde::Serialize)] pub struct SerializedError { pub code: String, diff --git a/crates/mds-core/src/lib.rs b/crates/mds-core/src/lib.rs index fda9f46..39304a6 100644 --- a/crates/mds-core/src/lib.rs +++ b/crates/mds-core/src/lib.rs @@ -63,6 +63,7 @@ pub use fs::{effective_parent, FileSystem, NativeFs, VirtualFs}; pub use lint::{ fix, named_source_for_render, neutralize_source_for_render, sanitize_control_chars, sanitize_control_chars_wire, FixLineSpan, LintConfig, LintDiagnostic, LintResult, Severity, + TextEdit, }; pub use options::{ format_unknown_keys_error, json_type_name, parse_json_vars, reject_unknown_json_keys, VarsError, @@ -971,7 +972,7 @@ pub fn compile_virtual_with_deps( /// /// ```rust,no_run /// use std::path::Path; -/// let result = mds::compile_with_deps_opts(Path::new("t.mds"), None, mds::CompileOptions { source_map: true, include_sources_content: false, ..Default::default() })?; +/// let result = mds::compile_with_deps_opts(Path::new("t.mds"), None, mds::CompileOptions::default().with_source_map(true))?; /// if let Some(sm) = result.source_map { println!("{}", sm.to_json()); } /// # Ok::<(), Box>(()) /// ``` @@ -1015,7 +1016,7 @@ pub fn compile_with_deps_opts( /// "Hello!\n", /// None, /// None, -/// mds::CompileOptions { source_map: true, include_sources_content: false, ..Default::default() }, +/// mds::CompileOptions::default().with_source_map(true), /// )?; /// assert!(result.source_map.is_some()); /// # Ok::<(), Box>(()) @@ -1056,7 +1057,7 @@ pub fn compile_str_with_deps_opts( /// modules, /// "main.mds", /// None, -/// mds::CompileOptions { source_map: true, include_sources_content: false, ..Default::default() }, +/// mds::CompileOptions::default().with_source_map(true), /// )?; /// assert!(result.source_map.is_some()); /// # Ok::<(), Box>(()) diff --git a/crates/mds-core/src/lint/config.rs b/crates/mds-core/src/lint/config.rs index ddc747e..4fb79ba 100644 --- a/crates/mds-core/src/lint/config.rs +++ b/crates/mds-core/src/lint/config.rs @@ -24,6 +24,12 @@ use super::diagnostic::Severity; /// /// Unknown severity *values* (e.g. `"verbose"`) cause a hard parse error (`exit 2`) /// because the closed enum has no sensible fallback. +/// +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases. +/// Use `LintConfig::default()` for a config with all rules at engine defaults, or +/// [`LintConfig::from_rules`] to supply per-rule overrides; do not construct via +/// struct literal. +#[non_exhaustive] #[derive(Debug, Default, Clone)] pub struct LintConfig { /// Per-rule severity overrides. Key = rule name (e.g. `"unused-variable"`), @@ -32,6 +38,30 @@ pub struct LintConfig { } impl LintConfig { + /// Construct a `LintConfig` with the given per-rule severity overrides. + /// + /// This is the supported construction path for external crates — struct literals + /// are not available because this type is `#[non_exhaustive]`. + /// + /// Per Rust API guidelines (C-CTOR): constructors are named `new`, `from_*`, + /// or `with_*` only when taking `self`. This function does not take `self`, + /// so it is named `from_rules`. + /// + /// # Examples + /// + /// ``` + /// use std::collections::HashMap; + /// use mds::{LintConfig, Severity}; + /// let config = LintConfig::from_rules(HashMap::from([ + /// ("unused-variable".to_string(), Severity::Off), + /// ])); + /// assert_eq!(config.severity_for("unused-variable"), Some(&Severity::Off)); + /// ``` + #[must_use] + pub fn from_rules(rules: HashMap) -> Self { + LintConfig { rules } + } + /// Look up the configured severity for a rule name. /// /// Returns `None` when the rule has no explicit override — callers should fall diff --git a/crates/mds-core/src/lint/diagnostic.rs b/crates/mds-core/src/lint/diagnostic.rs index 0d938ad..64940c5 100644 --- a/crates/mds-core/src/lint/diagnostic.rs +++ b/crates/mds-core/src/lint/diagnostic.rs @@ -225,6 +225,10 @@ impl fmt::Display for Severity { /// /// Used by the fix planner for in-place replacement edits (e.g. `{x}` → `{{x}}`). /// An empty `new_text` is equivalent to a pure deletion. +/// +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases. +/// Construct via [`TextEdit::new`]; do not use a struct literal. +#[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq)] pub struct TextEdit { /// Inclusive start byte offset of the range to replace. @@ -235,6 +239,32 @@ pub struct TextEdit { pub new_text: String, } +impl TextEdit { + /// Construct a `TextEdit` with all fields. + /// + /// This is the supported construction path for external crates — struct literals + /// are not available because this type is `#[non_exhaustive]`. + /// + /// # Examples + /// + /// ``` + /// use mds::TextEdit; + /// let edit = TextEdit::new(6, 12, "{{name}}"); + /// assert_eq!(edit.start, 6); + /// assert_eq!(edit.end, 12); + /// assert_eq!(edit.new_text, "{{name}}"); + /// ``` + #[must_use] + pub fn new(start: usize, end: usize, new_text: impl Into) -> Self { + debug_assert!(start <= end, "TextEdit::new: start ({start}) > end ({end})"); + TextEdit { + start, + end, + new_text: new_text.into(), + } + } +} + // ── FixLineSpan ─────────────────────────────────────────────────────────────── /// A line-range descriptor for a single lint auto-fix removal. @@ -252,6 +282,13 @@ pub struct TextEdit { /// /// **Single-line helper**: use `FixLineSpan::single(offset)` to remove exactly /// the one line that contains `offset`. +/// +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases. +/// Construct via [`FixLineSpan::single`] (single-line), +/// [`FixLineSpan::range_inclusive`] (multi-line, inclusive), or +/// [`FixLineSpan::range_exclusive`] (multi-line, exclusive); +/// external crates must not use a struct literal. +#[non_exhaustive] #[derive(Debug, Clone)] pub struct FixLineSpan { /// Byte offset of any character in the first line to remove. @@ -267,7 +304,8 @@ pub struct FixLineSpan { impl FixLineSpan { /// Remove exactly the one line that contains `offset`. /// - /// Equivalent to `FixLineSpan { from: offset, to: offset, to_inclusive: true }`. + /// Equivalent to `FixLineSpan::range_inclusive(offset, offset)`. + #[must_use] pub fn single(offset: usize) -> Self { FixLineSpan { from: offset, @@ -275,6 +313,48 @@ impl FixLineSpan { to_inclusive: true, } } + + /// Remove a range of lines **including** the line containing `to`. + /// + /// Both `from` and `to` are byte offsets within their respective lines. + /// The planner translates them to exact line boundaries. + /// + /// # Panics (debug) + /// + /// Panics in debug builds when `from > to`. + #[must_use] + pub fn range_inclusive(from: usize, to: usize) -> Self { + debug_assert!( + from <= to, + "FixLineSpan::range_inclusive: from ({from}) > to ({to})" + ); + FixLineSpan { + from, + to, + to_inclusive: true, + } + } + + /// Remove a range of lines **excluding** the line containing `to`. + /// + /// The line containing `to` is kept; removal stops at the start of that line. + /// Use this when a closing token (e.g. `@end`) must remain in the source. + /// + /// # Panics (debug) + /// + /// Panics in debug builds when `from > to`. + #[must_use] + pub fn range_exclusive(from: usize, to: usize) -> Self { + debug_assert!( + from <= to, + "FixLineSpan::range_exclusive: from ({from}) > to ({to})" + ); + FixLineSpan { + from, + to, + to_inclusive: false, + } + } } // ── LintDiagnostic ──────────────────────────────────────────────────────────── @@ -286,6 +366,11 @@ impl FixLineSpan { /// `Error` → Error; `Off` diagnostics are never constructed (the lint engine filters /// them before collecting). /// +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases. +/// Obtain values from `mds::lint_str`, `mds::lint`, and similar lint API functions, or +/// construct via [`LintDiagnostic::new`] and the `with_*` builder methods; do not +/// construct via struct literal. +/// /// **CLI render**: always use `mds_cli::output::eprint_error` to render diagnostics on /// a TTY — never call `eprintln!("{report:?}")` on a raw `miette::Report`. Writing the /// rendered frame directly bypasses input-level sanitization and can inject C0/C1 @@ -297,6 +382,7 @@ impl FixLineSpan { /// **Sanitization**: see the module-level "Sanitization discipline" note — `message` /// and `help` are sanitized at every output boundary; constructors keep raw bytes so /// span offsets and `fix_edits` stay byte-accurate. +#[non_exhaustive] pub struct LintDiagnostic { /// Short rule identifier, e.g. `"unused-variable"`. Becomes the miette code /// `mds::lint::`. @@ -329,6 +415,139 @@ pub struct LintDiagnostic { pub fix_edits: Option>, } +impl LintDiagnostic { + /// Construct a `LintDiagnostic` with required fields; all optional fields default + /// to `None`. + /// + /// This is the supported construction path for external crates — struct literals + /// are not available because this type is `#[non_exhaustive]`. + /// + /// # Examples + /// + /// ``` + /// use mds::{LintDiagnostic, Severity}; + /// let diag = LintDiagnostic::new("unused-variable", Severity::Warn, "Variable 'x' is unused"); + /// assert_eq!(diag.rule, "unused-variable"); + /// assert_eq!(diag.severity, Severity::Warn); + /// assert!(diag.help.is_none()); + /// ``` + #[must_use] + pub fn new(rule: impl Into, severity: Severity, message: impl Into) -> Self { + LintDiagnostic { + rule: rule.into(), + severity, + message: message.into(), + help: None, + span: None, + file: None, + fix_removals: None, + fix_edits: None, + } + } + + /// Set the help text for this diagnostic. + /// + /// # Examples + /// + /// ``` + /// use mds::{LintDiagnostic, Severity}; + /// let diag = LintDiagnostic::new("unused-variable", Severity::Warn, "Variable 'x' is unused") + /// .with_help("Remove the frontmatter key or reference it in the template body."); + /// assert!(diag.help.is_some()); + /// ``` + #[must_use] + pub fn with_help(mut self, help: impl Into) -> Self { + self.help = Some(help.into()); + self + } + + /// Set the source span for this diagnostic. + #[must_use] + pub fn with_span(mut self, span: crate::SerializedSpan) -> Self { + self.span = Some(span); + self + } + + /// Set the source file path for this diagnostic. + #[must_use] + pub fn with_file(mut self, file: impl Into) -> Self { + self.file = Some(file.into()); + self + } + + /// Set line-removal fix spans for this diagnostic. + #[must_use] + pub fn with_fix_removals(mut self, fix_removals: Vec) -> Self { + self.fix_removals = Some(fix_removals); + self + } + + /// Set in-place replacement edits for this diagnostic. + #[must_use] + pub fn with_fix_edits(mut self, fix_edits: Vec) -> Self { + self.fix_edits = Some(fix_edits); + self + } + + /// Return a sanitized clone of this diagnostic for use at the miette render boundary. + /// + /// Sanitizes `message` and `help` in HUMAN mode (preserves `\n`; escapes C0/DEL/C1 + /// controls, bidi controls, and BOM — see module-level "Sanitization discipline"). + /// Preserves `rule`, `severity`, `span`, and `file` unchanged. Intentionally sets + /// `fix_removals` and `fix_edits` to `None` — miette's `Diagnostic` impl does not + /// read those fields, so cloning them would waste allocations (architecture-3 / + /// rust-1). + /// + /// This method is the architecturally correct home for render-boundary sanitization + /// (PF-014): the CLI should not assemble sanitized copies itself. It owns the escape + /// logic because it lives alongside the sanitizers and the struct definition. + /// + /// **Behavior is byte-identical to the original CLI render path** — this is + /// security-critical code (#176 / CWE-150). Do not alter the escape mode or field + /// selection without a corresponding audit of all render boundaries. + /// + /// # Escaping is one-way + /// + /// The HUMAN-mode escaping applied to `message` and `help` is **lossy and + /// non-injective** — see the `# Escaping is one-way` section on + /// [`sanitize_control_chars`]. Callers **MUST NOT** un-escape `\uXXXX` sequences + /// back into bytes. + /// + /// # Result is render-only — do not serialize to a WIRE surface + /// + /// The returned diagnostic has `message` and `help` sanitized in **HUMAN mode**, + /// which preserves `\n`. Passing this value to `LintResult::to_canonical_json` or + /// any JSON / binding serializer would double-escape already-escaped text and, more + /// importantly, would emit raw newlines inside JSON string values — violating the + /// WIRE contract (log forging, YAML key injection). For JSON/binding output, call + /// `to_canonical_json()` on the *original* `LintResult` instead; it applies WIRE + /// mode at the correct boundary. + /// + /// # Fix data is intentionally stripped + /// + /// `fix_removals` and `fix_edits` are set to `None` in the returned value. A caller + /// that reuses the sanitized clone for the fix pipeline would silently lose every + /// `fixable` flag and every edit plan — the `fixable` field in the JSON wire format + /// is derived from `fix_removals`/`fix_edits`, so it would always serialize as + /// `false`. + #[must_use] + pub fn sanitized_for_render(&self) -> Self { + LintDiagnostic { + rule: self.rule.clone(), + severity: self.severity, + message: sanitize_control_chars(&self.message).into_owned(), + help: self + .help + .as_deref() + .map(|h| sanitize_control_chars(h).into_owned()), + span: self.span.clone(), + file: self.file.clone(), + fix_removals: None, + fix_edits: None, + } + } +} + impl fmt::Debug for LintDiagnostic { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("LintDiagnostic") @@ -402,6 +621,12 @@ impl miette::Diagnostic for LintDiagnostic { /// directives — used to determine whether Tier B fixes (unused-import, unused-function) /// are safe to apply (they change compiled output for non-standalone files because the /// importer's compiled output depends on what the imported module exports). +/// +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases. +/// Obtain values from `mds::lint_str`, `mds::lint`, and similar lint API functions, +/// or construct via [`LintResult::new`] (and chain [`.truncated()`] / [`.standalone()`]); +/// do not construct via struct literal. +#[non_exhaustive] #[derive(Debug)] pub struct LintResult { /// Collected lint findings. Never contains `Severity::Off` diagnostics. @@ -413,6 +638,48 @@ pub struct LintResult { } impl LintResult { + /// Construct a `LintResult` from a diagnostic list. + /// + /// Defaults: `truncated = false`, `is_standalone = false`. + /// Chain [`.truncated()`] or [`.standalone()`] to override. + /// + /// This is the supported construction path for external crates — struct literals + /// are not available because this type is `#[non_exhaustive]`. + /// + /// # Examples + /// + /// ``` + /// use mds::LintResult; + /// let result = LintResult::new(vec![]).standalone(); + /// assert!(result.diagnostics.is_empty()); + /// assert!(!result.truncated); + /// assert!(result.is_standalone); + /// ``` + #[must_use] + pub fn new(diagnostics: Vec) -> Self { + LintResult { + diagnostics, + truncated: false, + is_standalone: false, + } + } + + /// Mark this result as truncated (the diagnostic cap was reached). + #[must_use] + pub fn truncated(mut self) -> Self { + self.truncated = true; + self + } + + /// Mark this result as standalone (the file has no `@import` or `@extends`). + /// + /// Standalone status gates Tier B `--fix` eligibility. + #[must_use] + pub fn standalone(mut self) -> Self { + self.is_standalone = true; + self + } + /// Produce the canonical, LSP-stable JSON wire format. /// /// Schema: @@ -1660,4 +1927,120 @@ mod tests { }; assert_eq!(err.severity(), Some(miette::Severity::Error)); } + + // ── sanitized_for_render ────────────────────────────────────────────────── + // + // Security-critical: this method is the CLI's render-boundary sanitizer. + // It lives in mds-core and must pass these invariants even when no CLI test + // exercises it, so `cargo test -p mds-core` stays green independently. + + /// T-SFR-1: ESC byte in `message` is escaped; `\n` is preserved (HUMAN mode). + /// + /// Pins the HUMAN-mode choice: `\n` in a diagnostic body is legitimate prose + /// punctuation (multi-line miette frame) and must survive the round-trip. + /// ESC (U+001B) is a hostile control and must be replaced by `\\u001B`. + #[test] + fn sanitized_for_render_escapes_esc_preserves_newline() { + let diag = LintDiagnostic { + rule: "unused-variable".to_string(), + severity: Severity::Warn, + message: "line one\x1Bline two\nline three".to_string(), + help: Some("help\x1Btext\nwith newline".to_string()), + span: None, + file: None, + fix_removals: None, + fix_edits: None, + }; + + let rendered = diag.sanitized_for_render(); + + // ESC is replaced by the \uXXXX literal. + assert!( + !rendered.message.contains('\x1B'), + "raw ESC must not appear in sanitized message; got: {:?}", + rendered.message + ); + assert!( + rendered.message.contains("\\u001B"), + "\\u001B literal must appear in sanitized message; got: {:?}", + rendered.message + ); + + // \n is preserved (HUMAN mode). + assert!( + rendered.message.contains('\n'), + "newline must be preserved in HUMAN-mode message; got: {:?}", + rendered.message + ); + + // help is also sanitized. + let help = rendered.help.as_deref().expect("help must be Some"); + assert!(!help.contains('\x1B'), "raw ESC must not appear in help"); + assert!( + help.contains("\\u001B"), + "\\u001B must appear in sanitized help" + ); + assert!(help.contains('\n'), "newline must be preserved in help"); + } + + /// T-SFR-2: `span` and `file` pass through byte-identical; fix data is stripped. + /// + /// Pins three behaviours together: + /// - `span` offset/length/line/column are raw values, not altered by sanitization. + /// - `file` is copied verbatim — including hostile control bytes (WIRE escaping of + /// the filename is the caller's responsibility via `named_source_for_render`). + /// - `fix_removals` and `fix_edits` are set to `None` so the sanitized clone + /// cannot be mistaken for a fix-bearing diagnostic. + /// + /// The hostile filename `"a\u{1B}/b\u{202E}.mds"` exercises the no-sanitize + /// guarantee: `sanitized_for_render` must copy `file` byte-for-byte regardless of + /// what control bytes it contains. This is intentional — WIRE-escaping happens in + /// `named_source_for_render`, not here (PF-014). + #[test] + fn sanitized_for_render_span_file_unchanged_fix_nulled() { + let span = crate::error::SerializedSpan::new(42, 7) + .with_line(3) + .with_column(1); + let hostile_file = "a\u{1B}/b\u{202E}.mds".to_string(); + let diag = LintDiagnostic::new("duplicate-import", Severity::Error, "msg") + .with_span(span.clone()) + .with_file(hostile_file.clone()) + .with_fix_removals(vec![FixLineSpan::single(42)]) + .with_fix_edits(vec![TextEdit::new(0, 3, "x")]); + + let rendered = diag.sanitized_for_render(); + + // span is byte-identical, including line and column. + let rendered_span = rendered.span.as_ref().expect("span must survive"); + assert_eq!(rendered_span.offset, 42, "span.offset must be unchanged"); + assert_eq!(rendered_span.length, 7, "span.length must be unchanged"); + assert_eq!( + rendered_span.line, + Some(3), + "span.line must survive sanitization" + ); + assert_eq!( + rendered_span.column, + Some(1), + "span.column must survive sanitization" + ); + + // file is copied verbatim — hostile bytes are NOT sanitized by sanitized_for_render + // (the caller's named_source_for_render is responsible for WIRE-escaping the filename). + assert_eq!( + rendered.file.as_deref(), + Some(hostile_file.as_str()), + "file must be byte-identical, including hostile control bytes" + ); + + // fix data is intentionally stripped. + assert!( + rendered.fix_removals.is_none(), + "fix_removals must be None in the sanitized clone" + ); + assert!( + rendered.fix_edits.is_none(), + "fix_edits must be None in the sanitized clone" + ); + } } diff --git a/crates/mds-core/src/lint/fix.rs b/crates/mds-core/src/lint/fix.rs index 0af1677..545bc78 100644 --- a/crates/mds-core/src/lint/fix.rs +++ b/crates/mds-core/src/lint/fix.rs @@ -107,6 +107,10 @@ pub use super::tier::{is_fixable, is_output_neutral, rule_tier, FixTier}; /// /// **CRLF note**: `end` must be chosen to include the complete line terminator /// (call [`extend_to_line_end`] to adjust if needed) for line-removal edits. +/// +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases. +/// Construct via [`ByteEdit::deletion`] or [`ByteEdit::replacement`]; do not use a struct literal. +#[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct ByteEdit { /// Inclusive start byte offset of the range to replace. @@ -119,7 +123,64 @@ pub struct ByteEdit { pub replacement: String, } +impl ByteEdit { + /// Construct a `ByteEdit` that **deletes** the byte range `[start, end)`. + /// + /// Equivalent to a replacement with an empty string. + /// + /// This is the supported construction path for external crates — struct literals + /// are not available because this type is `#[non_exhaustive]`. + /// + /// # Examples + /// + /// ``` + /// use mds::fix::ByteEdit; + /// let edit = ByteEdit::deletion(0, 6, "duplicate-import"); + /// assert_eq!(edit.replacement, ""); + /// ``` + #[must_use] + pub fn deletion(start: usize, end: usize, rule: impl Into) -> Self { + ByteEdit { + start, + end, + rule: rule.into(), + replacement: String::new(), + } + } + + /// Construct a `ByteEdit` that **replaces** the byte range `[start, end)` with `text`. + /// + /// This is the supported construction path for external crates — struct literals + /// are not available because this type is `#[non_exhaustive]`. + /// + /// # Examples + /// + /// ``` + /// use mds::fix::ByteEdit; + /// let edit = ByteEdit::replacement(6, 12, "legacy-interpolation", "{{name}}"); + /// assert_eq!(edit.replacement, "{{name}}"); + /// ``` + #[must_use] + pub fn replacement( + start: usize, + end: usize, + rule: impl Into, + text: impl Into, + ) -> Self { + ByteEdit { + start, + end, + rule: rule.into(), + replacement: text.into(), + } + } +} + /// A fix edit that was rejected by the per-edit reverify gate in [`apply_fixes_incremental`]. +/// +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases. +/// Construct via [`RejectedEdit::new`]; do not use a struct literal. +#[non_exhaustive] #[derive(Debug, Clone)] pub struct RejectedEdit { /// The edit that was rejected. @@ -129,6 +190,20 @@ pub struct RejectedEdit { pub reason: String, } +impl RejectedEdit { + /// Construct a `RejectedEdit` with an edit and a rejection reason. + /// + /// This is the supported construction path for external crates — struct literals + /// are not available because this type is `#[non_exhaustive]`. + #[must_use] + pub fn new(edit: ByteEdit, reason: impl Into) -> Self { + RejectedEdit { + edit, + reason: reason.into(), + } + } +} + /// Render a reverify failure into a single-line, display-safe rejection reason. /// /// The single construction site for every rejection reason that embeds an @@ -158,6 +233,15 @@ fn reverify_failure_reason(err: &MdsError) -> String { } /// A plan of fix edits for a single file's source. +/// +/// Obtain via [`plan_fixes`] or [`plan_fixes_with_options`], then pass to +/// [`apply_fixes`] or [`apply_fixes_incremental`]. External crates that need an +/// empty plan can use `FixPlan::default()`; its fields are `pub`, so they remain +/// directly readable and writable. +/// +/// This type is `#[non_exhaustive]`: new fields may be added in minor releases; +/// do not use a struct literal in external crates. +#[non_exhaustive] #[derive(Debug, Default)] pub struct FixPlan { /// Sorted (start ASC, end DESC), deduplicated, non-overlapping byte edits diff --git a/crates/mds-core/src/lint/tier.rs b/crates/mds-core/src/lint/tier.rs index 7e1e4c5..304563a 100644 --- a/crates/mds-core/src/lint/tier.rs +++ b/crates/mds-core/src/lint/tier.rs @@ -22,6 +22,7 @@ //! unused import or function must produce byte-identical compiled output. /// Fix tier for a lint rule. +#[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq)] pub enum FixTier { /// Auto-fixable with a reverify gate. Diagnostic `fixable` = `true`. diff --git a/crates/mds-core/src/options.rs b/crates/mds-core/src/options.rs index a850fff..b1b9ba0 100644 --- a/crates/mds-core/src/options.rs +++ b/crates/mds-core/src/options.rs @@ -45,6 +45,7 @@ pub fn json_type_name(v: &serde_json::Value) -> &'static str { // ── VarsError ───────────────────────────────────────────────────────────────── /// Errors that can occur when parsing the `vars` option. +#[non_exhaustive] #[derive(Debug)] pub enum VarsError { /// The `vars` value was not a JSON object (e.g. it was an array or string). diff --git a/crates/mds-core/src/sourcemap.rs b/crates/mds-core/src/sourcemap.rs index 2e595e6..aa59979 100644 --- a/crates/mds-core/src/sourcemap.rs +++ b/crates/mds-core/src/sourcemap.rs @@ -440,6 +440,7 @@ pub(crate) fn encode_mappings(mut points: Vec<(u32, u32, u32, u32, u32)>) -> Str /// (`compile_with_deps_opts`, `compile_str_with_deps_opts`, /// `compile_virtual_with_deps_opts`) and threaded through the resolver and /// evaluator. +#[non_exhaustive] #[derive(Debug, Clone, Default)] pub struct CompileOptions { /// Generate a [`SourceMap`] and attach it to [`crate::CompileResult::source_map`]. @@ -469,6 +470,7 @@ pub struct CompileOptions { /// /// Each binding maps this to its own error type and message; the unit struct intentionally /// carries no context — the per-binding wording is always determined at the call site. +#[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct InvalidOptionsError; @@ -489,6 +491,60 @@ impl CompileOptions { Ok(()) } } + + /// Enable or disable source map generation. + /// + /// When `true`, [`crate::CompileResult::source_map`] will be populated. + /// + /// # Examples + /// + /// ``` + /// let opts = mds::CompileOptions::default().with_source_map(true); + /// assert!(opts.source_map); + /// ``` + #[must_use] + pub fn with_source_map(mut self, source_map: bool) -> Self { + self.source_map = source_map; + self + } + + /// Enable or disable embedding source file contents in the map. + /// + /// When `true`, `sourcesContent` is included in the generated source map. + /// + /// # Examples + /// + /// ``` + /// let opts = mds::CompileOptions::default() + /// .with_source_map(true) + /// .with_include_sources_content(true); + /// assert!(opts.include_sources_content); + /// ``` + #[must_use] + pub fn with_include_sources_content(mut self, include: bool) -> Self { + self.include_sources_content = include; + self + } + + /// Set the directory the source map file will be written to. + /// + /// When `Some`, `sources[]` paths are emitted relative to this directory; + /// when `None` (the default), paths are root-relative. + /// + /// # Examples + /// + /// ``` + /// use std::path::PathBuf; + /// let opts = mds::CompileOptions::default() + /// .with_source_map(true) + /// .with_source_map_base(Some(PathBuf::from("out"))); + /// assert!(opts.source_map_base.is_some()); + /// ``` + #[must_use] + pub fn with_source_map_base(mut self, base: Option) -> Self { + self.source_map_base = base; + self + } } // --------------------------------------------------------------------------- diff --git a/crates/mds-core/tests/api_surface.rs b/crates/mds-core/tests/api_surface.rs index 6105461..9edfe75 100644 --- a/crates/mds-core/tests/api_surface.rs +++ b/crates/mds-core/tests/api_surface.rs @@ -1018,31 +1018,38 @@ fn lint_types_exist() { let _err = Severity::Error; // LintConfig has a `rules` field (HashMap). - let config = LintConfig { - rules: HashMap::from([("unused-variable".to_string(), Severity::Off)]), - }; + let config = LintConfig::from_rules(HashMap::from([( + "unused-variable".to_string(), + Severity::Off, + )])); assert_eq!(config.rules.get("unused-variable"), Some(&Severity::Off)); // LintDiagnostic has the expected fields. - let diag = LintDiagnostic { - rule: "unused-variable".to_string(), - severity: Severity::Warn, - message: "Variable 'name' is never used".to_string(), - help: Some("Remove the frontmatter key or reference it in the body".to_string()), - span: None, - file: Some("test.mds".to_string()), - fix_removals: None, - fix_edits: None, - }; + let diag = LintDiagnostic::new( + "unused-variable", + Severity::Warn, + "Variable 'name' is never used", + ) + .with_help("Remove the frontmatter key or reference it in the body") + .with_file("test.mds") + .with_span(mds::SerializedSpan::new(0, 4)); assert_eq!(diag.rule, "unused-variable"); assert_eq!(diag.severity, Severity::Warn); + // Verify that the builder methods actually set their fields. + assert_eq!( + diag.help.as_deref(), + Some("Remove the frontmatter key or reference it in the body"), + "with_help must set the help field" + ); + assert_eq!( + diag.file.as_deref(), + Some("test.mds"), + "with_file must set the file field" + ); + assert!(diag.span.is_some(), "with_span must set the span field"); // LintResult has diagnostics, truncated, and is_standalone fields. - let result = LintResult { - diagnostics: vec![diag], - truncated: false, - is_standalone: false, - }; + let result = LintResult::new(vec![diag]); assert_eq!(result.diagnostics.len(), 1); assert!(!result.truncated); } @@ -1090,25 +1097,14 @@ fn max_diagnostics_pinned() { fn lint_canonical_json_schema() { use mds::SerializedSpan; - let result = LintResult { - diagnostics: vec![LintDiagnostic { - rule: "unused-variable".to_string(), - severity: Severity::Warn, - message: "Variable 'name' is never used".to_string(), - help: Some("Remove the frontmatter key or reference it in the body".to_string()), - span: Some(SerializedSpan { - offset: 4, - length: 4, - line: Some(2), - column: Some(1), - }), - file: Some("test.mds".to_string()), - fix_removals: None, - fix_edits: None, - }], - truncated: false, - is_standalone: false, - }; + let result = LintResult::new(vec![LintDiagnostic::new( + "unused-variable", + Severity::Warn, + "Variable 'name' is never used", + ) + .with_help("Remove the frontmatter key or reference it in the body") + .with_span(SerializedSpan::new(4, 4).with_line(2).with_column(1)) + .with_file("test.mds")]); let json = result.to_canonical_json(); @@ -1151,75 +1147,48 @@ fn lint_canonical_json_fixable_semantics() { use mds::LintDiagnostic; // Tier A rule (duplicate-import) with fix_removals → fixable regardless of is_standalone. - let tier_a = LintResult { - diagnostics: vec![LintDiagnostic { - rule: "duplicate-import".to_string(), - severity: Severity::Error, - message: "Duplicate import".to_string(), - help: None, - span: None, - file: Some("a.mds".to_string()), - fix_removals: Some(vec![FixLineSpan::single(0)]), - fix_edits: None, - }], - truncated: false, - is_standalone: false, // even non-standalone Tier A is fixable - }; + let tier_a = LintResult::new(vec![LintDiagnostic::new( + "duplicate-import", + Severity::Error, + "Duplicate import", + ) + .with_file("a.mds") + .with_fix_removals(vec![FixLineSpan::single(0)])]); // even non-standalone Tier A is fixable let json = tier_a.to_canonical_json(); assert_eq!(json["files"][0]["diagnostics"][0]["fixable"], true); // Tier B rule (unused-function) — fixable only for standalone files. // fix_removals: Some(...) + non-standalone → fixable: false - let tier_b_non_standalone = LintResult { - diagnostics: vec![LintDiagnostic { - rule: "unused-function".to_string(), - severity: Severity::Warn, - message: "Unused function".to_string(), - help: None, - span: None, - file: Some("b.mds".to_string()), - fix_removals: Some(vec![FixLineSpan::single(0)]), - fix_edits: None, - }], - truncated: false, - is_standalone: false, - }; + let tier_b_non_standalone = LintResult::new(vec![LintDiagnostic::new( + "unused-function", + Severity::Warn, + "Unused function", + ) + .with_file("b.mds") + .with_fix_removals(vec![FixLineSpan::single(0)])]); let json = tier_b_non_standalone.to_canonical_json(); assert_eq!(json["files"][0]["diagnostics"][0]["fixable"], false); // fix_removals: Some(...) + standalone → fixable: true - let tier_b_standalone = LintResult { - diagnostics: vec![LintDiagnostic { - rule: "unused-function".to_string(), - severity: Severity::Warn, - message: "Unused function".to_string(), - help: None, - span: None, - file: Some("c.mds".to_string()), - fix_removals: Some(vec![FixLineSpan::single(0)]), - fix_edits: None, - }], - truncated: false, - is_standalone: true, - }; + let tier_b_standalone = LintResult::new(vec![LintDiagnostic::new( + "unused-function", + Severity::Warn, + "Unused function", + ) + .with_file("c.mds") + .with_fix_removals(vec![FixLineSpan::single(0)])]) + .standalone(); let json = tier_b_standalone.to_canonical_json(); assert_eq!(json["files"][0]["diagnostics"][0]["fixable"], true); // Tier C rule (unused-variable) → never fixable (fix_removals: None also → false). - let tier_c = LintResult { - diagnostics: vec![LintDiagnostic { - rule: "unused-variable".to_string(), - severity: Severity::Warn, - message: "Unused variable".to_string(), - help: None, - span: None, - file: Some("d.mds".to_string()), - fix_removals: None, - fix_edits: None, - }], - truncated: false, - is_standalone: true, // even standalone Tier C is not fixable - }; + let tier_c = LintResult::new(vec![LintDiagnostic::new( + "unused-variable", + Severity::Warn, + "Unused variable", + ) + .with_file("d.mds")]) + .standalone(); // even standalone Tier C is not fixable let json = tier_c.to_canonical_json(); assert_eq!(json["files"][0]["diagnostics"][0]["fixable"], false); } @@ -1337,19 +1306,13 @@ fn native_fs_check_symlink_is_public() { #[test] fn compile_options_has_source_map_and_include_sources_content() { // T1: both fields must exist and be independently settable. - let off = mds::CompileOptions { - source_map: false, - include_sources_content: false, - ..Default::default() - }; + let off = mds::CompileOptions::default(); assert!(!off.source_map); assert!(!off.include_sources_content); - let on = mds::CompileOptions { - source_map: true, - include_sources_content: true, - ..Default::default() - }; + let on = mds::CompileOptions::default() + .with_source_map(true) + .with_include_sources_content(true); assert!(on.source_map); assert!(on.include_sources_content); @@ -1371,11 +1334,7 @@ fn include_sources_content_false_omits_sources_content() { modules, "main.mds", None, - mds::CompileOptions { - source_map: true, - include_sources_content: false, - ..Default::default() - }, + mds::CompileOptions::default().with_source_map(true), ) .expect("should compile"); @@ -1397,11 +1356,9 @@ fn include_sources_content_true_includes_sources_content() { modules, "main.mds", None, - mds::CompileOptions { - source_map: true, - include_sources_content: true, - ..Default::default() - }, + mds::CompileOptions::default() + .with_source_map(true) + .with_include_sources_content(true), ) .expect("should compile"); @@ -1438,38 +1395,20 @@ fn fix_api_incremental_exists() { } // RejectedEdit struct has `edit` and `reason` fields. - let edit = ByteEdit { - start: 0, - end: 5, - rule: "duplicate-import".to_string(), - replacement: String::new(), - }; - let rejected = RejectedEdit { - edit, - reason: "simulated reverify failure".to_string(), - }; + let edit = ByteEdit::deletion(0, 5, "duplicate-import"); + let rejected = RejectedEdit::new(edit, "simulated reverify failure"); assert_eq!(rejected.reason, "simulated reverify failure"); assert_eq!(rejected.edit.rule, "duplicate-import"); // apply_fixes_incremental is callable with F: Fn — compile-time and runtime check. let source = "Hello!\n"; - let original = LintResult { - diagnostics: vec![], - truncated: false, - is_standalone: false, - }; + let original = LintResult::new(vec![]); let plan = plan_fixes(&original, source); let outcome = apply_fixes_incremental( source, plan, &original, - |_s| -> Result { - Ok(LintResult { - diagnostics: vec![], - truncated: false, - is_standalone: false, - }) - }, + |_s| -> Result { Ok(LintResult::new(vec![])) }, ); // Empty source with no diagnostics → NothingToFix (no reverify called). assert!( @@ -1493,3 +1432,52 @@ fn string_source_map_label_is_in_public_api() { updating every surface that uses it" ); } + +/// F-API-2: TextEdit is publicly nameable and LintDiagnostic::with_fix_edits wires through. +/// +/// Pins: +/// - `mds::TextEdit` is a public type (was previously unnameable: pub inside pub(crate) mod). +/// - `LintDiagnostic::with_fix_edits` stores and exposes the edits. +/// - `LintResult::to_canonical_json()` emits the `fix_edits` array for a diagnostic that has them. +#[test] +fn text_edit_and_fix_edits_public_api() { + use mds::TextEdit; + + // TextEdit is publicly constructable. + let edit = TextEdit::new(6, 12, "{{name}}"); + assert_eq!(edit.start, 6); + assert_eq!(edit.end, 12); + assert_eq!(edit.new_text, "{{name}}"); + + // with_fix_edits stores the edits on LintDiagnostic. + let diag = LintDiagnostic::new( + "legacy-interpolation", + Severity::Warn, + "legacy brace syntax", + ) + .with_file("t.mds") + .with_fix_edits(vec![edit]); + assert!( + diag.fix_edits.is_some(), + "with_fix_edits must populate fix_edits" + ); + let edits = diag.fix_edits.as_ref().unwrap(); + assert_eq!(edits.len(), 1); + assert_eq!(edits[0].start, 6); + assert_eq!(edits[0].new_text, "{{name}}"); + + // to_canonical_json emits fix_edits as an array (not null) for this diagnostic. + let result = LintResult::new(vec![diag]); + let json = result.to_canonical_json(); + let diags = &json["files"][0]["diagnostics"]; + let fix_edits = &diags[0]["fix_edits"]; + assert!( + fix_edits.is_array(), + "fix_edits must be a JSON array when present; got: {fix_edits:?}" + ); + let arr = fix_edits.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["start"], 6); + assert_eq!(arr[0]["end"], 12); + assert_eq!(arr[0]["new_text"], "{{name}}"); +} diff --git a/crates/mds-core/tests/source_map_vfs.rs b/crates/mds-core/tests/source_map_vfs.rs index 7b1f7a8..626400b 100644 --- a/crates/mds-core/tests/source_map_vfs.rs +++ b/crates/mds-core/tests/source_map_vfs.rs @@ -47,23 +47,14 @@ fn vfs_with_map(modules: HashMap, entry: &str) -> CompileResult vfs_opts( modules, entry, - CompileOptions { - source_map: true, - include_sources_content: true, - ..Default::default() - }, + CompileOptions::default() + .with_source_map(true) + .with_include_sources_content(true), ) } fn vfs_no_map(modules: HashMap, entry: &str) -> CompileResult { - vfs_opts( - modules, - entry, - CompileOptions { - source_map: false, - ..Default::default() - }, - ) + vfs_opts(modules, entry, CompileOptions::default()) } // ── Tests ───────────────────────────────────────────────────────────────────── @@ -872,10 +863,7 @@ fn source_map_messages_mode_degrades_to_none() { let result = vfs_opts( modules, "chat.mds", - CompileOptions { - source_map: true, - ..Default::default() - }, + CompileOptions::default().with_source_map(true), ); // source_map must be None (messages-mode degrades gracefully). @@ -941,10 +929,7 @@ fn source_map_segment_cap_degrades_to_none() { modules, "big.mds", Some(vars), - CompileOptions { - source_map: true, - ..Default::default() - }, + CompileOptions::default().with_source_map(true), ) .expect("compilation must succeed even when cap is hit"); @@ -1061,19 +1046,9 @@ fn extends_output_byte_identical_with_and_without_source_map() { let with_map = vfs_opts( modules.clone(), "child.mds", - CompileOptions { - source_map: true, - ..Default::default() - }, - ); - let without_map = vfs_opts( - modules, - "child.mds", - CompileOptions { - source_map: false, - ..Default::default() - }, + CompileOptions::default().with_source_map(true), ); + let without_map = vfs_opts(modules, "child.mds", CompileOptions::default()); // ADR-002: byte-identical output regardless of source-map mode. assert_eq!( @@ -1136,10 +1111,7 @@ fn for_max_total_iterations_across_extends_regions_source_map() { modules, "child.mds", Some(vars), - CompileOptions { - source_map: true, - ..Default::default() - }, + CompileOptions::default().with_source_map(true), ) .expect_err( "REL-1: cumulative iteration budget across @extends regions must trip MAX_TOTAL_ITERATIONS", @@ -1167,11 +1139,7 @@ fn d1_string_source_sources_label_is_input_mds() { "Hello World!\n", None, None, - CompileOptions { - source_map: true, - include_sources_content: false, - ..Default::default() - }, + CompileOptions::default().with_source_map(true), ) .expect("should compile"); let sm = result.source_map.expect("source_map must be present"); @@ -1206,11 +1174,7 @@ fn d1_s8_locally_defined_function_no_source_sentinel() { "@define greet():\nHello!\n@end\n{{greet()}}\n", None, None, - CompileOptions { - source_map: true, - include_sources_content: false, - ..Default::default() - }, + CompileOptions::default().with_source_map(true), ) .expect("should compile"); let sm = result.source_map.expect("source_map must be present"); @@ -1245,11 +1209,7 @@ fn d1_extends_from_string_no_source_sentinel() { child, Some(dir.path()), None, - CompileOptions { - source_map: true, - include_sources_content: false, - ..Default::default() - }, + CompileOptions::default().with_source_map(true), ) .expect("should compile"); let sm = result.source_map.expect("source_map must be present"); diff --git a/crates/mds-core/tests/virtual_fs.rs b/crates/mds-core/tests/virtual_fs.rs index da35247..4a8058a 100644 --- a/crates/mds-core/tests/virtual_fs.rs +++ b/crates/mds-core/tests/virtual_fs.rs @@ -1529,11 +1529,7 @@ fn source_map_extends_type_mismatch_span_not_misattributed_to_child() { modules, "child.mds", None, - mds::CompileOptions { - source_map: true, - include_sources_content: false, - ..Default::default() - }, + mds::CompileOptions::default().with_source_map(true), ) .expect_err("cross-type mismatch in an inherited @if must error"); let serialized = err.serialize(); @@ -1569,11 +1565,7 @@ fn source_map_standalone_type_mismatch_carries_span() { src, None, None, - mds::CompileOptions { - source_map: true, - include_sources_content: false, - ..Default::default() - }, + mds::CompileOptions::default().with_source_map(true), ) .expect_err("cross-type == in @if must fail"); let serialized = err.serialize(); diff --git a/crates/mds-napi/src/lib.rs b/crates/mds-napi/src/lib.rs index 73a1fbb..e7af9ac 100644 --- a/crates/mds-napi/src/lib.rs +++ b/crates/mds-napi/src/lib.rs @@ -464,6 +464,8 @@ fn extract_vars_direct(env: &Env, obj: &Object) -> napi::Result throw_options_error(env, &msg), VarsError::Conversion(mds_err) => throw_mds_error(env, mds_err), + // VarsError is #[non_exhaustive]; handle future variants gracefully. + _ => throw_options_error(env, &format!("vars error: {e}")), }) } other => Err(throw_options_error( @@ -516,11 +518,9 @@ fn extract_bool_direct( fn extract_compile_options_direct(env: &Env, obj: &Object) -> napi::Result { let source_map = extract_bool_direct(env, obj, "sourceMap", false)?; let include_sources_content = extract_bool_direct(env, obj, "sourcesContent", false)?; - let opts = mds::CompileOptions { - source_map, - include_sources_content, - ..Default::default() - }; + let opts = mds::CompileOptions::default() + .with_source_map(source_map) + .with_include_sources_content(include_sources_content); opts.validate().map_err(|_| { throw_options_error( env, @@ -832,7 +832,7 @@ fn extract_rules_direct(env: &Env, obj: &Object) -> napi::Result Err(throw_options_error( env, diff --git a/crates/mds-python/src/lib.rs b/crates/mds-python/src/lib.rs index 56c3dce..3d8dedc 100644 --- a/crates/mds-python/src/lib.rs +++ b/crates/mds-python/src/lib.rs @@ -1126,6 +1126,8 @@ fn extract_vars( parse_json_vars(json).map(Some).map_err(|e| match e { VarsError::InvalidType(msg) => options_error(py, &msg), VarsError::Conversion(mds_err) => mds_err_to_py(py, &mds_err), + // VarsError is #[non_exhaustive]; handle future variants as conversion errors. + _ => options_error(py, &format!("vars error: {e}")), }) } @@ -1260,7 +1262,7 @@ fn extract_rules(py: Python<'_>, rules: Option<&Bound<'_, PyAny>>) -> PyResult PyResult { - let opts = mds::CompileOptions { - source_map, - include_sources_content: sources_content, - ..Default::default() - }; + let opts = mds::CompileOptions::default() + .with_source_map(source_map) + .with_include_sources_content(sources_content); opts.validate().map_err(|_| { options_error( py, diff --git a/crates/mds-wasm/src/lib.rs b/crates/mds-wasm/src/lib.rs index ee128ad..1cf97d8 100644 --- a/crates/mds-wasm/src/lib.rs +++ b/crates/mds-wasm/src/lib.rs @@ -364,6 +364,8 @@ fn extract_vars(obj: &js_sys::Object) -> Result>, parse_json_vars(vars_json).map(Some).map_err(|e| match e { VarsError::InvalidType(msg) => options_error(&msg), VarsError::Conversion(mds_err) => mds_error_to_js(mds_err), + // VarsError is #[non_exhaustive]; handle future variants gracefully. + _ => options_error(&format!("vars error: {e}")), }) } @@ -391,11 +393,9 @@ fn extract_bool_wasm(obj: &js_sys::Object, key: &str, default_val: bool) -> Resu fn extract_compile_options_wasm(obj: &js_sys::Object) -> Result { let source_map = extract_bool_wasm(obj, "sourceMap", false)?; let include_sources_content = extract_bool_wasm(obj, "sourcesContent", false)?; - let opts = mds::CompileOptions { - source_map, - include_sources_content, - ..Default::default() - }; + let opts = mds::CompileOptions::default() + .with_source_map(source_map) + .with_include_sources_content(include_sources_content); opts.validate().map_err(|_| { options_error("option \"sourcesContent\" requires \"sourceMap\" to be true") })?; @@ -493,7 +493,7 @@ fn extract_rules(obj: &js_sys::Object) -> Result { })?; rules.insert(key, severity); } - Ok(mds::LintConfig { rules }) + Ok(mds::LintConfig::from_rules(rules)) } /// Parse the JS options for `lint` and `lint_virtual`. @@ -719,11 +719,9 @@ pub fn compile(source: &str, options: JsValue) -> Result { catch_panic(AssertUnwindSafe(move || { let opts = parse_options(options)?; - let compile_opts = mds::CompileOptions { - source_map: opts.source_map, - include_sources_content: opts.include_sources_content, - ..Default::default() - }; + let compile_opts = mds::CompileOptions::default() + .with_source_map(opts.source_map) + .with_include_sources_content(opts.include_sources_content); let modules = build_modules(source, &opts.filename, opts.extra_modules)?; let result = mds::compile_virtual_with_deps_opts(modules, &opts.filename, opts.vars, compile_opts)