diff --git a/Cargo.lock b/Cargo.lock index acdc80a539..1fe5fee87b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -862,6 +862,7 @@ dependencies = [ "toml_edit 0.22.27", "tracing", "tree-sitter", + "tree-sitter-astro-next", "tree-sitter-c", "tree-sitter-c-sharp", "tree-sitter-cpp", @@ -874,6 +875,7 @@ dependencies = [ "tree-sitter-ruby", "tree-sitter-rust", "tree-sitter-scala", + "tree-sitter-svelte-next", "tree-sitter-swift", "tree-sitter-typescript", ] @@ -5351,7 +5353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -5809,6 +5811,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-astro-next" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "794a4a59fc2d88e49b4bc41fef9522d77184a36f4e68bbaf545cd1eb2364c46e" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-c" version = "0.23.4" @@ -5935,6 +5947,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-svelte-next" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f88190d0743e897c3e148a7e241aba0a8844b8afe816943851426e3f7b9753" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-swift" version = "0.7.2" diff --git a/Cargo.toml b/Cargo.toml index 818f03165d..7323ea0441 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,6 +72,8 @@ tree-sitter-scala = "0.26" tree-sitter-swift = "0.7" tree-sitter-kotlin = "0.3" tree-sitter-lua = "0.5" +tree-sitter-svelte-next = "0.1.1" +tree-sitter-astro-next = "0.1.1" # cli / async / fs clap = { version = "4", features = ["derive", "wrap_help"] } diff --git a/crates/codegraph-docs/src/graph.rs b/crates/codegraph-docs/src/graph.rs index 490fc243da..dbfcf70b06 100644 --- a/crates/codegraph-docs/src/graph.rs +++ b/crates/codegraph-docs/src/graph.rs @@ -1173,6 +1173,8 @@ fn kind_token(kind: &Kind) -> DocToken { Kind::Number => DocToken::num(0), Kind::Bool => DocToken::bool(0), Kind::Null | Kind::Reference => DocToken::null(), + Kind::Header => DocToken::header(), + Kind::Code => DocToken::code(), } } @@ -1188,6 +1190,8 @@ fn token_label(tag: DocTag) -> &'static str { DocTag::Num => "NUMBER", DocTag::Bool => "BOOL", DocTag::Null => "NULL", + DocTag::Header => "HEADER", + DocTag::Code => "CODE", } } @@ -1203,6 +1207,8 @@ pub fn parse_kind_label(label: &str) -> DocToken { "NUMBER" | "NUM" => DocToken::num(0), "BOOL" => DocToken::bool(0), "NULL" => DocToken::null(), + "HEADER" => DocToken::header(), + "CODE" => DocToken::code(), _ => DocToken::map(), } } diff --git a/crates/codegraph-docs/src/ir.rs b/crates/codegraph-docs/src/ir.rs index 82839ffe6c..c49b3aca05 100644 --- a/crates/codegraph-docs/src/ir.rs +++ b/crates/codegraph-docs/src/ir.rs @@ -21,6 +21,10 @@ pub enum Kind { Bool, Null, Reference, + /// Markdown heading — level thể hiện qua độ sâu lồng nhau. + Header, + /// Fenced code block (markdown). + Code, } /// Scalar value stored on a leaf node. diff --git a/crates/codegraph-docs/src/parsers/mod.rs b/crates/codegraph-docs/src/parsers/mod.rs index e6ba658a5f..e81862900e 100644 --- a/crates/codegraph-docs/src/parsers/mod.rs +++ b/crates/codegraph-docs/src/parsers/mod.rs @@ -21,6 +21,11 @@ pub enum RecursiveNode { Number(f64, ByteSpan), Bool(bool, ByteSpan), Null(ByteSpan), + /// Markdown heading: text tiêu đề + các node con (paragraph/header/code). + /// Node gốc của markdown cũng dùng variant này (text rỗng) → `Kind::Root`. + Header(String, Vec<(RecursiveNode, ByteSpan)>, ByteSpan), + /// Fenced code block: ngôn ngữ (nếu có) + nội dung thô. + Code(Option, String, ByteSpan), } /// Build a `Document` from `RecursiveNode`, assigning global node ids and @@ -75,6 +80,22 @@ impl DocBuilder { RecursiveNode::Number(n, _) => (Kind::Number, Some(Scalar::Number(*n))), RecursiveNode::Bool(b, _) => (Kind::Bool, Some(Scalar::Bool(*b))), RecursiveNode::Null(_) => (Kind::Null, Some(Scalar::Null)), + // Markdown: node gốc (id 1, text rỗng) là Root; header con là Header. + RecursiveNode::Header(..) => { + if id == 1 { + (Kind::Root, None) + } else { + (Kind::Header, None) + } + } + RecursiveNode::Code(_, code, _) => (Kind::Code, Some(Scalar::String(code.clone()))), + }; + // Header tự mang key = text tiêu đề; root markdown (text rỗng) không key. + // Code block dùng key = ngôn ngữ (nếu fence có khai báo). + let key = match node { + RecursiveNode::Header(text, _, _) if id != 1 && !text.is_empty() => Some(text.clone()), + RecursiveNode::Code(lang, _, _) => lang.clone().or(key), + _ => key, }; let built_node = Node { id, @@ -122,6 +143,15 @@ impl DocBuilder { self.nodes.insert(child_id, child_node); } } + RecursiveNode::Header(_, items, _) => { + for (i, (child, child_span)) in items.iter().enumerate() { + let child_id = self.next_id; + self.next_id += 1; + let child_node = + self.walk(child_id, Some(id), None, Some(i as u32), child, *child_span); + self.nodes.insert(child_id, child_node); + } + } _ => {} } // Sync children vào `order` — lúc push ban đầu `children` còn rỗng, @@ -388,6 +418,137 @@ fn convert_hcl_value(value: &hcl::Value, span: ByteSpan) -> RecursiveNode { } } +// ── markdown parser ────────────────────────────────────────────────────── + +pub struct MarkdownParser; + +impl DocParser for MarkdownParser { + fn format(&self) -> &'static str { + "markdown" + } + + fn parse(&self, path: &str, source: &str, id: u64) -> Result { + let root = parse_markdown(source); + Ok(build_document( + path.to_string(), + self.format().to_string(), + id, + root, + )) + } +} + +/// Parse markdown theo mô hình bao bọc: header bậc càng nhỏ (h1 < h2 < …) +/// càng bao bọc rộng — h1 chứa h2, h2 chứa h3… Paragraph là item con của +/// header gần nhất; fenced code block là node `Code` riêng. Phần tử khác +/// (list, table, blockquote…) giữ nguyên dạng text paragraph. +fn parse_markdown(source: &str) -> RecursiveNode { + let span = ByteSpan { + start: 0, + end: source.len() as u64, + }; + let zero = ByteSpan { start: 0, end: 0 }; + // Stack khung header: (mức #, text tiêu đề, các node con). Khung gốc + // level 0, text rỗng → trở thành `Kind::Root`. + type MdFrame = (u8, String, Vec<(RecursiveNode, ByteSpan)>); + let mut stack: Vec = vec![(0, String::new(), Vec::new())]; + let mut para: Vec = Vec::new(); + let mut fence: Option> = None; + let mut code: Vec = Vec::new(); + + macro_rules! flush_para { + () => { + if !para.is_empty() { + let text = para.join("\n"); + para.clear(); + stack + .last_mut() + .expect("markdown stack always has root frame") + .2 + .push((RecursiveNode::String(text, zero), zero)); + } + }; + } + + for line in source.lines() { + let trimmed = line.trim_start(); + if let Some(open) = fence.as_mut() { + // Dòng trong code fence: ``` đóng fence, còn lại là code thô. + if trimmed.starts_with("```") { + let lang = open.take(); + fence = None; + let body = code.join("\n"); + code.clear(); + stack + .last_mut() + .expect("markdown stack always has root frame") + .2 + .push((RecursiveNode::Code(lang, body, zero), zero)); + } else { + code.push(line.to_string()); + } + continue; + } + if let Some(info) = trimmed.strip_prefix("```") { + flush_para!(); + let info = info.trim(); + let lang = info.split_whitespace().next().filter(|s| !s.is_empty()); + fence = Some(lang.map(|s| s.to_string())); + continue; + } + // ATX header: 1–6 dấu `#` theo sau bởi space/tab hoặc hết dòng. + let hashes = trimmed.bytes().take_while(|&b| b == b'#').count(); + let is_header = (1..=6).contains(&hashes) + && (trimmed.len() == hashes + || matches!(trimmed.as_bytes().get(hashes), Some(b' ' | b'\t'))); + if is_header { + flush_para!(); + let text = trimmed[hashes..].trim().to_string(); + // Pop về header cha có mức nhỏ hơn; frame bị pop gói thành + // `Header` và nối vào items của khung mới hiện hành. + while let Some(&(top, _, _)) = stack.last() { + if top < hashes as u8 { + break; + } + let (_, text, items) = stack.pop().expect("checked non-empty"); + stack + .last_mut() + .expect("root frame has level 0, always stays") + .2 + .push((RecursiveNode::Header(text, items, zero), zero)); + } + stack.push((hashes as u8, text, Vec::new())); + continue; + } + if line.trim().is_empty() { + flush_para!(); + continue; + } + para.push(line.trim_end().to_string()); + } + // Fence chưa đóng: lấy phần code đã thu được làm block cuối. + if let Some(lang) = fence.take() { + let body = code.join("\n"); + stack + .last_mut() + .expect("markdown stack always has root frame") + .2 + .push((RecursiveNode::Code(lang, body, zero), zero)); + } + flush_para!(); + // Gói toàn bộ stack còn lại thành cây: top trước → lồng vào khung dưới. + while stack.len() > 1 { + let (_, text, items) = stack.pop().expect("stack.len() > 1"); + stack + .last_mut() + .expect("stack.len() > 1 before pop") + .2 + .push((RecursiveNode::Header(text, items, zero), zero)); + } + let (_, text, items) = stack.pop().expect("markdown stack non-empty"); + RecursiveNode::Header(text, items, span) +} + // ── nginx parser ────────────────────────────────────────────────────────── pub struct NginxParser; @@ -707,7 +868,8 @@ fn push_nginx_entry( } /// Detect document format từ extension: `tf`/`hcl` → hcl, `yaml`/`yml`, -/// `json`, `toml`. Lỗi khi extension không nhận diện được. +/// `json`, `toml`, `conf`/`nginx` → nginx, `md`/`markdown` → markdown. +/// Lỗi khi extension không nhận diện được. pub fn detect_format(path: &str) -> Result { let ext = std::path::Path::new(path) .extension() @@ -720,13 +882,15 @@ pub fn detect_format(path: &str) -> Result { "json" => Ok("json".to_string()), "toml" => Ok("toml".to_string()), "conf" | "nginx" => Ok("nginx".to_string()), + "md" | "markdown" => Ok("markdown".to_string()), _ => Err(anyhow::anyhow!( "unknown format for extension .{ext}; specify --format to override" )), } } -/// Chọn parser theo format name (`"hcl"`, `"yaml"`, `"json"`, `"toml"`). +/// Chọn parser theo format name (`"hcl"`, `"yaml"`, `"json"`, `"toml"`, +/// `"nginx"`, `"markdown"`). pub fn parser_for(format: &str) -> Result> { match format { "hcl" => Ok(Box::new(HclParser)), @@ -734,6 +898,7 @@ pub fn parser_for(format: &str) -> Result> { "json" => Ok(Box::new(JsonParser)), "toml" => Ok(Box::new(TomlParser)), "nginx" => Ok(Box::new(NginxParser)), + "markdown" => Ok(Box::new(MarkdownParser)), _ => Err(anyhow::anyhow!("unsupported document format: {format}")), } } @@ -741,7 +906,7 @@ pub fn parser_for(format: &str) -> Result> { #[cfg(test)] mod tests { use super::*; - use crate::{DocConfig, DocumentGraph}; + use crate::{DocConfig, DocToken, DocumentGraph}; use codegraph_graph::InMemoryStorage; use std::sync::Arc; use tokio::sync::RwLock as TokioRwLock; @@ -931,6 +1096,148 @@ http { assert_eq!(detect_format("a.CONF").unwrap(), "nginx"); } + #[test] + fn markdown_parser_header_nesting() { + let src = "\ +# Top +Intro paragraph. + +## Section +Body text. + +### Sub +Sub body. + +## Second section +# Back to top +Outro. +"; + let doc = MarkdownParser.parse("a.md", src, 1).unwrap(); + let find = |key: &str| doc.nodes.iter().find(|n| n.key.as_deref() == Some(key)); + // root, Top, intro, Section, body, Sub, sub body, Second section, + // Back to top, outro = 10 node. + assert_eq!(doc.nodes.len(), 10); + assert_eq!(doc.nodes[0].kind, Kind::Root); + let top = find("Top").unwrap(); + assert_eq!(top.kind, Kind::Header); + assert_eq!(top.parent, Some(1)); + // Paragraph là con của header gần nhất. + let intro = doc + .nodes + .iter() + .find(|n| n.parent == Some(top.id) && n.kind == Kind::String) + .unwrap(); + assert_eq!(intro.value, Some(Scalar::String("Intro paragraph.".into()))); + // h2 nằm dưới h1, h3 nằm dưới h2. + let section = find("Section").unwrap(); + assert_eq!(section.parent, Some(top.id)); + let sub = find("Sub").unwrap(); + assert_eq!(sub.parent, Some(section.id)); + let sub_body = doc + .nodes + .iter() + .find(|n| n.parent == Some(sub.id) && n.kind == Kind::String) + .unwrap(); + assert_eq!(sub_body.value, Some(Scalar::String("Sub body.".into()))); + // h2 mới sau h3 quay về cha là h1; h1 mới về root. + let second = find("Second section").unwrap(); + assert_eq!(second.parent, Some(top.id)); + let back = find("Back to top").unwrap(); + assert_eq!(back.parent, Some(1)); + let outro = doc + .nodes + .iter() + .find(|n| n.parent == Some(back.id) && n.kind == Kind::String) + .unwrap(); + assert_eq!(outro.value, Some(Scalar::String("Outro.".into()))); + } + + #[test] + fn markdown_parser_code_blocks() { + let src = "\ +# Doc +```rust +fn main() { + println!(\"# not a header\"); +} +``` +After code. + +``` +plain fence +``` +"; + let doc = MarkdownParser.parse("a.md", src, 1).unwrap(); + // root, Doc, code(rust), "After code.", code(không lang) = 5 node. + assert_eq!(doc.nodes.len(), 5); + let doc_header = doc + .nodes + .iter() + .find(|n| n.key.as_deref() == Some("Doc")) + .unwrap(); + let kids: Vec<_> = doc + .nodes + .iter() + .filter(|n| n.parent == Some(doc_header.id)) + .collect(); + assert_eq!(kids.len(), 3); + // Fence có lang → key = ngôn ngữ, value = code thô; `#` trong code + // không bị parse thành header. + assert_eq!(kids[0].kind, Kind::Code); + assert_eq!(kids[0].key.as_deref(), Some("rust")); + assert!(matches!(kids[0].value, Some(Scalar::String(ref s)) if s.contains("not a header"))); + // Fence không lang → key None. + assert_eq!(kids[2].kind, Kind::Code); + assert_eq!(kids[2].key, None); + assert_eq!(kids[2].value, Some(Scalar::String("plain fence".into()))); + } + + #[test] + fn markdown_parser_preamble_and_unclosed_fence() { + // Nội dung trước header đầu tiên nằm ngay dưới root. + let doc = MarkdownParser + .parse("a.md", "Preamble line.\n\n# Title\n", 1) + .unwrap(); + assert_eq!(doc.nodes.len(), 3); // root, preamble, Title + let preamble = doc.nodes[1].clone(); + assert_eq!(preamble.parent, Some(1)); + assert_eq!(preamble.kind, Kind::String); + assert_eq!( + preamble.value, + Some(Scalar::String("Preamble line.".into())) + ); + + // Fence chưa đóng: phần code thu được vẫn thành node Code. + let doc = MarkdownParser + .parse("a.md", "# T\n```js\nlet x = 1;\n", 1) + .unwrap(); + let code = doc.nodes.iter().find(|n| n.kind == Kind::Code).unwrap(); + assert_eq!(code.key.as_deref(), Some("js")); + assert_eq!(code.value, Some(Scalar::String("let x = 1;".into()))); + } + + #[tokio::test] + async fn markdown_detect_format_and_ingest() { + assert_eq!(detect_format("README.md").unwrap(), "markdown"); + assert_eq!(detect_format("doc.Markdown").unwrap(), "markdown"); + assert_eq!(parser_for("markdown").unwrap().format(), "markdown"); + + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("README.md"); + std::fs::write(&p, "# Title\nHello.\n").unwrap(); + let storage = Arc::new(TokioRwLock::new(InMemoryStorage::default())); + let mut graph = DocumentGraph::new(storage, DocConfig::default()); + graph.ingest_file(p.to_str().unwrap(), None).await.unwrap(); + // root + Title + Hello = 3. + assert_eq!(graph.stats().await.unwrap_or_default().nodes, 3); + // Pattern cấu trúc HEADER → STRING tìm thấy paragraph "Hello." + let hits = graph + .search_struct(&[DocToken::header(), DocToken::str(0)], None) + .await + .unwrap(); + assert_eq!(hits.len(), 1); + } + #[tokio::test] async fn nginx_ingest_file() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/codegraph-docs/src/tokenize.rs b/crates/codegraph-docs/src/tokenize.rs index 7c2d5b7d4b..7b51f0fd31 100644 --- a/crates/codegraph-docs/src/tokenize.rs +++ b/crates/codegraph-docs/src/tokenize.rs @@ -14,6 +14,8 @@ pub enum DocTag { Bool, Null, Root, + Header, + Code, } impl DocTag { @@ -51,6 +53,8 @@ impl DocToken { 6 => DocTag::Bool, 7 => DocTag::Null, 8 => DocTag::Root, + 9 => DocTag::Header, + 10 => DocTag::Code, _ => DocTag::Map, } } @@ -119,6 +123,12 @@ impl DocToken { pub fn root() -> Self { Self::new(DocTag::Root, 0) } + pub fn header() -> Self { + Self::new(DocTag::Header, 0) + } + pub fn code() -> Self { + Self::new(DocTag::Code, 0) + } } #[cfg(test)] diff --git a/crates/codegraph-extract/Cargo.toml b/crates/codegraph-extract/Cargo.toml index 47c0c27de3..1f30c74993 100644 --- a/crates/codegraph-extract/Cargo.toml +++ b/crates/codegraph-extract/Cargo.toml @@ -32,6 +32,10 @@ tree-sitter-swift = { workspace = true, optional = true } # tree-sitter-kotlin uses tree-sitter 0.20 — incompatible. Re-enable when upstream upgrades. # tree-sitter-kotlin = { workspace = true, optional = true } tree-sitter-lua = { workspace = true, optional = true } +# Svelte/Astro grammar forks tương thích tree-sitter 0.25+ — host grammar chỉ +# parse markup (script là raw_text) nên cần JS/TS spec để re-parse script block. +tree-sitter-svelte-next = { workspace = true, optional = true } +tree-sitter-astro-next = { workspace = true, optional = true } codegraph-binary = { path = "../codegraph-binary", optional = true } rusqlite = { workspace = true } serde_json = { workspace = true } @@ -56,7 +60,7 @@ default = ["all-langs", "binary"] all-langs = [ "lang-typescript", "lang-javascript", "lang-python", "lang-rust", "lang-go", "lang-java", "lang-c", "lang-cpp", "lang-csharp", "lang-ruby", "lang-php", - "lang-scala", "lang-swift", "lang-lua", + "lang-scala", "lang-swift", "lang-lua", "lang-svelte", "lang-astro", ] lang-typescript = ["dep:tree-sitter-typescript"] lang-javascript = ["dep:tree-sitter-javascript"] @@ -73,4 +77,6 @@ lang-scala = ["dep:tree-sitter-scala"] lang-swift = ["dep:tree-sitter-swift"] # lang-kotlin = ["dep:tree-sitter-kotlin"] lang-lua = ["dep:tree-sitter-lua"] +lang-svelte = ["dep:tree-sitter-svelte-next", "lang-javascript", "lang-typescript"] +lang-astro = ["dep:tree-sitter-astro-next", "lang-javascript", "lang-typescript"] binary = ["dep:codegraph-binary"] diff --git a/crates/codegraph-extract/src/languages.rs b/crates/codegraph-extract/src/languages.rs index 4e76fa0bf5..2199b7be16 100644 --- a/crates/codegraph-extract/src/languages.rs +++ b/crates/codegraph-extract/src/languages.rs @@ -4,6 +4,13 @@ pub mod common; pub mod effects; pub mod flows; +/// Parser hybrid cho ngôn ngữ host + script nhúng (Svelte, Astro) — grammar +/// host chỉ parse markup, script block re-parse bằng JS/TS spec. +pub mod embedded; + +#[cfg(feature = "lang-astro")] +pub mod astro; + #[cfg(feature = "lang-c")] pub mod c; #[cfg(feature = "lang-cpp")] @@ -28,6 +35,8 @@ pub mod ruby; pub mod rust; #[cfg(feature = "lang-scala")] pub mod scala; +#[cfg(feature = "lang-svelte")] +pub mod svelte; #[cfg(feature = "lang-swift")] pub mod swift; #[cfg(feature = "lang-typescript")] diff --git a/crates/codegraph-extract/src/languages/astro.rs b/crates/codegraph-extract/src/languages/astro.rs new file mode 100644 index 0000000000..f95ccab277 --- /dev/null +++ b/crates/codegraph-extract/src/languages/astro.rs @@ -0,0 +1,54 @@ +//! Astro — parser hybrid: grammar host parse markup + frontmatter, +//! `frontmatter_js_block` (TS) và `script_element > raw_text` re-parse bằng +//! JS/TS (`languages::embedded`). + +use crate::languages::embedded; +use codegraph_graph::ParseResult; +use tree_sitter::Node; + +fn ts_language() -> tree_sitter::Language { + tree_sitter_astro_next::LANGUAGE.into() +} + +fn is_chunk(node: &Node) -> bool { + // Frontmatter (luôn TS) hoặc ` + + +"#; + let parser = codegraph_extract::languages::svelte::SvelteParser::new(); + let res = parser.parse_file("src/App.svelte", src).unwrap(); + + assert_eq!(res.language, "svelte"); + // Component Module + double + greet (count là variable_declarator Variable). + let names: Vec<(&str, SymbolKind)> = res + .symbols + .iter() + .map(|s| (s.name.as_str(), s.kind)) + .collect(); + assert!(names.contains(&("App", SymbolKind::Module)), "{names:?}"); + assert!( + names.contains(&("double", SymbolKind::Function)), + "{names:?}" + ); + assert!( + names.contains(&("greet", SymbolKind::Function)), + "{names:?}" + ); + + // Line number khớp file gốc (không phải của script content). + let greet = res.symbols.iter().find(|s| s.name == "greet").unwrap(); + assert_eq!(greet.line, 7, "greet nằm ở dòng 7 của file .svelte"); + + // Chain: greet gọi helper qua placeholder + CallRecord. + let greet_id = greet.id; + let chain = res.chains.get(&greet_id).expect("greet có chain"); + assert_eq!(chain[0], greet_id); + assert!(chain.contains(&0), "greet gọi helper → placeholder 0"); + let call = res + .calls + .iter() + .find(|c| c.caller_id == greet_id && c.call_name == "helper") + .expect("call record helper"); + assert_eq!(call.line, 8); + + // Import deps từ script block → resolve cross-file. + let deps = res.file_deps.as_ref().expect("có import"); + assert!( + deps.imports + .iter() + .any(|i| i.alias == "helper" && i.path == "./helper"), + "{:?}", + deps.imports + ); +} + +#[test] +fn svelte_ts_script_uses_typescript_spec() { + let src = r#" + +

{name}

+"#; + let parser = codegraph_extract::languages::svelte::SvelteParser::new(); + let res = parser.parse_file("src/Comp.svelte", src).unwrap(); + // TS param annotation vẫn ra Parameter (script parse bằng TS spec). + let shout = res.symbols.iter().find(|s| s.name == "shout").unwrap(); + assert_eq!(shout.kind, SymbolKind::Function); + assert_eq!(shout.line, 3); +} + +#[test] +fn svelte_style_block_is_not_parsed_as_js() { + let src = r#" + + +"#; + let parser = codegraph_extract::languages::svelte::SvelteParser::new(); + let res = parser.parse_file("src/B.svelte", src).unwrap(); + // CSS không ra symbol rác; chỉ component + biến x (variable_declarator). + let names: Vec<&str> = res.symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&"B"), "{names:?}"); + assert!(!names.iter().any(|n| n.contains("color")), "{names:?}"); +} + +#[test] +fn astro_frontmatter_and_script_tag() { + let src = r#"--- +import Card from '../components/Card.astro'; +const title = 'Hello'; + +function buildGreeting(n: string): string { + return `hi ${n}`; +} +--- + + + + +"#; + let parser = codegraph_extract::languages::astro::AstroParser::new(); + let res = parser.parse_file("src/pages/index.astro", src).unwrap(); + + assert_eq!(res.language, "astro"); + let names: Vec<(&str, SymbolKind)> = res + .symbols + .iter() + .map(|s| (s.name.as_str(), s.kind)) + .collect(); + assert!(names.contains(&("index", SymbolKind::Module)), "{names:?}"); + assert!( + names.contains(&("buildGreeting", SymbolKind::Function)), + "{names:?}" + ); + assert!( + names.contains(&("clientGreet", SymbolKind::Function)), + "{names:?}" + ); + + // Frontmatter là TS: param annotation vẫn extract. + let bg = res + .symbols + .iter() + .find(|s| s.name == "buildGreeting") + .unwrap(); + assert_eq!(bg.line, 5); + + // clientGreet (script tag, JS) gọi buildGreeting (frontmatter) — 2 chunk + // khác nhau, id không đè nhau. + let cg = res + .symbols + .iter() + .find(|s| s.name == "clientGreet") + .unwrap(); + assert_ne!(cg.id, bg.id); + let call = res + .calls + .iter() + .find(|c| c.caller_id == cg.id && c.call_name == "buildGreeting") + .expect("call buildGreeting trong clientGreet"); + assert_eq!(call.line, 11); + + let deps = res.file_deps.as_ref().expect("có import Card"); + assert!( + deps.imports + .iter() + .any(|i| i.alias == "Card" && i.path == "../components/Card.astro"), + "{:?}", + deps.imports + ); +} + +#[test] +fn registry_dispatches_by_extension() { + use codegraph_extract::registry; + let parsers = registry(); + let svelte = parsers + .iter() + .find(|p| p.name() == "svelte") + .expect("svelte"); + assert!(svelte.extensions().contains(&"svelte")); + let astro = parsers.iter().find(|p| p.name() == "astro").expect("astro"); + assert!(astro.extensions().contains(&"astro")); +} + +#[test] +fn unique_symbol_ids_across_chunks() { + // Nhiều chunk → id local phải unique (shift theo số symbol đã gộp). + let src = r#" + +"#; + let parser = codegraph_extract::languages::svelte::SvelteParser::new(); + let res = parser.parse_file("src/M.svelte", src).unwrap(); + let mut ids: Vec = res.symbols.iter().map(|s| s.id).collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), res.symbols.len(), "id trùng: {:?}", res.symbols); + let names: Vec<&str> = res.symbols.iter().map(|s| s.name.as_str()).collect(); + assert!(names.contains(&("a")), "{names:?}"); + assert!(names.contains(&("b")), "{names:?}"); +} diff --git a/crates/codegraph-mcp/src/response_tests.rs b/crates/codegraph-mcp/src/response_tests.rs index e10c8e693d..62fdb79478 100644 --- a/crates/codegraph-mcp/src/response_tests.rs +++ b/crates/codegraph-mcp/src/response_tests.rs @@ -156,7 +156,7 @@ fn every_registered_tool_advertises_output_controls() { if tool.name == "codegraph_graphdoc_ingest" { assert_eq!( props["format"]["enum"], - json!(["hcl", "yaml", "json", "toml"]) + json!(["hcl", "yaml", "json", "toml", "nginx", "markdown"]) ); } } diff --git a/crates/codegraph-mcp/src/tools.rs b/crates/codegraph-mcp/src/tools.rs index 19ba2b0e7e..acd6fdad29 100644 --- a/crates/codegraph-mcp/src/tools.rs +++ b/crates/codegraph-mcp/src/tools.rs @@ -348,10 +348,10 @@ fn tool_defs() -> Vec { // ── Document tools (codegraph_graphdoc_*) ── tool( "codegraph_graphdoc_ingest", - "Parse and ingest a document file (HCL/Terraform, YAML, JSON, TOML). The file is read, parsed by the appropriate format parser, and added to the document graph.", + "Parse and ingest a document file (HCL/Terraform, YAML, JSON, TOML, nginx, Markdown). The file is read, parsed by the appropriate format parser, and added to the document graph.", json!({ "type": "object", "properties": { "path": { "type": "string", "description": "Path to the document file." }, - "format": { "type": "string", "enum": ["hcl", "yaml", "json", "toml"], "description": "Override auto-detected format. If omitted, format is inferred from file extension (.tf/.hcl → hcl, .yaml/.yml → yaml, .json → json, .toml → toml)." } + "format": { "type": "string", "enum": ["hcl", "yaml", "json", "toml", "nginx", "markdown"], "description": "Override auto-detected format. If omitted, format is inferred from file extension (.tf/.hcl → hcl, .yaml/.yml → yaml, .json → json, .toml → toml, .conf/.nginx → nginx, .md/.markdown → markdown)." } }, "required": ["path"] }), ), tool( @@ -1818,7 +1818,7 @@ pub async fn dispatch_doc_ingest_dir( path: &str, limit: usize, ) -> Result { - const EXTS: [&str; 6] = ["yaml", "yml", "json", "toml", "tf", "hcl"]; + const EXTS: [&str; 8] = ["yaml", "yml", "json", "toml", "tf", "hcl", "md", "markdown"]; let mut files = Vec::new(); let mut stack = vec![std::path::PathBuf::from(path)]; while let Some(dir) = stack.pop() {