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);