Writing a lint rule
A lint rule is a type that implements LintRule. Rules see the parsed document via a curated query surface and
emit Diagnostic values. mdwright ships nineteen stdlib rules; this page shows how to add an external twentieth
without forking the binary.
The trait
#![allow(unused)]
fn main() {
pub trait LintRule: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn check(&self, doc: &Document, out: &mut Vec<Diagnostic>);
fn is_default(&self) -> bool { true }
fn is_advisory(&self) -> bool { false }
fn produces_fix(&self) -> bool { false }
fn explain(&self) -> &str { "" }
}
}
nameis the kebab-case identifier ("no-todo-in-prose"); the dispatcher stamps it onto each emitted diagnostic.descriptionis the one-line summary shown bymdwright list-rules.checkreads theDocumentand pushesDiagnosticvalues.is_defaultcontrols whether the rule fires under--rules defaultand under the config defaultlint.preset = "default"when--rulesis omitted.is_advisorymakes diagnostics informational; they do not failmdwright check --check.produces_fixclaims that at least one diagnostic carries aFix.explainis the long-form markdown shown bymdwright explain <name>.
Worked example: no-todo-in-prose
A rule that flags TODO (case-sensitive) inside paragraph text but not inside code blocks, inline code, math regions,
or HTML blocks: Document::prose_chunks() handles every skip for you.
#![allow(unused)]
fn main() {
use mdwright_document::Document;
use mdwright_lint::{Diagnostic, LintRule};
pub struct NoTodoInProse;
impl LintRule for NoTodoInProse {
fn name(&self) -> &str {
"no-todo-in-prose"
}
fn description(&self) -> &str {
"Literal TODO in paragraph text"
}
fn explain(&self) -> &str {
"TODOs in user-facing documentation are usually accidents. \
Track pending work in an issue tracker, or suppress this \
rule with `<!-- mdwright: allow no-todo-in-prose -->`."
}
fn check(&self, doc: &Document, out: &mut Vec<Diagnostic>) {
for slice in doc.prose_chunks() {
for (offset, _) in slice.text.match_indices("TODO") {
if let Some(d) = Diagnostic::at(
doc,
slice.byte_offset,
offset..offset + "TODO".len(),
"literal `TODO` in prose".to_owned(),
None,
) {
out.push(d);
}
}
}
}
}
}
Diagnostic::at performs the byte-offset arithmetic and line-index lookup. It returns Option because pathological
offsets could fall outside the source; on failure the diagnostic is dropped rather than the rule panicking.
Registering the rule
Add it to a RuleSet and lint:
#![allow(unused)]
fn main() {
use mdwright_document::Document;
use mdwright_lint::RuleSet;
use mdwright_lint::{Diagnostic, LintRule};
struct NoTodoInProse;
impl LintRule for NoTodoInProse {
fn name(&self) -> &str { "no-todo-in-prose" }
fn description(&self) -> &str { "" }
fn check(&self, _: &Document, _: &mut Vec<Diagnostic>) {}
}
let mut rules = RuleSet::stdlib_defaults();
rules.add(Box::new(NoTodoInProse)).expect("unique name");
let doc = Document::parse("My TODO: write the docs.")?;
let diagnostics = rules.check(&doc);
}
RuleSet::add returns Result<&mut Self, DuplicateRuleName> so two rules with the same name fail fast.
Shipping a custom binary
The CLI crate entry point mdwright::run_with_rules takes your assembled RuleSet and runs the whole CLI on top
of it; clap parsing, config discovery, every output format, the LSP server, the suppression machinery. Your main is
ten lines:
use mdwright_lint::stdlib;
struct NoTodoInProse;
impl mdwright_lint::LintRule for NoTodoInProse {
fn name(&self) -> &str { "no-todo-in-prose" }
fn description(&self) -> &str { "" }
fn check(&self, _: &mdwright_document::Document, _: &mut Vec<mdwright_lint::Diagnostic>) {}
}
fn main() -> std::process::ExitCode {
let mut rules = stdlib::all();
rules.add(Box::new(NoTodoInProse)).expect("unique name");
mdwright::run_with_rules(rules)
}
Pass stdlib::all() (not stdlib::defaults()) so every opt-in stdlib rule remains selectable via --rules.
run_with_rules filters down from this pool based on the user’s --rules argument and the active configuration file.
A complete working sample lives at examples/extending/ in the mdwright repository. The same crate has
integration tests that prove the rule fires end-to-end.
Publishing your custom binary
Your binary is just a crate. Push to crates.io with cargo publish (we recommend a name like <org>-mdwright so users
distinguish it from the official binary), or distribute the compiled artifact directly. Downstream users install your
binary and run it in place of mdwright; the command-line interface is identical.
Caveats
- Config-driven rule reconfiguration applies to stdlib rules only. The
[lint.info-strings] extraoption, for example, mutates the stdlibinfo-string-typorule even when a downstream binary has registered its own implementation under that name. Downstream rules read their own configuration; mdwright does not route config keys into them. - mdwright does not load lint rules at runtime. See Plugin loading for the rationale and the comparison of dynamic-loading alternatives we considered.
See also
- Plugin loading: why custom binaries are the supported path; what we rejected and why.
- Architecture: the IR boundary
LintRule::checksees. - Suppression comments: how rules opt out per-document.
- Diagnostic schema: the shape your diagnostics take after the dispatcher stamps them.