1use crate::config::{MatchMode, RuleConfig, Severity, WikiConfig};
2use crate::error::WikiError;
3use crate::link_index::LinkIndex;
4use crate::resolve;
5use crate::walk::{is_markdown_file, wiki_walk_builder};
6use crate::wiki::Wiki;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum SeverityFilter {
11 All,
13 ErrorOnly,
15 WarnOnly,
17}
18
19struct LintResult {
20 errors: usize,
21 warnings: usize,
22}
23
24impl LintResult {
25 fn new() -> Self {
26 Self {
27 errors: 0,
28 warnings: 0,
29 }
30 }
31
32 fn tally(&mut self, count: usize, severity: Severity) {
33 match severity {
34 Severity::Error => self.errors += count,
35 Severity::Warn => self.warnings += count,
36 Severity::Off => {}
37 }
38 }
39}
40
41fn should_show(severity: Severity, filter: SeverityFilter) -> bool {
42 match (severity, filter) {
43 (Severity::Off, _) => false,
44 (_, SeverityFilter::All) => true,
45 (Severity::Error, SeverityFilter::ErrorOnly) => true,
46 (Severity::Warn, SeverityFilter::WarnOnly) => true,
47 _ => false,
48 }
49}
50
51pub fn lint(wiki: &Wiki, filter: SeverityFilter) -> Result<usize, WikiError> {
53 let mut result = LintResult::new();
54 let config = wiki.config();
55
56 if should_show(config.checks.broken_links, filter) {
58 let count = run_broken_links(wiki)?;
59 result.tally(count, config.checks.broken_links);
60 }
61
62 if should_show(config.checks.orphan_pages, filter) {
63 let link_index = LinkIndex::build(wiki)?;
64 let count = run_orphan_pages(wiki, &link_index);
65 result.tally(count, config.checks.orphan_pages);
66 }
67
68 if should_show(config.checks.index_coverage, filter)
69 && let Some(index_path) = wiki.index_path()
70 {
71 if index_path.is_file() {
72 let count = run_index_coverage(wiki, &index_path)?;
73 result.tally(count, config.checks.index_coverage);
74 } else {
75 eprintln!(
76 "warn: index file '{}' not found, skipping index coverage",
77 index_path.display()
78 );
79 }
80 }
81
82 for rule in &config.rules {
84 let severity = rule.severity();
85 if !should_show(severity, filter) {
86 continue;
87 }
88 let count = run_rule(wiki, rule)?;
89 result.tally(count, severity);
90 }
91
92 if result.errors > 0 || result.warnings > 0 {
93 let mut parts = Vec::new();
94 if result.errors > 0 {
95 parts.push(format!("{} error(s)", result.errors));
96 }
97 if result.warnings > 0 {
98 parts.push(format!("{} warning(s)", result.warnings));
99 }
100 eprintln!("{}", parts.join(", "));
101 }
102
103 Ok(result.errors)
104}
105
106fn run_rule(wiki: &Wiki, rule: &RuleConfig) -> Result<usize, WikiError> {
107 match rule {
108 RuleConfig::RequiredSections {
109 dirs,
110 sections,
111 severity,
112 ..
113 } => run_required_sections(wiki, dirs, sections, *severity),
114 RuleConfig::RequiredFrontmatter {
115 dirs,
116 fields,
117 severity,
118 ..
119 } => run_required_frontmatter(wiki, dirs, fields, *severity),
120 RuleConfig::MirrorParity {
121 left,
122 right,
123 severity,
124 ..
125 } => run_mirror_parity(wiki, left, right, *severity),
126 RuleConfig::CitationPattern {
127 name,
128 dirs,
129 pattern,
130 match_in,
131 match_mode,
132 severity,
133 } => run_citation_pattern(wiki, name, dirs, pattern, match_in, *match_mode, *severity),
134 }
135}
136
137fn run_broken_links(wiki: &Wiki) -> Result<usize, WikiError> {
138 let severity = wiki.config().checks.broken_links;
139 let mut count = 0;
140 for file_path in wiki.all_scannable_files() {
141 let broken = resolve::find_broken_links(wiki, &file_path)?;
142 let source = wiki.source(&file_path)?;
143 let rel_path = wiki.rel_path(&file_path);
144 for (wl, reason) in &broken {
145 let ref_text = &source[wl.byte_range.clone()];
146 eprintln!(
147 "{severity}[broken-link]: {} in {}",
148 ref_text.trim(),
149 rel_path.display(),
150 );
151 eprintln!(" -> {reason}");
152 count += 1;
153 }
154 }
155 Ok(count)
156}
157
158fn run_orphan_pages(wiki: &Wiki, link_index: &LinkIndex) -> usize {
159 let orphans = link_index.orphans(wiki);
160 for page_id in &orphans {
161 if let Some(entry) = wiki.get(page_id) {
162 eprintln!(
163 "error[orphan]: {} has no inbound wikilinks",
164 entry.rel_path.display(),
165 );
166 }
167 }
168 orphans.len()
169}
170
171fn run_index_coverage(wiki: &Wiki, index_path: &std::path::Path) -> Result<usize, WikiError> {
172 let index_wikilinks = wiki.wikilinks(index_path)?;
173 let referenced: std::collections::HashSet<&str> =
174 index_wikilinks.iter().map(|wl| wl.page.as_str()).collect();
175
176 let mut count = 0;
177 for (page_id, entry) in wiki.pages() {
178 if !referenced.contains(page_id.as_str()) {
179 eprintln!(
180 "error[not-in-index]: {} is not listed in index",
181 entry.rel_path.display(),
182 );
183 count += 1;
184 }
185 }
186 Ok(count)
187}
188
189fn run_required_sections(
190 wiki: &Wiki,
191 dirs: &[String],
192 sections: &[String],
193 severity: Severity,
194) -> Result<usize, WikiError> {
195 let mut count = 0;
196 for entry in wiki.pages().values() {
197 if !WikiConfig::matches_dirs(&entry.rel_path, dirs) {
198 continue;
199 }
200 let file_path = wiki.entry_path(entry);
201 let headings = wiki.headings(&file_path)?;
202 for required in sections {
203 if !headings
204 .iter()
205 .any(|h| h.text.eq_ignore_ascii_case(required))
206 {
207 eprintln!(
208 "{severity}[missing-section]: {} is missing '## {required}'",
209 entry.rel_path.display(),
210 );
211 count += 1;
212 }
213 }
214 }
215 Ok(count)
216}
217
218fn run_required_frontmatter(
219 wiki: &Wiki,
220 dirs: &[String],
221 fields: &[String],
222 severity: Severity,
223) -> Result<usize, WikiError> {
224 let mut count = 0;
225 for entry in wiki.pages().values() {
226 if !WikiConfig::matches_dirs(&entry.rel_path, dirs) {
227 continue;
228 }
229 let file_path = wiki.entry_path(entry);
230 match wiki.frontmatter(&file_path)? {
231 Ok(Some(fm)) => {
232 for field in fields {
233 if !fm.has_field(field) {
234 eprintln!(
235 "{severity}[missing-frontmatter]: {} is missing '{field}'",
236 entry.rel_path.display(),
237 );
238 count += 1;
239 }
240 }
241 }
242 Ok(None) => {
243 eprintln!(
244 "{severity}[no-frontmatter]: {} has no frontmatter",
245 entry.rel_path.display(),
246 );
247 count += 1;
248 }
249 Err(e) => {
250 eprintln!(
251 "{severity}[bad-frontmatter]: {}: {e}",
252 entry.rel_path.display(),
253 );
254 count += 1;
255 }
256 }
257 }
258 Ok(count)
259}
260
261fn run_mirror_parity(
262 wiki: &Wiki,
263 left: &str,
264 right: &str,
265 severity: Severity,
266) -> Result<usize, WikiError> {
267 let mut count = 0;
268
269 let left_dir = wiki.root().path().join(left);
270 let right_dir = wiki.root().path().join(right);
271
272 let left_stems = collect_md_stems(wiki, &left_dir)?;
273 let right_stems = collect_md_stems(wiki, &right_dir)?;
274
275 for stem in &left_stems {
276 if !right_stems.contains(stem) {
277 eprintln!("{severity}[missing-mirror]: {left}/{stem}.md has no {right}/{stem}.md",);
278 count += 1;
279 }
280 }
281 for stem in &right_stems {
282 if !left_stems.contains(stem) {
283 eprintln!("{severity}[missing-mirror]: {right}/{stem}.md has no {left}/{stem}.md",);
284 count += 1;
285 }
286 }
287
288 Ok(count)
289}
290
291fn collect_md_stems(
292 wiki: &Wiki,
293 dir: &std::path::Path,
294) -> Result<std::collections::HashSet<String>, WikiError> {
295 let mut stems = std::collections::HashSet::new();
296 if !dir.is_dir() {
297 return Ok(stems);
298 }
299 for entry in wiki_walk_builder(dir, wiki.root().path(), &wiki.config().ignore)?.build() {
300 let entry = entry.map_err(|e| WikiError::Walk {
301 path: dir.to_path_buf(),
302 source: e,
303 })?;
304 let path = entry.path();
305 if is_markdown_file(path)
306 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
307 {
308 stems.insert(stem.to_owned());
309 }
310 }
311 Ok(stems)
312}
313
314enum MatchDirCache {
316 Contents(Vec<String>),
317 Stems(std::collections::HashSet<String>),
319}
320
321impl MatchDirCache {
322 fn load(wiki: &Wiki, dir: &std::path::Path, mode: MatchMode) -> Result<Self, WikiError> {
323 if !dir.is_dir() {
324 return Ok(match mode {
325 MatchMode::Content => Self::Contents(Vec::new()),
326 MatchMode::Filename => Self::Stems(std::collections::HashSet::new()),
327 });
328 }
329 let mut contents = Vec::new();
330 let mut stems = std::collections::HashSet::new();
331 for entry in wiki_walk_builder(dir, wiki.root().path(), &wiki.config().ignore)?.build() {
332 let entry = entry.map_err(|e| WikiError::Walk {
333 path: dir.to_path_buf(),
334 source: e,
335 })?;
336 let path = entry.path();
337 if !is_markdown_file(path) {
338 continue;
339 }
340 match mode {
341 MatchMode::Content => {
342 let content =
343 std::fs::read_to_string(path).map_err(|e| WikiError::ReadFile {
344 path: path.to_path_buf(),
345 source: e,
346 })?;
347 contents.push(content);
348 }
349 MatchMode::Filename => {
350 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
351 stems.insert(stem.to_lowercase());
352 }
353 }
354 }
355 }
356 Ok(match mode {
357 MatchMode::Content => Self::Contents(contents),
358 MatchMode::Filename => Self::Stems(stems),
359 })
360 }
361
362 fn contains(&self, needle: &str) -> bool {
363 match self {
364 Self::Contents(pages) => pages.iter().any(|c| c.contains(needle)),
365 Self::Stems(stems) => stems.contains(&needle.to_lowercase()),
366 }
367 }
368}
369
370#[allow(clippy::too_many_arguments)]
371fn run_citation_pattern(
372 wiki: &Wiki,
373 name: &str,
374 dirs: &[String],
375 pattern: &str,
376 match_in: &str,
377 match_mode: MatchMode,
378 severity: Severity,
379) -> Result<usize, WikiError> {
380 let regex = regex_lite::Regex::new(pattern).expect("regex pre-validated at config load");
381
382 let match_dir = wiki.root().path().join(match_in);
383 let cache = MatchDirCache::load(wiki, &match_dir, match_mode)?;
384
385 let mut count = 0;
386
387 for entry in wiki.pages().values() {
388 if !WikiConfig::matches_dirs(&entry.rel_path, dirs) {
389 continue;
390 }
391 let file_path = wiki.entry_path(entry);
392 let source = wiki.source(&file_path)?;
393
394 for cap in regex.captures_iter(source) {
395 let Some(id) = cap.name("id").map(|m| m.as_str()) else {
396 continue;
397 };
398
399 if !cache.contains(id) {
400 eprintln!(
401 "{severity}[{name}]: {} references '{id}' but no matching page in {match_in}",
402 entry.rel_path.display(),
403 );
404 count += 1;
405 }
406 }
407 }
408
409 Ok(count)
410}