gitcortex_indexer/parser/
markdown.rs1use std::path::Path;
13
14use gitcortex_core::{
15 error::Result,
16 graph::{Edge, Node, NodeId, NodeMetadata, Span},
17 schema::{EdgeKind, NodeKind, Visibility},
18};
19use pulldown_cmark::{Event, HeadingLevel, Options, Parser as CmarkParser, Tag, TagEnd};
20
21use super::{LanguageParser, ParseResult};
22
23pub struct MarkdownParser;
24
25impl MarkdownParser {
26 pub fn new() -> Self {
27 Self
28 }
29}
30
31impl Default for MarkdownParser {
32 fn default() -> Self {
33 Self::new()
34 }
35}
36
37impl LanguageParser for MarkdownParser {
38 fn extensions(&self) -> &[&str] {
39 &["md", "markdown"]
40 }
41
42 fn parse(&self, path: &Path, source: &str) -> Result<ParseResult> {
43 let line_index = LineIndex::new(source);
44
45 let mut nodes: Vec<Node> = Vec::new();
46 let mut edges: Vec<Edge> = Vec::new();
47 let mut deferred_doc_refs: Vec<(NodeId, String)> = Vec::new();
48
49 let mut stack: Vec<(u8, NodeId)> = Vec::new();
51 let mut current_section: Option<NodeId> = None;
55
56 let mut in_heading = false;
57 let mut heading_text = String::new();
58 let mut heading_level: u8 = 1;
59 let mut heading_start_line: u32 = 1;
60
61 let mut in_link = false;
62 let mut link_text = String::new();
63
64 let cmark = CmarkParser::new_ext(source, Options::empty()).into_offset_iter();
65
66 for (event, range) in cmark {
67 match event {
68 Event::Start(Tag::Heading { level, .. }) => {
69 in_heading = true;
70 heading_level = heading_level_to_u8(level);
71 heading_text.clear();
72 heading_start_line = line_index.line_at(range.start);
73 }
74 Event::End(TagEnd::Heading(_)) => {
75 in_heading = false;
76 let name = heading_text.trim().to_owned();
77 if name.is_empty() {
78 continue;
79 }
80
81 while matches!(stack.last(), Some((lvl, _)) if *lvl >= heading_level) {
84 stack.pop();
85 }
86 let parent_id = stack.last().map(|(_, id)| id.clone());
87
88 let id = NodeId::new();
89 let qualified_name = format!("{}#{}", path.display(), slugify(&name));
90 nodes.push(Node {
91 id: id.clone(),
92 kind: NodeKind::Section,
93 name,
94 qualified_name,
95 file: path.to_owned(),
96 span: Span {
97 start_line: heading_start_line,
98 end_line: heading_start_line,
99 },
100 metadata: NodeMetadata {
101 visibility: Visibility::Pub,
102 ..Default::default()
103 },
104 });
105 if let Some(parent_id) = parent_id {
106 edges.push(Edge::new(parent_id, id.clone(), EdgeKind::Contains));
107 }
108 stack.push((heading_level, id.clone()));
109 current_section = Some(id);
110 }
111 Event::Text(text) => {
112 if in_heading {
113 heading_text.push_str(&text);
114 } else if in_link {
115 link_text.push_str(&text);
116 }
117 }
118 Event::Start(Tag::Link { .. }) => {
119 in_link = true;
120 link_text.clear();
121 }
122 Event::End(TagEnd::Link) => {
123 in_link = false;
124 if let (Some(name), Some(src)) =
125 (candidate_symbol_name(&link_text), current_section.clone())
126 {
127 deferred_doc_refs.push((src, name));
128 }
129 }
130 Event::Code(code) => {
131 if let (Some(name), Some(src)) =
132 (candidate_symbol_name(&code), current_section.clone())
133 {
134 deferred_doc_refs.push((src, name));
135 }
136 }
137 _ => {}
138 }
139 }
140
141 Ok(ParseResult {
142 nodes,
143 edges,
144 deferred_calls: Vec::new(),
145 deferred_uses: Vec::new(),
146 deferred_implements: Vec::new(),
147 deferred_imports: Vec::new(),
148 deferred_inherits: Vec::new(),
149 deferred_throws: Vec::new(),
150 deferred_annotated: Vec::new(),
151 deferred_doc_refs,
152 })
153 }
154}
155
156fn heading_level_to_u8(level: HeadingLevel) -> u8 {
157 match level {
158 HeadingLevel::H1 => 1,
159 HeadingLevel::H2 => 2,
160 HeadingLevel::H3 => 3,
161 HeadingLevel::H4 => 4,
162 HeadingLevel::H5 => 5,
163 HeadingLevel::H6 => 6,
164 }
165}
166
167fn slugify(text: &str) -> String {
171 let mut slug = String::with_capacity(text.len());
172 let mut last_was_dash = true; for c in text.chars() {
174 if c.is_alphanumeric() {
175 slug.push(c.to_ascii_lowercase());
176 last_was_dash = false;
177 } else if !last_was_dash {
178 slug.push('-');
179 last_was_dash = true;
180 }
181 }
182 while slug.ends_with('-') {
183 slug.pop();
184 }
185 slug
186}
187
188fn candidate_symbol_name(text: &str) -> Option<String> {
197 let trimmed = text.trim();
198 let without_call = trimmed.strip_suffix("()").unwrap_or(trimmed);
199
200 if without_call.is_empty()
204 || !without_call
205 .chars()
206 .all(|c| c.is_alphanumeric() || c == '_' || c == ':' || c == '.')
207 {
208 return None;
209 }
210
211 let last_segment = without_call
212 .rsplit("::")
213 .next()
214 .unwrap_or(without_call)
215 .rsplit('.')
216 .next()
217 .unwrap_or(without_call);
218
219 let mut chars = last_segment.chars();
220 let starts_ok = matches!(chars.next(), Some(c) if c.is_alphabetic() || c == '_');
221 if !starts_ok || last_segment.len() < 3 {
222 return None;
223 }
224
225 Some(last_segment.to_owned())
226}
227
228struct LineIndex {
231 newlines: Vec<usize>,
233}
234
235impl LineIndex {
236 fn new(source: &str) -> Self {
237 let newlines = source
238 .bytes()
239 .enumerate()
240 .filter(|(_, b)| *b == b'\n')
241 .map(|(i, _)| i)
242 .collect();
243 Self { newlines }
244 }
245
246 fn line_at(&self, byte_offset: usize) -> u32 {
248 self.newlines.partition_point(|&nl| nl < byte_offset) as u32 + 1
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 fn parse(md: &str) -> ParseResult {
257 MarkdownParser::new()
258 .parse(Path::new("README.md"), md)
259 .unwrap()
260 }
261
262 #[test]
263 fn headings_become_section_nodes() {
264 let result = parse("# Title\n\n## Installation\n\nSome text.\n");
265 let names: Vec<&str> = result.nodes.iter().map(|n| n.name.as_str()).collect();
266 assert_eq!(names, vec!["Title", "Installation"]);
267 assert!(result.nodes.iter().all(|n| n.kind == NodeKind::Section));
268 }
269
270 #[test]
271 fn nested_headings_produce_contains_edges() {
272 let result = parse("# Title\n\n## Installation\n\n### Quick Start\n");
273 assert_eq!(result.nodes.len(), 3);
274 let contains: Vec<&Edge> = result
275 .edges
276 .iter()
277 .filter(|e| e.kind == EdgeKind::Contains)
278 .collect();
279 assert_eq!(
280 contains.len(),
281 2,
282 "Title->Installation, Installation->Quick Start"
283 );
284 }
285
286 #[test]
287 fn sibling_heading_closes_deeper_section() {
288 let result = parse("# Title\n\n## A\n\n### Sub\n\n## B\n");
290 let b = result.nodes.iter().find(|n| n.name == "B").unwrap();
291 let b_contains_edge = result
292 .edges
293 .iter()
294 .find(|e| e.dst == b.id && e.kind == EdgeKind::Contains)
295 .unwrap();
296 let title = result.nodes.iter().find(|n| n.name == "Title").unwrap();
297 assert_eq!(
298 b_contains_edge.src, title.id,
299 "B should nest under Title, not Sub"
300 );
301 }
302
303 #[test]
304 fn inline_code_span_matching_identifier_becomes_doc_ref() {
305 let result = parse("# Title\n\nRun `validate_token` to check input.\n");
306 assert_eq!(result.deferred_doc_refs.len(), 1);
307 assert_eq!(result.deferred_doc_refs[0].1, "validate_token");
308 }
309
310 #[test]
311 fn qualified_code_span_uses_trailing_segment() {
312 let result = parse("# Title\n\nSee `auth::validate_token` for details.\n");
313 assert_eq!(result.deferred_doc_refs[0].1, "validate_token");
314 }
315
316 #[test]
317 fn non_symbol_code_spans_are_excluded() {
318 let result = parse("# Title\n\nRun `npm install` then `cd ..`.\n");
319 assert!(result.deferred_doc_refs.is_empty());
320 }
321
322 #[test]
323 fn short_code_spans_are_excluded() {
324 let result = parse("# Title\n\nUse `id` here.\n");
325 assert!(result.deferred_doc_refs.is_empty());
326 }
327
328 #[test]
329 fn doc_ref_before_first_heading_is_dropped() {
330 let result = parse("Mentions `validate_token` before any heading.\n\n# Title\n");
331 assert!(result.deferred_doc_refs.is_empty());
332 }
333
334 #[test]
335 fn link_text_matching_identifier_becomes_doc_ref() {
336 let result = parse("# Title\n\nSee [validate_token](src/auth.rs#L10) for details.\n");
337 assert_eq!(result.deferred_doc_refs.len(), 1);
338 assert_eq!(result.deferred_doc_refs[0].1, "validate_token");
339 }
340
341 #[test]
342 fn slugify_handles_spaces_and_punctuation() {
343 assert_eq!(slugify("Quick Start!"), "quick-start");
344 assert_eq!(slugify("API Reference (v2)"), "api-reference-v2");
345 }
346
347 #[test]
348 fn line_index_finds_correct_line() {
349 let idx = LineIndex::new("a\nb\nc\n");
350 assert_eq!(idx.line_at(0), 1); assert_eq!(idx.line_at(2), 2); assert_eq!(idx.line_at(4), 3); }
354}