use super::Language;
use regex::Regex;
use std::sync::LazyLock;
use tree_sitter::Node;
static MARKDOWN_LINK_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\[[^\]]*\]\(([^)]+\.(?:md|markdown))(?:#[^)]*)?\)")
.expect("markdown link regex must be valid")
});
fn extract_markdown_links(contents: &str) -> Vec<String> {
let mut links = Vec::new();
for capture in MARKDOWN_LINK_RE.captures_iter(contents) {
if let Some(target) = capture.get(1) {
let path = target.as_str();
if !path.starts_with("http://") && !path.starts_with("https://") {
links.push(path.to_string());
}
}
}
links.sort_unstable();
links.dedup();
links
}
pub struct Markdown;
impl Language for Markdown {
fn name(&self) -> &'static str {
"markdown"
}
fn get_ts_language(&self) -> tree_sitter::Language {
tree_sitter_json::LANGUAGE.into()
}
fn get_meaningful_kinds(&self) -> Vec<&'static str> {
vec![]
}
fn extract_symbols(&self, _node: Node, contents: &str) -> Vec<String> {
let mut symbols = Vec::new();
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') && !trimmed.starts_with("```") {
let heading_text = trimmed.trim_start_matches('#').trim();
if !heading_text.is_empty() {
symbols.push(heading_text.to_string());
}
}
}
symbols
}
fn extract_identifiers(&self, _node: Node, _contents: &str, _symbols: &mut Vec<String>) {
}
fn get_node_type_description(&self, _node_type: &str) -> &'static str {
"markdown headings"
}
fn extract_imports_exports(&self, node: Node, contents: &str) -> (Vec<String>, Vec<String>) {
if node.parent().is_some() {
return (Vec::new(), Vec::new());
}
let links = extract_markdown_links(contents);
let exports = self.extract_symbols(node, contents);
(links, exports)
}
fn resolve_import(
&self,
import_path: &str,
source_file: &str,
all_files: &[String],
) -> Option<String> {
use super::resolution_utils::resolve_relative_path;
use crate::utils::path::PathNormalizer;
let resolved = resolve_relative_path(source_file, import_path)?;
PathNormalizer::find_path_in_collection(&resolved.to_string_lossy(), all_files)
.map(str::to_string)
}
fn get_file_extensions(&self) -> Vec<&'static str> {
vec!["md", "markdown"]
}
}
impl Markdown {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_markdown_links() {
let content = r#"
# Credit Accounts
See [Credit Suite](../core-architecture/credit-suite.md) for details.
Also check [Pool](../core-architecture/pool.md#liquidity) and
[Adapters](./adapters.md).
External links are ignored: [Docs](https://docs.example.com/guide.md)
Non-md links are ignored: [Image](./photo.png)
Long extension is kept: [Long](./manual.markdown)
"#;
let links = extract_markdown_links(content);
assert_eq!(links.len(), 4);
assert!(links.contains(&"../core-architecture/credit-suite.md".to_string()));
assert!(links.contains(&"../core-architecture/pool.md".to_string()));
assert!(links.contains(&"./adapters.md".to_string()));
assert!(links.contains(&"./manual.markdown".to_string()));
assert!(!links.iter().any(|l| l.contains("https://")));
assert!(!links.iter().any(|l| l.contains(".png")));
}
#[test]
fn test_resolve_markdown_import() {
let md = Markdown;
let all_files = vec![
"core-architecture/credit-suite.md".to_string(),
"core-architecture/pool.md".to_string(),
"introduction/adapters.md".to_string(),
];
let resolved = md.resolve_import(
"../core-architecture/credit-suite.md",
"introduction/credit-accounts.md",
&all_files,
);
assert_eq!(
resolved,
Some("core-architecture/credit-suite.md".to_string())
);
let resolved = md.resolve_import(
"./adapters.md",
"introduction/credit-accounts.md",
&all_files,
);
assert_eq!(resolved, Some("introduction/adapters.md".to_string()));
let resolved = md.resolve_import(
"../nonexistent.md",
"introduction/credit-accounts.md",
&all_files,
);
assert_eq!(resolved, None);
}
#[test]
fn test_resolve_with_deep_project_paths() {
let md = Markdown;
let all_files = vec![
"projects/gearbox/autodocs-about/docs/core-architecture/credit-suite.md".to_string(),
"projects/gearbox/autodocs-about/docs/core-architecture/pool.md".to_string(),
"projects/gearbox/autodocs-about/docs/introduction/credit-accounts.md".to_string(),
];
let resolved = md.resolve_import(
"credit-suite.md",
"projects/gearbox/autodocs-about/docs/core-architecture/adapters-integrations.md",
&all_files,
);
assert_eq!(
resolved,
Some(
"projects/gearbox/autodocs-about/docs/core-architecture/credit-suite.md"
.to_string()
),
"Same-dir link should resolve"
);
let resolved = md.resolve_import(
"../introduction/credit-accounts.md",
"projects/gearbox/autodocs-about/docs/core-architecture/adapters-integrations.md",
&all_files,
);
assert_eq!(
resolved,
Some(
"projects/gearbox/autodocs-about/docs/introduction/credit-accounts.md".to_string()
),
"Parent-dir link should resolve"
);
}
#[test]
fn test_no_links_in_empty_doc() {
let content = "# Simple heading\nNo links here.";
let links = extract_markdown_links(content);
assert!(links.is_empty());
}
}