diff --git a/Cargo.lock b/Cargo.lock index 6c80e93..886f024 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -432,7 +432,7 @@ dependencies = [ [[package]] name = "codegraph-bench" -version = "0.35.0" +version = "0.36.0" dependencies = [ "anyhow", "clap", @@ -447,7 +447,7 @@ dependencies = [ [[package]] name = "codegraph-core" -version = "0.35.0" +version = "0.36.0" dependencies = [ "anyhow", "serde", @@ -463,7 +463,7 @@ dependencies = [ [[package]] name = "codegraph-daemon" -version = "0.35.0" +version = "0.36.0" dependencies = [ "anyhow", "codegraph-core", @@ -483,7 +483,7 @@ dependencies = [ [[package]] name = "codegraph-extract" -version = "0.35.0" +version = "0.36.0" dependencies = [ "anyhow", "codegraph-core", @@ -502,6 +502,7 @@ dependencies = [ "tree-sitter-dart", "tree-sitter-gdscript", "tree-sitter-go", + "tree-sitter-hcl", "tree-sitter-html", "tree-sitter-java", "tree-sitter-javascript", @@ -528,7 +529,7 @@ dependencies = [ [[package]] name = "codegraph-graph" -version = "0.35.0" +version = "0.36.0" dependencies = [ "anyhow", "codegraph-core", @@ -542,7 +543,7 @@ dependencies = [ [[package]] name = "codegraph-mcp" -version = "0.35.0" +version = "0.36.0" dependencies = [ "anyhow", "axum", @@ -565,7 +566,7 @@ dependencies = [ [[package]] name = "codegraph-resolve" -version = "0.35.0" +version = "0.36.0" dependencies = [ "anyhow", "codegraph-core", @@ -579,7 +580,7 @@ dependencies = [ [[package]] name = "codegraph-rs" -version = "0.35.0" +version = "0.36.0" dependencies = [ "anyhow", "assert_cmd", @@ -615,7 +616,7 @@ dependencies = [ [[package]] name = "codegraph-store" -version = "0.35.0" +version = "0.36.0" dependencies = [ "anyhow", "codegraph-core", @@ -627,7 +628,7 @@ dependencies = [ [[package]] name = "codegraph-watch" -version = "0.35.0" +version = "0.36.0" dependencies = [ "anyhow", "codegraph-core", @@ -3504,6 +3505,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-hcl" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a7b2cc3d7121553b84309fab9d11b3ff3d420403eef9ae50f9fd1cd9d9cf012" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-html" version = "0.23.2" diff --git a/README.md b/README.md index e9c2878..6efe80e 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 35 languages (TypeScript, +**Does:** deterministic code-structure extraction across 36 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 **35 languages** grouped by extraction depth. Quick overview: +CodeGraph supports **36 languages** grouped by extraction depth. Quick overview: -- **Tier 1 — Full symbol extraction (26):** 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, GDScript, Pascal. +- **Tier 1 — Full symbol extraction (27):** 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, 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/terraform/main.tf b/crates/codegraph-bench/fixtures/terraform/main.tf new file mode 100644 index 0000000..a8ecacb --- /dev/null +++ b/crates/codegraph-bench/fixtures/terraform/main.tf @@ -0,0 +1,30 @@ +terraform {} + +provider "aws" {} + +variable "region" {} + +locals { + name = "demo" + tagged = var.region +} + +data "aws_ami" "ubuntu" {} + +resource "aws_s3_bucket" "b" { + bucket = var.region + kms_key_id = aws_kms_key.logs.arn +} + +module "vpc" { + source = "./vpc" + region = var.region +} + +output "bucket_id" { + value = aws_s3_bucket.b.id +} + +output "vpc_id" { + value = module.vpc.id +} diff --git a/crates/codegraph-bench/tests/equivalence.rs b/crates/codegraph-bench/tests/equivalence.rs index 0c93da1..efe0227 100644 --- a/crates/codegraph-bench/tests/equivalence.rs +++ b/crates/codegraph-bench/tests/equivalence.rs @@ -179,6 +179,31 @@ fn nix_db_is_self_equivalent_to_nix_golden() { assert_equivalent(&nix_db(), &nix_golden_dir()).unwrap(); } +#[test] +fn generated_golden_matches_committed_terraform_fixture() { + // Guards Terraform/HCL extraction (upstream #1173, extraction slice only): + // the `.tf`/`.tfvars`/`.tofu`->Terraform mapping, block-type dispatch + // (resource/data->Class, module->Module, variable/output->Variable, + // provider->Namespace, locals->Constant per attr) with qualified names, and + // plain attribute-expression traversal refs + // (var.X/local.X/module.M/data.T.N/.)->References with built-ins + // skipped. The module-boundary TerraformResolver, emitModuleWiring's + // :-scoped refs, and the .tfvars var ref are DEFERRED, so no :-scoped ref is + // emitted; the undeclared aws_kms_key.logs stays unresolved. + let tempdir = TestDir::new("generated-golden-terraform"); + write_golden(&terraform_db(), tempdir.path()).unwrap(); + + let expected = load_golden(&terraform_golden_dir()).unwrap(); + let actual = load_golden(tempdir.path()).unwrap(); + + diff_canonical(&expected, &actual, None).unwrap(); +} + +#[test] +fn terraform_db_is_self_equivalent_to_terraform_golden() { + assert_equivalent(&terraform_db(), &terraform_golden_dir()).unwrap(); +} + #[test] fn tier1_node_drift_is_reported() { let expected = load_golden(&mini_golden_dir()).unwrap(); @@ -297,6 +322,14 @@ fn nix_golden_dir() -> PathBuf { workspace_root().join("reference/golden/nix") } +fn terraform_db() -> PathBuf { + workspace_root().join("reference/golden/terraform/colby.db") +} + +fn terraform_golden_dir() -> PathBuf { + workspace_root().join("reference/golden/terraform") +} + struct TestDir { path: PathBuf, } diff --git a/crates/codegraph-core/src/types.rs b/crates/codegraph-core/src/types.rs index 92ea64e..7459c43 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; 39] = [ +pub const LANGUAGE_STRINGS: [&str; 40] = [ "typescript", "javascript", "tsx", @@ -80,6 +80,7 @@ pub const LANGUAGE_STRINGS: [&str; 39] = [ "r", "solidity", "nix", + "terraform", "yaml", "twig", "xml", @@ -329,6 +330,8 @@ pub enum Language { Solidity, #[serde(rename = "nix")] Nix, + #[serde(rename = "terraform")] + Terraform, #[serde(rename = "yaml")] Yaml, #[serde(rename = "twig")] @@ -350,7 +353,7 @@ pub enum Language { } impl Language { - pub const ALL: [Self; 39] = [ + pub const ALL: [Self; 40] = [ Self::TypeScript, Self::JavaScript, Self::Tsx, @@ -381,6 +384,7 @@ impl Language { Self::R, Self::Solidity, Self::Nix, + Self::Terraform, Self::Yaml, Self::Twig, Self::Xml, @@ -424,6 +428,7 @@ impl Language { Self::R => "r", Self::Solidity => "solidity", Self::Nix => "nix", + Self::Terraform => "terraform", Self::Yaml => "yaml", Self::Twig => "twig", Self::Xml => "xml", diff --git a/crates/codegraph-extract/Cargo.toml b/crates/codegraph-extract/Cargo.toml index 72fe682..97fb9df 100644 --- a/crates/codegraph-extract/Cargo.toml +++ b/crates/codegraph-extract/Cargo.toml @@ -31,6 +31,7 @@ tree-sitter-css = "0.25.0" tree-sitter-dart = "0.2.0" tree-sitter-go = "0.25.0" tree-sitter-gdscript = "6.1.0" +tree-sitter-hcl = "1.1.0" tree-sitter-html = "0.23.2" tree-sitter-java = "0.23.5" tree-sitter-javascript = "0.25.0" diff --git a/crates/codegraph-extract/src/engine.rs b/crates/codegraph-extract/src/engine.rs index 39069f0..7978be6 100644 --- a/crates/codegraph-extract/src/engine.rs +++ b/crates/codegraph-extract/src/engine.rs @@ -84,6 +84,7 @@ pub fn builtin_language_for_ext(ext: &str) -> Option { "r" => Language::R, "sol" => Language::Solidity, "nix" => Language::Nix, + "tf" | "tfvars" | "tofu" => Language::Terraform, "yml" | "yaml" => Language::Yaml, "twig" => Language::Twig, "xml" => Language::Xml, @@ -872,7 +873,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(), 39); + assert_eq!(Language::ALL.len(), 40); } #[test] @@ -890,6 +891,13 @@ mod tests { assert_eq!(detect_language("flake.nix"), Language::Nix); } + #[test] + fn terraform_extensions_map_to_terraform() { + assert_eq!(detect_language("main.tf"), Language::Terraform); + assert_eq!(detect_language("prod.tfvars"), Language::Terraform); + assert_eq!(detect_language("main.tofu"), Language::Terraform); + } + #[test] fn plain_ts_stays_typescript() { assert_eq!(detect_language("m.ts"), Language::TypeScript); diff --git a/crates/codegraph-extract/src/lang/mod.rs b/crates/codegraph-extract/src/lang/mod.rs index 4a2074b..d26bda9 100644 --- a/crates/codegraph-extract/src/lang/mod.rs +++ b/crates/codegraph-extract/src/lang/mod.rs @@ -27,6 +27,7 @@ mod rust; mod scala; mod solidity; mod swift; +mod terraform; mod tsx; mod typescript; @@ -64,6 +65,11 @@ pub use rust::RUST_SPEC; pub use scala::SCALA_SPEC; pub use solidity::SOLIDITY_SPEC; pub use swift::SWIFT_SPEC; +pub use terraform::TERRAFORM_SPEC; +pub(crate) use terraform::{ + TerraformBlockDecl, collect_terraform_references, describe_terraform_block, + read_terraform_block_header, terraform_block_body, +}; pub use tsx::TSX_SPEC; pub use typescript::TYPESCRIPT_SPEC; @@ -94,6 +100,7 @@ pub fn spec_for_language(language: Language) -> Option<&'static dyn LanguageSpec Language::R => Some(&R_SPEC), Language::Solidity => Some(&SOLIDITY_SPEC), Language::Nix => Some(&NIX_SPEC), + Language::Terraform => Some(&TERRAFORM_SPEC), Language::Gdscript => Some(&GDSCRIPT_SPEC), _ => None, } diff --git a/crates/codegraph-extract/src/lang/terraform.rs b/crates/codegraph-extract/src/lang/terraform.rs new file mode 100644 index 0000000..8da5013 --- /dev/null +++ b/crates/codegraph-extract/src/lang/terraform.rs @@ -0,0 +1,444 @@ +//! Terraform / OpenTofu (`.tf` / `.tfvars` / `.tofu`) `LanguageSpec`. +//! +//! Extraction-tier port of `upstream extraction/languages/terraform.ts` (commit +//! `6c24f4b`, #1173 — the extraction slice only). HCL is intentionally generic: +//! ALL Terraform top-level constructs share the AST node kind `block`, +//! distinguished only by the first `identifier` child (the block "type": +//! `resource` / `data` / `module` / `variable` / `output` / `provider` / +//! `locals` / `terraform`). There are no C-family `class`/`struct`/`method`/ +//! `enum`/`interface`/`function` node kinds, so — faithful to upstream's +//! entirely empty `terraformExtractor` config — [`TerraformSpec`] returns `&[]` +//! for every type-set and the extraction is driven entirely by the +//! `Language::Terraform`-guarded `visit_terraform_node` walker extension in +//! [`crate::walker`]. +//! +//! The module-boundary framework RESOLVER (`resolution/frameworks/terraform.ts`), +//! its registration, the directory-scoping resolution gate, and the +//! extraction-side emitters that feed ONLY that resolver (`emitModuleWiring`'s +//! `module.M:file` / `module.M:var.X` / `module.M:output.X` `:`-scoped refs, the +//! `.tfvars` top-level-assignment `var.X` ref, and the `module.M:output.` +//! scoped half of `qualifyReference`) are DEFERRED — the port has exactly one +//! concrete `FrameworkResolver` (`GodotResolver`). +//! +//! The pure AST helper fns below are `pub(crate)` and re-exported through +//! [`crate::lang`] so the walker can reach them as `crate::lang::`. + +use codegraph_core::types::{Language, NodeKind}; +use tree_sitter::{Language as TsLanguage, Node}; + +use crate::spec::LanguageSpec; +use crate::walker::node_text; + +pub struct TerraformSpec; + +pub static TERRAFORM_SPEC: TerraformSpec = TerraformSpec; + +impl LanguageSpec for TerraformSpec { + fn language(&self) -> Language { + Language::Terraform + } + + fn tree_sitter_language(&self) -> TsLanguage { + tree_sitter_hcl::LANGUAGE.into() + } + + fn function_types(&self) -> &'static [&'static str] { + &[] + } + + fn class_types(&self) -> &'static [&'static str] { + &[] + } + + fn method_types(&self) -> &'static [&'static str] { + &[] + } + + 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] { + &[] + } + + fn call_types(&self) -> &'static [&'static str] { + &[] + } + + fn variable_types(&self) -> &'static [&'static str] { + &[] + } + + fn name_field(&self) -> &'static str { + "" + } + + fn body_field(&self) -> &'static str { + "" + } + + fn params_field(&self) -> &'static str { + "" + } + + fn return_field(&self) -> &'static str { + "" + } +} + +/// Built-in reference heads that should NOT be resolved to project nodes. +/// Ports `BUILTIN_HEADS` (terraform.ts:37-43). +const BUILTIN_HEADS: [&str; 5] = ["each", "count", "self", "path", "terraform"]; + +/// Bare strings never treated as references. Ports `BUILTIN_KEYWORDS` +/// (terraform.ts:46). +const BUILTIN_KEYWORDS: [&str; 3] = ["null", "true", "false"]; + +/// A described top-level Terraform block: its `NodeKind`, symbol name, qualified +/// name, and signature. Ports the `BlockDecl` interface (terraform.ts:361-366). +pub(crate) struct TerraformBlockDecl { + pub kind: NodeKind, + pub name: String, + pub qualified_name: String, + pub signature: String, +} + +/// Read a `string_lit` value (its `template_literal` named child text), else +/// `""` (an empty `""` string parses with no `template_literal`). Ports +/// `stringLitValue` (terraform.ts:49-55). +pub(crate) fn terraform_string_lit_value(node: Node<'_>, source: &str) -> String { + node.named_children(&mut node.walk()) + .find(|c| c.kind() == "template_literal") + .map(|literal| node_text(literal, source)) + .unwrap_or_default() +} + +/// Block "type" and its label values. `None` if the block is malformed (first +/// named child is not an `identifier`). Ports `readBlockHeader` +/// (terraform.ts:58-79). +pub(crate) fn read_terraform_block_header( + block: Node<'_>, + source: &str, +) -> Option<(String, Vec)> { + let named: Vec> = block.named_children(&mut block.walk()).collect(); + let first = named.first()?; + if first.kind() != "identifier" { + return None; + } + let block_type = node_text(*first, source); + let mut labels: Vec = Vec::new(); + for child in named.iter().skip(1) { + match child.kind() { + "string_lit" => labels.push(terraform_string_lit_value(*child, source)), + // HCL allows unquoted identifier labels (rare in Terraform, legal). + "identifier" => labels.push(node_text(*child, source)), + _ => break, + } + } + Some((block_type, labels)) +} + +/// The `body` named child of a block. Ports `getBlockBody` +/// (terraform.ts:82-84). +pub(crate) fn terraform_block_body(block: Node<'_>) -> Option> { + block + .named_children(&mut block.walk()) + .find(|c| c.kind() == "body") +} + +/// Map a block type + labels to a symbol declaration. Ports `describeBlock` +/// (terraform.ts:368-435): resource/data → Class, module → Module, +/// variable/output → Variable, provider → Namespace. `None` for unknown types +/// or missing required labels. +pub(crate) fn describe_terraform_block( + block_type: &str, + labels: &[String], +) -> Option { + let first = labels.first(); + let second = labels.get(1); + match block_type { + "resource" => { + let (first, second) = (first?, second?); + Some(TerraformBlockDecl { + kind: NodeKind::Class, + name: format!("{first}.{second}"), + qualified_name: format!("{first}.{second}"), + signature: format!("resource \"{first}\" \"{second}\""), + }) + } + "data" => { + let (first, second) = (first?, second?); + Some(TerraformBlockDecl { + kind: NodeKind::Class, + name: format!("{first}.{second}"), + qualified_name: format!("data.{first}.{second}"), + signature: format!("data \"{first}\" \"{second}\""), + }) + } + "module" => { + let first = first?; + Some(TerraformBlockDecl { + kind: NodeKind::Module, + name: first.clone(), + qualified_name: format!("module.{first}"), + signature: format!("module \"{first}\""), + }) + } + "variable" => { + let first = first?; + Some(TerraformBlockDecl { + kind: NodeKind::Variable, + name: first.clone(), + qualified_name: format!("var.{first}"), + signature: format!("variable \"{first}\""), + }) + } + "output" => { + let first = first?; + Some(TerraformBlockDecl { + kind: NodeKind::Variable, + name: first.clone(), + qualified_name: format!("output.{first}"), + signature: format!("output \"{first}\""), + }) + } + "provider" => { + let first = first?; + Some(TerraformBlockDecl { + kind: NodeKind::Namespace, + name: first.clone(), + qualified_name: format!("provider.{first}"), + signature: format!("provider \"{first}\""), + }) + } + _ => None, + } +} + +/// Turn a reference head + attribute chain into qualified name(s). Ports +/// `qualifyReference` (terraform.ts:158-189) MINUS the DEFERRED +/// `module.M:output.` scoped half — a `module.M.out` chain emits ONLY the +/// plain `module.M`. +fn qualify_terraform_reference(head: &str, attrs: &[String]) -> Vec { + match head { + "var" => attrs + .first() + .map(|a| vec![format!("var.{a}")]) + .unwrap_or_default(), + "local" => attrs + .first() + .map(|a| vec![format!("local.{a}")]) + .unwrap_or_default(), + "module" => { + // Plain `module.M` only; the `module.M:output.` scoped half is + // DEFERRED (it feeds the deferred TerraformResolver). + attrs + .first() + .map(|a| vec![format!("module.{a}")]) + .unwrap_or_default() + } + "data" => match (attrs.first(), attrs.get(1)) { + (Some(t), Some(n)) => vec![format!("data.{t}.{n}")], + _ => Vec::new(), + }, + _ => { + // .[....] — managed resource (e.g. + // aws_s3_bucket.my). Skip a bare head with no dotted chain. + attrs + .first() + .map(|a| vec![format!("{head}.{a}")]) + .unwrap_or_default() + } + } +} + +/// Read a `variable_expr` head + its `get_attr` sibling chain and emit qualified +/// reference(s). Ports `emitRefFromVariableExpr` (terraform.ts:126-155). +fn emit_ref_from_variable_expr( + var_expr: Node<'_>, + source: &str, + out: &mut Vec<(String, u32, u32)>, +) { + let Some(id) = var_expr + .named_children(&mut var_expr.walk()) + .find(|c| c.kind() == "identifier") + else { + return; + }; + let head = node_text(id, source); + if BUILTIN_HEADS.contains(&head.as_str()) || BUILTIN_KEYWORDS.contains(&head.as_str()) { + return; + } + + let mut attrs: Vec = Vec::new(); + let mut cursor = var_expr.next_named_sibling(); + while let Some(node) = cursor { + match node.kind() { + "get_attr" => { + let Some(attr_id) = node + .named_children(&mut node.walk()) + .find(|c| c.kind() == "identifier") + else { + break; + }; + attrs.push(node_text(attr_id, source)); + cursor = node.next_named_sibling(); + } + "index" | "new_index" | "legacy_index" | "splat" | "attr_splat" | "full_splat" => { + // foo[0], foo[*], foo.* — keep walking, add no segment. + cursor = node.next_named_sibling(); + } + _ => break, + } + } + + let line = var_expr.start_position().row as u32 + 1; + let col = var_expr.start_position().column as u32; + for qname in qualify_terraform_reference(&head, &attrs) { + out.push((qname, line, col)); + } +} + +/// BFS an `expression` subtree, emitting a qualified reference for every dotted +/// name whose head is a Terraform reference root. Ports `collectReferences` +/// (terraform.ts:106-121). Returns `(qualified_name, line, col)` tuples in +/// traversal order. +pub(crate) fn collect_terraform_references( + expr: Node<'_>, + source: &str, +) -> Vec<(String, u32, u32)> { + let mut out: Vec<(String, u32, u32)> = Vec::new(); + let mut queue: std::collections::VecDeque> = std::collections::VecDeque::new(); + queue.push_back(expr); + while let Some(node) = queue.pop_front() { + if node.kind() == "variable_expr" { + emit_ref_from_variable_expr(node, source, &mut out); + } + for child in node.named_children(&mut node.walk()) { + queue.push_back(child); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(src: &str) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + parser + .set_language(&tree_sitter_hcl::LANGUAGE.into()) + .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 terraform_spec_has_empty_type_sets() { + assert!(TERRAFORM_SPEC.function_types().is_empty()); + assert!(TERRAFORM_SPEC.class_types().is_empty()); + assert!(TERRAFORM_SPEC.method_types().is_empty()); + assert!(TERRAFORM_SPEC.call_types().is_empty()); + assert!(TERRAFORM_SPEC.variable_types().is_empty()); + assert!(TERRAFORM_SPEC.import_types().is_empty()); + assert_eq!(TERRAFORM_SPEC.language(), Language::Terraform); + } + + #[test] + fn describe_terraform_block_resource() { + let decl = describe_terraform_block("resource", &["aws_s3_bucket".into(), "b".into()]) + .expect("resource decl"); + assert_eq!(decl.kind, NodeKind::Class); + assert_eq!(decl.name, "aws_s3_bucket.b"); + assert_eq!(decl.qualified_name, "aws_s3_bucket.b"); + } + + #[test] + fn describe_terraform_block_data() { + let decl = describe_terraform_block("data", &["aws_ami".into(), "ubuntu".into()]) + .expect("data decl"); + assert_eq!(decl.kind, NodeKind::Class); + assert_eq!(decl.qualified_name, "data.aws_ami.ubuntu"); + } + + #[test] + fn describe_terraform_block_module() { + let decl = describe_terraform_block("module", &["vpc".into()]).expect("module decl"); + assert_eq!(decl.kind, NodeKind::Module); + assert_eq!(decl.qualified_name, "module.vpc"); + } + + #[test] + fn describe_terraform_block_variable() { + let decl = describe_terraform_block("variable", &["region".into()]).expect("variable decl"); + assert_eq!(decl.kind, NodeKind::Variable); + assert_eq!(decl.qualified_name, "var.region"); + } + + #[test] + fn describe_terraform_block_provider() { + let decl = describe_terraform_block("provider", &["aws".into()]).expect("provider decl"); + assert_eq!(decl.kind, NodeKind::Namespace); + assert_eq!(decl.qualified_name, "provider.aws"); + } + + #[test] + fn describe_terraform_block_unknown_is_none() { + assert!(describe_terraform_block("terraform", &[]).is_none()); + assert!(describe_terraform_block("resource", &["only_one".into()]).is_none()); + } + + #[test] + fn qualify_terraform_reference_module_output_is_plain() { + // A two-segment module.M.out chain emits ONLY plain module.M (the + // module.M:output.out scoped half is DEFERRED). + assert_eq!( + qualify_terraform_reference("module", &["vpc".into(), "id".into()]), + vec!["module.vpc".to_string()] + ); + } + + #[test] + fn terraform_parses_block() { + let tree = parse("resource \"aws_s3_bucket\" \"b\" { bucket = var.name }"); + let root = tree.root_node(); + let block = first_of_kind(root, "block").expect("a block node"); + let (block_type, labels) = read_terraform_block_header( + block, + "resource \"aws_s3_bucket\" \"b\" { bucket = var.name }", + ) + .expect("header"); + assert_eq!(block_type, "resource"); + assert_eq!(labels, vec!["aws_s3_bucket".to_string(), "b".to_string()]); + } +} diff --git a/crates/codegraph-extract/src/walker.rs b/crates/codegraph-extract/src/walker.rs index 35fe0d6..3e0d61e 100644 --- a/crates/codegraph-extract/src/walker.rs +++ b/crates/codegraph-extract/src/walker.rs @@ -230,10 +230,152 @@ impl<'a, 'tree> TreeSitterWalker<'a, 'tree> { Language::Cpp => self.visit_cpp_node(node), Language::Solidity => self.visit_solidity_node(node), Language::Nix => self.visit_nix_node(node), + Language::Terraform => self.visit_terraform_node(node), _ => false, } } + /// Terraform/HCL extraction (upstream `terraformExtractor.visitNode`, + /// `terraform.ts`, #1173 — the extraction slice only). HCL's AST has no + /// C-family type-set node kinds — EVERY top-level construct is a `block` + /// distinguished only by its first `identifier` child — so `TERRAFORM_SPEC` + /// returns `&[]` for every type-set and this custom visitor (like + /// `visit_nix_node`) is the SOLE owner of the `block` kind. Every other kind + /// returns `false` → generic `visit_named_children` descent (so + /// `config_file`/`body` are walked into automatically, reaching each + /// `block`). Strictly `Language::Terraform`-guarded. + /// + /// Block-type dispatch: `resource`/`data` → Class, `module` → Module, + /// `variable`/`output` → Variable, `provider` → Namespace, `locals` → + /// Constant per attribute; body attribute-expression traversal refs + /// (`var.X`/`local.X`/`module.M`/`data.T.N`/`.`) → + /// `EdgeKind::References` with built-ins skipped. The `emitModuleWiring` + /// `:`-scoped refs and the `.tfvars` var ref are DEFERRED (they feed the + /// deferred `TerraformResolver`). + fn visit_terraform_node(&mut self, node: SyntaxNode<'tree>) -> bool { + if node.kind() != "block" { + // .tfvars top-level-assignment `var.X` ref DEFERRED — no special + // case; let the generic walker descend. + return false; + } + + let Some((block_type, labels)) = + crate::lang::read_terraform_block_header(node, self.source) + else { + return false; + }; + let body = crate::lang::terraform_block_body(node); + + // locals: every body attribute becomes its own Constant. + if block_type == "locals" && labels.is_empty() { + self.visit_terraform_locals(body); + return true; + } + + // terraform { ... } settings block — no symbols, no refs. + if block_type == "terraform" && labels.is_empty() { + return true; + } + + // resource / data / module / variable / output / provider. + let Some(decl) = crate::lang::describe_terraform_block(&block_type, &labels) else { + // Unknown top-level block — generic descent. + return false; + }; + let crate::lang::TerraformBlockDecl { + kind, + name, + qualified_name, + signature, + } = decl; + let is_exported = kind == NodeKind::Variable; + if let Some(created) = self.create_node( + kind, + &name, + node, + NodeExtra { + signature: Some(signature), + is_exported, + qualified_name: Some(qualified_name), + ..NodeExtra::default() + }, + ) { + self.node_stack.push(created.id); + if let Some(body) = body { + self.emit_terraform_references_in_body(body); + } + // emitModuleWiring for `module` blocks DEFERRED. + self.node_stack.pop(); + } + true + } + + /// `locals { k = … }` → one `NodeKind::Constant` per body `attribute` + /// (qualified `local.`), plus traversal refs inside each attr's + /// `expression`. Ports `emitLocals` (terraform.ts:437-524). + fn visit_terraform_locals(&mut self, body: Option>) { + let Some(body) = body else { + return; + }; + for attr in body.named_children(&mut body.walk()) { + if attr.kind() != "attribute" { + continue; + } + let Some(id_node) = attr + .named_children(&mut attr.walk()) + .find(|c| c.kind() == "identifier") + else { + continue; + }; + let name = node_text(id_node, self.source); + if let Some(created) = self.create_node( + NodeKind::Constant, + &name, + attr, + NodeExtra { + signature: Some(format!("local.{name}")), + qualified_name: Some(format!("local.{name}")), + ..NodeExtra::default() + }, + ) { + if let Some(expr) = attr + .named_children(&mut attr.walk()) + .find(|c| c.kind() == "expression") + { + let refs = crate::lang::collect_terraform_references(expr, self.source); + for (qname, _line, _col) in refs { + self.push_ref(&created.id, &qname, EdgeKind::References, expr); + } + } + } + } + } + + /// BFS the block body; when a node's kind is `expression`, run + /// `collect_terraform_references` and push each ref from the current owner, + /// then DON'T descend into that expression (the collector already walked + /// it). Ports `emitReferencesInBody` (terraform.ts:526-550). + fn emit_terraform_references_in_body(&mut self, body: SyntaxNode<'tree>) { + let Some(from_id) = self.node_stack.last().cloned() else { + return; + }; + let mut queue: std::collections::VecDeque> = + std::collections::VecDeque::new(); + queue.push_back(body); + while let Some(node) = queue.pop_front() { + if node.kind() == "expression" { + let refs = crate::lang::collect_terraform_references(node, self.source); + for (qname, _line, _col) in refs { + self.push_ref(&from_id, &qname, EdgeKind::References, node); + } + continue; + } + for child in node.named_children(&mut node.walk()) { + queue.push_back(child); + } + } + } + /// Nix extraction (upstream `nixExtractor.visitNode`, `nix.ts`, #1190 — the /// extraction slice only). Nix's expression AST has no C-family type-set /// node kinds, so `NIX_SPEC` returns `&[]` for every type-set and this @@ -5792,4 +5934,116 @@ public: assert!(has_node(&nodes, NodeKind::Variable, "lib")); assert!(has_node(&nodes, NodeKind::Variable, "pkgs")); } + + // ---- Terraform / HCL (hook-driven; block-type dispatch) ---- + + #[test] + fn terraform_resource_is_class_qualified() { + let (nodes, _refs) = run( + "main.tf", + "resource \"aws_s3_bucket\" \"b\" {}", + Language::Terraform, + ); + let n = node(&nodes, NodeKind::Class, "aws_s3_bucket.b"); + assert_eq!(n.qualified_name, "aws_s3_bucket.b"); + } + + #[test] + fn terraform_variable_is_variable() { + let (nodes, _refs) = run("main.tf", "variable \"region\" {}", Language::Terraform); + let n = node(&nodes, NodeKind::Variable, "region"); + assert_eq!(n.qualified_name, "var.region"); + assert!(n.is_exported); + } + + #[test] + fn terraform_output_is_variable_with_ref() { + let (nodes, refs) = run( + "main.tf", + "output \"arn\" { value = aws_s3_bucket.b.arn }", + Language::Terraform, + ); + let n = node(&nodes, NodeKind::Variable, "arn"); + assert_eq!(n.qualified_name, "output.arn"); + assert!(has_ref(&refs, EdgeKind::References, "aws_s3_bucket.b")); + } + + #[test] + fn terraform_module_is_module_no_scoped_ref() { + let (nodes, refs) = run( + "main.tf", + "module \"vpc\" { source = \"./vpc\" }", + Language::Terraform, + ); + let n = node(&nodes, NodeKind::Module, "vpc"); + assert_eq!(n.qualified_name, "module.vpc"); + assert!( + !refs.iter().any(|r| r.reference_name.contains(':')), + "no :-scoped module wiring ref (emitModuleWiring DEFERRED): {refs:?}" + ); + } + + #[test] + fn terraform_provider_is_namespace() { + let (nodes, _refs) = run("main.tf", "provider \"aws\" {}", Language::Terraform); + let n = node(&nodes, NodeKind::Namespace, "aws"); + assert_eq!(n.qualified_name, "provider.aws"); + } + + #[test] + fn terraform_locals_emit_constants() { + let (nodes, refs) = run( + "main.tf", + "locals {\n name = \"x\"\n region = var.r\n}", + Language::Terraform, + ); + assert_eq!( + node(&nodes, NodeKind::Constant, "name").qualified_name, + "local.name" + ); + assert_eq!( + node(&nodes, NodeKind::Constant, "region").qualified_name, + "local.region" + ); + assert!(has_ref(&refs, EdgeKind::References, "var.r")); + } + + #[test] + fn terraform_body_ref_var() { + let (_nodes, refs) = run( + "main.tf", + "resource \"aws_s3_bucket\" \"b\" { bucket = var.name }", + Language::Terraform, + ); + assert!(has_ref(&refs, EdgeKind::References, "var.name")); + } + + #[test] + fn terraform_module_output_ref_is_plain() { + let (_nodes, refs) = run( + "main.tf", + "output \"vpc_id\" { value = module.vpc.id }", + Language::Terraform, + ); + assert!(has_ref(&refs, EdgeKind::References, "module.vpc")); + assert!( + !refs.iter().any(|r| r.reference_name.contains(':')), + "the module.vpc:output.id scoped half is DEFERRED: {refs:?}" + ); + } + + #[test] + fn terraform_builtins_skipped() { + let (_nodes, refs) = run( + "main.tf", + "resource \"x\" \"y\" {\n a = each.key\n b = count.index\n c = path.module\n}", + Language::Terraform, + ); + assert!( + !refs + .iter() + .any(|r| r.reference_kind == EdgeKind::References), + "built-in heads (each/count/path) emit no ref: {refs:?}" + ); + } } diff --git a/crates/codegraph-store/src/queries.rs b/crates/codegraph-store/src/queries.rs index b3e0c67..0cf3ca3 100644 --- a/crates/codegraph-store/src/queries.rs +++ b/crates/codegraph-store/src/queries.rs @@ -1526,6 +1526,7 @@ fn parse_language(value: String) -> rusqlite::Result { "r" => Language::R, "solidity" => Language::Solidity, "nix" => Language::Nix, + "terraform" => Language::Terraform, "yaml" => Language::Yaml, "twig" => Language::Twig, "xml" => Language::Xml, @@ -2199,6 +2200,14 @@ mod tests { assert_eq!(parse_language("nix".to_string()).unwrap(), Language::Nix); } + #[test] + fn parse_language_accepts_terraform() { + assert_eq!( + parse_language("terraform".to_string()).unwrap(), + Language::Terraform + ); + } + #[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 5e88a2e..8fc03fe 100644 --- a/docs/equivalence.md +++ b/docs/equivalence.md @@ -504,6 +504,59 @@ The `generated_golden_matches_committed_nix_fixture` and `nix_db_is_self_equivalent_to_nix_golden` tests in `crates/codegraph-bench/tests/equivalence.rs` enforce byte-stability. +### Terraform fixture + +A tenth golden fixture, `reference/golden/terraform/`, guards Terraform/OpenTofu +(HCL) extraction (upstream #1173 / `6c24f4b`, the extraction slice only). +Terraform is a **new `Language::Terraform` variant** backed by a **dedicated +`tree-sitter-hcl` grammar** (`.tf`/`.tfvars`/`.tofu` → `Language::Terraform`). +HCL is intentionally generic — every top-level construct is a `block` +distinguished only by its first `identifier` child — so `TERRAFORM_SPEC` has +all-empty type-sets and extraction is driven by the `Language::Terraform`-guarded +`visit_terraform_node` walker extension. The corpus +(`crates/codegraph-bench/fixtures/terraform/main.tf`) is a single deterministic +file with a `terraform {}` settings block, a `provider "aws"`, a +`variable "region"`, a `locals` block, a `data "aws_ami" "ubuntu"`, a +`resource "aws_s3_bucket" "b"`, a `module "vpc"`, and two `output` blocks. What +it guards: + +- the `.tf` file with `"language": "terraform"`; +- block-type dispatch: `resource`/`data` → `NodeKind::Class` (qualified `T.N` / + `data.T.N`), `module` → `NodeKind::Module` (`module.M`), `variable`/`output` → + `NodeKind::Variable` (`var.V` / `output.O`, `is_exported`), `provider` → + `NodeKind::Namespace` (`provider.P`), `locals` attributes → `NodeKind::Constant` + per attribute (`local.k`); +- plain attribute-expression traversal refs + (`var.X`/`local.X`/`module.M`/`data.T.N`/`.`) → `References`, with + built-in heads (`each`/`count`/`self`/`path`/`terraform`) skipped. + +The plain traversal refs with a unique same-file target resolve via the existing +generic qualified-name matcher: `var.region` ×3 → `variable "region"`, +`aws_s3_bucket.b` → the resource, `module.vpc` → the module (each an EDGE, absent +from `refs.json`). The undeclared `aws_kms_key.logs` stays the sole unresolved +`refs.json` row. The module-boundary `TerraformResolver`, `emitModuleWiring`'s +`:`-scoped refs (`module.M:file`/`:var.X`/`:output.X`), the `.tfvars` +top-level-assignment `var.X` ref, and the `module.M:output.` scoped half of +`qualifyReference` are all **DEFERRED** — the port keeps its single +`GodotResolver` — so no `:`-scoped ref is emitted. Adding the variant is +byte-neutral for `colby.schema.sql` (language is a stored TEXT value, not DDL) +and for the nine existing goldens (none holds a `.tf`/`.tfvars`/`.tofu` file). + +Regenerate reproducibly (identical recipe to the Nix fixture, substituting +`terraform`): + +```bash +rm -rf /tmp/cg-fixture-terraform && cp -r crates/codegraph-bench/fixtures/terraform /tmp/cg-fixture-terraform +cargo build --release -p codegraph-rs +CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 ./target/release/codegraph init /tmp/cg-fixture-terraform +cp /tmp/cg-fixture-terraform/.codegraph/codegraph.db reference/golden/terraform/colby.db +cargo run -p codegraph-bench --bin bench -- --gen-golden reference/golden/terraform/colby.db reference/golden/terraform +``` + +The `generated_golden_matches_committed_terraform_fixture` and +`terraform_db_is_self_equivalent_to_terraform_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 ab43097..2c30441 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. -**35 concrete languages** are supported, grouped into three extraction tiers based on what +**36 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,40 +14,41 @@ the extractor produces. --- -## Tier 1 — Full symbol extraction (26 languages) +## Tier 1 — Full symbol extraction (27 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 level. -| Language | Extensions | Extraction | Notes | -| ----------- | ------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| TypeScript | `.ts` `.mts` `.cts` | Full tree-sitter | | -| TSX | `.tsx` | Full tree-sitter | TypeScript grammar, JSX syntax | -| JavaScript | `.js` `.mjs` `.cjs` `.xsjs` `.xsjslib` | Full tree-sitter | | -| JSX | `.jsx` | Full tree-sitter | JavaScript grammar, JSX syntax | -| ArkTS | `.ets` | Full tree-sitter | HarmonyOS / OpenHarmony; `tree-sitter-arkts` grammar. `@Component struct` → struct symbol. ArkUI dynamic-dispatch bridges deferred. Plain `.ts` stays TypeScript | -| Python | `.py` `.pyw` | Full tree-sitter | | -| Go | `.go` | Full tree-sitter | | -| Rust | `.rs` | Full tree-sitter | | -| Java | `.java` | Full tree-sitter | | -| C | `.c` `.h` | Full tree-sitter | `.h` may be promoted to C++ or Objective-C by heuristics | -| C++ | `.cpp` `.cc` `.cxx` `.hpp` `.hxx` | Full tree-sitter | | -| C# | `.cs` | Full tree-sitter | | -| PHP | `.php` `.module` `.install` `.theme` `.inc` | Full tree-sitter | | -| Ruby | `.rb` `.rake` | Full tree-sitter | | -| Swift | `.swift` | Full tree-sitter | | -| Kotlin | `.kt` `.kts` | Full tree-sitter | | -| Dart | `.dart` | Full tree-sitter | | -| Scala | `.scala` `.sc` | Full tree-sitter | | -| Lua | `.lua` | Full tree-sitter | | -| Luau | `.luau` | Full tree-sitter | Roblox Luau dialect | -| Objective-C | `.m` `.mm` | Full tree-sitter | | -| R | `.r` | Full tree-sitter | | -| Solidity | `.sol` | Full tree-sitter | `tree-sitter-solidity` grammar; contracts/libraries/interfaces, structs, enums, modifiers, events, errors; `is`-inheritance → Extends (resolver promotes to Implements for interfaces); emit/revert/modifier-guard call edges | -| Nix | `.nix` | Full tree-sitter | `tree-sitter-nix` grammar; `let`/attrset bindings, curried lambdas, `inherit`; `import`/`callPackage`/`imports`-list file imports; module-system option synthesizer 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 | +| Language | Extensions | Extraction | Notes | +| ----------- | ------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| TypeScript | `.ts` `.mts` `.cts` | Full tree-sitter | | +| TSX | `.tsx` | Full tree-sitter | TypeScript grammar, JSX syntax | +| JavaScript | `.js` `.mjs` `.cjs` `.xsjs` `.xsjslib` | Full tree-sitter | | +| JSX | `.jsx` | Full tree-sitter | JavaScript grammar, JSX syntax | +| ArkTS | `.ets` | Full tree-sitter | HarmonyOS / OpenHarmony; `tree-sitter-arkts` grammar. `@Component struct` → struct symbol. ArkUI dynamic-dispatch bridges deferred. Plain `.ts` stays TypeScript | +| Python | `.py` `.pyw` | Full tree-sitter | | +| Go | `.go` | Full tree-sitter | | +| Rust | `.rs` | Full tree-sitter | | +| Java | `.java` | Full tree-sitter | | +| C | `.c` `.h` | Full tree-sitter | `.h` may be promoted to C++ or Objective-C by heuristics | +| C++ | `.cpp` `.cc` `.cxx` `.hpp` `.hxx` | Full tree-sitter | | +| C# | `.cs` | Full tree-sitter | | +| PHP | `.php` `.module` `.install` `.theme` `.inc` | Full tree-sitter | | +| Ruby | `.rb` `.rake` | Full tree-sitter | | +| Swift | `.swift` | Full tree-sitter | | +| Kotlin | `.kt` `.kts` | Full tree-sitter | | +| Dart | `.dart` | Full tree-sitter | | +| Scala | `.scala` `.sc` | Full tree-sitter | | +| Lua | `.lua` | Full tree-sitter | | +| Luau | `.luau` | Full tree-sitter | Roblox Luau dialect | +| Objective-C | `.m` `.mm` | Full tree-sitter | | +| R | `.r` | Full tree-sitter | | +| Solidity | `.sol` | Full tree-sitter | `tree-sitter-solidity` grammar; contracts/libraries/interfaces, structs, enums, modifiers, events, errors; `is`-inheritance → Extends (resolver promotes to Implements for interfaces); emit/revert/modifier-guard call edges | +| Nix | `.nix` | Full tree-sitter | `tree-sitter-nix` grammar; `let`/attrset bindings, curried lambdas, `inherit`; `import`/`callPackage`/`imports`-list file imports; module-system option synthesizer deferred | +| Terraform | `.tf` `.tfvars` `.tofu` | Full tree-sitter | `tree-sitter-hcl` grammar (HCL/Terraform/OpenTofu); `resource`/`data`→class, `module`→module, `variable`/`output`→variable, `provider`→namespace, `locals`→constant; qualified names + `var`/`local`/`module`/`data`/resource traversal refs; module-boundary framework resolver 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 | --- diff --git a/reference/golden/terraform/colby.db b/reference/golden/terraform/colby.db new file mode 100644 index 0000000..1415c65 Binary files /dev/null and b/reference/golden/terraform/colby.db differ diff --git a/reference/golden/terraform/edges.json b/reference/golden/terraform/edges.json new file mode 100644 index 0000000..dc860b9 --- /dev/null +++ b/reference/golden/terraform/edges.json @@ -0,0 +1,143 @@ +[ + { + "col": 10, + "kind": "references", + "line": 25, + "metadata": { + "confidence": 0.95, + "resolvedBy": "qualified-name" + }, + "provenance": null, + "source": "variable:60a98b4d776ea6b99ec109f16565e238", + "target": "class:9ea649776b48847a71f06911daf0119e" + }, + { + "col": 10, + "kind": "references", + "line": 29, + "metadata": { + "confidence": 0.95, + "resolvedBy": "qualified-name" + }, + "provenance": null, + "source": "variable:20d776b47f45d7da835d4d4e82010d17", + "target": "module:6387ce4b696974e8905686ae83527943" + }, + { + "col": 11, + "kind": "references", + "line": 21, + "metadata": { + "confidence": 0.95, + "resolvedBy": "qualified-name" + }, + "provenance": null, + "source": "module:6387ce4b696974e8905686ae83527943", + "target": "variable:161a5352b21594f9a56b14df3143a6c4" + }, + { + "col": 11, + "kind": "references", + "line": 9, + "metadata": { + "confidence": 0.95, + "resolvedBy": "qualified-name" + }, + "provenance": null, + "source": "constant:60a0bf4de8e88ec795bc637f794b133e", + "target": "variable:161a5352b21594f9a56b14df3143a6c4" + }, + { + "col": 15, + "kind": "references", + "line": 15, + "metadata": { + "confidence": 0.95, + "resolvedBy": "qualified-name" + }, + "provenance": null, + "source": "class:9ea649776b48847a71f06911daf0119e", + "target": "variable:161a5352b21594f9a56b14df3143a6c4" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:main.tf", + "target": "class:4dd4b9246c74373ca3146282f84872bd" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:main.tf", + "target": "class:9ea649776b48847a71f06911daf0119e" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:main.tf", + "target": "constant:30d18d17f1263f980b13bb367101b29e" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:main.tf", + "target": "constant:60a0bf4de8e88ec795bc637f794b133e" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:main.tf", + "target": "module:6387ce4b696974e8905686ae83527943" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:main.tf", + "target": "namespace:97d1ebc29d6e37a70a7ade3c6e6441c5" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:main.tf", + "target": "variable:161a5352b21594f9a56b14df3143a6c4" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:main.tf", + "target": "variable:20d776b47f45d7da835d4d4e82010d17" + }, + { + "col": null, + "kind": "contains", + "line": null, + "metadata": null, + "provenance": null, + "source": "file:main.tf", + "target": "variable:60a98b4d776ea6b99ec109f16565e238" + } +] diff --git a/reference/golden/terraform/files.json b/reference/golden/terraform/files.json new file mode 100644 index 0000000..549b157 --- /dev/null +++ b/reference/golden/terraform/files.json @@ -0,0 +1,10 @@ +[ + { + "content_hash": "7df7854f67b585b19f9fb67712ff48434fc209255e89d426d2036200cd4d960c", + "errors": null, + "language": "terraform", + "node_count": 10, + "path": "main.tf", + "size": 387 + } +] diff --git a/reference/golden/terraform/nodes.json b/reference/golden/terraform/nodes.json new file mode 100644 index 0000000..200df10 --- /dev/null +++ b/reference/golden/terraform/nodes.json @@ -0,0 +1,222 @@ +[ + { + "decorators": null, + "docstring": null, + "end_column": 26, + "end_line": 12, + "file_path": "main.tf", + "id": "class:4dd4b9246c74373ca3146282f84872bd", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "class", + "language": "terraform", + "name": "aws_ami.ubuntu", + "qualified_name": "data.aws_ami.ubuntu", + "return_type": null, + "signature": "data \"aws_ami\" \"ubuntu\"", + "start_column": 0, + "start_line": 12, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 17, + "file_path": "main.tf", + "id": "class:9ea649776b48847a71f06911daf0119e", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "class", + "language": "terraform", + "name": "aws_s3_bucket.b", + "qualified_name": "aws_s3_bucket.b", + "return_type": null, + "signature": "resource \"aws_s3_bucket\" \"b\"", + "start_column": 0, + "start_line": 14, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 17, + "end_line": 8, + "file_path": "main.tf", + "id": "constant:30d18d17f1263f980b13bb367101b29e", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "constant", + "language": "terraform", + "name": "name", + "qualified_name": "local.name", + "return_type": null, + "signature": "local.name", + "start_column": 2, + "start_line": 8, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 21, + "end_line": 9, + "file_path": "main.tf", + "id": "constant:60a0bf4de8e88ec795bc637f794b133e", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "constant", + "language": "terraform", + "name": "tagged", + "qualified_name": "local.tagged", + "return_type": null, + "signature": "local.tagged", + "start_column": 2, + "start_line": 9, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 0, + "end_line": 31, + "file_path": "main.tf", + "id": "file:main.tf", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "file", + "language": "terraform", + "name": "main.tf", + "qualified_name": "main.tf", + "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": 22, + "file_path": "main.tf", + "id": "module:6387ce4b696974e8905686ae83527943", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "module", + "language": "terraform", + "name": "vpc", + "qualified_name": "module.vpc", + "return_type": null, + "signature": "module \"vpc\"", + "start_column": 0, + "start_line": 19, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 17, + "end_line": 3, + "file_path": "main.tf", + "id": "namespace:97d1ebc29d6e37a70a7ade3c6e6441c5", + "is_abstract": 0, + "is_async": 0, + "is_exported": 0, + "is_static": 0, + "kind": "namespace", + "language": "terraform", + "name": "aws", + "qualified_name": "provider.aws", + "return_type": null, + "signature": "provider \"aws\"", + "start_column": 0, + "start_line": 3, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 20, + "end_line": 5, + "file_path": "main.tf", + "id": "variable:161a5352b21594f9a56b14df3143a6c4", + "is_abstract": 0, + "is_async": 0, + "is_exported": 1, + "is_static": 0, + "kind": "variable", + "language": "terraform", + "name": "region", + "qualified_name": "var.region", + "return_type": null, + "signature": "variable \"region\"", + "start_column": 0, + "start_line": 5, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 30, + "file_path": "main.tf", + "id": "variable:20d776b47f45d7da835d4d4e82010d17", + "is_abstract": 0, + "is_async": 0, + "is_exported": 1, + "is_static": 0, + "kind": "variable", + "language": "terraform", + "name": "vpc_id", + "qualified_name": "output.vpc_id", + "return_type": null, + "signature": "output \"vpc_id\"", + "start_column": 0, + "start_line": 28, + "type_parameters": null, + "visibility": null + }, + { + "decorators": null, + "docstring": null, + "end_column": 1, + "end_line": 26, + "file_path": "main.tf", + "id": "variable:60a98b4d776ea6b99ec109f16565e238", + "is_abstract": 0, + "is_async": 0, + "is_exported": 1, + "is_static": 0, + "kind": "variable", + "language": "terraform", + "name": "bucket_id", + "qualified_name": "output.bucket_id", + "return_type": null, + "signature": "output \"bucket_id\"", + "start_column": 0, + "start_line": 24, + "type_parameters": null, + "visibility": null + } +] diff --git a/reference/golden/terraform/refs.json b/reference/golden/terraform/refs.json new file mode 100644 index 0000000..ec090be --- /dev/null +++ b/reference/golden/terraform/refs.json @@ -0,0 +1,12 @@ +[ + { + "candidates": null, + "col": 15, + "file_path": "main.tf", + "from_node_id": "class:9ea649776b48847a71f06911daf0119e", + "language": "terraform", + "line": 16, + "reference_kind": "references", + "reference_name": "aws_kms_key.logs" + } +] diff --git a/reference/golden/terraform/schema.sql b/reference/golden/terraform/schema.sql new file mode 100644 index 0000000..d36218f --- /dev/null +++ b/reference/golden/terraform/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);