use std::collections::HashMap;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use smol_str::SmolStr;
use crate::file_discovery::file_kind_or_tex;
use crate::linter::check::lint_document;
use crate::linter::diagnostic::{Diagnostic, Fix};
use crate::linter::fix::apply_fixes;
use crate::linter::render::{OutputMode, render_findings};
use crate::linter::rules::{Example, Rule, all_rules};
use crate::parser::parse_with_flavor;
use crate::project::include::BibTarget;
use crate::project::labels::{document_label_names, document_ref_names};
use crate::project::{
CiteFileFacts, FileFacts, IncludeGraph, PackageOptionFacts, ResolvedCitations, ResolvedLabels,
ResolvedPackageOptions, collect_bib_resource_targets, collect_include_edge_keys,
package_option_facts,
};
use crate::semantic::SemanticModel;
use crate::syntax::SyntaxNode;
fn example_path() -> PathBuf {
PathBuf::from("example.tex")
}
pub fn demo_diagnostics(source: &str) -> Vec<Diagnostic> {
demo_diagnostics_at(&example_path(), source)
}
pub fn demo_diagnostics_at(path: &Path, source: &str) -> Vec<Diagnostic> {
demo_diagnostics_with(path, source, &[])
}
pub fn demo_diagnostics_with(
path: &Path,
source: &str,
companions: &[(&str, &str)],
) -> Vec<Diagnostic> {
let path = path.to_path_buf();
let root =
SyntaxNode::new_root(parse_with_flavor(source, file_kind_or_tex(&path).lex_config()).green);
let model = SemanticModel::build(&root);
let mut option_facts: Vec<PackageOptionFacts> = package_option_facts(&path, &root, &model)
.into_iter()
.collect();
for (companion_path, companion_source) in companions {
let companion_path = Path::new(companion_path);
let parsed = parse_with_flavor(
companion_source,
file_kind_or_tex(companion_path).lex_config(),
);
let companion_root = SyntaxNode::new_root(parsed.green);
let companion_model = SemanticModel::build(&companion_root);
option_facts.extend(package_option_facts(
companion_path,
&companion_root,
&companion_model,
));
}
let resolved_packages = ResolvedPackageOptions::build(option_facts);
let facts = [FileFacts {
path: path.clone(),
include_edges: collect_include_edge_keys(&root, None),
}];
let graph = IncludeGraph::build(&facts, None);
let labels = [(
path.clone(),
document_label_names(&model),
document_ref_names(&model),
true,
)];
let resolved_labels = ResolvedLabels::build(&labels, &graph);
let bib_targets = collect_bib_resource_targets(&root, None);
let mut bib_keys: HashMap<PathBuf, Vec<SmolStr>> = HashMap::new();
for target in &bib_targets {
if let BibTarget::Path(p) = target {
bib_keys.entry(p.clone()).or_default();
}
}
let cite_facts = [CiteFileFacts {
path: path.clone(),
bib_targets,
nocite_all: model.has_wildcard_nocite(),
is_document_root: true,
}];
let resolved_citations = ResolvedCitations::build(&cite_facts, &graph, &bib_keys);
lint_document(
&path,
&root,
&model,
Some(&resolved_labels),
Some(&resolved_citations),
Some(&resolved_packages),
)
}
pub(crate) struct RuleDocSection<'a> {
pub id: &'a str,
pub description: &'a str,
pub default_enabled: bool,
pub examples: &'a [Example],
pub companions: &'a [(&'static str, &'static str)],
pub example_path: &'a Path,
pub lang: &'a str,
}
pub(crate) fn render_rule_section(
section: &RuleDocSection<'_>,
lint: &dyn Fn(&str) -> Vec<Diagnostic>,
) -> String {
let mut out = String::new();
let id = section.id;
let _ = writeln!(out, "## `{id}`");
let description = section.description.trim();
if !description.is_empty() {
let _ = writeln!(out);
let _ = writeln!(out, "{description}");
}
let _ = writeln!(out);
if section.default_enabled {
let _ = writeln!(out, "This rule is **enabled by default**.");
} else {
let _ = writeln!(
out,
"This rule is **disabled by default**; enable it with `select`."
);
}
for (companion_path, companion_source) in section.companions {
let _ = writeln!(out);
let _ = writeln!(out, "With a sibling `{companion_path}`:");
let _ = writeln!(out);
fenced(&mut out, section.lang, companion_source);
}
for example in section.examples {
let _ = writeln!(out);
if !example.caption.is_empty() {
let _ = writeln!(out, "{}", example.caption);
let _ = writeln!(out);
}
fenced(&mut out, section.lang, example.source);
let diagnostics: Vec<Diagnostic> = lint(example.source)
.into_iter()
.filter(|d| d.rule == id)
.collect();
let source = example.source.to_string();
let rendered = render_findings(&diagnostics, OutputMode::Pretty, false, &|path| {
(path == section.example_path).then(|| source.clone())
});
let _ = writeln!(out);
fenced(&mut out, "text", &rendered);
let fixes: Vec<Fix> = diagnostics.iter().filter_map(|d| d.fix.clone()).collect();
let after = apply_fixes(&source, &fixes, false);
if after.applied > 0 {
let _ = writeln!(out);
let _ = writeln!(out, "After applying the fix:");
let _ = writeln!(out);
fenced(&mut out, section.lang, &after.output);
}
}
out
}
pub fn render_rule_doc(rule: &dyn Rule) -> String {
let example_path = PathBuf::from(rule.example_path());
let companions = rule.example_companions();
render_rule_section(
&RuleDocSection {
id: rule.id(),
description: rule.description(),
default_enabled: rule.default_enabled(),
examples: rule.examples(),
companions,
example_path: &example_path,
lang: "tex",
},
&|source| demo_diagnostics_with(&example_path, source, companions),
)
}
pub fn explain_rule(id: &str) -> Option<String> {
all_rules()
.iter()
.find(|rule| rule.id() == id)
.map(|rule| render_rule_doc(rule.as_ref()))
.or_else(|| retired_rule_doc(id))
}
fn retired_rule_doc(id: &str) -> Option<String> {
match id {
"mismatched-delimiter" => Some(String::from(
"## `mismatched-delimiter`\n\n\
Retired. TeX permits any valid delimiter glyph after either `\\left` \
or `\\right`, including reversed and same-facing pairs such as \
`\\left] ... \\right[` and `\\left] ... \\right]`. Delimiter \
orientation therefore cannot prove an error, and the rule produced \
false positives. The id remains accepted in `select` and `ignore` \
for configuration compatibility, but it emits no diagnostics.\n",
)),
_ => None,
}
}
pub fn render_reference_page() -> String {
let mut out = String::from(PREAMBLE);
for rule in all_rules() {
out.push('\n');
out.push_str(&render_rule_doc(rule.as_ref()));
}
out.push('\n');
out.push_str(FOOTER);
out
}
fn fenced(out: &mut String, lang: &str, body: &str) {
let _ = writeln!(out, "```{lang}");
let _ = out.write_str(body);
if !body.ends_with('\n') {
let _ = out.write_str("\n");
}
let _ = writeln!(out, "```");
}
const PREAMBLE: &str = "\
<!-- Generated by `cargo run --example docgen`. Do not edit by hand: edit each \
rule's `description()`/`examples()` in `src/linter/rules/` and regenerate. -->
# Linter Rules
`badness lint` runs a set of built-in rules over each file's parse tree and
reports a diagnostic for every finding. Each section describes one rule,
identified by its stable **rule id**. The id appears in diagnostics and selects
the rule in `[lint]` settings, `--select` and `--ignore` flags, and
`% badness-lint skip <id>` comments.
Most rules are **on by default**. Each rule's section states whether it is
enabled by default. Use `select` to enable an opt-in rule, or use `select` and
`ignore` in the `[lint]` table to narrow the default set. See the
[Configuration reference](configuration.md#lint).
Some rules offer an **autofix** when a rewrite is unambiguous.
`badness lint --fix` applies *safe* fixes, shown below under \"After applying the
fix\". An *unsafe* fix may change output, such as by inserting a tie that
prevents a line break. It applies only with `--unsafe-fixes` or an editor code
action, so it has no \"after\" block here.
Each example below is linted live to produce its diagnostic and fixed output, so
this page never drifts from the rules' actual behavior.
This page covers the **LaTeX** linter. BibTeX files have a parallel set of rules
(a separate `BibRule` registry under `src/bib/linter/`), selectable through the
same `[lint]` config and cataloged in
[BibTeX Linter Rules](bib-linter-rules.md).
";
const FOOTER: &str = "\
## Suppression
To suppress a rule at a single site, use a comment directive:
```tex
% badness-lint skip deprecated-command: legacy code, leave as-is
{\\bf here}
```
The verb determines the scope. `skip` covers the next construct, `off` and `on`
delimit a region, and `skip-file` covers the whole file wherever the directive
appears:
```tex
% badness-lint off deprecated-command: legacy chapter
{\\bf here}
{\\it and here}
% badness-lint on deprecated-command
```
The `<id>` is optional. Omitting it suppresses every rule over the same span.
The `: <reason>` is optional everywhere.
`% badness skip` / `off` / `on` / `skip-file` do the same and turn off the
**formatter** at the same time; see [Formatting](../guide/formatting.md#turning-the-formatter-off)
for the layout-only `% badness-format` spellings.
Parse diagnostics (rule id `parse`) are never suppressed by `select`/`ignore`.
";