Skip to main content

gitcortex_indexer/parser/
markdown.rs

1//! Structural Markdown parser — headings become `Section` nodes, inline
2//! code-spans and link text that look like identifiers become unresolved
3//! `References` edges to code symbols. No LLM calls, no HTML rendering: pure
4//! CommonMark structural extraction via `pulldown-cmark`'s event stream.
5//!
6//! Doc→code references are intentionally cross-language (a README can
7//! reference a Python function from a Rust repo's Python bindings, etc.) —
8//! see `EdgeKind::References` and `resolve_deferred` in `indexer.rs`, which
9//! only scopes resolution by language when the source file's extension is
10//! recognised. Markdown isn't, so resolution is a no-op pass-through there.
11
12use 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        // Stack of (heading_level, section_node_id) — open ancestors, deepest last.
50        let mut stack: Vec<(u8, NodeId)> = Vec::new();
51        // The innermost section currently in scope, used as the source of any
52        // doc-ref found in its body. `None` before the first heading — refs
53        // found there are dropped (see module doc / known limitation).
54        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                    // Pop ancestors at this level or deeper — a new H2 closes
82                    // a previous H2 or H3, but stays nested under an H1.
83                    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
167/// Slugify heading text into a stable, URL-safe fragment for `qualified_name`
168/// (e.g. "Quick Start" → "quick-start"). Not GitHub-anchor-perfect, just
169/// stable and collision-resistant within a file.
170fn slugify(text: &str) -> String {
171    let mut slug = String::with_capacity(text.len());
172    let mut last_was_dash = true; // avoid leading dash
173    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
188/// Conservative heuristic: does `text` look like a code identifier worth
189/// trying to resolve, rather than a shell command, file path, or prose
190/// fragment? Returns the trailing identifier segment (after the last `::`
191/// or `.`, with a trailing `()` stripped) to match against `Node::name`.
192///
193/// This is deliberately precision-first, not a fuzzy NLP matcher — known
194/// limitation: hyphenated or otherwise non-identifier-shaped names never
195/// match, by design.
196fn candidate_symbol_name(text: &str) -> Option<String> {
197    let trimmed = text.trim();
198    let without_call = trimmed.strip_suffix("()").unwrap_or(trimmed);
199
200    // Reject anything containing whitespace, slashes, or other punctuation
201    // that signals a shell command / file path / prose fragment rather than
202    // a single qualified identifier.
203    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
228/// Precomputed newline byte offsets for O(log n) byte→line-number lookups,
229/// since `pulldown-cmark`'s offset iterator yields byte ranges, not lines.
230struct LineIndex {
231    /// Byte offset of each `\n` in the source.
232    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    /// 1-indexed line number containing `byte_offset`.
247    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        // H2 "A" contains H3 "Sub". A following H2 "B" must NOT nest under "Sub".
289        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); // 'a'
351        assert_eq!(idx.line_at(2), 2); // 'b'
352        assert_eq!(idx.line_at(4), 3); // 'c'
353    }
354}