From 75e317518c682d1dbcd3fbab7b47cb669dc619ad Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 10 Oct 2026 13:53:54 +0200 Subject: [PATCH] fix(hcl): index .tofu and .tfvars; only top-level blocks are Classes - OpenTofu configuration (`.tofu`) and variable files (`.tfvars`) had no language and were never indexed (cloud-foundation-fabric: 104 `.tofu`, 208 `.tfvars`). - Every HCL `block` became a Class, nested ones included: a resource's `lifecycle`, `dynamic`/`content` and argument blocks were entities next to the resource (cloud-foundation-fabric: `dynamic` 1,342, `content` 205 beside 898 resources). Only a block directly in the file's body -- resource, data, module, variable, output, locals, provider, terraform, Terragrunt's include/dependency -- is a Class now. - A file's own assignments (a `.tfvars` file, Terragrunt `inputs`) sit one level below the root the variable walk scanned; none was extracted. They are Variables now. On real input (HCL Classes / .tofu+.tfvars files / HCL Variables), against main: cloud-foundation-fabric 6,324 -> 3,919 / 0 -> 312 / 0 -> 1,067, kubernetes/k8s.io 2,102 -> 1,590 / 0 -> 11 / 0 -> 42, kubespray 892 -> 804 / 0 -> 13 / 0 -> 174, eks-blueprints 924 -> 733, kube-hetzner 757 -> 683, deckhouse 976 -> 830; the terragrunt example keeps its 31 top-level blocks and gains 5 input Variables. Not changed here: the remaining HCL USAGE edges are generic name guesses (a `range` call resolves to a Class in , module outputs to same-named outputs of unrelated modules), and the name-similarity config links now also see the 104 OpenTofu files. The nested-block, .tofu and .tfvars probes and the extension unit test fail without the change. Signed-off-by: Martin Vogel --- internal/cbm/extract_defs.c | 35 ++++++++++++++++++++++++- src/discover/language.c | 4 ++- tests/test_grammar_probe_f.c | 50 ++++++++++++++++++++++++++++++++++++ tests/test_language.c | 9 +++++++ 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index ddf165d8c..feb64086c 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -6862,6 +6862,19 @@ static char *find_ini_section_name(CBMArena *a, TSNode node, const char *source) return NULL; } +// HCL: a top-level block (resource, data, module, variable, output, locals, +// provider, terraform; Terragrunt's include, dependency, ...) sits directly in +// the file's body. Blocks nested in one (lifecycle, dynamic, content, a +// resource's own argument blocks) are part of it, not entities of their own. +static bool hcl_block_is_top_level(TSNode node) { + TSNode body = ts_node_parent(node); + if (ts_node_is_null(body) || strcmp(ts_node_type(body), "body") != 0) { + return false; + } + TSNode file = ts_node_parent(body); + return !ts_node_is_null(file) && strcmp(ts_node_type(file), "config_file") == 0; +} + // HCL: extract block name from identifier child. static char *find_hcl_block_name(CBMArena *a, TSNode node, const char *source) { TSNode id = cbm_find_child_by_kind(node, "identifier"); @@ -6923,7 +6936,7 @@ static bool extract_config_class_def(CBMExtractCtx *ctx, TSNode node, const char // #518: index what the section SAYS, not just its title. docstring = extract_markdown_section_body(a, node, ctx->source); } else if (ctx->language == CBM_LANG_HCL && strcmp(kind, "block") == 0) { - name = find_hcl_block_name(a, node, ctx->source); + name = hcl_block_is_top_level(node) ? find_hcl_block_name(a, node, ctx->source) : NULL; } else { return false; } @@ -10011,6 +10024,26 @@ static void extract_variables(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec return; } + /* HCL: a file's own assignments -- a .tfvars file, Terragrunt's `inputs` + * -- sit in the top-level body, one level below the root the generic loop + * scans, so none was extracted. Attributes inside blocks belong to their + * block. */ + if (ctx->language == CBM_LANG_HCL) { + TSNode body = cbm_find_child_by_kind(root, "body"); + uint32_t n = ts_node_is_null(body) ? 0 : ts_node_named_child_count(body); + for (uint32_t i = 0; i < n; i++) { + TSNode attr = ts_node_named_child(body, i); + if (strcmp(ts_node_type(attr), "attribute") != 0) { + continue; + } + TSNode id = cbm_find_child_by_kind(attr, "identifier"); + if (!ts_node_is_null(id)) { + push_var_def(ctx, cbm_node_text(ctx->arena, id, ctx->source), attr); + } + } + return; + } + /* Nix: the file's top level sits behind its header lambda(s), so the root's * only child is a function_expression and the generic loop below would see * nothing. Resolve past the header to the binding container(s) that actually diff --git a/src/discover/language.c b/src/discover/language.c index f7fff9544..7b66f717a 100644 --- a/src/discover/language.c +++ b/src/discover/language.c @@ -152,9 +152,11 @@ static const ext_entry_t EXT_TABLE[] = { /* Haskell */ {".hs", CBM_LANG_HASKELL}, - /* HCL / Terraform */ + /* HCL / Terraform / OpenTofu */ {".hcl", CBM_LANG_HCL}, {".tf", CBM_LANG_HCL}, + {".tofu", CBM_LANG_HCL}, + {".tfvars", CBM_LANG_HCL}, /* HTML */ {".htm", CBM_LANG_HTML}, diff --git a/tests/test_grammar_probe_f.c b/tests/test_grammar_probe_f.c index 8daf6d9c9..5995b8f7e 100644 --- a/tests/test_grammar_probe_f.c +++ b/tests/test_grammar_probe_f.c @@ -276,6 +276,53 @@ TEST(probe_hcl_no_spurious_imports) { PASS(); } +/* Only a top-level block (resource, data, module, variable, output, ...) is an + * entity; blocks nested in it (lifecycle, dynamic, content, the resource's own + * argument blocks) are part of it. cloud-foundation-fabric minted 1,342 + * `dynamic` and 205 `content` Classes next to 898 resources. */ +TEST(probe_hcl_nested_blocks_are_not_classes) { + GpfMetrics m = gpf_metrics("main.tf", + "resource \"google_container_cluster\" \"main\" {\n" + " name = \"main\"\n" + " addons_config {\n" + " http_load_balancing { disabled = false }\n" + " }\n" + " dynamic \"node_pool\" {\n" + " for_each = var.pools\n" + " content {\n" + " name = node_pool.value.name\n" + " }\n" + " }\n" + " lifecycle {\n" + " ignore_changes = [node_pool]\n" + " }\n" + "}\n"); + ASSERT_TRUE(m.ok); + ASSERT_EQ(m.classes, 1); + PASS(); +} + +/* OpenTofu configuration (.tofu) is indexed like .tf. */ +TEST(probe_tofu_file_indexed) { + GpfMetrics m = gpf_metrics("main.tofu", + "resource \"aws_s3_bucket\" \"logs\" {\n" + " bucket = \"logs\"\n" + "}\n"); + ASSERT_TRUE(m.ok); + ASSERT_EQ(m.classes, 1); + PASS(); +} + +/* A variable file's assignments are its content. */ +TEST(probe_tfvars_file_indexed) { + GpfMetrics m = gpf_metrics("prod.tfvars", + "region = \"europe-west1\"\n" + "node_count = 3\n"); + ASSERT_TRUE(m.ok); + ASSERT_TRUE(m.variables >= 2); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * GROUP 2 — Kubernetes manifests (.yaml with apiVersion:) * @@ -2011,6 +2058,9 @@ SUITE(grammar_probe_f) { RUN_TEST(probe_hcl_module_block_node); RUN_TEST(probe_hcl_infra_maps_edge); RUN_TEST(probe_hcl_no_spurious_imports); + RUN_TEST(probe_hcl_nested_blocks_are_not_classes); + RUN_TEST(probe_tofu_file_indexed); + RUN_TEST(probe_tfvars_file_indexed); /* Kubernetes manifests */ RUN_TEST(probe_k8s_pod_resource_node); diff --git a/tests/test_language.c b/tests/test_language.c index 286733c7f..26c6f05c0 100644 --- a/tests/test_language.c +++ b/tests/test_language.c @@ -386,6 +386,14 @@ TEST(lang_ext_hcl2) { ASSERT_EQ(cbm_language_for_extension(".hcl"), CBM_LANG_HCL); PASS(); } +/* OpenTofu's own configuration files and Terraform/OpenTofu variable files + * are HCL too; neither was indexed (cloud-foundation-fabric: 104 .tofu, + * 208 .tfvars). */ +TEST(lang_ext_tofu_tfvars) { + ASSERT_EQ(cbm_language_for_extension(".tofu"), CBM_LANG_HCL); + ASSERT_EQ(cbm_language_for_extension(".tfvars"), CBM_LANG_HCL); + PASS(); +} TEST(lang_ext_sql) { ASSERT_EQ(cbm_language_for_extension(".sql"), CBM_LANG_SQL); PASS(); @@ -1588,6 +1596,7 @@ SUITE(language) { RUN_TEST(lang_ext_toml); RUN_TEST(lang_ext_hcl); RUN_TEST(lang_ext_hcl2); + RUN_TEST(lang_ext_tofu_tfvars); RUN_TEST(lang_ext_sql); RUN_TEST(lang_ext_dockerfile); RUN_TEST(lang_ext_json);