marco_core/intelligence/analysis/
lint.rs1use crate::intelligence::analysis::{
4 compute_diagnostics_with_options, DiagnosticCode, DiagnosticSeverity, DiagnosticsOptions,
5};
6use crate::parser::Document;
7use std::collections::BTreeMap;
8
9#[derive(Debug, Default, Clone, PartialEq, Eq)]
10pub struct LintReport {
12 pub errors: usize,
14 pub warnings: usize,
16 pub infos: usize,
18 pub hints: usize,
20}
21
22impl LintReport {
23 pub const fn total(&self) -> usize {
25 self.errors + self.warnings + self.infos + self.hints
26 }
27
28 pub const fn is_clean(&self) -> bool {
30 self.total() == 0
31 }
32
33 pub const fn has_errors(&self) -> bool {
35 self.errors > 0
36 }
37
38 pub const fn has_non_error_issues(&self) -> bool {
40 self.warnings > 0 || self.infos > 0 || self.hints > 0
41 }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct LintCodeBucket {
47 pub code: DiagnosticCode,
49 pub count: usize,
51}
52
53#[derive(Debug, Default, Clone, PartialEq, Eq)]
54pub struct LintDetailedReport {
56 pub summary: LintReport,
58 pub by_code: Vec<LintCodeBucket>,
60}
61
62impl LintDetailedReport {
63 pub fn total_from_buckets(&self) -> usize {
65 self.by_code.iter().map(|bucket| bucket.count).sum()
66 }
67
68 pub fn is_consistent(&self) -> bool {
70 self.total_from_buckets() == self.summary.total()
71 }
72}
73
74pub fn compute_lints(document: &Document) -> LintReport {
76 compute_lints_with_options(document, DiagnosticsOptions::all())
77}
78
79pub fn compute_lints_with_options(document: &Document, options: DiagnosticsOptions) -> LintReport {
81 compute_lints_detailed_with_options(document, options).summary
82}
83
84pub fn compute_lints_detailed(document: &Document) -> LintDetailedReport {
86 compute_lints_detailed_with_options(document, DiagnosticsOptions::all())
87}
88
89pub fn compute_lints_detailed_with_options(
91 document: &Document,
92 options: DiagnosticsOptions,
93) -> LintDetailedReport {
94 let mut report = LintReport::default();
95
96 let mut counts: BTreeMap<&'static str, (DiagnosticCode, usize)> = BTreeMap::new();
98
99 for diagnostic in compute_diagnostics_with_options(document, options) {
100 match diagnostic.severity {
101 DiagnosticSeverity::Error => report.errors += 1,
102 DiagnosticSeverity::Warning => report.warnings += 1,
103 DiagnosticSeverity::Info => report.infos += 1,
104 DiagnosticSeverity::Hint => report.hints += 1,
105 }
106
107 let code_id = diagnostic.code.as_str();
108 if let Some((_code, count)) = counts.get_mut(code_id) {
109 *count += 1;
110 } else {
111 counts.insert(code_id, (diagnostic.code, 1));
112 }
113 }
114
115 let by_code = counts
116 .into_iter()
117 .map(|(_id, (code, count))| LintCodeBucket { code, count })
118 .collect();
119
120 LintDetailedReport {
121 summary: report,
122 by_code,
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use crate::parser::{Document, Node, NodeKind, Position, Span};
130
131 fn span(line: usize, start_col: usize, end_col: usize, start_offset: usize) -> Span {
132 Span {
133 start: Position {
134 line,
135 column: start_col,
136 offset: start_offset,
137 },
138 end: Position {
139 line,
140 column: end_col,
141 offset: start_offset + (end_col.saturating_sub(start_col)),
142 },
143 }
144 }
145
146 #[test]
147 fn smoke_test_lint_report_helpers() {
148 let report = LintReport {
149 errors: 1,
150 warnings: 2,
151 infos: 3,
152 hints: 4,
153 };
154
155 assert_eq!(report.total(), 10);
156 assert!(!report.is_clean());
157 assert!(report.has_errors());
158 assert!(report.has_non_error_issues());
159 }
160
161 #[test]
162 fn smoke_test_compute_lints_detailed_groups_by_code() {
163 let doc = Document {
164 children: vec![
165 Node {
166 kind: NodeKind::Image {
167 url: "".to_string(),
168 alt: "".to_string(),
169 },
170 span: Some(span(1, 1, 5, 0)),
171 children: vec![],
172 },
173 Node {
174 kind: NodeKind::Image {
175 url: "".to_string(),
176 alt: "image".to_string(),
177 },
178 span: Some(span(2, 1, 5, 10)),
179 children: vec![],
180 },
181 ],
182 ..Default::default()
183 };
184
185 let detailed = compute_lints_detailed(&doc);
186 assert_eq!(detailed.summary.errors, 2);
187
188 let empty_image_url = detailed
189 .by_code
190 .iter()
191 .find(|bucket| bucket.code == DiagnosticCode::EmptyImageUrl)
192 .expect("Expected EmptyImageUrl bucket");
193 assert_eq!(empty_image_url.count, 2);
194 }
195
196 #[test]
197 fn smoke_test_compute_lints_with_options_respects_critical_profile() {
198 let doc = Document {
199 children: vec![Node {
200 kind: NodeKind::Paragraph,
201 span: Some(span(1, 1, 40, 0)),
202 children: vec![Node {
203 kind: NodeKind::Link {
204 url: ["http", "://example.com"].concat(),
205 title: None,
206 },
207 span: Some(span(1, 2, 20, 1)),
208 children: vec![],
209 }],
210 }],
211 ..Default::default()
212 };
213
214 let all = compute_lints_with_options(&doc, DiagnosticsOptions::all());
215 let critical = compute_lints_with_options(&doc, DiagnosticsOptions::critical_only());
216
217 assert!(all.infos > 0);
218 assert_eq!(critical.total(), 0);
219 }
220
221 #[test]
222 fn smoke_test_lint_detailed_report_consistency() {
223 let doc = Document {
224 children: vec![
225 Node {
226 kind: NodeKind::Image {
227 url: "".to_string(),
228 alt: "".to_string(),
229 },
230 span: Some(span(1, 1, 5, 0)),
231 children: vec![],
232 },
233 Node {
234 kind: NodeKind::Paragraph,
235 span: Some(span(2, 1, 30, 10)),
236 children: vec![Node {
237 kind: NodeKind::Link {
238 url: ["http", "://example.com"].concat(),
239 title: None,
240 },
241 span: Some(span(2, 2, 24, 11)),
242 children: vec![],
243 }],
244 },
245 ],
246 ..Default::default()
247 };
248
249 let detailed = compute_lints_detailed(&doc);
250 assert!(detailed.is_consistent());
251 }
252
253 #[test]
254 fn smoke_test_lint_code_buckets_are_sorted_by_code_id() {
255 let doc = Document {
256 children: vec![
257 Node {
258 kind: NodeKind::Heading {
259 level: 10,
260 text: "".to_string(),
261 id: None,
262 },
263 span: Some(span(1, 1, 2, 0)),
264 children: vec![],
265 },
266 Node {
267 kind: NodeKind::Image {
268 url: "".to_string(),
269 alt: "".to_string(),
270 },
271 span: Some(span(2, 1, 5, 10)),
272 children: vec![],
273 },
274 ],
275 ..Default::default()
276 };
277
278 let detailed = compute_lints_detailed(&doc);
279 let mut previous = "";
280 for bucket in &detailed.by_code {
281 let current = bucket.code.as_str();
282 assert!(
283 previous <= current,
284 "lint buckets must be sorted by code id"
285 );
286 previous = current;
287 }
288 }
289}