use std::path::Path;
use gitcortex_core::{
error::Result,
graph::{Edge, Node, NodeId, NodeMetadata, Span},
schema::{EdgeKind, NodeKind, Visibility},
};
use pulldown_cmark::{Event, HeadingLevel, Options, Parser as CmarkParser, Tag, TagEnd};
use super::{LanguageParser, ParseResult};
pub struct MarkdownParser;
impl MarkdownParser {
pub fn new() -> Self {
Self
}
}
impl Default for MarkdownParser {
fn default() -> Self {
Self::new()
}
}
impl LanguageParser for MarkdownParser {
fn extensions(&self) -> &[&str] {
&["md", "markdown"]
}
fn parse(&self, path: &Path, source: &str) -> Result<ParseResult> {
let line_index = LineIndex::new(source);
let mut nodes: Vec<Node> = Vec::new();
let mut edges: Vec<Edge> = Vec::new();
let mut deferred_doc_refs: Vec<(NodeId, String)> = Vec::new();
let mut stack: Vec<(u8, NodeId)> = Vec::new();
let mut current_section: Option<NodeId> = None;
let mut in_heading = false;
let mut heading_text = String::new();
let mut heading_level: u8 = 1;
let mut heading_start_line: u32 = 1;
let mut in_link = false;
let mut link_text = String::new();
let cmark = CmarkParser::new_ext(source, Options::empty()).into_offset_iter();
for (event, range) in cmark {
match event {
Event::Start(Tag::Heading { level, .. }) => {
in_heading = true;
heading_level = heading_level_to_u8(level);
heading_text.clear();
heading_start_line = line_index.line_at(range.start);
}
Event::End(TagEnd::Heading(_)) => {
in_heading = false;
let name = heading_text.trim().to_owned();
if name.is_empty() {
continue;
}
while matches!(stack.last(), Some((lvl, _)) if *lvl >= heading_level) {
stack.pop();
}
let parent_id = stack.last().map(|(_, id)| id.clone());
let id = NodeId::new();
let qualified_name = format!("{}#{}", path.display(), slugify(&name));
nodes.push(Node {
id: id.clone(),
kind: NodeKind::Section,
name,
qualified_name,
file: path.to_owned(),
span: Span {
start_line: heading_start_line,
end_line: heading_start_line,
},
metadata: NodeMetadata {
visibility: Visibility::Pub,
..Default::default()
},
});
if let Some(parent_id) = parent_id {
edges.push(Edge::new(parent_id, id.clone(), EdgeKind::Contains));
}
stack.push((heading_level, id.clone()));
current_section = Some(id);
}
Event::Text(text) => {
if in_heading {
heading_text.push_str(&text);
} else if in_link {
link_text.push_str(&text);
}
}
Event::Start(Tag::Link { .. }) => {
in_link = true;
link_text.clear();
}
Event::End(TagEnd::Link) => {
in_link = false;
if let (Some(name), Some(src)) =
(candidate_symbol_name(&link_text), current_section.clone())
{
deferred_doc_refs.push((src, name));
}
}
Event::Code(code) => {
if let (Some(name), Some(src)) =
(candidate_symbol_name(&code), current_section.clone())
{
deferred_doc_refs.push((src, name));
}
}
_ => {}
}
}
Ok(ParseResult {
nodes,
edges,
deferred_calls: Vec::new(),
deferred_uses: Vec::new(),
deferred_implements: Vec::new(),
deferred_imports: Vec::new(),
deferred_inherits: Vec::new(),
deferred_throws: Vec::new(),
deferred_annotated: Vec::new(),
deferred_doc_refs,
})
}
}
fn heading_level_to_u8(level: HeadingLevel) -> u8 {
match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
}
}
fn slugify(text: &str) -> String {
let mut slug = String::with_capacity(text.len());
let mut last_was_dash = true; for c in text.chars() {
if c.is_alphanumeric() {
slug.push(c.to_ascii_lowercase());
last_was_dash = false;
} else if !last_was_dash {
slug.push('-');
last_was_dash = true;
}
}
while slug.ends_with('-') {
slug.pop();
}
slug
}
fn candidate_symbol_name(text: &str) -> Option<String> {
let trimmed = text.trim();
let without_call = trimmed.strip_suffix("()").unwrap_or(trimmed);
if without_call.is_empty()
|| !without_call
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == ':' || c == '.')
{
return None;
}
let last_segment = without_call
.rsplit("::")
.next()
.unwrap_or(without_call)
.rsplit('.')
.next()
.unwrap_or(without_call);
let mut chars = last_segment.chars();
let starts_ok = matches!(chars.next(), Some(c) if c.is_alphabetic() || c == '_');
if !starts_ok || last_segment.len() < 3 {
return None;
}
Some(last_segment.to_owned())
}
struct LineIndex {
newlines: Vec<usize>,
}
impl LineIndex {
fn new(source: &str) -> Self {
let newlines = source
.bytes()
.enumerate()
.filter(|(_, b)| *b == b'\n')
.map(|(i, _)| i)
.collect();
Self { newlines }
}
fn line_at(&self, byte_offset: usize) -> u32 {
self.newlines.partition_point(|&nl| nl < byte_offset) as u32 + 1
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(md: &str) -> ParseResult {
MarkdownParser::new()
.parse(Path::new("README.md"), md)
.unwrap()
}
#[test]
fn headings_become_section_nodes() {
let result = parse("# Title\n\n## Installation\n\nSome text.\n");
let names: Vec<&str> = result.nodes.iter().map(|n| n.name.as_str()).collect();
assert_eq!(names, vec!["Title", "Installation"]);
assert!(result.nodes.iter().all(|n| n.kind == NodeKind::Section));
}
#[test]
fn nested_headings_produce_contains_edges() {
let result = parse("# Title\n\n## Installation\n\n### Quick Start\n");
assert_eq!(result.nodes.len(), 3);
let contains: Vec<&Edge> = result
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Contains)
.collect();
assert_eq!(
contains.len(),
2,
"Title->Installation, Installation->Quick Start"
);
}
#[test]
fn sibling_heading_closes_deeper_section() {
let result = parse("# Title\n\n## A\n\n### Sub\n\n## B\n");
let b = result.nodes.iter().find(|n| n.name == "B").unwrap();
let b_contains_edge = result
.edges
.iter()
.find(|e| e.dst == b.id && e.kind == EdgeKind::Contains)
.unwrap();
let title = result.nodes.iter().find(|n| n.name == "Title").unwrap();
assert_eq!(
b_contains_edge.src, title.id,
"B should nest under Title, not Sub"
);
}
#[test]
fn inline_code_span_matching_identifier_becomes_doc_ref() {
let result = parse("# Title\n\nRun `validate_token` to check input.\n");
assert_eq!(result.deferred_doc_refs.len(), 1);
assert_eq!(result.deferred_doc_refs[0].1, "validate_token");
}
#[test]
fn qualified_code_span_uses_trailing_segment() {
let result = parse("# Title\n\nSee `auth::validate_token` for details.\n");
assert_eq!(result.deferred_doc_refs[0].1, "validate_token");
}
#[test]
fn non_symbol_code_spans_are_excluded() {
let result = parse("# Title\n\nRun `npm install` then `cd ..`.\n");
assert!(result.deferred_doc_refs.is_empty());
}
#[test]
fn short_code_spans_are_excluded() {
let result = parse("# Title\n\nUse `id` here.\n");
assert!(result.deferred_doc_refs.is_empty());
}
#[test]
fn doc_ref_before_first_heading_is_dropped() {
let result = parse("Mentions `validate_token` before any heading.\n\n# Title\n");
assert!(result.deferred_doc_refs.is_empty());
}
#[test]
fn link_text_matching_identifier_becomes_doc_ref() {
let result = parse("# Title\n\nSee [validate_token](src/auth.rs#L10) for details.\n");
assert_eq!(result.deferred_doc_refs.len(), 1);
assert_eq!(result.deferred_doc_refs[0].1, "validate_token");
}
#[test]
fn slugify_handles_spaces_and_punctuation() {
assert_eq!(slugify("Quick Start!"), "quick-start");
assert_eq!(slugify("API Reference (v2)"), "api-reference-v2");
}
#[test]
fn line_index_finds_correct_line() {
let idx = LineIndex::new("a\nb\nc\n");
assert_eq!(idx.line_at(0), 1); assert_eq!(idx.line_at(2), 2); assert_eq!(idx.line_at(4), 3); }
}