1use std::{collections::BTreeMap, process::ExitCode};
6
7use wowlab_common::markdown::{self, Doc, Table};
8use wowlab_fs::path::Path;
9
10use crate::{Config, Example, ParamDefault, RuleKind, RuleParam, Severity, Violation, runner};
11
12struct LlmRuleInfo {
13 name: &'static str,
14 kind: RuleKind,
15 description: &'static str,
16 justification: &'static str,
17 severity: Severity,
18 examples: &'static [Example],
19 params: &'static [RuleParam],
20 enabled: bool,
21 fixable: bool,
22 ignore_patterns: Vec<String>,
23}
24
25fn gather_rules(config: &Config, rule_filter: &[String]) -> Vec<LlmRuleInfo> {
26 crate::all_rules()
27 .into_iter()
28 .filter(|r| rule_filter.is_empty() || rule_filter.iter().any(|f| f == r.name))
29 .map(|r| LlmRuleInfo {
30 name: r.name,
31 kind: r.kind,
32 description: r.description,
33 justification: r.justification,
34 severity: r.severity,
35 examples: r.examples,
36 params: r.params,
37 enabled: config.is_enabled(r.name),
38 fixable: r.fixable,
39 ignore_patterns: config.ignore_patterns(r.name).to_vec(),
40 })
41 .collect()
42}
43
44fn render_summary_table(doc: Doc, rules: &[LlmRuleInfo]) -> Doc {
45 let mut table = Table::new().headers([
46 "Rule",
47 "Severity",
48 "Type",
49 "Enabled",
50 "Fixable",
51 "Description",
52 ]);
53
54 for r in rules {
55 let enabled = if r.enabled { "yes" } else { "no" };
56 let fixable = if r.fixable { "yes" } else { "no" };
57
58 table = table.row([
59 r.name,
60 r.severity.as_str(),
61 r.kind.as_str(),
62 enabled,
63 fixable,
64 r.description,
65 ]);
66 }
67
68 doc.raw(table.build_markdown()).blank()
69}
70
71fn render_rule_details(mut doc: Doc, rules: &[LlmRuleInfo]) -> Doc {
72 for r in rules {
73 doc = doc.h3(r.name).blank().line(r.description).blank();
74
75 if !r.justification.is_empty() {
76 doc = doc.quote(r.justification).blank();
77 }
78
79 let enabled = if r.enabled { "yes" } else { "no" };
80 let fixable = if r.fixable { "yes" } else { "no" };
81 let mut meta = Table::new()
82 .headers(["", ""])
83 .row(["Severity", r.severity.as_str()])
84 .row(["Type", r.kind.as_str()])
85 .row(["Enabled", enabled])
86 .row(["Fixable", fixable]);
87
88 for p in r.params {
89 let type_str = match p.param_type {
90 crate::ParamType::Int => "i64",
91 crate::ParamType::StringArray => "[String]",
92 };
93 let default_str = match &p.default {
94 ParamDefault::Int(d) => d.to_string(),
95 ParamDefault::StringArray(d) => format!("{d:?}"),
96 };
97 let label = format!("Param: {}", p.name);
98 let value = format!("{type_str}, default = {default_str}");
99
100 meta = meta.row([&label, &value]);
101 }
102
103 doc = doc.raw(meta.build_markdown());
104
105 if !r.ignore_patterns.is_empty() {
106 let patterns = r
107 .ignore_patterns
108 .iter()
109 .map(markdown::code)
110 .collect::<Vec<_>>()
111 .join(", ");
112
113 doc = doc.kv_bullet("Ignored paths", &patterns);
114 }
115
116 doc = doc.blank();
117
118 let bad: Vec<_> = r.examples.iter().filter(|e| !e.pass).collect();
119 let good: Vec<_> = r.examples.iter().filter(|e| e.pass).collect();
120
121 if !bad.is_empty() {
122 doc = doc
123 .line(&markdown::bold("Bad (triggers violation):"))
124 .blank();
125
126 for ex in &bad {
127 if !ex.label.is_empty() {
128 doc = doc.line(&markdown::italic(ex.label));
129 }
130
131 doc = doc.code_block("rust", ex.code).blank();
132 }
133 }
134
135 if !good.is_empty() {
136 doc = doc.line(&markdown::bold("Good (passes):")).blank();
137
138 for ex in &good {
139 if !ex.label.is_empty() {
140 doc = doc.line(&markdown::italic(ex.label));
141 }
142
143 doc = doc.code_block("rust", ex.code).blank();
144 }
145 }
146 }
147
148 doc
149}
150
151fn render_violations(mut doc: Doc, violations: &[Violation]) -> Doc {
152 if violations.is_empty() {
153 return doc.line("No violations found.").blank();
154 }
155
156 doc = doc
157 .line(&markdown::bold(format!(
158 "{} violation(s) found. Fix these:",
159 violations.len()
160 )))
161 .blank();
162
163 let mut by_rule: BTreeMap<&str, Vec<&Violation>> = BTreeMap::new();
164
165 for v in violations {
166 by_rule.entry(v.rule_name()).or_default().push(v);
167 }
168
169 for (rule, vs) in &by_rule {
170 doc = doc.h3(&format!("{rule} ({} issues)", vs.len())).blank();
171
172 for v in vs {
173 let loc = format!("{}:{}", v.rel, v.line);
174
175 doc = doc.def(&loc, &v.message);
176 }
177
178 doc = doc.blank();
179 }
180
181 doc
182}
183
184#[rustfmt::skip]
185const DIRECTIVES: &[(&str, &str)] = &[
186 ("// #t(rule) reason" , "suppress the next line for this rule"),
188 ("// #t(rule1, rule2) reason", "suppress the next line for multiple rules") ,
189 ("// #t(file: rule) reason" , "skip the entire file for this rule") ,
190 ("// #t(block: rule) reason" , "suppress until blank line or closing brace") ,
191 ("// #t(fn: rule) reason" , "suppress until end of next function") ,
192 ("// #t(*) reason" , "suppress all rules (next line)") ,
193];
194
195pub fn print(
198 rule_filter: &[String],
199 crate_filter: &[String],
200 config: &Config,
201 root: &Path,
202 crates_dir: &Path,
203) -> ExitCode {
204 let rules = gather_rules(config, rule_filter);
205
206 let mut doc = Doc::new()
207 .h1("tidy — Rust source code linter")
208 .blank()
209 .line("Custom lint rules for the WoW Lab Rust workspace. Runs beyond what clippy and rustfmt cover.")
210 .blank();
211
212 let severity_table = Table::new()
213 .headers(["Severity", "Meaning"])
214 .row([
215 "high",
216 "Likely bug, security issue, or correctness problem. Fix these first.",
217 ])
218 .row([
219 "medium",
220 "Potential bug, performance issue, or maintainability concern.",
221 ])
222 .row([
223 "low",
224 "Style nit or minor improvement. Safe to suppress with good reason.",
225 ]);
226
227 doc = doc
228 .h2("Severity levels")
229 .blank()
230 .raw(severity_table.build_markdown())
231 .blank();
232
233 doc = doc
234 .h2("Suppression directives")
235 .blank()
236 .line("When a rule fires on code that is intentionally written that way, suppress it with a directive comment:")
237 .blank();
238
239 for (syntax, desc) in DIRECTIVES {
240 doc = doc.def(syntax, desc);
241 }
242
243 doc = doc
244 .blank()
245 .line("Rule names and a reason after `)` are required. Missing either is a violation.")
246 .blank();
247
248 doc = doc.h2("Rules").blank();
249 doc = render_summary_table(doc, &rules);
250
251 doc = doc.h2("Rule details").blank();
252 doc = render_rule_details(doc, &rules);
253
254 doc = doc.h2("Current violations").blank();
255 let run_ctx = runner::RunCtx {
256 rule_filter,
257 crate_filter,
258 config,
259 root,
260 crates_dir,
261 quiet: true,
262 dirty: false,
263 };
264 let violations = match runner::collect_violations(&run_ctx, false, None) {
265 Ok((violations, _, _)) => violations,
266 Err(error) => {
267 eprintln!("tidy: analysis aborted: {error}");
268
269 return ExitCode::FAILURE;
270 }
271 };
272
273 doc = render_violations(doc, &violations);
274
275 print!("{}", doc.build());
276
277 ExitCode::SUCCESS
278}