Skip to content
41 changes: 39 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 13 additions & 16 deletions crates/mds-cli/src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
70 changes: 19 additions & 51 deletions crates/mds-cli/src/lint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion crates/mds-cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
7 changes: 2 additions & 5 deletions crates/mds-cli/tests/producer_discipline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value> = (0..100_000)
.map(|_| Value::String("x".to_string()))
Expand All @@ -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");

Expand Down
67 changes: 67 additions & 0 deletions crates/mds-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,9 +26,71 @@ pub struct SerializedSpan {
pub column: Option<usize>,
}

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,
Expand Down
7 changes: 4 additions & 3 deletions crates/mds-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<dyn std::error::Error>>(())
/// ```
Expand Down Expand Up @@ -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<dyn std::error::Error>>(())
Expand Down Expand Up @@ -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<dyn std::error::Error>>(())
Expand Down
30 changes: 30 additions & 0 deletions crates/mds-core/src/lint/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"`),
Expand All @@ -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<String, Severity>) -> Self {
LintConfig { rules }
}

/// Look up the configured severity for a rule name.
///
/// Returns `None` when the rule has no explicit override — callers should fall
Expand Down
Loading
Loading