-
Pick the rule id (kebab-case). This is the diagnostic's rule, the
[lint] select/ignore key, and the % badness-ignore <id> target. It is
user-facing and stable—renaming it later is a breaking config change. Match
the tone of existing ids (deprecated-command, dollar-display-math,
missing-nonbreaking-space, undefined-ref).
-
Decide the shape before writing code:
- Node-shape vs whole-file. If the finding is local to a CST node kind
(a command, an environment, a delimiter), it's node-shape: declare
interests() and implement check(). If it needs the semantic model or
cross-file resolution (undefined refs, duplicate labels across files), it's
whole-file: leave interests() empty and implement check_file().
- Severity.
Warning is the default and almost always right. Reserve
Error for genuinely broken output. Override default_severity() only to
change it.
- Gating. Cross-file rules read
ctx.resolution / ctx.citations and
must early-return when they are None (no project view → stay silent).
If the rule is only sound over a complete namespace, also gate on
resolution.is_closed(ctx.path) / is_root_component(ctx.path) the way
undefined_ref.rs does. Conservative by default: a false positive is worse
than a miss.
- Auto-fix. Ship a
Fix only when the rewrite is unambiguous and correct
by construction. Fix::safe(...) for meaning-preserving edits (applied by
lint --fix); Fix::unsafe_(...) when the edit can change output—e.g.
inserting a tie changes line breaking (missing-nonbreaking-space), applied
only under --unsafe-fixes or as an LSP code action. If several resolutions
are valid (rename vs delete for duplicate-label), omit the fix and say why
in the docs.
-
Write a failing test first (TDD, per AGENTS.md). Prefer an inline unit
test in the new module's #[cfg(test)] mod tests. The idiom (copy from
deprecated_command.rs for node-shape, undefined_ref.rs for whole-file):
fn findings(src: &str) -> Vec<Diagnostic> {
let root = SyntaxNode::new_root(parse(src).green);
let model = SemanticModel::build(&root);
let ctx = RuleContext {
path: std::path::Path::new("x.tex"),
root: &root,
model: &model,
resolution: None,
citations: None,
};
let mut out = Vec::new();
for el in root.descendants_with_tokens() {
if MyRule.interests().contains(&el.kind()) {
MyRule.check(&el, &ctx, &mut out);
}
}
out
}
Cover the positive case, the negative ("must not flag") case, and each edge
the rule explicitly handles. For a fix, assert the Applicability, the tight
(start, end) span, and the applied output via
crate::linter::fix::apply_fixes(src, std::slice::from_ref(fix), false).output.
-
Implement the rule in src/linter/rules/<rule_name>.rs:
- Open with a module doc-comment explaining what it flags and why, and—if
there's a fix—why it's Safe/Unsafe and correct by construction. Existing
rules set the bar; match it.
impl Rule: id() returns the kebab id; override default_severity() only
if not Warning; for node-shape declare
fn interests(&self) -> &'static [SyntaxKind] { &[SyntaxKind::COMMAND] }
and implement check; for whole-file implement check_file.
- Add
description() and examples() (both required in practice—the docs
tests fail without them). description() returns a one-paragraph markdown
&'static str (what it flags and why; if it fixes, why the fix is
safe/unsafe and correct by construction). examples() returns a
&'static [Example] const; each Example { caption, source } is a snippet
that must actually trigger the rule under the docs renderer's synthetic
closed, rooted single-file project view (so a cross-file rule's example can
be a bare \ref{…}/\cite{…} with no companion files). Copy the
const EXAMPLES: &[Example] = &[…] pattern from an existing rule and add
Example to the use super::{…} import.
- In
check, unwrap the element: let Some(node) = el.as_node() else { return };
(interests can match tokens too). Read structure via src/ast.rs helpers.
- Build spans from the CST:
usize::from(range.start()), usize::from(range.end()).
Keep them tight—point at the offending construct (the control word, the
delimiter), not the whole node or line. This drives both the CLI caret and
LSP underline.
- Construct diagnostics as a struct literal with
path: PathBuf::new() (the
driver stamps the real path):
sink.push(Diagnostic {
rule: self.id(),
severity: self.default_severity(),
path: PathBuf::new(),
start, end,
message: format!("…"),
fix,
});
- Prefer a fix over a tight, precise span (e.g. just the control-word
token), not a whole-node rewrite that could drop a greedily-attached group.
Fix is a single contiguous start..end replacement; withhold it on shapes
where you can't isolate that span.
- Imports:
use super::{Example, Rule, RuleContext}; and
use crate::linter::diagnostic::{Diagnostic, Fix, Severity}; plus
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode}; as needed.
-
Register it in src/linter/rules.rs (three edits, kept in lockstep by
registry_and_id_list_agree):
- Add
pub mod <rule_name>; and pub use <rule_name>::<Name>; to the module
block (alphabetical with the rest).
- Add
Box::new(<Name>), to the all_rules() Vec.
- Add
"<rule-id>", to ALL_RULE_IDS, in the same position as the
registry entry (the test asserts equal order, not just equal sets).
There is no if-guard and no metadata table—select/ignore filtering is
applied centrally by RuleSelection, so nothing else needs editing.
-
Regenerate the docs (do not hand-edit linter-rules.md—it is
generated from description()/examples()):
- Run
cargo run --example docgen (or task docs:rules). This rewrites
docs/src/reference/linter-rules.md with your rule's section slotted in at
registry order, its live diagnostic output, and (for a safe fix) the
after-fix block.
- Then run
cargo insta test --review (or INSTA_UPDATE=always cargo test --test rule_docs) to create/accept the per-rule snapshot in
tests/snapshots/rule_docs__<rule_name>.snap. Review it before accepting.
- Optionally add the id to the example list in
docs/src/guide/linting.md.
-
Add an integration test in tests/lint.rs if the rule is cross-file or
worth an end-to-end check: use lint(src) for single-file, lint_project(...)
for cross-file labels, lint_with_bib(...) for citations, and filter findings
by your rule id. For fixes, assert_fix_is_correct(...) enforces the tenet-1
contract (fixed text still parses and reconstructs losslessly).
-
Validate in this order (single-crate package—no --workspace):
- Targeted:
cargo test --lib <rule_name>, cargo test --test lint,
cargo test --test rule_docs (checks the description/examples requirement,
that each example triggers, the per-rule snapshot, and that the committed
page matches a fresh render — fails if you forgot docgen).
- CLI smoke check on a scratch fixture:
cargo run -q -- lint /tmp/f.tex, then if it has a Safe fix
cargo run -q -- lint --fix /tmp/f.tex (or --unsafe-fixes for an Unsafe
one) and inspect the file.
- Full:
cargo test,
cargo clippy --all-targets --all-features -- -D warnings,
cargo fmt (the rustfmt git hook aborts the commit on unformatted files).