1use scrybe_core::{ast::Node, Ast, Document};
7
8#[derive(Debug, Clone, Default)]
10pub struct LintReport {
11 pub word_count: usize,
13 pub heading_count: usize,
15 pub max_heading_depth: u8,
17 pub code_block_count: usize,
19 pub code_block_langs: Vec<String>,
22 pub has_math: bool,
24 pub has_mermaid: bool,
26 pub broken_links: Vec<BrokenLink>,
28}
29
30#[derive(Debug, Clone)]
32pub struct BrokenLink {
33 pub text: String,
35 pub url: String,
37}
38
39impl LintReport {
40 pub fn is_clean(&self) -> bool {
42 self.broken_links.is_empty()
43 }
44}
45
46pub fn lint_document(doc: &Document) -> LintReport {
48 let mut report = LintReport {
49 word_count: doc.source.split_whitespace().count(),
51 has_math: doc.source.contains('$'),
53 ..Default::default()
54 };
55
56 let ast = Ast::parse(&doc.source);
58 visit_nodes(&ast.nodes, &mut report);
59
60 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}