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 <python-builtins>, 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 <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-10-10 13:53:54 +02:00
parent 0dac32ad0d
commit 75e317518c
4 changed files with 96 additions and 2 deletions
+34 -1
View File
@@ -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
+3 -1
View File
@@ -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},
+50
View File
@@ -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);
+9
View File
@@ -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);