Skip to main content

tfparser_core/projection/
mod.rs

1//! `RawComponent` → IR projection.
2//!
3//! Phase 3 closes M0 by turning the loader's flat list of [`RawBlock`]s into
4//! the typed IR shapes downstream code consumes: [`Resource`], [`ProviderBlock`],
5//! [`ModuleCall`], [`Variable`], [`Local`], [`Output`], and the workspace-level
6//! [`Component`].
7//!
8//! Per [10-data-model.md § 2.2] and [20-parquet-exporter.md § 3], the projection
9//! is **structural**: it copies fields verbatim out of the [`AttributeMap`] the
10//! loader produced. No evaluation, no template expansion, no reference
11//! resolution. Anything that needs the evaluator stays as
12//! [`Expression::Unresolved`] (Phase 4 will reduce them).
13//!
14//! Provider aliases on resources land as [`ProviderRef`]; the lookup is a
15//! syntactic check that the right-hand side of `provider = aws.<alias>` is an
16//! [`Expression::Unresolved`] with a non-`Var`/`Local` symbol kind. Anything
17//! richer (a conditional, a function call) is treated as "no alias known" and
18//! the resource keeps `provider_ref = None`.
19//!
20//! [10-data-model.md § 2.2]: ../../../specs/10-data-model.md
21//! [20-parquet-exporter.md § 3]: ../../../specs/20-parquet-exporter.md
22
23use std::sync::Arc;
24
25use crate::{
26    Diagnostic, Severity,
27    diagnostic::Diagnostic as Diag,
28    ir::{
29        Address, AttributeMap, BlockKind, Component, ComponentId, Expression, Local, ModuleCall,
30        ModuleSource, Output, ProviderBlock, ProviderRef, Resource, ResourceKind, SymbolKind,
31        Value, Variable,
32    },
33    loader::{RawBlock, RawComponent},
34};
35
36/// Project a [`RawComponent`] into a fully-typed [`Component`].
37///
38/// `component_id` is assigned by the orchestrator (1-based, per
39/// [`ComponentId`]). The returned [`Component`] carries an empty
40/// `terragrunt` field (populated in Phase 6) and `state_backend = None`
41/// until either the `terraform { backend ... }` block extractor or the
42/// Terragrunt resolver fills it.
43///
44/// Diagnostics surfaced by the projection (malformed addresses, unsupported
45/// shapes) are appended to `out_diagnostics`.
46#[must_use]
47pub fn project_component(
48    raw: &RawComponent,
49    component_id: ComponentId,
50    out_diagnostics: &mut Vec<Diagnostic>,
51) -> Component {
52    let mut variables: Vec<Variable> = Vec::new();
53    let mut locals: Vec<Local> = Vec::new();
54    let mut providers: Vec<ProviderBlock> = Vec::new();
55    let mut resources: Vec<Resource> = Vec::new();
56    let mut module_calls: Vec<ModuleCall> = Vec::new();
57    let mut outputs: Vec<Output> = Vec::new();
58
59    for block in &raw.raw_blocks {
60        match block.kind {
61            BlockKind::Resource | BlockKind::Data => {
62                match project_resource(block, out_diagnostics) {
63                    Some(r) => resources.push(r),
64                    None => out_diagnostics.push(Diag::new(
65                        Severity::Warn,
66                        "TF1301",
67                        format!(
68                            "resource/data block missing labels in {}",
69                            block.source.display()
70                        ),
71                    )),
72                }
73            }
74            BlockKind::Provider => {
75                if let Some(p) = project_provider(block, out_diagnostics) {
76                    providers.push(p);
77                }
78            }
79            BlockKind::Module => {
80                if let Some(m) = project_module_call(block, out_diagnostics) {
81                    module_calls.push(m);
82                }
83            }
84            BlockKind::Variable => {
85                if let Some(v) = project_variable(block) {
86                    variables.push(v);
87                }
88            }
89            BlockKind::Locals => project_locals_into(block, &mut locals),
90            BlockKind::Output => {
91                if let Some(o) = project_output(block) {
92                    outputs.push(o);
93                }
94            }
95            BlockKind::Terraform
96            | BlockKind::Include
97            | BlockKind::Generate
98            | BlockKind::Dependency
99            | BlockKind::Inputs
100            | BlockKind::Unknown => {
101                // Captured by other phases (Terragrunt, the terraform-block
102                // backend extractor, etc.). Phase 3 skips them — they
103                // round-trip through `raw_blocks` so later phases still see
104                // them.
105            }
106        }
107    }
108
109    Component::builder()
110        .id(component_id)
111        .path(Arc::clone(&raw.path))
112        .kind(raw.kind)
113        .variables(variables)
114        .locals(locals)
115        .providers(providers)
116        .resources(resources)
117        .modules(module_calls)
118        .outputs(outputs)
119        .build()
120}
121
122fn project_resource(block: &RawBlock, diags: &mut Vec<Diagnostic>) -> Option<Resource> {
123    let (type_label, name_label) = match (block.labels.first(), block.labels.get(1)) {
124        (Some(t), Some(n)) => (Arc::clone(t), Arc::clone(n)),
125        _ => return None,
126    };
127    let (kind, addr_str) = match block.kind {
128        BlockKind::Resource => (ResourceKind::Managed, format!("{type_label}.{name_label}")),
129        BlockKind::Data => (
130            ResourceKind::Data,
131            format!("data.{type_label}.{name_label}"),
132        ),
133        _ => return None,
134    };
135    let address = match Address::new(&addr_str) {
136        Ok(a) => a,
137        Err(err) => {
138            diags.push(Diag::new(
139                Severity::Warn,
140                "TF1302",
141                format!(
142                    "invalid resource address `{addr_str}` in {}: {err}",
143                    block.source.display()
144                ),
145            ));
146            return None;
147        }
148    };
149
150    let mut attributes: AttributeMap = Vec::with_capacity(block.body.len());
151    let mut count_expr: Option<Expression> = None;
152    let mut for_each_expr: Option<Expression> = None;
153    let mut provider_ref: Option<ProviderRef> = None;
154    let mut depends_on: Vec<Address> = Vec::new();
155
156    for (key, value) in &block.body {
157        match key.as_ref() {
158            "count" => count_expr = Some(value.clone()),
159            "for_each" => for_each_expr = Some(value.clone()),
160            "provider" => {
161                provider_ref = extract_provider_ref(value);
162                if provider_ref.is_none() {
163                    diags.push(Diag::new(
164                        Severity::Trace,
165                        "TF1303",
166                        format!(
167                            "ignored non-static provider expression on {addr_str} in {}",
168                            block.source.display()
169                        ),
170                    ));
171                }
172            }
173            "depends_on" => depends_on.extend(extract_depends_on(value, diags)),
174            _ => attributes.push((Arc::clone(key), value.clone())),
175        }
176    }
177
178    Some(
179        Resource::builder()
180            .address(address)
181            .kind(kind)
182            .type_(Arc::clone(&type_label))
183            .name(Arc::clone(&name_label))
184            .provider_ref(provider_ref)
185            .count_expr(count_expr)
186            .for_each_expr(for_each_expr)
187            .depends_on(depends_on)
188            .attributes(attributes)
189            .span(block.span.clone())
190            .build(),
191    )
192}
193
194fn project_provider(block: &RawBlock, diags: &mut Vec<Diagnostic>) -> Option<ProviderBlock> {
195    let local_name = block.labels.first().cloned().or_else(|| {
196        diags.push(Diag::new(
197            Severity::Warn,
198            "TF1304",
199            format!("provider block missing label in {}", block.source.display()),
200        ));
201        None
202    })?;
203
204    let mut alias: Option<Arc<str>> = None;
205    let mut region_expr: Option<Expression> = None;
206    let mut profile_expr: Option<Expression> = None;
207    let mut raw: AttributeMap = Vec::with_capacity(block.body.len());
208
209    for (key, value) in &block.body {
210        match key.as_ref() {
211            "alias" => {
212                if let Expression::Literal(Value::Str(s)) = value {
213                    alias = Some(Arc::clone(s));
214                } else {
215                    raw.push((Arc::clone(key), value.clone()));
216                }
217            }
218            "region" => {
219                region_expr = Some(value.clone());
220                raw.push((Arc::clone(key), value.clone()));
221            }
222            "profile" => {
223                profile_expr = Some(value.clone());
224                raw.push((Arc::clone(key), value.clone()));
225            }
226            _ => raw.push((Arc::clone(key), value.clone())),
227        }
228    }
229
230    Some(
231        ProviderBlock::builder()
232            .local_name(local_name)
233            .alias(alias)
234            .region_expr(region_expr)
235            .profile_expr(profile_expr)
236            .raw(raw)
237            .span(block.span.clone())
238            .build(),
239    )
240}
241
242fn project_module_call(block: &RawBlock, diags: &mut Vec<Diagnostic>) -> Option<ModuleCall> {
243    let name = block.labels.first()?;
244    let addr_str = format!("module.{name}");
245    let address = match Address::new(&addr_str) {
246        Ok(a) => a,
247        Err(err) => {
248            diags.push(Diag::new(
249                Severity::Warn,
250                "TF1305",
251                format!(
252                    "invalid module address `{addr_str}` in {}: {err}",
253                    block.source.display()
254                ),
255            ));
256            return None;
257        }
258    };
259    let mut source_raw: Arc<str> = Arc::from("");
260    let mut inputs: AttributeMap = Vec::new();
261    let mut count_expr: Option<Expression> = None;
262    let mut for_each_expr: Option<Expression> = None;
263    let mut providers: Vec<(Arc<str>, ProviderRef)> = Vec::new();
264
265    for (key, value) in &block.body {
266        match key.as_ref() {
267            "source" => {
268                if let Expression::Literal(Value::Str(s)) = value {
269                    source_raw = Arc::clone(s);
270                }
271            }
272            "count" => count_expr = Some(value.clone()),
273            "for_each" => for_each_expr = Some(value.clone()),
274            "providers" => providers.extend(extract_providers_map(value)),
275            _ => inputs.push((Arc::clone(key), value.clone())),
276        }
277    }
278
279    let source = ModuleSource::classify(source_raw.as_ref());
280    Some(
281        ModuleCall::builder()
282            .address(address)
283            .source_raw(source_raw)
284            .source(source)
285            .providers(providers)
286            .inputs(inputs)
287            .count_expr(count_expr)
288            .for_each_expr(for_each_expr)
289            .span(block.span.clone())
290            .build(),
291    )
292}
293
294fn project_variable(block: &RawBlock) -> Option<Variable> {
295    let name = block.labels.first()?;
296    let mut description: Option<Arc<str>> = None;
297    let mut type_expr: Option<Expression> = None;
298    let mut default: Option<Expression> = None;
299    let mut sensitive = false;
300    for (key, value) in &block.body {
301        match key.as_ref() {
302            "description" => {
303                if let Expression::Literal(Value::Str(s)) = value {
304                    description = Some(Arc::clone(s));
305                }
306            }
307            "type" => type_expr = Some(normalize_variable_type(value)),
308            "default" => default = Some(value.clone()),
309            "sensitive" => {
310                if let Expression::Literal(Value::Bool(b)) = value {
311                    sensitive = *b;
312                }
313            }
314            _ => {}
315        }
316    }
317    Some(
318        Variable::builder()
319            .name(Arc::clone(name))
320            .description(description)
321            .type_expr(type_expr)
322            .default(default)
323            .sensitive(sensitive)
324            .span(block.span.clone())
325            .build(),
326    )
327}
328
329/// Normalize the RHS of `variable { type = ... }`.
330///
331/// HCL doesn't distinguish a bare type-constraint keyword (`string`,
332/// `number`, `bool`, `any`, `null`) from an identifier reference: both
333/// lower to `Expression::Unresolved(SymbolKind::Other, "<keyword>")`.
334/// In the specific position `variable { type = ... }` we know the RHS is
335/// a Terraform type constraint, so we hoist those keywords to a string
336/// literal — collapses thousands of noisy `Unresolved` markers in the
337/// exported `attributes_json` and gives consumers a usable type string.
338///
339/// Constructor-shaped forms (`list(string)`, `map(any)`,
340/// `object({foo = number})`) lower as function calls and object literals;
341/// we recurse into their arguments / values to normalize the inner
342/// keywords. The function name (`list`, `map`, ...) is left alone — it
343/// stays as a [`FuncCall::name`] string.
344fn normalize_variable_type(value: &Expression) -> Expression {
345    match value {
346        Expression::Unresolved(sym)
347            if sym.kind == SymbolKind::Other && is_terraform_type_keyword(&sym.source) =>
348        {
349            Expression::Literal(Value::Str(Arc::clone(&sym.source)))
350        }
351        Expression::FuncCall(call) => {
352            let mut new_call = (**call).clone();
353            new_call.args = call.args.iter().map(normalize_variable_type).collect();
354            Expression::FuncCall(Box::new(new_call))
355        }
356        Expression::Object(entries) => Expression::Object(
357            entries
358                .iter()
359                .map(|(k, v)| (k.clone(), normalize_variable_type(v)))
360                .collect(),
361        ),
362        Expression::Array(items) => {
363            Expression::Array(items.iter().map(normalize_variable_type).collect())
364        }
365        other => other.clone(),
366    }
367}
368
369fn is_terraform_type_keyword(s: &str) -> bool {
370    matches!(
371        s,
372        "string" | "number" | "bool" | "any" | "null" | "list" | "map" | "set" | "tuple" | "object"
373    )
374}
375
376fn project_locals_into(block: &RawBlock, out: &mut Vec<Local>) {
377    for (key, value) in &block.body {
378        out.push(
379            Local::builder()
380                .name(Arc::clone(key))
381                .value(value.clone())
382                .span(block.span.clone())
383                .build(),
384        );
385    }
386}
387
388fn project_output(block: &RawBlock) -> Option<Output> {
389    let name = block.labels.first()?;
390    let mut value_expr: Option<Expression> = None;
391    let mut description: Option<Arc<str>> = None;
392    let mut sensitive = false;
393    for (key, expr) in &block.body {
394        match key.as_ref() {
395            "value" => value_expr = Some(expr.clone()),
396            "description" => {
397                if let Expression::Literal(Value::Str(s)) = expr {
398                    description = Some(Arc::clone(s));
399                }
400            }
401            "sensitive" => {
402                if let Expression::Literal(Value::Bool(b)) = expr {
403                    sensitive = *b;
404                }
405            }
406            _ => {}
407        }
408    }
409    let value = value_expr?;
410    Some(
411        Output::builder()
412            .name(Arc::clone(name))
413            .value(value)
414            .description(description)
415            .sensitive(sensitive)
416            .span(block.span.clone())
417            .build(),
418    )
419}
420
421/// Parse `provider = aws` or `provider = aws.<alias>` into a [`ProviderRef`].
422///
423/// Accepts only syntactic provider identifiers: a bare identifier (lowered
424/// as [`SymbolKind::Other`]) or a single-dot traversal that the lowerer
425/// classified as [`SymbolKind::Resource`] (the shape `<ident>.<ident>`
426/// matches the resource-ref heuristic in
427/// `crates/core/src/loader/lowering.rs::symbol_kind_for`). Rejects
428/// `var.x` / `local.x` / `path.module` / `terraform.workspace` /
429/// `dependency.x` / `each.value` because none of those name a provider.
430fn extract_provider_ref(expr: &Expression) -> Option<ProviderRef> {
431    let Expression::Unresolved(s) = expr else {
432        return None;
433    };
434    if !matches!(s.kind, SymbolKind::Other | SymbolKind::Resource) {
435        return None;
436    }
437    let source: &str = s.source.as_ref();
438    if !is_provider_identifier(source) {
439        return None;
440    }
441    let mut parts = source.splitn(2, '.');
442    let local_name: Arc<str> = Arc::from(parts.next()?);
443    let alias: Option<Arc<str>> = parts.next().map(Arc::from);
444    Some(
445        ProviderRef::builder()
446            .local_name(local_name)
447            .alias(alias)
448            .span(s.span.clone())
449            .build(),
450    )
451}
452
453/// `^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)?$` — provider local name
454/// optionally followed by `.alias`. Identifier rules mirror HCL's
455/// (lowercase, digits, underscore; first char not a digit).
456fn is_provider_identifier(s: &str) -> bool {
457    fn is_segment(seg: &str) -> bool {
458        let mut bytes = seg.bytes();
459        match bytes.next() {
460            Some(b) if b.is_ascii_lowercase() || b == b'_' => {}
461            _ => return false,
462        }
463        bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
464    }
465    let mut parts = s.split('.');
466    let Some(first) = parts.next() else {
467        return false;
468    };
469    if !is_segment(first) {
470        return false;
471    }
472    match parts.next() {
473        None => true,
474        Some(second) => is_segment(second) && parts.next().is_none(),
475    }
476}
477
478/// Parse `depends_on = [<addr>, <addr>, ...]` into a list of [`Address`].
479///
480/// Each element must be a static reference (an [`Expression::Unresolved`]
481/// with an [`Address::address_hint`] that parses cleanly). Anything else is
482/// dropped with a low-severity diagnostic — graph-phase code will pick up
483/// the implicit references through `attribute_json` traversal.
484fn extract_depends_on(expr: &Expression, diags: &mut Vec<Diagnostic>) -> Vec<Address> {
485    let mut out: Vec<Address> = Vec::new();
486    let elements: &[Expression] = match expr {
487        Expression::Literal(Value::List(items)) => {
488            for item in items {
489                if let Value::Str(s) = item {
490                    match Address::new(s.as_ref()) {
491                        Ok(a) => out.push(a),
492                        Err(err) => diags.push(Diag::new(
493                            Severity::Trace,
494                            "TF1306",
495                            format!("depends_on entry rejected (`{s}`): {err}"),
496                        )),
497                    }
498                }
499            }
500            return out;
501        }
502        Expression::Array(items) => items.as_slice(),
503        _ => return out,
504    };
505    for item in elements {
506        match item {
507            Expression::Unresolved(s) => {
508                if let Some(hint) = &s.address_hint {
509                    out.push(hint.clone());
510                } else if let Ok(addr) = Address::new(s.source.as_ref()) {
511                    out.push(addr);
512                } else {
513                    diags.push(Diag::new(
514                        Severity::Trace,
515                        "TF1306",
516                        format!("depends_on entry not a static reference: {}", s.source),
517                    ));
518                }
519            }
520            Expression::Literal(Value::Str(s)) => match Address::new(s.as_ref()) {
521                Ok(a) => out.push(a),
522                Err(err) => diags.push(Diag::new(
523                    Severity::Trace,
524                    "TF1306",
525                    format!("depends_on entry rejected (`{s}`): {err}"),
526                )),
527            },
528            _ => diags.push(Diag::new(
529                Severity::Trace,
530                "TF1306",
531                "depends_on entry not a static reference",
532            )),
533        }
534    }
535    out
536}
537
538/// Parse `providers = { aws = aws.main }` into a vector of `(alias, ProviderRef)`.
539fn extract_providers_map(expr: &Expression) -> Vec<(Arc<str>, ProviderRef)> {
540    let mut out: Vec<(Arc<str>, ProviderRef)> = Vec::new();
541    let Expression::Object(entries) = expr else {
542        return out;
543    };
544    for (key, value) in entries {
545        let key_str: Arc<str> = match key {
546            Expression::Literal(Value::Str(s)) => Arc::clone(s),
547            _ => continue,
548        };
549        if let Some(reference) = extract_provider_ref(value) {
550            out.push((key_str, reference));
551        }
552    }
553    out
554}
555
556#[cfg(test)]
557#[allow(
558    clippy::unwrap_used,
559    clippy::expect_used,
560    clippy::panic,
561    clippy::indexing_slicing
562)]
563mod tests {
564    use std::path::Path;
565
566    use super::*;
567    use crate::{
568        ir::{ComponentKind, Span, Symbolic},
569        loader::{HclEditLoader, LoaderLimits},
570    };
571
572    fn raw_from_source(src: &str) -> RawComponent {
573        let path: Arc<Path> = Arc::from(Path::new("/tmp/x.tf"));
574        let limits = LoaderLimits::default();
575        let parsed = HclEditLoader.parse_bytes(src.as_bytes(), &path, &limits);
576        let mut raw = RawComponent::new(Arc::from(Path::new("")), ComponentKind::Component);
577        raw.raw_blocks.extend(parsed.blocks);
578        raw.diagnostics.extend(parsed.diagnostics);
579        raw
580    }
581
582    #[test]
583    fn test_should_project_resource_with_count_and_provider() {
584        let src = r#"
585resource "aws_iam_role" "service" {
586  provider = aws.main
587  count    = 3
588  name     = "x"
589}
590"#;
591        let raw = raw_from_source(src);
592        let mut diags = Vec::new();
593        let comp = project_component(&raw, ComponentId::from_index(0), &mut diags);
594        assert_eq!(comp.resources.len(), 1);
595        let r = &comp.resources[0];
596        assert_eq!(r.address.as_str(), "aws_iam_role.service");
597        assert!(r.count_expr.is_some());
598        let provider_ref = r.provider_ref.as_ref().expect("provider_ref");
599        assert_eq!(provider_ref.local_name.as_ref(), "aws");
600        assert_eq!(provider_ref.alias.as_deref(), Some("main"));
601        assert!(r.attributes.iter().any(|(k, _)| k.as_ref() == "name"));
602        assert!(r.attributes.iter().all(|(k, _)| k.as_ref() != "count"));
603        assert!(r.attributes.iter().all(|(k, _)| k.as_ref() != "provider"));
604        assert!(diags.is_empty(), "{diags:?}");
605    }
606
607    #[test]
608    fn test_should_project_data_block_with_data_prefix() {
609        let src = r#"data "aws_caller_identity" "self" {}"#;
610        let raw = raw_from_source(src);
611        let mut diags = Vec::new();
612        let comp = project_component(&raw, ComponentId::from_index(0), &mut diags);
613        assert_eq!(comp.resources.len(), 1);
614        let r = &comp.resources[0];
615        assert_eq!(r.kind, ResourceKind::Data);
616        assert_eq!(r.address.as_str(), "data.aws_caller_identity.self");
617    }
618
619    #[test]
620    fn test_should_project_provider_with_alias() {
621        let src = r#"
622provider "aws" {
623  alias  = "main"
624  region = "us-east-1"
625}
626"#;
627        let raw = raw_from_source(src);
628        let mut diags = Vec::new();
629        let comp = project_component(&raw, ComponentId::from_index(0), &mut diags);
630        assert_eq!(comp.providers.len(), 1);
631        let p = &comp.providers[0];
632        assert_eq!(p.local_name.as_ref(), "aws");
633        assert_eq!(p.alias.as_deref(), Some("main"));
634        assert!(p.region_expr.is_some());
635        assert!(diags.is_empty(), "{diags:?}");
636    }
637
638    #[test]
639    fn test_should_project_module_call_with_source_and_inputs() {
640        let src = r#"
641module "vpc" {
642  source = "../../modules/vpc"
643  cidr   = "10.0.0.0/16"
644  providers = {
645    aws = aws.main
646  }
647}
648"#;
649        let raw = raw_from_source(src);
650        let mut diags = Vec::new();
651        let comp = project_component(&raw, ComponentId::from_index(0), &mut diags);
652        assert_eq!(comp.modules.len(), 1);
653        let m = &comp.modules[0];
654        assert_eq!(m.address.as_str(), "module.vpc");
655        assert_eq!(m.source_raw.as_ref(), "../../modules/vpc");
656        assert!(matches!(m.source, ModuleSource::Local(_)));
657        assert!(m.inputs.iter().any(|(k, _)| k.as_ref() == "cidr"));
658        assert_eq!(m.providers.len(), 1);
659        assert_eq!(m.providers[0].0.as_ref(), "aws");
660        assert_eq!(m.providers[0].1.alias.as_deref(), Some("main"));
661    }
662
663    #[test]
664    fn test_should_project_locals_block() {
665        let src = r#"
666locals {
667  name   = "northwind"
668  region = "us-west-2"
669}
670"#;
671        let raw = raw_from_source(src);
672        let mut diags = Vec::new();
673        let comp = project_component(&raw, ComponentId::from_index(0), &mut diags);
674        assert_eq!(comp.locals.len(), 2);
675        let names: Vec<&str> = comp.locals.iter().map(|l| l.name.as_ref()).collect();
676        assert_eq!(names, vec!["name", "region"]);
677    }
678
679    #[test]
680    fn test_should_project_variable_with_default_and_sensitive() {
681        let src = r#"
682variable "db_password" {
683  type      = string
684  default   = "ignored-in-debug"
685  sensitive = true
686}
687"#;
688        let raw = raw_from_source(src);
689        let mut diags = Vec::new();
690        let comp = project_component(&raw, ComponentId::from_index(0), &mut diags);
691        assert_eq!(comp.variables.len(), 1);
692        let v = &comp.variables[0];
693        assert!(v.sensitive);
694        assert!(v.default.is_some());
695        assert!(v.type_expr.is_some());
696    }
697
698    /// `variable { type = string }` (and number/bool/any/null) is a
699    /// Terraform type constraint, not an identifier reference — but HCL
700    /// has no way to tell them apart at lower time, so the bare keyword
701    /// arrives here as `Expression::Unresolved(SymbolKind::Other, ...)`.
702    /// We normalize the recognized keywords to literal strings so the
703    /// exported `attributes_json` carries `"string"` instead of an
704    /// `{"__kind__":"Other","__unresolved__":"string"}` marker.
705    #[test]
706    fn test_should_normalize_bare_type_keyword_to_literal_string() {
707        let src = r#"
708variable "name" { type = string }
709variable "age"  { type = number }
710variable "ok"   { type = bool }
711variable "any_" { type = any }
712"#;
713        let raw = raw_from_source(src);
714        let mut diags = Vec::new();
715        let comp = project_component(&raw, ComponentId::from_index(0), &mut diags);
716        let by_name = |n: &str| -> Expression {
717            comp.variables
718                .iter()
719                .find(|v| v.name.as_ref() == n)
720                .and_then(|v| v.type_expr.clone())
721                .unwrap_or_else(|| panic!("variable {n} missing"))
722        };
723        for (var, expected) in [
724            ("name", "string"),
725            ("age", "number"),
726            ("ok", "bool"),
727            ("any_", "any"),
728        ] {
729            match by_name(var) {
730                Expression::Literal(crate::ir::Value::Str(s)) => assert_eq!(s.as_ref(), expected),
731                other => panic!("variable {var}: expected literal `{expected}`, got {other:?}"),
732            }
733        }
734    }
735
736    /// `variable { type = list(string) }` and friends lower as a function
737    /// call with the inner type keyword as an `Unresolved(Other, "string")`
738    /// argument. The normalizer recurses into `FuncCall::args` so the
739    /// inner keyword becomes a literal `"string"` too.
740    #[test]
741    fn test_should_normalize_constructor_type_keyword_args() {
742        let src = r#"
743variable "regions" { type = list(string) }
744variable "tags"    { type = map(string) }
745variable "shape"   { type = object({ enabled = bool, count = number }) }
746"#;
747        let raw = raw_from_source(src);
748        let mut diags = Vec::new();
749        let comp = project_component(&raw, ComponentId::from_index(0), &mut diags);
750
751        let regions = comp
752            .variables
753            .iter()
754            .find(|v| v.name.as_ref() == "regions")
755            .and_then(|v| v.type_expr.clone())
756            .expect("regions type_expr");
757        match regions {
758            Expression::FuncCall(call) => {
759                assert_eq!(call.name.as_ref(), "list");
760                assert!(
761                    matches!(
762                        &call.args[..],
763                        [Expression::Literal(crate::ir::Value::Str(s))] if s.as_ref() == "string"
764                    ),
765                    "args={:?}",
766                    call.args
767                );
768            }
769            other => panic!("regions: expected list(...) FuncCall, got {other:?}"),
770        }
771
772        let shape = comp
773            .variables
774            .iter()
775            .find(|v| v.name.as_ref() == "shape")
776            .and_then(|v| v.type_expr.clone())
777            .expect("shape type_expr");
778        let shape_call = match shape {
779            Expression::FuncCall(call) => call,
780            other => panic!("shape: expected object(...) FuncCall, got {other:?}"),
781        };
782        assert_eq!(shape_call.name.as_ref(), "object");
783        // First arg is the object literal `{ enabled = bool, count = number }`.
784        let entries = match &shape_call.args[..] {
785            [Expression::Object(entries)] => entries,
786            other => panic!("shape: expected object literal arg, got {other:?}"),
787        };
788        let entry = |key: &str| -> Expression {
789            entries
790                .iter()
791                .find(|(k, _)| {
792                    matches!(k, Expression::Literal(crate::ir::Value::Str(s)) if s.as_ref() == key)
793                })
794                .map_or_else(
795                    || panic!("entry {key} missing"),
796                    |(_, v)| v.clone(),
797                )
798        };
799        match entry("enabled") {
800            Expression::Literal(crate::ir::Value::Str(s)) => assert_eq!(s.as_ref(), "bool"),
801            other => panic!("enabled: expected literal `bool`, got {other:?}"),
802        }
803        match entry("count") {
804            Expression::Literal(crate::ir::Value::Str(s)) => assert_eq!(s.as_ref(), "number"),
805            other => panic!("count: expected literal `number`, got {other:?}"),
806        }
807    }
808
809    #[test]
810    fn test_should_project_output_with_value() {
811        let src = r#"
812output "role_arn" {
813  value = aws_iam_role.r.arn
814}
815"#;
816        let raw = raw_from_source(src);
817        let mut diags = Vec::new();
818        let comp = project_component(&raw, ComponentId::from_index(0), &mut diags);
819        assert_eq!(comp.outputs.len(), 1);
820        assert_eq!(comp.outputs[0].name.as_ref(), "role_arn");
821    }
822
823    #[test]
824    fn test_should_project_depends_on_static_addresses() {
825        let src = r#"
826resource "aws_iam_role" "r" {
827  depends_on = [aws_iam_role.parent]
828}
829"#;
830        let raw = raw_from_source(src);
831        let mut diags = Vec::new();
832        let comp = project_component(&raw, ComponentId::from_index(0), &mut diags);
833        let r = &comp.resources[0];
834        assert_eq!(r.depends_on.len(), 1);
835        assert_eq!(r.depends_on[0].as_str(), "aws_iam_role.parent");
836    }
837
838    #[test]
839    fn test_should_drop_non_static_provider_to_none() {
840        let src = r#"
841resource "aws_iam_role" "r" {
842  provider = var.x
843}
844"#;
845        let raw = raw_from_source(src);
846        let mut diags = Vec::new();
847        let comp = project_component(&raw, ComponentId::from_index(0), &mut diags);
848        let r = &comp.resources[0];
849        assert!(r.provider_ref.is_none());
850        assert!(diags.iter().any(|d| d.severity == Severity::Trace));
851    }
852
853    #[test]
854    fn test_should_extract_provider_ref_without_alias() {
855        let s = Symbolic::builder()
856            .kind(SymbolKind::Other)
857            .source(Arc::<str>::from("aws"))
858            .span(Span::synthetic())
859            .build();
860        let expr = Expression::Unresolved(s);
861        let pr = extract_provider_ref(&expr).expect("provider ref");
862        assert_eq!(pr.local_name.as_ref(), "aws");
863        assert!(pr.alias.is_none());
864    }
865
866    #[test]
867    fn test_should_reject_var_x_as_provider_ref() {
868        let s = Symbolic::builder()
869            .kind(SymbolKind::Var)
870            .source(Arc::<str>::from("var.x"))
871            .span(Span::synthetic())
872            .build();
873        let expr = Expression::Unresolved(s);
874        assert!(extract_provider_ref(&expr).is_none());
875    }
876
877    #[test]
878    fn test_should_reject_path_module_as_provider_ref() {
879        // path.module / terraform.workspace / each.value / dependency.foo
880        // all parse to non-Resource/Other SymbolKinds and must be rejected
881        // even if their syntactic shape happens to look like `id.id`.
882        for (kind, source) in [
883            (SymbolKind::Path, "path.module"),
884            (SymbolKind::Terraform, "terraform.workspace"),
885            (SymbolKind::Iteration, "each.value"),
886            (SymbolKind::TerragruntDependency, "dependency.vpc"),
887        ] {
888            let s = Symbolic::builder()
889                .kind(kind)
890                .source(Arc::<str>::from(source))
891                .span(Span::synthetic())
892                .build();
893            assert!(
894                extract_provider_ref(&Expression::Unresolved(s)).is_none(),
895                "expected None for {source:?}"
896            );
897        }
898    }
899
900    #[test]
901    fn test_should_reject_too_many_dot_segments_as_provider_ref() {
902        // Three-part shape (`aws.main.something`) must not lift to
903        // ProviderRef; that's a resource attribute reference.
904        let s = Symbolic::builder()
905            .kind(SymbolKind::Resource)
906            .source(Arc::<str>::from("aws_iam_role.r.arn"))
907            .span(Span::synthetic())
908            .build();
909        assert!(extract_provider_ref(&Expression::Unresolved(s)).is_none());
910    }
911
912    #[test]
913    fn test_provider_identifier_charset() {
914        assert!(is_provider_identifier("aws"));
915        assert!(is_provider_identifier("aws.main"));
916        assert!(is_provider_identifier("_foo"));
917        assert!(!is_provider_identifier("Aws.main"));
918        assert!(!is_provider_identifier("aws main"));
919        assert!(!is_provider_identifier("aws."));
920        assert!(!is_provider_identifier(".aws"));
921        assert!(!is_provider_identifier("aws.main.extra"));
922        assert!(!is_provider_identifier("3aws"));
923    }
924
925    #[test]
926    fn test_should_project_resource_with_for_each_unresolved() {
927        let src = r#"
928resource "aws_iam_role" "r" {
929  for_each = var.roles
930}
931"#;
932        let raw = raw_from_source(src);
933        let mut diags = Vec::new();
934        let comp = project_component(&raw, ComponentId::from_index(0), &mut diags);
935        let r = &comp.resources[0];
936        assert!(matches!(r.for_each_expr, Some(Expression::Unresolved(_))));
937    }
938
939    #[test]
940    fn test_should_skip_unknown_blocks_silently() {
941        let src = r#"
942terraform {
943  required_version = ">= 1.5.0"
944}
945"#;
946        let raw = raw_from_source(src);
947        let mut diags = Vec::new();
948        let comp = project_component(&raw, ComponentId::from_index(0), &mut diags);
949        assert!(comp.resources.is_empty());
950        assert!(comp.providers.is_empty());
951        assert!(diags.is_empty(), "{diags:?}");
952    }
953}