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