Skip to main content

scrybe_cli/
lint.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Shawn Hartsock and contributors
3
4//! Document linter — walks the AST and produces a [`LintReport`].
5
6use scrybe_core::{ast::Node, Ast, Document};
7
8/// A structured lint report for a Markdown document.
9#[derive(Debug, Clone, Default)]
10pub struct LintReport {
11    /// Number of words in the document (rough whitespace split).
12    pub word_count: usize,
13    /// Total number of heading nodes.
14    pub heading_count: usize,
15    /// Maximum heading depth seen (1 = H1, 6 = H6). 0 if no headings.
16    pub max_heading_depth: u8,
17    /// Number of fenced code blocks.
18    pub code_block_count: usize,
19    /// Languages used in fenced code blocks (sorted, deduplicated).
20    /// Does not include `"mermaid"` — that is tracked by [`has_mermaid`].
21    pub code_block_langs: Vec<String>,
22    /// Whether any inline or block math was found (`$` marker).
23    pub has_math: bool,
24    /// Whether any Mermaid diagram blocks were found.
25    pub has_mermaid: bool,
26    /// Broken links: `[text]()` or `[text](#)`.
27    pub broken_links: Vec<BrokenLink>,
28}
29
30/// A broken link found during linting.
31#[derive(Debug, Clone)]
32pub struct BrokenLink {
33    /// The link text.
34    pub text: String,
35    /// The (empty or fragment-only) URL that was found.
36    pub url: String,
37}
38
39impl LintReport {
40    /// Returns `true` if there are no broken links.
41    pub fn is_clean(&self) -> bool {
42        self.broken_links.is_empty()
43    }
44}
45
46/// Analyses a [`Document`] and returns a [`LintReport`].
47pub fn lint_document(doc: &Document) -> LintReport {
48    let mut report = LintReport {
49        // Word count — rough whitespace split on the raw source.
50        word_count: doc.source.split_whitespace().count(),
51        // Math detection — look for `$` in source.
52        has_math: doc.source.contains('$'),
53        ..Default::default()
54    };
55
56    // Walk the AST for structural counts.
57    let ast = Ast::parse(&doc.source);
58    visit_nodes(&ast.nodes, &mut report);
59
60    // Deduplicate and sort code languages.
61    report.code_block_langs.sort();
62    report.code_block_langs.dedup();
63
64    report
65}
66
67fn visit_nodes(nodes: &[Node], report: &mut LintReport) {
68    for node in nodes {
69        match node {
70            Node::Heading { level, children } => {
71                report.heading_count += 1;
72                if *level > report.max_heading_depth {
73                    report.max_heading_depth = *level;
74                }
75                visit_nodes(children, report);
76            }
77            Node::FencedCode { lang, .. } => {
78                report.code_block_count += 1;
79                if lang == "mermaid" {
80                    report.has_mermaid = true;
81                } else if !lang.is_empty() {
82                    report.code_block_langs.push(lang.clone());
83                }
84            }
85            Node::Link { href, children, .. } => {
86                let is_broken = href.is_empty() || href == "#";
87                if is_broken {
88                    report.broken_links.push(BrokenLink {
89                        text: collect_text(children),
90                        url: href.clone(),
91                    });
92                }
93                visit_nodes(children, report);
94            }
95            Node::Paragraph { children }
96            | Node::BlockQuote { children }
97            | Node::ListItem { children }
98            | Node::Emphasis { children }
99            | Node::Strong { children } => {
100                visit_nodes(children, report);
101            }
102            Node::List { items, .. } => {
103                visit_nodes(items, report);
104            }
105            _ => {}
106        }
107    }
108}
109
110fn collect_text(nodes: &[Node]) -> String {
111    let mut out = String::new();
112    for node in nodes {
113        match node {
114            Node::Text(s) => out.push_str(s),
115            Node::InlineCode { content } => out.push_str(content),
116            Node::Emphasis { children }
117            | Node::Strong { children }
118            | Node::Link { children, .. } => {
119                out.push_str(&collect_text(children));
120            }
121            _ => {}
122        }
123    }
124    out
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    fn doc(s: &str) -> Document {
132        Document::new(s)
133    }
134
135    #[test]
136    fn test_word_count() {
137        assert_eq!(lint_document(&doc("one two three four five")).word_count, 5);
138    }
139
140    #[test]
141    fn test_headings() {
142        let r = lint_document(&doc("# H1\n\n## H2\n\n### H3\n"));
143        assert_eq!(r.heading_count, 3);
144        assert_eq!(r.max_heading_depth, 3);
145    }
146
147    #[test]
148    fn test_no_headings() {
149        let r = lint_document(&doc("Just a paragraph.\n"));
150        assert_eq!(r.heading_count, 0);
151        assert_eq!(r.max_heading_depth, 0);
152    }
153
154    #[test]
155    fn test_code_blocks() {
156        let r = lint_document(&doc(
157            "```rust\nfn main() {}\n```\n\n```python\nprint('hi')\n```\n",
158        ));
159        assert_eq!(r.code_block_count, 2);
160        assert!(r.code_block_langs.contains(&"rust".to_string()));
161        assert!(r.code_block_langs.contains(&"python".to_string()));
162    }
163
164    #[test]
165    fn test_broken_links() {
166        let r = lint_document(&doc(
167            "[empty]()\n\n[fragment](#)\n\n[ok](https://example.com)\n",
168        ));
169        assert_eq!(r.broken_links.len(), 2);
170        assert!(!r.is_clean());
171    }
172
173    #[test]
174    fn test_clean_document() {
175        let r = lint_document(&doc("# Title\n\nSome [link](https://example.com).\n"));
176        assert!(r.is_clean());
177    }
178
179    #[test]
180    fn test_mermaid_detected() {
181        let r = lint_document(&doc("```mermaid\ngraph TD; A-->B;\n```\n"));
182        assert!(r.has_mermaid);
183        assert!(!r.code_block_langs.contains(&"mermaid".to_string()));
184    }
185
186    #[test]
187    fn test_math_detected() {
188        let r = lint_document(&doc("Here is $x^2$ and $$E=mc^2$$.\n"));
189        assert!(r.has_math);
190    }
191}