diff --git a/Cargo.lock b/Cargo.lock index b3d35f6..2d33d6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -432,7 +432,7 @@ dependencies = [ [[package]] name = "codegraph-bench" -version = "0.37.0" +version = "0.38.0" dependencies = [ "anyhow", "clap", @@ -447,7 +447,7 @@ dependencies = [ [[package]] name = "codegraph-core" -version = "0.37.0" +version = "0.38.0" dependencies = [ "anyhow", "serde", @@ -463,7 +463,7 @@ dependencies = [ [[package]] name = "codegraph-daemon" -version = "0.37.0" +version = "0.38.0" dependencies = [ "anyhow", "codegraph-core", @@ -483,7 +483,7 @@ dependencies = [ [[package]] name = "codegraph-extract" -version = "0.37.0" +version = "0.38.0" dependencies = [ "anyhow", "codegraph-core", @@ -497,6 +497,7 @@ dependencies = [ "tree-sitter-arkts", "tree-sitter-c", "tree-sitter-c-sharp", + "tree-sitter-cfml", "tree-sitter-cpp", "tree-sitter-css", "tree-sitter-dart", @@ -530,7 +531,7 @@ dependencies = [ [[package]] name = "codegraph-graph" -version = "0.37.0" +version = "0.38.0" dependencies = [ "anyhow", "codegraph-core", @@ -544,7 +545,7 @@ dependencies = [ [[package]] name = "codegraph-mcp" -version = "0.37.0" +version = "0.38.0" dependencies = [ "anyhow", "axum", @@ -567,7 +568,7 @@ dependencies = [ [[package]] name = "codegraph-resolve" -version = "0.37.0" +version = "0.38.0" dependencies = [ "anyhow", "codegraph-core", @@ -581,7 +582,7 @@ dependencies = [ [[package]] name = "codegraph-rs" -version = "0.37.0" +version = "0.38.0" dependencies = [ "anyhow", "assert_cmd", @@ -617,7 +618,7 @@ dependencies = [ [[package]] name = "codegraph-store" -version = "0.37.0" +version = "0.38.0" dependencies = [ "anyhow", "codegraph-core", @@ -629,7 +630,7 @@ dependencies = [ [[package]] name = "codegraph-watch" -version = "0.37.0" +version = "0.38.0" dependencies = [ "anyhow", "codegraph-core", @@ -3456,6 +3457,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-cfml" +version = "0.26.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73c959e3728a4da88bff330340990ee2dbd9864901422760f7abfc4c93be6a95" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-cpp" version = "0.23.4" diff --git a/README.md b/README.md index 380f1e6..1bb9b14 100644 --- a/README.md +++ b/README.md @@ -439,7 +439,7 @@ Omit `--install` to print to stdout. Full per-shell install paths and notes: ## What CodeGraph Does (and Doesn't) -**Does:** deterministic code-structure extraction across 37 languages (TypeScript, +**Does:** deterministic code-structure extraction across 38 languages (TypeScript, Python, Go, Rust, Java, C/C++, C#, Vue, Svelte, GDScript, and more — see [`docs/languages.md`](docs/languages.md)), cross-file resolution (including Godot project graphs), graph traversal, FTS5 search, whole-graph export with @@ -454,9 +454,9 @@ beyond the fixed `LANGUAGES` set. ## Supported Languages -CodeGraph supports **37 languages** grouped by extraction depth. Quick overview: +CodeGraph supports **38 languages** grouped by extraction depth. Quick overview: -- **Tier 1 — Full symbol extraction (28):** TypeScript, TSX, JavaScript, JSX, ArkTS, Python, Go, Rust, Java, C, C++, C#, PHP, Ruby, Swift, Kotlin, Dart, Scala, Lua, Luau, Objective-C, R, Solidity, Nix, Terraform, Erlang, GDScript, Pascal. +- **Tier 1 — Full symbol extraction (29):** TypeScript, TSX, JavaScript, JSX, ArkTS, Python, Go, Rust, Java, C, C++, C#, PHP, Ruby, Swift, Kotlin, Dart, Scala, Lua, Luau, Objective-C, R, Solidity, Nix, Terraform, Erlang, CFML, GDScript, Pascal. - **Tier 2 — Embedded / template extraction (6):** Vue, Svelte, Astro, Razor/`.cshtml`, Liquid, XML/MyBatis mapper. - **Tier 3 — File-level only (3):** YAML, Twig, Properties. diff --git a/crates/codegraph-bench/fixtures/cfml/Base.cfc b/crates/codegraph-bench/fixtures/cfml/Base.cfc new file mode 100644 index 0000000..0fcaff3 --- /dev/null +++ b/crates/codegraph-bench/fixtures/cfml/Base.cfc @@ -0,0 +1,5 @@ +component { + public function ping() { + return true; + } +} diff --git a/crates/codegraph-bench/fixtures/cfml/Gadget.cfs b/crates/codegraph-bench/fixtures/cfml/Gadget.cfs new file mode 100644 index 0000000..91277d7 --- /dev/null +++ b/crates/codegraph-bench/fixtures/cfml/Gadget.cfs @@ -0,0 +1,6 @@ +component extends="Base" { + property name="x"; + public function doThing() { + helper(); + } +} diff --git a/crates/codegraph-bench/fixtures/cfml/Widget.cfm b/crates/codegraph-bench/fixtures/cfml/Widget.cfm new file mode 100644 index 0000000..7655057 --- /dev/null +++ b/crates/codegraph-bench/fixtures/cfml/Widget.cfm @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/codegraph-bench/tests/equivalence.rs b/crates/codegraph-bench/tests/equivalence.rs index 2f5346b..e499c82 100644 --- a/crates/codegraph-bench/tests/equivalence.rs +++ b/crates/codegraph-bench/tests/equivalence.rs @@ -230,6 +230,31 @@ fn erlang_db_is_self_equivalent_to_erlang_golden() { assert_equivalent(&erlang_db(), &erlang_golden_dir()).unwrap(); } +#[test] +fn generated_golden_matches_committed_cfml_fixture() { + // Guards CFML extraction (upstream #1153, scope-B extraction slice): the + // `.cfc`/`.cfm`/`.cfs`->Cfml mapping, the dual-grammar dialect switch + // (`is_bare_script_cfml`: cfscript for bare-script, cfml tag grammar for + // tag files), a bare-script `component`->Class named from the FILE (unnamed + // in the grammar) + script-style `extends`->Extends, a tag + // ``->Class from `name` attr + ``->Method with + // access/returntype + tag `extends`->Extends. Both extends resolve to + // `Base.cfc`; `helper()` stays unresolved. The ``-in-tag delegation + // + cfquery SQL-body extraction + framework resolvers are DEFERRED. + let tempdir = TestDir::new("generated-golden-cfml"); + write_golden(&cfml_db(), tempdir.path()).unwrap(); + + let expected = load_golden(&cfml_golden_dir()).unwrap(); + let actual = load_golden(tempdir.path()).unwrap(); + + diff_canonical(&expected, &actual, None).unwrap(); +} + +#[test] +fn cfml_db_is_self_equivalent_to_cfml_golden() { + assert_equivalent(&cfml_db(), &cfml_golden_dir()).unwrap(); +} + #[test] fn tier1_node_drift_is_reported() { let expected = load_golden(&mini_golden_dir()).unwrap(); @@ -364,6 +389,14 @@ fn erlang_golden_dir() -> PathBuf { workspace_root().join("reference/golden/erlang") } +fn cfml_db() -> PathBuf { + workspace_root().join("reference/golden/cfml/colby.db") +} + +fn cfml_golden_dir() -> PathBuf { + workspace_root().join("reference/golden/cfml") +} + struct TestDir { path: PathBuf, } diff --git a/crates/codegraph-core/src/types.rs b/crates/codegraph-core/src/types.rs index d87c509..a03b254 100644 --- a/crates/codegraph-core/src/types.rs +++ b/crates/codegraph-core/src/types.rs @@ -49,7 +49,7 @@ pub const EDGE_KIND_STRINGS: [&str; 12] = [ "decorates", ]; -pub const LANGUAGE_STRINGS: [&str; 41] = [ +pub const LANGUAGE_STRINGS: [&str; 42] = [ "typescript", "javascript", "tsx", @@ -82,6 +82,7 @@ pub const LANGUAGE_STRINGS: [&str; 41] = [ "nix", "terraform", "erlang", + "cfml", "yaml", "twig", "xml", @@ -335,6 +336,8 @@ pub enum Language { Terraform, #[serde(rename = "erlang")] Erlang, + #[serde(rename = "cfml")] + Cfml, #[serde(rename = "yaml")] Yaml, #[serde(rename = "twig")] @@ -356,7 +359,7 @@ pub enum Language { } impl Language { - pub const ALL: [Self; 41] = [ + pub const ALL: [Self; 42] = [ Self::TypeScript, Self::JavaScript, Self::Tsx, @@ -389,6 +392,7 @@ impl Language { Self::Nix, Self::Terraform, Self::Erlang, + Self::Cfml, Self::Yaml, Self::Twig, Self::Xml, @@ -434,6 +438,7 @@ impl Language { Self::Nix => "nix", Self::Terraform => "terraform", Self::Erlang => "erlang", + Self::Cfml => "cfml", Self::Yaml => "yaml", Self::Twig => "twig", Self::Xml => "xml", diff --git a/crates/codegraph-extract/Cargo.toml b/crates/codegraph-extract/Cargo.toml index 42b3d52..463435f 100644 --- a/crates/codegraph-extract/Cargo.toml +++ b/crates/codegraph-extract/Cargo.toml @@ -26,6 +26,7 @@ tracing = { workspace = true } tree-sitter-arkts = "0.2.0" tree-sitter-c = "0.24.2" tree-sitter-c-sharp = "0.23.5" +tree-sitter-cfml = "0.26.30" tree-sitter-cpp = "0.23.4" tree-sitter-css = "0.25.0" tree-sitter-dart = "0.2.0" diff --git a/crates/codegraph-extract/src/engine.rs b/crates/codegraph-extract/src/engine.rs index 5f0bc17..1e834e5 100644 --- a/crates/codegraph-extract/src/engine.rs +++ b/crates/codegraph-extract/src/engine.rs @@ -86,6 +86,7 @@ pub fn builtin_language_for_ext(ext: &str) -> Option { "nix" => Language::Nix, "tf" | "tfvars" | "tofu" => Language::Terraform, "erl" | "hrl" => Language::Erlang, + "cfc" | "cfm" | "cfs" => Language::Cfml, "yml" | "yaml" => Language::Yaml, "twig" => Language::Twig, "xml" => Language::Xml, @@ -203,7 +204,15 @@ pub fn extract_source( }; let mut parser = Parser::new(); - let ts_language = spec.tree_sitter_language(); + // Run `pre_parse` first so the grammar selection can see the (possibly + // rewritten) source. Only CFML overrides `tree_sitter_language_for_source` + // to pick between its cfscript / cfml tag grammars per file dialect; every + // other spec inherits the default (`tree_sitter_language`), so this is a + // behavior-neutral reorder (their `pre_parse` is the identity default, and + // the two that override it — C++/embedded — don't override the grammar + // hook, so their parse path is byte-identical). + let parsed_source = spec.pre_parse(source, file_path); + let ts_language = spec.tree_sitter_language_for_source(&parsed_source); if let Err(error) = parser.set_language(&ts_language) { return ExtractionResult { nodes: Vec::new(), @@ -213,7 +222,6 @@ pub fn extract_source( duration_ms: start.elapsed().as_millis() as i64, }; } - let parsed_source = spec.pre_parse(source, file_path); let Some(tree) = parser.parse(&parsed_source, None) else { return ExtractionResult { nodes: Vec::new(), @@ -874,7 +882,7 @@ mod tests { assert_eq!(detect_language("s.metal"), Language::Cpp); assert_eq!(detect_language("k.cu"), Language::Cpp); assert_eq!(detect_language("k.cuh"), Language::Cpp); - assert_eq!(Language::ALL.len(), 41); + assert_eq!(Language::ALL.len(), 42); } #[test] @@ -905,6 +913,13 @@ mod tests { assert_eq!(detect_language("defs.hrl"), Language::Erlang); } + #[test] + fn cfml_extensions_map_to_cfml() { + assert_eq!(detect_language("Widget.cfc"), Language::Cfml); + assert_eq!(detect_language("page.cfm"), Language::Cfml); + assert_eq!(detect_language("Gadget.cfs"), Language::Cfml); + } + #[test] fn plain_ts_stays_typescript() { assert_eq!(detect_language("m.ts"), Language::TypeScript); diff --git a/crates/codegraph-extract/src/lang/cfml.rs b/crates/codegraph-extract/src/lang/cfml.rs new file mode 100644 index 0000000..4a8250c --- /dev/null +++ b/crates/codegraph-extract/src/lang/cfml.rs @@ -0,0 +1,401 @@ +//! CFML / ColdFusion (`.cfc` / `.cfm` / `.cfs`) `LanguageSpec`. +//! +//! Extraction-tier port of `upstream extraction/cfml-extractor.ts` + +//! `languages/cfscript.ts` (commit `816bacb`, #1153 — the extraction slice +//! only, scope B). CFML is DUAL-GRAMMAR: the `tree-sitter-cfml` crate bundles a +//! `cfscript` grammar (modern script style) and a `cfml` tag grammar. A file's +//! dialect is chosen by a first-token sniff (`is_bare_script_cfml`): a `<` +//! first token is a tag file, anything else is script. [`CfmlSpec`] wires the +//! cfscript grammar as the DEFAULT and overrides +//! [`LanguageSpec::tree_sitter_language_for_source`] to hand back the cfml tag +//! grammar for tag files, so a `.cfc`/`.cfm`/`.cfs` file is parsed with EXACTLY +//! one grammar. +//! +//! Bare-script files drive the generic type-set dispatch (`component` → Class, +//! `function_declaration` → function/method, `property_declaration` → Property, +//! `call_expression` → Calls, `import_statement`/`include_statement` → Import); +//! the `Language::Cfml`-guarded `visit_cfml_node` walker extension owns the +//! script `component` (file-name rename + script-style `extends`) and the tag +//! grammar's `cf_component_open_tag`/`cf_function_tag`. +//! +//! DEFERRED (recorded follow-ups, consistent with H1–H5): the +//! ``-in-tag-body re-parse delegation, the `cfquery` SQL-body +//! extraction (`LANGUAGE_CFQUERY`), and the CFML framework RESOLVER bridges. + +use codegraph_core::types::Language; +use tree_sitter::{Language as TsLanguage, Node}; + +use crate::spec::{ImportInfo, LanguageSpec}; +use crate::walker::{child_by_field, node_text}; + +pub struct CfmlSpec; + +pub static CFML_SPEC: CfmlSpec = CfmlSpec; + +impl LanguageSpec for CfmlSpec { + fn language(&self) -> Language { + Language::Cfml + } + + fn tree_sitter_language(&self) -> TsLanguage { + tree_sitter_cfml::LANGUAGE_CFSCRIPT.into() + } + + fn tree_sitter_language_for_source(&self, source: &str) -> TsLanguage { + if is_bare_script_cfml(source) { + tree_sitter_cfml::LANGUAGE_CFSCRIPT.into() + } else { + tree_sitter_cfml::LANGUAGE_CFML.into() + } + } + + fn function_types(&self) -> &'static [&'static str] { + &[ + "function_declaration", + "function_expression", + "arrow_function", + ] + } + + fn class_types(&self) -> &'static [&'static str] { + // The script `component`/`interface` is handled directly by + // `visit_cfml_node` (file-name rename + script-style extends), which + // short-circuits this dispatch; declared for spec self-description. + &["component"] + } + + fn method_types(&self) -> &'static [&'static str] { + &["function_declaration", "method_definition"] + } + + fn interface_types(&self) -> &'static [&'static str] { + &[] + } + + fn struct_types(&self) -> &'static [&'static str] { + &[] + } + + fn enum_types(&self) -> &'static [&'static str] { + &[] + } + + fn enum_member_types(&self) -> &'static [&'static str] { + &[] + } + + fn type_alias_types(&self) -> &'static [&'static str] { + &[] + } + + fn import_types(&self) -> &'static [&'static str] { + &["import_statement", "include_statement"] + } + + fn call_types(&self) -> &'static [&'static str] { + &["call_expression"] + } + + fn variable_types(&self) -> &'static [&'static str] { + &["variable_declaration"] + } + + fn property_types(&self) -> &'static [&'static str] { + &["property_declaration"] + } + + fn name_field(&self) -> &'static str { + "name" + } + + fn body_field(&self) -> &'static str { + "body" + } + + fn params_field(&self) -> &'static str { + "parameters" + } + + fn return_field(&self) -> &'static str { + "" + } + + fn classify_class_node(&self, node: Node<'_>) -> codegraph_core::types::NodeKind { + if node.child(0).map(|c| c.kind()) == Some("interface") { + codegraph_core::types::NodeKind::Interface + } else { + codegraph_core::types::NodeKind::Class + } + } + + fn get_signature(&self, node: Node<'_>, source: &str) -> Option { + let params = child_by_field(node, "parameters")?; + Some(node_text(params, source)) + } + + fn get_visibility(&self, node: Node<'_>) -> Option { + let access = node + .named_children(&mut node.walk()) + .find(|c| c.kind() == "access_type")?; + let keyword = access.child(0).map(|c| c.kind()).unwrap_or(""); + let visibility = match keyword { + "private" => "private", + "package" => "internal", + "remote" | "public" => "public", + _ => return None, + }; + Some(visibility.to_string()) + } + + fn extract_property_name(&self, node: Node<'_>, source: &str) -> Option { + cfml_property_name(node, source) + } + + fn extract_import(&self, node: Node<'_>, source: &str) -> Option { + let module_name = if node.kind() == "include_statement" { + let string = node + .named_children(&mut node.walk()) + .find(|c| c.kind() == "string")?; + cfml_unquote_string(string, source) + } else { + let source_node = child_by_field(node, "source")?; + match source_node.kind() { + "import_path" => { + let parts: Vec = source_node + .named_children(&mut source_node.walk()) + .filter(|c| c.kind() == "identifier") + .map(|c| node_text(c, source)) + .collect(); + parts.join(".") + } + "string" => cfml_unquote_string(source_node, source), + _ => node_text(source_node, source), + } + }; + if module_name.is_empty() { + return None; + } + Some(ImportInfo { + module_name, + signature: node_text(node, source).trim().chars().take(200).collect(), + handled_refs: false, + }) + } +} + +/// First-token dialect sniff. Skips a leading UTF-8 BOM, whitespace, and `//` +/// and `/* */` comments to the first real token; returns `true` (bare script) +/// when that token is NOT `<` (tag files open with `<`). Empty / all-trivia +/// source → `true` (a script no-op). Ports `isBareScriptCfml` +/// (cfml-extractor.ts). +pub(crate) fn is_bare_script_cfml(source: &str) -> bool { + let bytes = source.as_bytes(); + let mut i = 0; + if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { + i = 3; + } + while i < bytes.len() { + let b = bytes[i]; + if b.is_ascii_whitespace() { + i += 1; + continue; + } + if b == b'/' && i + 1 < bytes.len() { + match bytes[i + 1] { + b'/' => { + i += 2; + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + continue; + } + b'*' => { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i += 2; + continue; + } + _ => {} + } + } + return b != b'<'; + } + true +} + +/// The component name from a file path: the basename with a `.cfc`/`.cfm`/`.cfs` +/// extension stripped (case-insensitive). Ports `componentNameFromPath`. +pub(crate) fn cfml_component_name_from_path(file_path: &str) -> String { + let base = file_path.rsplit(['/', '\\']).next().unwrap_or(file_path); + let lower = base.to_ascii_lowercase(); + for ext in [".cfc", ".cfm", ".cfs"] { + if lower.ends_with(ext) { + return base[..base.len() - ext.len()].to_string(); + } + } + base.to_string() +} + +/// A tag attribute value by name (case-insensitive), for the tag grammar. Scans +/// the tag's `cf_attribute` children — directly (`cf_function_tag`) and inside +/// each `cf_tag_attributes` wrapper (`cf_component_open_tag`) — matching the +/// `cf_attribute_name` leaf, and reads the value from the +/// `quoted_cf_attribute_value` / `cf_attribute_value`'s inner `attribute_value`. +/// Ports `tagAttr`. +pub(crate) fn cfml_tag_attr(tag: Node<'_>, name: &str, source: &str) -> Option { + fn attr_value(attr: Node<'_>, source: &str) -> Option { + let value = attr + .named_children(&mut attr.walk()) + .find(|c| matches!(c.kind(), "quoted_cf_attribute_value" | "cf_attribute_value"))?; + let inner = value + .named_children(&mut value.walk()) + .find(|c| c.kind() == "attribute_value") + .unwrap_or(value); + Some(node_text(inner, source)) + } + fn matches_name(attr: Node<'_>, name: &str, source: &str) -> bool { + attr.named_children(&mut attr.walk()) + .find(|c| c.kind() == "cf_attribute_name") + .map(|n| node_text(n, source).eq_ignore_ascii_case(name)) + .unwrap_or(false) + } + for child in tag.named_children(&mut tag.walk()) { + match child.kind() { + "cf_attribute" if matches_name(child, name, source) => { + return attr_value(child, source); + } + "cf_tag_attributes" => { + for attr in child.named_children(&mut child.walk()) { + if attr.kind() == "cf_attribute" && matches_name(attr, name, source) { + return attr_value(attr, source); + } + } + } + _ => {} + } + } + None +} + +/// A script-style `component_attribute(identifier, string)` value by name +/// (case-insensitive), for the cfscript grammar — used for script-style +/// `extends="Base"`. Matches the `identifier` child and returns the `string` +/// child's value with quotes stripped. +pub(crate) fn cfml_string_attr_value(node: Node<'_>, name: &str, source: &str) -> Option { + for attr in node.named_children(&mut node.walk()) { + if attr.kind() != "component_attribute" { + continue; + } + let ident = attr + .named_children(&mut attr.walk()) + .find(|c| c.kind() == "identifier"); + let Some(ident) = ident else { continue }; + if !node_text(ident, source).eq_ignore_ascii_case(name) { + continue; + } + if let Some(string) = attr + .named_children(&mut attr.walk()) + .find(|c| c.kind() == "string") + { + let value = cfml_unquote_string(string, source); + if !value.is_empty() { + return Some(value); + } + } + } + None +} + +/// The name of a `property_declaration`. Tries the optional `name` field (the +/// typed form `property String x;`) then the attribute form `property name="x";` +/// whose name lives in a `component_attribute(identifier="name", string)` pair. +pub(crate) fn cfml_property_name(node: Node<'_>, source: &str) -> Option { + if let Some(name_node) = child_by_field(node, "name") { + let text = node_text(name_node, source); + if !text.is_empty() { + return Some(text); + } + } + cfml_string_attr_value(node, "name", source) +} + +/// Strip the surrounding quotes from a cfscript `string` node, reading the +/// `string_fragment` child when present. +pub(crate) fn cfml_unquote_string(string: Node<'_>, source: &str) -> String { + if let Some(fragment) = string + .named_children(&mut string.walk()) + .find(|c| c.kind() == "string_fragment") + { + return node_text(fragment, source); + } + node_text(string, source) + .trim() + .trim_matches(|c| c == '"' || c == '\'') + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(lang: TsLanguage, src: &str) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + fn first_of_kind<'t>(node: Node<'t>, kind: &str) -> Option> { + if node.kind() == kind { + return Some(node); + } + for i in 0..node.named_child_count() as u32 { + let child = node.named_child(i)?; + if let Some(found) = first_of_kind(child, kind) { + return Some(found); + } + } + None + } + + #[test] + fn cfml_spec_dialect_switch() { + assert!(is_bare_script_cfml("component {}")); + assert!(!is_bare_script_cfml("")); + assert!(is_bare_script_cfml("\u{FEFF}// x\ncomponent{}")); + assert!(is_bare_script_cfml("/* c */ component {}")); + assert!(is_bare_script_cfml("")); + assert!(is_bare_script_cfml(" \n ")); + } + + #[test] + fn cfml_component_name_from_path_strips_ext() { + assert_eq!(cfml_component_name_from_path("a/Gadget.cfs"), "Gadget"); + assert_eq!(cfml_component_name_from_path("X.CFC"), "X"); + assert_eq!(cfml_component_name_from_path("dir\\Widget.cfm"), "Widget"); + } + + #[test] + fn cfml_script_component_parses() { + let tree = parse( + tree_sitter_cfml::LANGUAGE_CFSCRIPT.into(), + "component extends=\"Base\" { }", + ); + let component = first_of_kind(tree.root_node(), "component").expect("component"); + assert_eq!( + cfml_string_attr_value(component, "extends", "component extends=\"Base\" { }") + .as_deref(), + Some("Base") + ); + } + + #[test] + fn cfml_tag_component_parses() { + let src = ""; + let tree = parse(tree_sitter_cfml::LANGUAGE_CFML.into(), src); + let open = first_of_kind(tree.root_node(), "cf_component_open_tag").expect("open tag"); + assert_eq!(cfml_tag_attr(open, "name", src).as_deref(), Some("W")); + assert_eq!(cfml_tag_attr(open, "extends", src).as_deref(), Some("B")); + } +} diff --git a/crates/codegraph-extract/src/lang/mod.rs b/crates/codegraph-extract/src/lang/mod.rs index bccc825..ade65d1 100644 --- a/crates/codegraph-extract/src/lang/mod.rs +++ b/crates/codegraph-extract/src/lang/mod.rs @@ -5,6 +5,7 @@ mod arkts; mod c; +mod cfml; mod cpp; mod csharp; mod dart; @@ -38,6 +39,10 @@ use crate::spec::LanguageSpec; pub use arkts::ARKTS_SPEC; pub use c::C_SPEC; +pub use cfml::CFML_SPEC; +pub(crate) use cfml::{ + cfml_component_name_from_path, cfml_string_attr_value, cfml_tag_attr, is_bare_script_cfml, +}; pub use cpp::CPP_SPEC; pub(crate) use cpp::{ExportMacroClass, detect_export_macro_class}; pub use csharp::CSHARP_SPEC; @@ -110,6 +115,7 @@ pub fn spec_for_language(language: Language) -> Option<&'static dyn LanguageSpec Language::Nix => Some(&NIX_SPEC), Language::Terraform => Some(&TERRAFORM_SPEC), Language::Erlang => Some(&ERLANG_SPEC), + Language::Cfml => Some(&CFML_SPEC), Language::Gdscript => Some(&GDSCRIPT_SPEC), _ => None, } diff --git a/crates/codegraph-extract/src/spec.rs b/crates/codegraph-extract/src/spec.rs index 2b87fe0..7a4b6aa 100644 --- a/crates/codegraph-extract/src/spec.rs +++ b/crates/codegraph-extract/src/spec.rs @@ -20,6 +20,15 @@ pub trait LanguageSpec: Sync { fn language(&self) -> Language; fn tree_sitter_language(&self) -> TsLanguage; + /// Select the tree-sitter grammar for a specific file's SOURCE, defaulting + /// to [`Self::tree_sitter_language`]. Only the dual-grammar CFML spec + /// overrides this (cfscript vs cfml tag grammar, chosen by a first-token + /// dialect sniff); every other spec inherits the default so its parse path + /// is byte-identical. + fn tree_sitter_language_for_source(&self, _source: &str) -> TsLanguage { + self.tree_sitter_language() + } + fn function_types(&self) -> &'static [&'static str]; fn class_types(&self) -> &'static [&'static str]; fn method_types(&self) -> &'static [&'static str]; diff --git a/crates/codegraph-extract/src/walker.rs b/crates/codegraph-extract/src/walker.rs index 5c39053..2ea0795 100644 --- a/crates/codegraph-extract/src/walker.rs +++ b/crates/codegraph-extract/src/walker.rs @@ -49,6 +49,11 @@ pub struct TreeSitterWalker<'a, 'tree> { /// clause and attaches to the existing node. Empty outside Erlang. erlang_last_fn_name: Option, erlang_last_fn_id: Option, + /// CFML tag-dialect state: the byte offset up to which a + /// `cf_component_open_tag`'s following siblings (the implicit-end-tag + /// component body) have already been consumed, so the top-level `program` + /// walk skips them instead of re-visiting. 0 outside CFML tag files. + cfml_consumed_until: usize, } impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { @@ -72,6 +77,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { namespace_prefix: Vec::new(), erlang_last_fn_name: None, erlang_last_fn_id: None, + cfml_consumed_until: 0, } } @@ -239,6 +245,7 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { Language::Nix => self.visit_nix_node(node), Language::Terraform => self.visit_terraform_node(node), Language::Erlang => self.visit_erlang_node(node), + Language::Cfml => self.visit_cfml_node(node), _ => false, } } @@ -263,6 +270,168 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { /// so `-spec f(integer()) -> integer().` mints no bogus `integer` call ref. /// The `-behaviour`, gen_server, spawn/apply MFA, and `.app` resource-tuple /// bridges are DEFERRED. + /// CFML extraction (upstream `CfmlExtractor`, `cfml-extractor.ts`, #1153 — + /// scope-B extraction slice). A first-token sniff (`is_bare_script_cfml`) + /// selects the dialect. Bare-script: the cfscript type-set config drives the + /// generic dispatch, so this visitor owns only the unnamed script + /// `component`/`interface` (file-name rename + script-style `extends`). Tag: + /// owns `cf_component_open_tag`/`cf_function_tag`; `cf_script_tag`/ + /// `cf_query_tag` inner-body delegation is DEFERRED. `Language::Cfml`-guarded. + fn visit_cfml_node(&mut self, node: SyntaxNode<'tree>) -> bool { + if crate::lang::is_bare_script_cfml(self.source) { + match node.kind() { + "component" | "interface" => { + self.emit_cfml_script_component(node); + true + } + _ => false, + } + } else { + if self.cfml_consumed_until > 0 && node.end_byte() <= self.cfml_consumed_until { + return true; + } + match node.kind() { + "cf_component_open_tag" => { + self.emit_cfml_tag_component(node); + true + } + "cf_function_tag" => { + self.emit_cfml_tag_function(node, false); + true + } + "cf_script_tag" | "cf_query_tag" => true, + _ => false, + } + } + } + + /// Ports `extractBareScript`: the unnamed script `component`/`interface` is + /// named from the FILE, qualified `{file}::{name}`; script-style `extends` + /// becomes an `Extends` ref; the body is walked for the generic dispatch. + fn emit_cfml_script_component(&mut self, node: SyntaxNode<'tree>) { + let source = self.source; + let name = crate::lang::cfml_component_name_from_path(self.file_path); + let kind = match self.spec.classify_class_node(node) { + NodeKind::Interface => NodeKind::Interface, + _ => NodeKind::Class, + }; + let qualified_name = format!("{}::{}", self.file_path, name); + let Some(created) = self.create_node( + kind, + &name, + node, + NodeExtra { + qualified_name: Some(qualified_name), + ..NodeExtra::default() + }, + ) else { + return; + }; + if let Some(extends) = crate::lang::cfml_string_attr_value(node, "extends", source) { + self.push_ref(&created.id, &extends, EdgeKind::Extends, node); + } + self.node_stack.push(created.id); + self.visit_named_children(node); + self.node_stack.pop(); + } + + /// Ports `extractComponent`: Class from the `name` tag-attr else file; + /// `extends`/`implements` tag-attr refs; the implicit-end-tag body is the + /// open tag's FOLLOWING SIBLINGS, walked to `cf_component_close_tag` with + /// each `cf_function_tag` a Method. `cfml_consumed_until` marks the consumed + /// range so the top-level walk skips it. + fn emit_cfml_tag_component(&mut self, node: SyntaxNode<'tree>) { + let source = self.source; + let name = crate::lang::cfml_tag_attr(node, "name", source) + .unwrap_or_else(|| crate::lang::cfml_component_name_from_path(self.file_path)); + let qualified_name = format!("{}::{}", self.file_path, name); + let extends = crate::lang::cfml_tag_attr(node, "extends", source); + let implements = crate::lang::cfml_tag_attr(node, "implements", source); + let Some(created) = self.create_node( + NodeKind::Class, + &name, + node, + NodeExtra { + is_exported: true, + qualified_name: Some(qualified_name), + ..NodeExtra::default() + }, + ) else { + return; + }; + if let Some(extends) = extends { + let extends = extends.trim(); + if !extends.is_empty() { + self.push_ref(&created.id, extends, EdgeKind::Extends, node); + } + } + if let Some(implements) = implements { + for iface in implements.split(',') { + let iface = iface.trim(); + if !iface.is_empty() { + self.push_ref(&created.id, iface, EdgeKind::Implements, node); + } + } + } + self.node_stack.push(created.id); + let mut consumed_end = node.end_byte(); + let mut sibling = node.next_named_sibling(); + while let Some(current) = sibling { + if current.kind() == "cf_component_close_tag" { + consumed_end = current.end_byte(); + break; + } + if current.kind() == "cf_function_tag" { + self.emit_cfml_tag_function(current, true); + } + consumed_end = current.end_byte(); + sibling = current.next_named_sibling(); + } + self.node_stack.pop(); + self.cfml_consumed_until = consumed_end; + } + + /// Ports `extractFunctionTag`: Method (in a component) or Function + /// (top-level) from the `name` tag-attr (skip if absent), visibility from + /// `access`, return type from `returntype`; the body is walked for calls. + fn emit_cfml_tag_function(&mut self, node: SyntaxNode<'tree>, is_method: bool) { + let source = self.source; + let Some(name) = crate::lang::cfml_tag_attr(node, "name", source) else { + return; + }; + let visibility = crate::lang::cfml_tag_attr(node, "access", source).and_then(|access| { + match access.trim().to_ascii_lowercase().as_str() { + "private" => Some("private".to_string()), + "package" => Some("internal".to_string()), + "remote" | "public" => Some("public".to_string()), + _ => None, + } + }); + let return_type = crate::lang::cfml_tag_attr(node, "returntype", source) + .map(|rt| rt.trim().to_string()) + .filter(|rt| !rt.is_empty()); + let kind = if is_method { + NodeKind::Method + } else { + NodeKind::Function + }; + let Some(created) = self.create_node( + kind, + &name, + node, + NodeExtra { + visibility, + return_type, + ..NodeExtra::default() + }, + ) else { + return; + }; + self.node_stack.push(created.id); + self.visit_named_children(node); + self.node_stack.pop(); + } + fn visit_erlang_node(&mut self, node: SyntaxNode<'tree>) -> bool { match node.kind() { "fun_decl" => { @@ -6399,4 +6568,102 @@ g() ->\n\ assert!(has_node(&nodes, NodeKind::Constant, "X")); assert!(has_node(&nodes, NodeKind::Namespace, "m")); } + + // ---- CFML (dual-grammar: cfscript for bare-script, cfml for tag) ---- + + #[test] + fn cfml_script_component_from_file_name() { + let (nodes, _refs) = run( + "Gadget.cfs", + "component { public function doThing(){} }", + Language::Cfml, + ); + assert!(has_node(&nodes, NodeKind::Class, "Gadget")); + assert!(!has_node(&nodes, NodeKind::Class, "")); + assert!( + has_node(&nodes, NodeKind::Method, "doThing") + || has_node(&nodes, NodeKind::Function, "doThing") + ); + } + + #[test] + fn cfml_tag_component_is_class() { + let (nodes, refs) = run( + "Widget.cfm", + "", + Language::Cfml, + ); + assert!(has_node(&nodes, NodeKind::Class, "Widget")); + assert!(has_ref(&refs, EdgeKind::Extends, "Base")); + } + + #[test] + fn cfml_tag_function_is_method() { + let (nodes, _refs) = run( + "Widget.cfm", + "", + Language::Cfml, + ); + assert!(has_node(&nodes, NodeKind::Method, "doThing")); + } + + #[test] + fn cfml_script_extends_is_extends() { + let (_nodes, refs) = run( + "Gadget.cfs", + "component extends=\"Base\" { }", + Language::Cfml, + ); + assert!(has_ref(&refs, EdgeKind::Extends, "Base")); + } + + #[test] + fn cfml_script_property_is_property() { + let (nodes, _refs) = run( + "Gadget.cfs", + "component { property name=\"x\"; }", + Language::Cfml, + ); + assert!( + has_node(&nodes, NodeKind::Property, "x") || has_node(&nodes, NodeKind::Field, "x") + ); + } + + #[test] + fn cfml_call_is_calls() { + let (_nodes, refs) = run( + "Gadget.cfs", + "component { public function doThing(){ helper(); } }", + Language::Cfml, + ); + assert!(has_ref(&refs, EdgeKind::Calls, "helper")); + } + + #[test] + fn cfml_implements_splits() { + let (_nodes, refs) = run( + "Widget.cfm", + "", + Language::Cfml, + ); + assert!(has_ref(&refs, EdgeKind::Implements, "IFoo")); + assert!(has_ref(&refs, EdgeKind::Implements, "IBar")); + } + + #[test] + fn cfml_import_is_import() { + let (nodes, refs) = run( + "Gadget.cfs", + "component { import com.foo.Bar; include \"sub.cfm\"; }", + Language::Cfml, + ); + assert!( + has_node(&nodes, NodeKind::Import, "com.foo.Bar") + || has_ref(&refs, EdgeKind::Imports, "com.foo.Bar") + ); + assert!( + has_node(&nodes, NodeKind::Import, "sub.cfm") + || has_ref(&refs, EdgeKind::Imports, "sub.cfm") + ); + } } diff --git a/crates/codegraph-store/src/queries.rs b/crates/codegraph-store/src/queries.rs index cfeb683..f92d220 100644 --- a/crates/codegraph-store/src/queries.rs +++ b/crates/codegraph-store/src/queries.rs @@ -1528,6 +1528,7 @@ fn parse_language(value: String) -> rusqlite::Result { "nix" => Language::Nix, "terraform" => Language::Terraform, "erlang" => Language::Erlang, + "cfml" => Language::Cfml, "yaml" => Language::Yaml, "twig" => Language::Twig, "xml" => Language::Xml, @@ -2217,6 +2218,11 @@ mod tests { ); } + #[test] + fn parse_language_accepts_cfml() { + assert_eq!(parse_language("cfml".to_string()).unwrap(), Language::Cfml); + } + #[test] fn edges_by_target_and_all_getters() { let mut store = store("edges-target"); diff --git a/docs/equivalence.md b/docs/equivalence.md index 06297b8..91d14d0 100644 --- a/docs/equivalence.md +++ b/docs/equivalence.md @@ -616,6 +616,53 @@ The `generated_golden_matches_committed_erlang_fixture` and `erlang_db_is_self_equivalent_to_erlang_golden` tests in `crates/codegraph-bench/tests/equivalence.rs` enforce byte-stability. +### CFML fixture + +A twelfth golden fixture, `reference/golden/cfml/`, guards CFML / ColdFusion +extraction (upstream #1153 / `816bacb`, the scope-B extraction slice only). CFML +is a **new `Language::Cfml` variant** backed by the **dual-grammar +`tree-sitter-cfml`** crate (`.cfc`/`.cfm`/`.cfs` → `Language::Cfml`). A file's +dialect is picked by a first-token sniff (`is_bare_script_cfml`): script files +parse with the bundled `cfscript` grammar and drive the generic type-set +dispatch; tag files parse with the `cfml` tag grammar and are handled by the +`Language::Cfml`-guarded `visit_cfml_node` walker extension. The corpus +(`crates/codegraph-bench/fixtures/cfml/`) has three deterministic files — a +script `Base.cfc`, a tag `Widget.cfm`, and a bare-script `Gadget.cfs`. What it +guards: + +- all three files with `"language": "cfml"`; +- `Base.cfc` (script) → `NodeKind::Class` `Base` (named from the FILE — the + cfscript `component` is unnamed) + `NodeKind::Function` `ping`; +- `Widget.cfm` (tag) → `NodeKind::Class` `Widget` (from the `name` tag-attr) + + `NodeKind::Method` `doThing` (access `public`, returntype `void`), and a tag + `extends="Base"` → `Extends`; +- `Gadget.cfs` (bare script) → `NodeKind::Class` `Gadget` (from the FILE) + + `NodeKind::Property` `x` + `NodeKind::Function` `doThing`, and a script-style + `extends="Base"` (`component_attribute`) → `Extends`; +- both `extends Base` refs RESOLVE to the `Base.cfc` component (edges); + `Gadget.doThing`'s `helper()` call → an unresolved `helper` ref. + +The ``-in-tag-body re-parse delegation, the `cfquery` SQL-body +extraction (`LANGUAGE_CFQUERY`), and the CFML framework RESOLVER bridges +(FW/1 / ColdBox / CFWheels, dotted/relative inheritance, receiver-type inference) +are all **DEFERRED**. Adding the variant is byte-neutral for `colby.schema.sql` +(language is a stored TEXT value, not DDL) and for the eleven existing goldens +(none holds a `.cfc`/`.cfm`/`.cfs` file). + +Regenerate reproducibly (identical recipe, substituting `cfml`): + +```bash +rm -rf /tmp/cg-fixture-cfml && cp -r crates/codegraph-bench/fixtures/cfml /tmp/cg-fixture-cfml +cargo build --release -p codegraph-rs +CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 ./target/release/codegraph init /tmp/cg-fixture-cfml +cp /tmp/cg-fixture-cfml/.codegraph/codegraph.db reference/golden/cfml/colby.db +cargo run -p codegraph-bench --bin bench -- --gen-golden reference/golden/cfml/colby.db reference/golden/cfml +``` + +The `generated_golden_matches_committed_cfml_fixture` and +`cfml_db_is_self_equivalent_to_cfml_golden` tests in +`crates/codegraph-bench/tests/equivalence.rs` enforce byte-stability. + ### KNOWN_DIFFS.md format Tier-3 differences are allowlisted by grep-able lines in repo-root diff --git a/docs/languages.md b/docs/languages.md index 9d58ea5..61b17cb 100644 --- a/docs/languages.md +++ b/docs/languages.md @@ -4,7 +4,7 @@ CodeGraph extracts code structure deterministically using tree-sitter grammars a embedded extractors. No AI, vectors, or embeddings are involved. The output is byte-stable across runs. -**37 concrete languages** are supported, grouped into three extraction tiers based on what +**38 concrete languages** are supported, grouped into three extraction tiers based on what the extractor produces. > **Note on TypeScript/JavaScript variants:** `typescript` and `tsx`, and `javascript` and @@ -14,7 +14,7 @@ the extractor produces. --- -## Tier 1 — Full symbol extraction (28 languages) +## Tier 1 — Full symbol extraction (29 languages) Tree-sitter parses the file and extracts all symbols (functions, classes, structs, methods, variables, imports, etc.) plus call and dependency edges. This is the richest extraction @@ -50,6 +50,7 @@ level. | Erlang | `.erl` `.hrl` | Full tree-sitter | `tree-sitter-erlang` grammar (WhatsApp/ELP); `-module`→namespace (functions qualify as `m::f`), multi-clause `fun_decl`→one function (clause-merge), `-record`→struct+fields, `-define`→constant, `-type`/`-opaque`→type alias, `-include`→import; local/remote `mod:f`→calls, `fun f/1`/record usages→references; `-spec`/`-callback` type bodies mint no bogus refs; behaviour/gen_server/spawn MFA resolver bridges deferred | | GDScript | `.gd` | Full tree-sitter | Godot scripting; extracts functions, classes, enums, variables, signals, extends, preload. Dynamic dispatch edges (connect/get_node/$/%/call/group) added by the Godot resolver — see [`docs/godot.md`](godot.md) | | Pascal | `.pas` `.dpr` `.dpk` `.lpr` `.dfm` `.fmx` | Full tree-sitter / custom | `.dfm`/`.fmx` form files use a custom path | +| CFML | `.cfc` `.cfm` `.cfs` | Full tree-sitter | ColdFusion; dual-grammar `tree-sitter-cfml` (cfscript + cfml tag), dialect chosen by first-token sniff. Bare-script `component`→class (name from file) + `extends`→extends; tag ``→class (name attr) + ``→method + `extends`/`implements`→refs. ``-in-tag body delegation and cfquery SQL-body extraction deferred | --- diff --git a/reference/golden/cfml/colby.db b/reference/golden/cfml/colby.db new file mode 100644 index 0000000..c7072ae Binary files /dev/null and b/reference/golden/cfml/colby.db differ diff --git a/reference/golden/cfml/edges.json b/reference/golden/cfml/edges.json new file mode 100644 index 0000000..10a64e0 --- /dev/null +++ b/reference/golden/cfml/edges.json @@ -0,0 +1,89 @@ +[ + { + "col": 0, + "kind": "extends", + "line": 1, + "metadata": { + "confidence": 0.9, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "class:93c39257dd3c90eb320fcd02f5ddbc01", + "target": "class:7b249660730529f301c3db8163759969" + }, + { + "col": 0, + "kind": "extends", + "line": 1, + "metadata": { + "confidence": 0.9, + "resolvedBy": "exact-match" + }, + "provenance": null, + "source": "class:b60688d316d54e324299d22677269906", + "target": "class:7b249660730529f301c3db8163759969" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "class:7b249660730529f301c3db8163759969", + "target": "method:54e7b7c2fdf0c76f0984142661a54479" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "class:93c39257dd3c90eb320fcd02f5ddbc01", + "target": "method:c432989f1e6e8a738827b8c560f31059" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "class:b60688d316d54e324299d22677269906", + "target": "method:5f4b8da59fedd244e30807cbc03a66b3" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "class:b60688d316d54e324299d22677269906", + "target": "property:0625c36d63c08952c8612ee18a665fe2" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:Base.cfc", + "target": "class:7b249660730529f301c3db8163759969" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:Gadget.cfs", + "target": "class:b60688d316d54e324299d22677269906" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:Widget.cfm", + "target": "class:93c39257dd3c90eb320fcd02f5ddbc01" + } +] diff --git a/reference/golden/cfml/files.json b/reference/golden/cfml/files.json new file mode 100644 index 0000000..b27d561 --- /dev/null +++ b/reference/golden/cfml/files.json @@ -0,0 +1,26 @@ +[ + { + "content_hash": "1def57c4a549fb3f0100b4c1a3da65eb5d88d7048a030d66004ce46d157b1aa2", + "errors": null, + "language": "cfml", + "node_count": 3, + "path": "Base.cfc", + "size": 62 + }, + { + "content_hash": "9fe3c477ccee5efc741c301c391bf45a9b153bf9e2c02593f310d452280699c2", + "errors": null, + "language": "cfml", + "node_count": 4, + "path": "Gadget.cfs", + "size": 98 + }, + { + "content_hash": "c618667f944b633d15c80bf1e274aac80504af2fb2a82266f54ec5c819f76d6e", + "errors": null, + "language": "cfml", + "node_count": 3, + "path": "Widget.cfm", + "size": 156 + } +] diff --git a/reference/golden/cfml/nodes.json b/reference/golden/cfml/nodes.json new file mode 100644 index 0000000..b8f0a99 --- /dev/null +++ b/reference/golden/cfml/nodes.json @@ -0,0 +1,222 @@ +[ + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 5, + "file_path": "Base.cfc", + "id": "class:7b249660730529f301c3db8163759969", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "class", + "language": "cfml", + "name": "Base", + "qualified_name": "Base.cfc::Base", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 42, + "end_line": 1, + "file_path": "Widget.cfm", + "id": "class:93c39257dd3c90eb320fcd02f5ddbc01", + "is_abstract": 0, + "is_async": 0, + "is_exported": 1, + "is_static": 0, + "kind": "class", + "language": "cfml", + "name": "Widget", + "qualified_name": "Widget.cfm::Widget", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 6, + "file_path": "Gadget.cfs", + "id": "class:b60688d316d54e324299d22677269906", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "class", + "language": "cfml", + "name": "Gadget", + "qualified_name": "Gadget.cfs::Gadget", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 0, + "end_line": 6, + "file_path": "Base.cfc", + "id": "file:Base.cfc", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "file", + "language": "cfml", + "name": "Base.cfc", + "qualified_name": "Base.cfc", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 0, + "end_line": 7, + "file_path": "Gadget.cfs", + "id": "file:Gadget.cfs", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "file", + "language": "cfml", + "name": "Gadget.cfs", + "qualified_name": "Gadget.cfs", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 0, + "end_line": 6, + "file_path": "Widget.cfm", + "id": "file:Widget.cfm", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "file", + "language": "cfml", + "name": "Widget.cfm", + "qualified_name": "Widget.cfm", + "return_type": null, + "signature": null, + "start_column": 0, + "start_line": 1, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 3, + "end_line": 4, + "file_path": "Base.cfc", + "id": "method:54e7b7c2fdf0c76f0984142661a54479", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "cfml", + "name": "ping", + "qualified_name": "Base::ping", + "return_type": null, + "signature": "()", + "start_column": 2, + "start_line": 2, + "type_parameters": null, + "visibility": "public" + }, + { + "decorators": null, + "docstring": null, + "end_column": 3, + "end_line": 5, + "file_path": "Gadget.cfs", + "id": "method:5f4b8da59fedd244e30807cbc03a66b3", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "cfml", + "name": "doThing", + "qualified_name": "Gadget::doThing", + "return_type": null, + "signature": "()", + "start_column": 2, + "start_line": 3, + "type_parameters": null, + "visibility": "public" + }, + { + "decorators": null, + "docstring": null, + "end_column": 15, + "end_line": 4, + "file_path": "Widget.cfm", + "id": "method:c432989f1e6e8a738827b8c560f31059", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "method", + "language": "cfml", + "name": "doThing", + "qualified_name": "Widget::doThing", + "return_type": "void", + "signature": null, + "start_column": 2, + "start_line": 2, + "type_parameters": null, + "visibility": "public" + }, + { + "decorators": null, + "docstring": null, + "end_column": 20, + "end_line": 2, + "file_path": "Gadget.cfs", + "id": "property:0625c36d63c08952c8612ee18a665fe2", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "property", + "language": "cfml", + "name": "x", + "qualified_name": "Gadget::x", + "return_type": null, + "signature": "name=\"x\" x", + "start_column": 2, + "start_line": 2, + "type_parameters": null, + "visibility": null + } +] diff --git a/reference/golden/cfml/refs.json b/reference/golden/cfml/refs.json new file mode 100644 index 0000000..cf9bc3f --- /dev/null +++ b/reference/golden/cfml/refs.json @@ -0,0 +1,12 @@ +[ + { + "candidates": null, + "col": 4, + "file_path": "Gadget.cfs", + "from_node_id": "method:5f4b8da59fedd244e30807cbc03a66b3", + "language": "cfml", + "line": 4, + "reference_kind": "calls", + "reference_name": "helper" + } +] diff --git a/reference/golden/cfml/schema.sql b/reference/golden/cfml/schema.sql new file mode 100644 index 0000000..d36218f --- /dev/null +++ b/reference/golden/cfml/schema.sql @@ -0,0 +1,117 @@ +CREATE TABLE schema_versions ( +version INTEGER PRIMARY KEY, +applied_at INTEGER NOT NULL, +description TEXT +); +CREATE TABLE nodes ( +id TEXT PRIMARY KEY, +kind TEXT NOT NULL, +name TEXT NOT NULL, +qualified_name TEXT NOT NULL, +file_path TEXT NOT NULL, +language TEXT NOT NULL, +start_line INTEGER NOT NULL, +end_line INTEGER NOT NULL, +start_column INTEGER NOT NULL, +end_column INTEGER NOT NULL, +docstring TEXT, +signature TEXT, +visibility TEXT, +is_exported INTEGER DEFAULT 0, +is_async INTEGER DEFAULT 0, +is_static INTEGER DEFAULT 0, +is_abstract INTEGER DEFAULT 0, +decorators TEXT, -- JSON array +type_parameters TEXT, -- JSON array +return_type TEXT, -- normalized return/result type name (e.g. C++ method return, for receiver-type inference) +updated_at INTEGER NOT NULL +); +CREATE TABLE edges ( +id INTEGER PRIMARY KEY AUTOINCREMENT, +source TEXT NOT NULL, +target TEXT NOT NULL, +kind TEXT NOT NULL, +metadata TEXT, -- JSON object +line INTEGER, +col INTEGER, +provenance TEXT DEFAULT NULL, +FOREIGN KEY (source) REFERENCES nodes(id) ON DELETE CASCADE, +FOREIGN KEY (target) REFERENCES nodes(id) ON DELETE CASCADE +); +CREATE TABLE sqlite_sequence(name,seq); +CREATE TABLE files ( +path TEXT PRIMARY KEY, +content_hash TEXT NOT NULL, +language TEXT NOT NULL, +size INTEGER NOT NULL, +modified_at INTEGER NOT NULL, +indexed_at INTEGER NOT NULL, +node_count INTEGER DEFAULT 0, +errors TEXT -- JSON array +); +CREATE TABLE unresolved_refs ( +id INTEGER PRIMARY KEY AUTOINCREMENT, +from_node_id TEXT NOT NULL, +reference_name TEXT NOT NULL, +reference_kind TEXT NOT NULL, +line INTEGER NOT NULL, +col INTEGER NOT NULL, +candidates TEXT, -- JSON array +file_path TEXT NOT NULL DEFAULT '', +language TEXT NOT NULL DEFAULT 'unknown', +reference_subkind TEXT, +FOREIGN KEY (from_node_id) REFERENCES nodes(id) ON DELETE CASCADE +); +CREATE INDEX idx_nodes_kind ON nodes(kind); +CREATE INDEX idx_nodes_name ON nodes(name); +CREATE INDEX idx_nodes_qualified_name ON nodes(qualified_name); +CREATE INDEX idx_nodes_file_path ON nodes(file_path); +CREATE INDEX idx_nodes_language ON nodes(language); +CREATE INDEX idx_nodes_file_line ON nodes(file_path, start_line); +CREATE INDEX idx_nodes_lower_name ON nodes(lower(name)); +CREATE VIRTUAL TABLE nodes_fts USING fts5( +id, +name, +qualified_name, +docstring, +signature, +content='nodes', +content_rowid='rowid' +) +/* nodes_fts(id,name,qualified_name,docstring,signature) */; +CREATE TABLE 'nodes_fts_data'(id INTEGER PRIMARY KEY, block BLOB); +CREATE TABLE 'nodes_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID; +CREATE TABLE 'nodes_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB); +CREATE TABLE 'nodes_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID; +CREATE TRIGGER nodes_ai AFTER INSERT ON nodes BEGIN +INSERT INTO nodes_fts(rowid, id, name, qualified_name, docstring, signature) +VALUES (NEW.rowid, NEW.id, NEW.name, NEW.qualified_name, NEW.docstring, NEW.signature); +END; +CREATE TRIGGER nodes_ad AFTER DELETE ON nodes BEGIN +INSERT INTO nodes_fts(nodes_fts, rowid, id, name, qualified_name, docstring, signature) +VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.qualified_name, OLD.docstring, OLD.signature); +END; +CREATE TRIGGER nodes_au AFTER UPDATE ON nodes BEGIN +INSERT INTO nodes_fts(nodes_fts, rowid, id, name, qualified_name, docstring, signature) +VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.qualified_name, OLD.docstring, OLD.signature); +INSERT INTO nodes_fts(rowid, id, name, qualified_name, docstring, signature) +VALUES (NEW.rowid, NEW.id, NEW.name, NEW.qualified_name, NEW.docstring, NEW.signature); +END; +CREATE INDEX idx_edges_kind ON edges(kind); +CREATE INDEX idx_edges_source_kind ON edges(source, kind); +CREATE INDEX idx_edges_target_kind ON edges(target, kind); +CREATE UNIQUE INDEX idx_edges_identity +ON edges(source, target, kind, IFNULL(line, -1), IFNULL(col, -1)); +CREATE INDEX idx_files_language ON files(language); +CREATE INDEX idx_files_modified_at ON files(modified_at); +CREATE INDEX idx_unresolved_from_node ON unresolved_refs(from_node_id); +CREATE INDEX idx_unresolved_name ON unresolved_refs(reference_name); +CREATE INDEX idx_unresolved_file_path ON unresolved_refs(file_path); +CREATE INDEX idx_unresolved_from_name ON unresolved_refs(from_node_id, reference_name); +CREATE INDEX idx_edges_provenance ON edges(provenance); +CREATE TABLE project_metadata ( +key TEXT PRIMARY KEY, +value TEXT NOT NULL, +updated_at INTEGER NOT NULL +); +CREATE TABLE sqlite_stat1(tbl,idx,stat);