diff --git a/PR-PLAN.md b/PR-PLAN.md new file mode 100644 index 0000000..8324594 --- /dev/null +++ b/PR-PLAN.md @@ -0,0 +1,313 @@ +# Azure Policy Compiler — PR Submission Plan + +Main is the source of truth for RVM, aliases, parser, builtins, RBAC, bindings, +engine, etc. Only compiler/ code and its tests remain to be submitted. + +## Completed + +- **PR #686** (`azure-policy-compiler-eval` → `microsoft:main`): 2 commits + - Commit 1 (`68d935f`): Compiler skeleton with core types and stubs + - Commit 2 (`c17a438`): Condition, expression, field, and template dispatch compilation + - Status: Draft, Copilot review clean (0 new comments on latest push) + - Files: 14 new files in compiler/, +2,557 lines vs main + +- **PR #688** (Count support): 1 squashed commit on `azure-policy-compiler-count` + - Full count loop compilation replacing stubs + - Status: In review, Copilot comments addressed + +## Total remaining (compiler only): 7 files, +4,330 lines vs main + +After PR #686: +2,984/-1,211 lines across 14 compiler files (restructuring) + +Final state on `azure-policy-compiler`: +- mod.rs (1,681 LOC) — main pipeline, effects, metadata, emit helpers, aliases +- count.rs (912 LOC) — count loops, count-as-any, bindings +- conditions.rs — condition compilation + wildcard allOf +- fields.rs (385 LOC) — field path compilation +- template_dispatch.rs (369 LOC) — ARM function dispatch +- expressions.rs (337 LOC) — expression & JSON value compilation +- utils.rs (143 LOC) — shared helpers +- (stubs from PR #686 deleted: core.rs, conditions_wildcard.rs, metadata.rs, + effects.rs, effects_modify_append.rs, count_any.rs, count_bindings.rs) + +--- + +## PR 4: Effects + Metadata + File Restructure + +### Goal +Complete the compiler by implementing effects, metadata, and consolidating files +(core.rs → mod.rs, conditions_wildcard.rs → conditions.rs, etc.). + +### Phase A: Implement effects (in effects.rs or mod.rs) + +#### Step 1: Implement compile_effect() +Replace the bail stub with full effect dispatch: +- Resolve effect kind via `resolve_effect_kind()` (handles parameterized `[parameters('effect')]`) +- Match on EffectKind: Deny, Audit, Disabled, Append, Modify, AuditIfNotExists, DeployIfNotExists, DenyAction, AddToNetworkGroup +- Simple effects (Deny, Audit, Disabled): load effect name literal, wrap via `wrap_effect_result()` +- Detail effects (Modify, Append): call `compile_effect_with_details()` → routes to `compile_modify_details()` or `compile_append_details()` +- Cross-resource effects (AINE, DINE): call `compile_cross_resource_effect()` which emits `HostAwait` instruction + +#### Step 2: Implement wrap_effect_result() +Replace bail stub: +- Build structured result object `{ "effect": , "details": }` +- Uses `Instruction::ObjectNew`, `Instruction::ObjectInsert` sequences +- When details_reg is None, omit the details field + +#### Step 3: Implement Modify/Append details +In effects_modify_append.rs (or same file depending on restructure): +- `compile_modify_details()` — iterates `details.operations` array, compiles each modify operation +- `compile_modify_operation()` — handles addOrReplace/Add/Remove operations with field/value pairs +- `compile_append_details()` — iterates `details` array items +- `compile_append_item()` — compiles individual append { field, value } items + +#### Step 4: Implement cross-resource effects (AINE/DINE) +- `compile_cross_resource_effect()` — emits HostAwait instruction to request related resource lookup +- Sets `resource_override_reg` to the host response register for existenceCondition compilation +- Compiles `details.existenceCondition` constraint against the related resource +- Builds structured result with effect name + details (including type, resourceGroupName, etc.) + +#### Step 5: Implement effect resolution helpers +- `resolve_effect_kind()` — if effect node is parameter reference, resolves via `parameter_defaults` +- `resolve_effect_kind_from_parameter_default()` — extracts effect value from `parameters('effectParam')` expression +- `resolve_effect_name_from_parameter_default()` — string version +- `effect_kind_from_string()` — maps lowercase string → EffectKind enum +- `compile_effect_name_expression()` — compiles runtime effect name from parameter expression + +### Phase B: Implement metadata + +#### Step 6: Implement metadata recording functions +Replace no-op stubs in metadata.rs: +- `record_field_kind()` — `self.observed_field_kinds.insert(name.to_string())` +- `record_alias()` — `self.observed_aliases.insert(path.to_string())` +- `record_tag_name()` — `self.observed_tag_names.insert(tag.to_string())` +- `record_operator()` — maps OperatorKind to string, `self.observed_operators.insert()` +- `record_resource_type_from_condition()` — if condition is `{ field: "type", equals: X }`, insert X into `observed_resource_types` + +#### Step 7: Implement resolve_effect_annotation() +Replace raw-clone stub: +- When effect is parameterized, resolve from `parameter_defaults` to get the actual effect name +- Fall back to `effect.raw` if resolution fails + +#### Step 8: Implement populate_compiled_annotations() +Replace no-op stub: +- Insert into `program.metadata.annotations`: field_kinds, aliases, tag_names, operators, resource_types (as Value sets) +- Insert boolean flags: uses_count, has_dynamic_fields, has_wildcard_aliases, has_host_await +- Set `program.metadata.annotations["effect"]` (already done in init_effect_annotation) + +#### Step 9: Implement populate_definition_metadata() +Replace no-op stub: +- Extract from PolicyDefinition: display_name, description, mode, category, version, preview flag +- Insert into `program.metadata.annotations`: parameter_names list, policy_type, policy_id, policy_name + +### Phase C: File restructure + +#### Step 10: Merge core.rs into mod.rs +Move all content from core.rs into mod.rs: +- `Compiler` struct definition +- `CountBinding` struct definition +- `compile()` pipeline +- All register/span/emit helpers +- All literal/builtin/chained-index helpers +- All alias resolution functions (`resolve_alias_path`, `strip_fq_prefix`) +- `patch_end_pc`, `current_pc`, `emit_coalesce_undefined_to_null`, `load_input`, `load_context` + +Update all `use super::core::Compiler;` → `use super::Compiler;` in: +- conditions.rs +- expressions.rs +- fields.rs +- template_dispatch.rs + +Delete `core.rs` and remove `mod core;` from mod.rs. + +#### Step 11: Merge conditions_wildcard.rs into conditions.rs +Move 4 functions into conditions.rs: +- `has_unbound_wildcard_field()` +- `has_inner_unbound_wildcard_field()` +- `compile_condition_wildcard_allof()` +- `compile_allof_loop_inner()` + +Delete `conditions_wildcard.rs` and remove `mod conditions_wildcard;` from mod.rs. + +#### Step 12: Merge effects/metadata stubs into mod.rs +If effects.rs and metadata.rs have been implemented as separate files, merge them into mod.rs. +Alternatively, implement directly in mod.rs. + +Delete: effects.rs, effects_modify_append.rs, metadata.rs +Remove their `mod` declarations from mod.rs. + +#### Step 13: Simplify utils.rs +On the final branch, utils.rs is 143 LOC (current eval has ~429 LOC extensions that were trimmed). +- Verify `split_count_wildcard_path` matches final version +- Verify `split_path_without_wildcards` matches +- Ensure `json_value_to_runtime` has `pub(crate)` visibility + +#### Step 14: Apply comment/doc and minor code differences +Based on comparison, apply these adjustments to match final branch: +- **expressions.rs**: Import path changes, comment enhancements, minor code tweaks +- **fields.rs**: Import path changes, documentation expansion +- **template_dispatch.rs**: Import path change, section header formatting +- **conditions.rs**: Import changes, `patch_end_pc` return type, documentation additions + +### Relevant files +- `src/languages/azure_policy/compiler/mod.rs` — absorbs core.rs + effects + metadata → grows to ~1,681 LOC +- `src/languages/azure_policy/compiler/core.rs` — DELETE (merged into mod.rs) +- `src/languages/azure_policy/compiler/conditions.rs` — absorbs conditions_wildcard.rs content +- `src/languages/azure_policy/compiler/conditions_wildcard.rs` — DELETE (merged into conditions.rs) +- `src/languages/azure_policy/compiler/effects.rs` — DELETE (merged into mod.rs) +- `src/languages/azure_policy/compiler/effects_modify_append.rs` — DELETE (merged into mod.rs) +- `src/languages/azure_policy/compiler/metadata.rs` — DELETE (merged into mod.rs) +- `src/languages/azure_policy/compiler/expressions.rs` — import path + minor adjustments +- `src/languages/azure_policy/compiler/fields.rs` — import path + documentation +- `src/languages/azure_policy/compiler/template_dispatch.rs` — import path + formatting +- `src/languages/azure_policy/compiler/utils.rs` — streamline to 143 LOC final version + +### Line counts +- mod.rs: +1,614 (absorbs core.rs, adds effects, metadata, emit helpers, aliases) +- Delete: core.rs (-367), conditions_wildcard.rs (-199), metadata.rs (-52 stub), + effects.rs (-30 stub), effects_modify_append.rs (-6 stub) +- utils.rs: -320 (functions moved into mod.rs) +- template_dispatch.rs: +75 (new function dispatches) +- Effects: Deny, Audit, Modify, Append, DenyAction, AINE, DINE +- Cross-resource evaluation (host_await) +- Modify/Append details, effect resolution from parameters +- Metadata: field kinds, aliases, operators, resource types + +### Verification +1. `cargo build` — all effects/metadata compiled, no stubs remain +2. `cargo clippy` — remove all `#![allow(dead_code)]` from deleted stubs +3. `cargo test --features azure_policy` — existing tests still pass +4. `TEST_CASE_FILTER="effect" cargo test --features azure_policy -- --nocapture` +5. Verify final file list matches: mod.rs, conditions.rs, count.rs, expressions.rs, fields.rs, template_dispatch.rs, utils.rs (7 files) + +--- + +## PR 5: Test Suite + +### Goal +Add the full YAML-driven test suite: 58 high-level cases + 8 parser cases + alias test data. + +### Step 1: Update tests/azure_policy/mod.rs +Replace the 5-line eval version with the full 700+ line test runner that includes: +- `TestCase` struct with all fields (host_await, want_details, api_version, request_context, context, etc.) +- `HostAwaitEntry` struct +- `YamlTest` struct with aliases/global policy_rule/policy_definition support +- `yaml_test_impl()` — full evaluation pipeline (parse → compile → normalize → VM execute → assert) +- Helper functions: `make_input()`, `make_context()`, `yaml_to_regorus_value()`, `lowercase_value_keys()`, `lowercase_json_keys()`, `extract_effect_name()`, `extract_details()`, `extract_details_resource_type()`, `inject_type_field()` +- `#[test_resources("tests/azure_policy/cases/*.yaml")]` auto-discovery +- `test_specific_case()` with `TEST_CASE_FILTER` support +- `DEBUG_LISTING` and `DEBUG_RESOURCE` environment variable support +- Remove `mod normalization;` (normalization tests already on main) + +### Step 2: Add test_aliases.json (if not already present) +- Verify `tests/azure_policy/aliases/test_aliases.json` exists (it does on eval branch) +- Add `tests/azure_policy/aliases/versioned_aliases.json` if needed + +### Step 3: Create tests/azure_policy/cases/ directory with 74 YAML files +Add all YAML test case files. Categories: + +**Foundation tests (13 files):** +- aliases.yaml, casing.yaml, effects.yaml, effect_details.yaml, exists.yaml +- expressions.yaml, fields.yaml, field_wildcard_collect.yaml +- implicit_allof.yaml, logical_combinators.yaml, modifiable_check.yaml +- operators.yaml, value_conditions.yaml + +**Count tests (1 file):** +- count.yaml (field count, value count, where clauses, nested, count-as-any) + +**Template function tests (3 files):** +- template_functions.yaml, template_functions_datetime_ip.yaml, template_functions_extra.yaml + +**Advanced tests (4 files):** +- deep_nesting.yaml, type_coercion.yaml, parse_errors.yaml, policy_definition.yaml + +**Infrastructure tests (2 files):** +- azure_policies.yaml, complex_policies.yaml, versioned_normalization.yaml + +**E2E real-world policies (51 files):** +- e2e_aci_*.yaml, e2e_aks_*.yaml, e2e_approved_*.yaml, e2e_asc_*.yaml +- e2e_automanage_*.yaml, e2e_azupdate_*.yaml, e2e_cmk_*.yaml +- e2e_container_*.yaml, e2e_cosmos_*.yaml, e2e_custom_*.yaml +- e2e_datafactory_*.yaml, e2e_dcra_*.yaml, e2e_double_*.yaml +- e2e_fic_*.yaml, e2e_functionapp_*.yaml, e2e_guest_*.yaml +- e2e_keyvault_*.yaml, e2e_managed_*.yaml, e2e_monitoring_*.yaml +- e2e_nic_*.yaml, e2e_nsg_*.yaml, e2e_pg_*.yaml, e2e_portal_*.yaml +- e2e_servicebus_*.yaml, e2e_shared_*.yaml, e2e_signalr_*.yaml +- e2e_sql_*.yaml, e2e_ssh_*.yaml, e2e_storage_*.yaml +- e2e_stream_*.yaml, e2e_tags_*.yaml, e2e_vm_*.yaml, e2e_vnet_*.yaml + +### Step 4: Update parser tests if needed +- Verify `tests/azure_policy/parser_tests/` cases are up to date +- Check if any new parser test YAML files need to be added (8 files on final branch) + +### Step 5: Handle normalization test directory +- The eval branch has `tests/azure_policy/normalization/` with 13 YAML cases +- The final branch does NOT have this directory (these tests are already on main) +- Ensure `mod normalization;` is removed from the test mod.rs if normalization tests shipped in an earlier PR + +### Relevant files +- `tests/azure_policy/mod.rs` — replace with full 700+ line test runner +- `tests/azure_policy/cases/*.yaml` — 74 new YAML test case files +- `tests/azure_policy/aliases/test_aliases.json` — verify present +- `tests/azure_policy/aliases/versioned_aliases.json` — verify present +- `tests/azure_policy/parser_tests/` — verify/update + +### Line counts +- ~84 azure_policy test files (+32,806/-6,051 across 156 test files total) +- E2e YAML test suites (74+ cases) +- External test runner with known-failure tracking +- Lockdown test policies (9 real-world policies) +- RVM VM suite updates for changed instruction semantics + +### Verification +1. `cargo test --features azure_policy` — all 74 YAML cases + 8 parser cases pass +2. `TEST_CASE_FILTER="count" cargo test --features azure_policy -- --nocapture` — count cases pass +3. `TEST_CASE_FILTER="effect" cargo test --features azure_policy -- --nocapture` — effect cases pass +4. `TEST_CASE_FILTER="e2e" cargo test --features azure_policy -- --nocapture` — all E2E policies pass +5. `cargo clippy --features azure_policy --all-targets` — no warnings in test code +6. `cargo xtask pre-push` — full CI check passes + +--- + +## Execution Order & Dependencies + +``` +PR #686 (Skeleton + Conditions) ← merged/in review + ↓ +PR #688 (Count) ← in review, builds on PR #686 + ↓ +PR 4 (Effects + Restructure) ← depends on PR #688 (count bindings used in effects) + ↓ +PR 5 (Tests) ← depends on PR 4 (tests exercise full compiler including effects) +``` + +PRs #688 and 4 could potentially be combined into one PR if review size is acceptable (~2,000 lines). +PR 5 is large (~33k lines) but is purely test data — can be reviewed for structure rather than line-by-line. + +## Key Decisions +- All implementation should match the final `azure-policy-compiler` branch state +- `to_lowercase()` vs `to_ascii_lowercase()`: eval branch already fixed to `to_ascii_lowercase()`; keep that fix (it's better) +- `patch_end_pc` return type: eval has `Result<()>`, final has `()` — reconcile during restructure +- Strict path validation in utils.rs: eval has more guard rails; reconcile to match simpler final version +- `pub(super)` visibility on `emit_policy_operator`: eval has it; final makes it `fn` private — reconcile during merge + +## Key Context + +### Source branches +- **`azure-policy-compiler`** — final branch with completed compiler (source of truth for target state) +- **`azure-policy-compiler-eval`** — worktree at `/tmp/azure-policy-compiler-eval` where PRs are built incrementally + +### Build & test commands +- `cargo fmt` — format +- `cargo clippy --all-features` — lint +- `cargo test --all-features -- count` — run count-related tests +- `cargo xtask pre-commit` — pre-commit hook (build + fmt + clippy) +- `cargo xtask pre-push` — full CI (pre-commit + doc tests + no_std + full test suite + 2861 OPA tests) + +### Git workflow +- Edit files → `cargo fmt` → `git add -A && git commit --amend --no-edit` → `git push origin --force` +- All from `/tmp/azure-policy-compiler-eval` worktree + +### Crate constraints +- `#![deny(clippy::indexing_slicing, clippy::expect_used)]` — cannot use `.expect()` or `[]` indexing +- `no_std` compatible: use `alloc::{format, string, vec}` imports diff --git a/src/languages/azure_policy/compiler/conditions_wildcard.rs b/src/languages/azure_policy/compiler/conditions_wildcard.rs index 37b1aa0..897402a 100644 --- a/src/languages/azure_policy/compiler/conditions_wildcard.rs +++ b/src/languages/azure_policy/compiler/conditions_wildcard.rs @@ -4,6 +4,7 @@ //! Implicit allOf for unbound `[*]` wildcard fields. +use alloc::format; use alloc::string::{String, ToString as _}; use alloc::vec::Vec; @@ -67,12 +68,12 @@ impl Compiler { }; if let Some(prefix) = &binding.field_wildcard_prefix { - let bound_len = prefix.len().saturating_add(4); // len("prefix") + len("[*].") - if path.len() > bound_len { - if let Some(remainder) = path.get(bound_len..) { - if remainder.contains("[*]") { - return Ok(Some((binding, remainder.to_string()))); - } + let lc_prefix = prefix.to_ascii_lowercase(); + let bound_prefix = format!("{}[*].", lc_prefix); + if let Some(remainder) = path.to_ascii_lowercase().strip_prefix(&bound_prefix) { + let remainder = remainder.to_string(); + if remainder.contains("[*]") { + return Ok(Some((binding, remainder))); } } } diff --git a/src/languages/azure_policy/compiler/count.rs b/src/languages/azure_policy/compiler/count.rs index 409ba6b..2350683 100644 --- a/src/languages/azure_policy/compiler/count.rs +++ b/src/languages/azure_policy/compiler/count.rs @@ -1,32 +1,1641 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#![allow(dead_code, clippy::pattern_type_mismatch)] +#![allow(clippy::pattern_type_mismatch)] -//! `count` / `count.where` loop compilation. -//! -//! Stub — real implementation added in a later commit. +//! `count` / `count.where` compilation and count-binding resolution. -use anyhow::{bail, Result}; +use alloc::format; +use alloc::string::{String, ToString as _}; +use alloc::vec::Vec; -use crate::languages::azure_policy::ast::{Condition, CountNode}; +use anyhow::{anyhow, bail, Result}; -use super::core::Compiler; +use crate::languages::azure_policy::ast::{ + Condition, Constraint, CountNode, FieldKind, JsonValue, OperatorKind, ValueOrExpr, +}; +use crate::rvm::instructions::{GuardMode, LoopMode, LoopStartParams, PolicyOp}; +use crate::rvm::Instruction; +use crate::Value; + +use super::core::{Compiler, CountBinding}; +use super::utils::{split_count_wildcard_path, split_path_without_wildcards}; impl Compiler { pub(super) fn compile_count(&mut self, count_node: &CountNode) -> Result { - let _ = self; - let span = match count_node { - CountNode::Field { span, .. } | CountNode::Value { span, .. } => span, - }; - bail!(span.error("count compilation not yet implemented")) + self.observed_uses_count = true; + match count_node { + CountNode::Value { + span, + value, + name, + where_, + } => { + let collection_reg = self.compile_value_or_expr(value, span)?; + self.compile_count_loop( + collection_reg, + name.as_ref().map(|n| n.name.clone()), + None, + where_.as_deref(), + span, + ) + } + CountNode::Field { + span, + field, + where_, + } => { + let field_path = self.extract_field_count_path(field, span)?; + let (_prefix, suffix) = split_count_wildcard_path(&field_path) + .map_err(|e| span.error(&e.to_string()))?; + + // Multi-level wildcard (e.g. `A[*].B[*]`) → emit nested loops. + // If an outer count binding covers part of the path, start + // from the bound element instead of the resource root. + if suffix.as_ref().is_some_and(|s| s.contains("[*]")) { + if let Some(binding) = self.resolve_count_binding(&field_path)? { + if let Some(outer_prefix) = &binding.field_wildcard_prefix { + let lc_prefix = outer_prefix.to_ascii_lowercase(); + let wildcard_dot = format!("{}[*].", lc_prefix); + if let Some(inner_path) = + field_path.to_ascii_lowercase().strip_prefix(&wildcard_dot) + { + let inner_path = inner_path.to_string(); + return self.compile_count_nested( + Some(binding.current_reg), + &inner_path, + where_.as_deref(), + outer_prefix, + span, + ); + } + } + } + return self.compile_count_nested( + None, + &field_path, + where_.as_deref(), + "", + span, + ); + } + + // Single wildcard → existing path via resolve + single count loop. + let (collection_reg, prefix) = self.resolve_count_field_collection(field, span)?; + self.compile_count_loop(collection_reg, None, Some(prefix), where_.as_deref(), span) + } + } } - pub(super) const fn try_compile_count_as_any( + /// Resolve the collection register and wildcard prefix for a field-based + /// count node, handling nested count bindings. + fn resolve_count_field_collection( &mut self, - _count_node: &CountNode, - _condition: &Condition, + field: &crate::languages::azure_policy::ast::FieldNode, + span: &crate::lexer::Span, + ) -> Result<(u8, String)> { + let field_path = self.extract_field_count_path(field, span)?; + let (collection_prefix, suffix) = + split_count_wildcard_path(&field_path).map_err(|e| span.error(&e.to_string()))?; + + // Check if this field path is relative to an outer count binding. + if let Some(binding) = self.resolve_count_binding(&field_path)? { + if let Some(outer_prefix) = &binding.field_wildcard_prefix { + let lc_prefix = outer_prefix.to_ascii_lowercase(); + let wildcard_dot = format!("{}[*].", lc_prefix); + if let Some(inner_path) = + field_path.to_ascii_lowercase().strip_prefix(&wildcard_dot) + { + let inner_path = inner_path.to_string(); + if inner_path.contains("[*]") { + let (inner_collection, _) = split_count_wildcard_path(&inner_path) + .map_err(|e| span.error(&e.to_string()))?; + let inner_collection = inner_collection.to_ascii_lowercase(); + let parts = split_path_without_wildcards(&inner_collection)?; + let refs = parts.iter().map(String::as_str).collect::>(); + let collection_reg = + self.emit_chained_index_literal_path(binding.current_reg, &refs, span)?; + let inner_prefix = format!("{}[*].{}", lc_prefix, inner_collection); + return Ok((collection_reg, inner_prefix)); + } + } + } + } + + // Multi-level wildcard: now handled by compile_count_nested in compile_count. + // (Single-wildcard paths fall through to here.) + if suffix.as_ref().is_some_and(|s| s.contains("[*]")) { + bail!(span.error(&format!( + "multi-wildcard path should have been handled before resolve_count_field_collection: {}", + field_path + ))); + } + + let collection_reg = self.compile_resource_path_value(&collection_prefix, span)?; + Ok((collection_reg, collection_prefix)) + } + + /// Compile a multi-wildcard count path as nested loops. + /// + /// Each intermediate `[*]` level emits a `ForEach` loop that accumulates + /// the inner count. The innermost `[*]` emits the real count loop with + /// the where clause and binding. + /// + /// * `base_reg` — `None` for resource root, `Some` when inside an outer loop. + /// * `remaining_path` — the portion of the field path still to process; + /// must contain at least one `[*]`. + /// * `where_clause` — the optional where constraint (applied only at the + /// innermost level). + /// * `accumulated_prefix` — the path prefix accumulated from outer levels, + /// used to build binding prefixes. + fn compile_count_nested( + &mut self, + base_reg: Option, + remaining_path: &str, + where_clause: Option<&Constraint>, + accumulated_prefix: &str, + span: &crate::lexer::Span, + ) -> Result { + let (collection_part, suffix) = + split_count_wildcard_path(remaining_path).map_err(|e| span.error(&e.to_string()))?; + let has_more_wildcards = suffix.as_ref().is_some_and(|s| s.contains("[*]")); + + // Build the binding prefix for this level. + let binding_prefix = if accumulated_prefix.is_empty() { + collection_part.clone() + } else { + format!("{}[*].{}", accumulated_prefix, collection_part) + }; + + // Lowercase the collection path to match normalized resource keys. + let collection_lower = collection_part.to_ascii_lowercase(); + + // Navigate to the collection. `split_count_wildcard_path` guarantees + // the collection segment before `[*]` is non-empty. + let collection_reg = match base_reg { + Some(base) => { + let parts = split_path_without_wildcards(&collection_lower)?; + let refs = parts.iter().map(String::as_str).collect::>(); + self.emit_chained_index_literal_path(base, &refs, span)? + } + None => self.compile_resource_path_value(&collection_lower, span)?, + }; + + if !has_more_wildcards { + // Innermost wildcard → delegate to the regular count loop. + // Optimization: if no where clause, just emit Count instruction. + // Note: Count returns Undefined for non-iterable collections, + // which differs from LoopMode::Any (treats them as empty). The + // existence-pattern optimizer (`try_compile_count_as_any`) skips + // nested-wildcard no-where counts so this path is always taken + // for that case, preserving Undefined-propagation semantics. + if where_clause.is_none() { + let dest = self.alloc_register()?; + self.emit( + Instruction::Count { + dest, + collection: collection_reg, + }, + span, + ); + return Ok(dest); + } + return self.compile_count_loop( + collection_reg, + None, + Some(binding_prefix), + where_clause, + span, + ); + } + + // Intermediate wildcard → ForEach loop that accumulates inner counts. + let count_reg = self.load_literal(Value::from(0_i64), span)?; + let key_reg = self.alloc_register()?; + let current_reg = self.alloc_register()?; + let loop_result_reg = self.alloc_register()?; + + let params_index = self.program.add_loop_params(LoopStartParams { + mode: LoopMode::ForEach, + collection: collection_reg, + key_reg, + value_reg: current_reg, + result_reg: loop_result_reg, + body_start: 0, + loop_end: 0, + }); + + self.emit(Instruction::LoopStart { params_index }, span); + + let body_start = u16::try_from(self.program.instructions.len()) + .map_err(|_| anyhow!("instruction index overflow"))?; + + // Push binding for this level so inner where-clause field references + // can resolve through this wildcard level. + self.count_bindings.push(CountBinding { + name: None, + field_wildcard_prefix: Some(binding_prefix.clone()), + current_reg, + }); + + // Recurse for the inner level(s). + let suffix_ref = suffix + .as_ref() + .ok_or_else(|| anyhow::anyhow!("suffix should be Some for nested count"))?; + let inner_count = self.compile_count_nested( + Some(current_reg), + suffix_ref, + where_clause, + &binding_prefix, + span, + )?; + + // Accumulate inner count into outer count. + self.emit( + Instruction::Add { + dest: count_reg, + left: count_reg, + right: inner_count, + }, + span, + ); + + self.count_bindings.pop(); + + self.emit( + Instruction::LoopNext { + body_start, + loop_end: 0, + }, + span, + ); + + let loop_end = u16::try_from(self.program.instructions.len()) + .map_err(|_| anyhow!("instruction index overflow"))?; + + self.program.update_loop_params(params_index, |params| { + params.body_start = body_start; + params.loop_end = loop_end; + }); + + if let Some(Instruction::LoopNext { loop_end: le, .. }) = + self.program.instructions.last_mut() + { + *le = loop_end; + } + + Ok(count_reg) + } + + /// Compile a multi-wildcard count path as nested `Any` loops for the + /// `count > 0` / `count == 0` existence-pattern optimization. + /// + /// Each intermediate `[*]` level emits an `Any` loop whose body is the + /// next level. The innermost `[*]` emits `compile_count_any_loop` with + /// the where clause. If `exists` is false the result is negated. + /// + /// **Important:** This must only be called when `where_clause` is `Some`. + /// Without a where clause the non-optimized path (`compile_count_nested`) + /// uses `Instruction::Count` at the innermost level. That instruction + /// returns `Undefined` for missing/non-iterable collections, whereas the + /// `Any` loop treats them as empty (false). The difference changes the + /// semantics of `count == 0` from false (via Undefined propagation) to + /// true (via `Not(false)`). The caller (`try_compile_count_as_any`) + /// returns `None` for no-where nested counts so the generic count+compare + /// path is used instead. + fn compile_count_nested_any( + &mut self, + base_reg: Option, + remaining_path: &str, + where_clause: &Constraint, + accumulated_prefix: &str, + exists: bool, + span: &crate::lexer::Span, ) -> Result> { - _ = self.register_counter; + let (collection_part, suffix) = + split_count_wildcard_path(remaining_path).map_err(|e| span.error(&e.to_string()))?; + let has_more_wildcards = suffix.as_ref().is_some_and(|s| s.contains("[*]")); + + let binding_prefix = if accumulated_prefix.is_empty() { + collection_part.clone() + } else { + format!("{}[*].{}", accumulated_prefix, collection_part) + }; + + // Lowercase the collection path to match normalized resource keys. + let collection_lower = collection_part.to_ascii_lowercase(); + + // Navigate to the collection. `split_count_wildcard_path` guarantees + // the collection segment before `[*]` is non-empty. + let collection_reg = match base_reg { + Some(base) => { + let parts = split_path_without_wildcards(&collection_lower)?; + let refs = parts.iter().map(String::as_str).collect::>(); + self.emit_chained_index_literal_path(base, &refs, span)? + } + None => self.compile_resource_path_value(&collection_lower, span)?, + }; + + if !has_more_wildcards { + // Innermost wildcard → regular Any loop. + let any_result = self.compile_count_any_loop( + collection_reg, + None, + Some(binding_prefix), + Some(where_clause), + span, + )?; + return if exists { + Ok(Some(any_result)) + } else { + let dest = self.alloc_register()?; + self.emit( + Instruction::PolicyCondition { + dest, + left: any_result, + right: 0, + op: PolicyOp::Not, + }, + span, + ); + Ok(Some(dest)) + }; + } + + // Intermediate wildcard → Any loop wrapping inner nested Any. + let key_reg = self.alloc_register()?; + let current_reg = self.alloc_register()?; + let result_reg = self.alloc_register()?; + + let params_index = self.program.add_loop_params(LoopStartParams { + mode: LoopMode::Any, + collection: collection_reg, + key_reg, + value_reg: current_reg, + result_reg, + body_start: 0, + loop_end: 0, + }); + + self.emit(Instruction::LoopStart { params_index }, span); + + let body_start = u16::try_from(self.program.instructions.len()) + .map_err(|_| anyhow!("instruction index overflow"))?; + + // Push binding for this level. + self.count_bindings.push(CountBinding { + name: None, + field_wildcard_prefix: Some(binding_prefix.clone()), + current_reg, + }); + + // Recurse — the inner call returns Some(result_reg) with the final + // negation already applied at the innermost level. For the outer + // Any loop, we need "any inner satisfies" so we pass `exists = true` + // here and handle the overall negation at the end. + let suffix_ref = suffix + .as_ref() + .ok_or_else(|| anyhow::anyhow!("suffix should be Some for nested any"))?; + let inner = self + .compile_count_nested_any( + Some(current_reg), + suffix_ref, + where_clause, + &binding_prefix, + /* exists */ true, + span, + )? + .ok_or_else(|| anyhow::anyhow!("nested any should always return Some"))?; + + // The outer Any body succeeds when the inner Any returned true. + self.emit( + Instruction::Guard { + register: inner, + mode: GuardMode::Condition, + }, + span, + ); + + self.count_bindings.pop(); + + self.emit( + Instruction::LoopNext { + body_start, + loop_end: 0, + }, + span, + ); + + let loop_end = u16::try_from(self.program.instructions.len()) + .map_err(|_| anyhow!("instruction index overflow"))?; + + self.program.update_loop_params(params_index, |params| { + params.body_start = body_start; + params.loop_end = loop_end; + }); + + if let Some(Instruction::LoopNext { loop_end: le, .. }) = + self.program.instructions.last_mut() + { + *le = loop_end; + } + + // If !exists (count == 0), negate the Any result. + if exists { + Ok(Some(result_reg)) + } else { + let dest = self.alloc_register()?; + self.emit( + Instruction::PolicyCondition { + dest, + left: result_reg, + right: 0, + op: PolicyOp::Not, + }, + span, + ); + Ok(Some(dest)) + } + } + + /// Map a [`FieldNode`] to the dotted property path used for count + /// resolution. Built-in field kinds (`type`, `id`, …) are returned + /// as-is; aliases go through [`resolve_alias_path`] which normalises + /// and lowercases when the alias catalog is loaded. + fn extract_field_count_path( + &self, + field: &crate::languages::azure_policy::ast::FieldNode, + span: &crate::lexer::Span, + ) -> Result { + match &field.kind { + FieldKind::Type => Ok("type".to_string()), + FieldKind::Id => Ok("id".to_string()), + FieldKind::Kind => Ok("kind".to_string()), + FieldKind::Name => Ok("name".to_string()), + FieldKind::Location => Ok("location".to_string()), + FieldKind::FullName => Ok("fullName".to_string()), + FieldKind::IdentityType => Ok("identity.type".to_string()), + FieldKind::IdentityField(subpath) => { + Ok(format!("identity.{}", subpath.to_ascii_lowercase())) + } + FieldKind::ApiVersion => Ok("apiVersion".to_string()), + FieldKind::Tags => Ok("tags".to_string()), + FieldKind::Tag(tag) => Ok(format!("tags.{}", tag)), + FieldKind::Alias(path) => self.resolve_alias_path(path, span), + FieldKind::Expr(_) => { + bail!(span.error("count over expression field is not supported in core subset",)) + } + } + } + + /// Emit a single-level `ForEach` count loop. + /// + /// Iterates `collection_reg`, pushes a [`CountBinding`] for the duration + /// of the loop body (so nested `current()` / field references resolve), + /// optionally guards with the where clause, and increments a counter + /// register on each passing iteration. + /// + /// Returns the register holding the final count. + fn compile_count_loop( + &mut self, + collection_reg: u8, + binding_name: Option, + field_wildcard_prefix: Option, + where_constraint: Option<&Constraint>, + span: &crate::lexer::Span, + ) -> Result { + let count_reg = self.load_literal(Value::from(0_i64), span)?; + // Hoist the increment constant above the loop. + let one_reg = self.load_literal(Value::from(1_i64), span)?; + let key_reg = self.alloc_register()?; + let current_reg = self.alloc_register()?; + let loop_result_reg = self.alloc_register()?; + + let params_index = self.program.add_loop_params(LoopStartParams { + mode: LoopMode::ForEach, + collection: collection_reg, + key_reg, + value_reg: current_reg, + result_reg: loop_result_reg, + body_start: 0, + loop_end: 0, + }); + + self.emit(Instruction::LoopStart { params_index }, span); + + let body_start_u16 = u16::try_from(self.program.instructions.len()) + .map_err(|_| anyhow!("instruction index overflow"))?; + + self.count_bindings.push(CountBinding { + name: binding_name, + field_wildcard_prefix, + current_reg, + }); + + // Compile where clause body (if present) as a conditional increment. + if let Some(where_clause) = where_constraint { + let where_reg = self.compile_constraint(where_clause)?; + self.emit( + Instruction::Guard { + register: where_reg, + mode: GuardMode::Condition, + }, + span, + ); + } + + self.emit( + Instruction::Add { + dest: count_reg, + left: count_reg, + right: one_reg, + }, + span, + ); + + self.count_bindings.pop(); + + self.emit( + Instruction::LoopNext { + body_start: body_start_u16, + loop_end: 0, + }, + span, + ); + + let loop_end_u16 = u16::try_from(self.program.instructions.len()) + .map_err(|_| anyhow!("instruction index overflow"))?; + + self.program.update_loop_params(params_index, |params| { + params.body_start = body_start_u16; + params.loop_end = loop_end_u16; + }); + + if let Some(Instruction::LoopNext { loop_end, .. }) = self.program.instructions.last_mut() { + *loop_end = loop_end_u16; + } + + Ok(count_reg) + } + + // -- count existence optimization (Any mode) --------------------------- + + /// Try to compile a count condition as a `LoopMode::Any` loop when the + /// operator + RHS form an existence check (e.g., `count > 0`). + /// + /// Returns `Some(result_reg)` if optimized, `None` to fall back to the + /// generic count + compare path. + pub(super) fn try_compile_count_as_any( + &mut self, + count_node: &CountNode, + condition: &Condition, + ) -> Result> { + // Determine whether the operator+RHS is an existence pattern. + let exists = match Self::classify_existence_pattern(condition) { + Some(e) => e, + None => return Ok(None), + }; + + // Keep the where clause optional so plain `count(field: 'a[*]') > 0` + // can also use the early-exit Any lowering. + let where_constraint = match count_node { + CountNode::Field { where_, .. } | CountNode::Value { where_, .. } => where_.as_deref(), + }; + + self.observed_uses_count = true; + + // Resolve collection and compile as Any loop. + let any_result = match count_node { + CountNode::Value { + span, value, name, .. + } => { + let collection_reg = self.compile_value_or_expr(value, span)?; + self.compile_count_any_loop( + collection_reg, + name.as_ref().map(|n| n.name.clone()), + None, + where_constraint, + span, + )? + } + CountNode::Field { span, field, .. } => { + // Multi-wildcard field paths use nested Any loops. + // Resolve outer bindings so we start from the bound element. + let field_path = self.extract_field_count_path(field, span)?; + let (_, suffix) = split_count_wildcard_path(&field_path) + .map_err(|e| span.error(&e.to_string()))?; + if suffix.as_ref().is_some_and(|s| s.contains("[*]")) { + // Skip the nested Any optimization when there is no where + // clause. The non-optimized path in `compile_count_nested` + // uses `Instruction::Count` for the innermost level, which + // returns `Undefined` when the collection is missing or + // non-iterable. The Any-based lowering instead treats a + // missing collection as empty (Any → false), so + // `Not(false)` → true, changing `count == 0` from false to + // true. Falling back to the generic count+compare path + // preserves the Undefined-propagation semantics. + let Some(wc) = where_constraint else { + return Ok(None); + }; + + if let Some(binding) = self.resolve_count_binding(&field_path)? { + if let Some(outer_prefix) = &binding.field_wildcard_prefix { + let lc_prefix = outer_prefix.to_ascii_lowercase(); + let expected_prefix = format!("{}[*].", lc_prefix); + if let Some(inner_path) = field_path + .to_ascii_lowercase() + .strip_prefix(&expected_prefix) + { + let inner_path = inner_path.to_string(); + return self.compile_count_nested_any( + Some(binding.current_reg), + &inner_path, + wc, + outer_prefix, + exists, + span, + ); + } + } + } + return self.compile_count_nested_any(None, &field_path, wc, "", exists, span); + } + + let (collection_reg, prefix) = self.resolve_count_field_collection(field, span)?; + self.compile_count_any_loop( + collection_reg, + None, + Some(prefix), + where_constraint, + span, + )? + } + }; + + if exists { + Ok(Some(any_result)) + } else { + let dest = self.alloc_register()?; + self.emit( + Instruction::PolicyCondition { + dest, + left: any_result, + right: 0, + op: PolicyOp::Not, + }, + &condition.span, + ); + Ok(Some(dest)) + } + } + + /// Check whether a count condition's operator + RHS form an existence + /// pattern. Returns `Some(true)` for "at least one" semantics, + /// `Some(false)` for "none" semantics, or `None` if not applicable. + fn classify_existence_pattern(condition: &Condition) -> Option { + let n = match &condition.rhs { + ValueOrExpr::Value(JsonValue::Number(_, s)) => s.parse::().ok()?, + _ => return None, + }; + match (&condition.operator.kind, n) { + (OperatorKind::Greater, 0) + | (OperatorKind::GreaterOrEquals, 1) + | (OperatorKind::NotEquals, 0) => Some(true), + (OperatorKind::Equals, 0) + | (OperatorKind::Less, 1) + | (OperatorKind::LessOrEquals, 0) => Some(false), + _ => None, + } + } + + /// Compile a count's where clause as a `LoopMode::Any` loop. + /// + /// The result register is `true` if any element satisfies the where + /// constraint (or simply exists when `where_constraint` is `None`), + /// `false` otherwise. The loop exits on the first match. + fn compile_count_any_loop( + &mut self, + collection_reg: u8, + binding_name: Option, + field_wildcard_prefix: Option, + where_constraint: Option<&Constraint>, + span: &crate::lexer::Span, + ) -> Result { + let key_reg = self.alloc_register()?; + let current_reg = self.alloc_register()?; + let result_reg = self.alloc_register()?; + + let params_index = self.program.add_loop_params(LoopStartParams { + mode: LoopMode::Any, + collection: collection_reg, + key_reg, + value_reg: current_reg, + result_reg, + body_start: 0, + loop_end: 0, + }); + + self.emit(Instruction::LoopStart { params_index }, span); + + let body_start = u16::try_from(self.program.instructions.len()) + .map_err(|_| anyhow!("instruction index overflow"))?; + + self.count_bindings.push(CountBinding { + name: binding_name, + field_wildcard_prefix, + current_reg, + }); + + if let Some(wc) = where_constraint { + let where_reg = self.compile_constraint(wc)?; + self.emit( + Instruction::Guard { + register: where_reg, + mode: GuardMode::Condition, + }, + span, + ); + } + + self.count_bindings.pop(); + + self.emit( + Instruction::LoopNext { + body_start, + loop_end: 0, + }, + span, + ); + + let loop_end = u16::try_from(self.program.instructions.len()) + .map_err(|_| anyhow!("instruction index overflow"))?; + + self.program.update_loop_params(params_index, |params| { + params.body_start = body_start; + params.loop_end = loop_end; + }); + + if let Some(Instruction::LoopNext { loop_end: le, .. }) = + self.program.instructions.last_mut() + { + *le = loop_end; + } + + Ok(result_reg) + } + + /// Find the innermost active count binding that covers `field_path`. + /// + /// Matching rules (all case-insensitive): + /// 1. **Named binding** — `field_path` equals the binding's `name`. + /// 2. **Wildcard prefix** — `field_path` matches the binding's prefix, + /// its `prefix[*]` form, or starts with `prefix.` / `prefix[*].`. + /// + /// Bindings are searched innermost-first (reverse stack order) so a + /// nested count's binding shadows an outer one for the same prefix. + pub(super) fn resolve_count_binding(&self, field_path: &str) -> Result> { + let fp = field_path.to_ascii_lowercase(); + for binding in self.count_bindings.iter().rev() { + if let Some(name) = &binding.name { + if fp.eq_ignore_ascii_case(name) { + return Ok(Some(binding.clone())); + } + } + + if let Some(prefix) = &binding.field_wildcard_prefix { + let lc_prefix = prefix.to_ascii_lowercase(); + let wildcard_prefix = format!("{}[*]", lc_prefix); + let prefix_dot = format!("{}.", lc_prefix); + let wildcard_dot = format!("{}.", wildcard_prefix); + if fp == lc_prefix + || fp.starts_with(&prefix_dot) + || fp == wildcard_prefix + || fp.starts_with(&wildcard_dot) + { + return Ok(Some(binding.clone())); + } + } + } + Ok(None) } + + /// Compile a field reference relative to an active count binding. + /// + /// If `field_path` matches the binding exactly (name or prefix), + /// emits a `Move` from the binding's current-element register. + /// If `field_path` extends past the binding (e.g. `prefix.sub.key`), + /// navigates the suffix via chained index lookups. All comparisons + /// are case-insensitive. + pub(super) fn compile_from_binding( + &mut self, + binding: &CountBinding, + field_path: &str, + span: &crate::lexer::Span, + ) -> Result { + let fp = field_path.to_ascii_lowercase(); + + if let Some(name) = &binding.name { + if fp.eq_ignore_ascii_case(name) { + let dest = self.alloc_register()?; + self.emit( + Instruction::Move { + dest, + src: binding.current_reg, + }, + span, + ); + return Ok(dest); + } + } + + if let Some(prefix) = &binding.field_wildcard_prefix { + let lc_prefix = prefix.to_ascii_lowercase(); + let wildcard_prefix = format!("{}[*]", lc_prefix); + + if fp == lc_prefix || fp == wildcard_prefix { + let dest = self.alloc_register()?; + self.emit( + Instruction::Move { + dest, + src: binding.current_reg, + }, + span, + ); + return Ok(dest); + } + + let prefix_dot = format!("{}.", lc_prefix); + if let Some(suffix) = fp.strip_prefix(&prefix_dot) { + return self.compile_suffix_from_binding(binding.current_reg, suffix, span); + } + + let wildcard_dot = format!("{}[*].", lc_prefix); + if let Some(suffix) = fp.strip_prefix(&wildcard_dot) { + return self.compile_suffix_from_binding(binding.current_reg, suffix, span); + } + } + + bail!(span.error(&format!( + "invalid current count binding for field path '{}'", + field_path + ))) + } + + /// Compile a suffix path from a binding's current register. + /// + /// If the suffix contains `[*]` (from a nested count context), only the + /// portion before the first `[*]` is used for navigation. The inner + /// count's loop will handle the iteration. + fn compile_suffix_from_binding( + &mut self, + base_reg: u8, + suffix: &str, + span: &crate::lexer::Span, + ) -> Result { + // Strip any trailing [*] or [*].suffix — we only navigate to the + // array itself; the count loop iterates its elements. + let nav_path = suffix + .split_once("[*]") + .map_or(suffix, |(prefix, _)| prefix); + // Lowercase to match normalizer-lowercased keys. + let nav_path = nav_path.to_ascii_lowercase(); + let parts = split_path_without_wildcards(&nav_path)?; + let refs = parts.iter().map(String::as_str).collect::>(); + self.emit_chained_index_literal_path(base_reg, &refs, span) + } + + /// Compile a `current('key')` reference inside a count's where clause. + /// + /// Resolution is two-phase: + /// 1. Try matching `key` directly against the active binding stack + /// (case-insensitive). This handles literal alias paths and + /// named value-count bindings. + /// 2. If no direct match, resolve `key` through the alias catalog + /// and retry. When the catalog is loaded and fallback is disabled, + /// alias-resolution errors propagate so the caller sees "unknown + /// alias" rather than a generic scope error. + /// + /// Bails with a "used outside an active count scope" error if neither + /// phase finds a matching binding. + pub(super) fn compile_current_reference( + &mut self, + key: &str, + span: &crate::lexer::Span, + ) -> Result { + let resolve_for_key = |compiler: &mut Self, candidate: &str| -> Result> { + let lc_candidate = candidate.to_ascii_lowercase(); + for binding in compiler.count_bindings.iter().rev() { + if let Some(name) = &binding.name { + let lc_name = name.to_ascii_lowercase(); + if lc_candidate == lc_name { + let current_reg = binding.current_reg; + let dest = compiler.alloc_register()?; + compiler.emit( + Instruction::Move { + dest, + src: current_reg, + }, + span, + ); + return Ok(Some(dest)); + } + + let name_dot = format!("{}.", lc_name); + if let Some(suffix) = lc_candidate.strip_prefix(&name_dot) { + let parts = split_path_without_wildcards(suffix)?; + let refs = parts.iter().map(String::as_str).collect::>(); + return compiler + .emit_chained_index_literal_path(binding.current_reg, &refs, span) + .map(Some); + } + } + + if let Some(prefix) = &binding.field_wildcard_prefix { + let lc_prefix = prefix.to_ascii_lowercase(); + if lc_candidate == lc_prefix || lc_candidate == format!("{}[*]", lc_prefix) { + let current_reg = binding.current_reg; + let dest = compiler.alloc_register()?; + compiler.emit( + Instruction::Move { + dest, + src: current_reg, + }, + span, + ); + return Ok(Some(dest)); + } + + let prefix_dot = format!("{}.", lc_prefix); + if let Some(suffix) = lc_candidate.strip_prefix(&prefix_dot) { + return compiler + .compile_suffix_from_binding(binding.current_reg, suffix, span) + .map(Some); + } + + let prefix_wildcard_dot = format!("{}[*].", lc_prefix); + if let Some(suffix) = lc_candidate.strip_prefix(&prefix_wildcard_dot) { + return compiler + .compile_suffix_from_binding(binding.current_reg, suffix, span) + .map(Some); + } + } + } + + Ok(None) + }; + + if let Some(result) = resolve_for_key(self, key)? { + return Ok(result); + } + + // Try resolving via the alias catalog. When the catalog is loaded + // and fallback is disabled, propagate alias-resolution errors so the + // caller sees "unknown alias" instead of the generic "outside an + // active count scope" message. + match self.resolve_alias_path(key, span) { + Ok(normalized_key) if normalized_key != key => { + if let Some(result) = resolve_for_key(self, &normalized_key)? { + return Ok(result); + } + } + Err(e) if !self.alias_map.is_empty() && !self.alias_fallback_to_raw => { + return Err(e); + } + _ => {} + } + + bail!(span.error(&format!( + "current('{}') is used outside an active count scope", + key + ))) + } +} + +// =========================================================================== +// Tests +// =========================================================================== + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)] +mod tests { + use alloc::string::ToString as _; + use alloc::vec; + use alloc::vec::Vec; + + use crate::languages::azure_policy::ast::{ + Condition, Constraint, CountNode, FieldKind, FieldNode, JsonValue, OperatorKind, + OperatorNode, ValueOrExpr, + }; + use crate::languages::azure_policy::compiler::core::{Compiler, CountBinding}; + use crate::lexer::Source; + use crate::rvm::instructions::{GuardMode, LoopMode, PolicyOp}; + use crate::rvm::Instruction; + + fn dummy_span() -> crate::lexer::Span { + let source = Source::from_contents("test".into(), " ".into()).unwrap(); + crate::lexer::Span { + source, + line: 1, + col: 1, + start: 0, + end: 0, + } + } + + // ----------------------------------------------------------------------- + // resolve_count_binding + // ----------------------------------------------------------------------- + + #[test] + fn resolve_binding_empty_stack() { + let c = Compiler::new(); + assert!(c.resolve_count_binding("a[*].b").unwrap().is_none()); + } + + #[test] + fn resolve_binding_by_field_prefix() { + let mut c = Compiler::new(); + c.count_bindings.push(CountBinding { + name: None, + field_wildcard_prefix: Some("a".to_string()), + current_reg: 5, + }); + let binding = c.resolve_count_binding("a[*].b").unwrap().unwrap(); + assert_eq!(binding.current_reg, 5); + assert_eq!(binding.field_wildcard_prefix.as_deref(), Some("a")); + } + + #[test] + fn resolve_binding_by_name() { + let mut c = Compiler::new(); + c.count_bindings.push(CountBinding { + name: Some("myCollection".to_string()), + field_wildcard_prefix: None, + current_reg: 3, + }); + let binding = c.resolve_count_binding("myCollection").unwrap().unwrap(); + assert_eq!(binding.current_reg, 3); + } + + #[test] + fn resolve_binding_case_insensitive() { + let mut c = Compiler::new(); + c.count_bindings.push(CountBinding { + name: Some("MyCollection".to_string()), + field_wildcard_prefix: None, + current_reg: 4, + }); + // Lookup with different casing should still match. + let binding = c.resolve_count_binding("mycollection").unwrap().unwrap(); + assert_eq!(binding.current_reg, 4); + + let binding_upper = c.resolve_count_binding("MYCOLLECTION").unwrap().unwrap(); + assert_eq!(binding_upper.current_reg, 4); + } + + #[test] + fn resolve_binding_field_prefix_case_insensitive() { + let mut c = Compiler::new(); + c.count_bindings.push(CountBinding { + name: None, + field_wildcard_prefix: Some("Microsoft.Test/resource".to_string()), + current_reg: 6, + }); + // Mixed-case lookup against the prefix. + let binding = c + .resolve_count_binding("microsoft.test/resource[*].prop") + .unwrap() + .unwrap(); + assert_eq!(binding.current_reg, 6); + } + + #[test] + fn resolve_binding_innermost_wins() { + let mut c = Compiler::new(); + c.count_bindings.push(CountBinding { + name: None, + field_wildcard_prefix: Some("a".to_string()), + current_reg: 1, + }); + c.count_bindings.push(CountBinding { + name: None, + field_wildcard_prefix: Some("a[*].b".to_string()), + current_reg: 2, + }); + // The inner binding (a[*].b) matches a[*].b[*].c, and since we + // iterate in reverse, it wins. + let binding = c.resolve_count_binding("a[*].b[*].c").unwrap().unwrap(); + assert_eq!(binding.current_reg, 2); + } + + #[test] + fn resolve_binding_no_match() { + let mut c = Compiler::new(); + c.count_bindings.push(CountBinding { + name: None, + field_wildcard_prefix: Some("x".to_string()), + current_reg: 1, + }); + assert!(c.resolve_count_binding("y[*].z").unwrap().is_none()); + } + + // ----------------------------------------------------------------------- + // compile_count_nested — instruction shape for multi-wildcard paths + // ----------------------------------------------------------------------- + + #[test] + fn nested_count_no_where_emits_foreach_and_count() { + let mut c = Compiler::new(); + let span = dummy_span(); + // Compile a[*].b[*] (no where clause) starting from resource root. + let result_reg = c + .compile_count_nested(None, "a[*].b[*]", None, "", &span) + .unwrap(); + + // The outer loop should be ForEach (accumulating inner counts). + // Find the first LoopStart and check its mode. + let first_loop_idx = c + .program + .instructions + .iter() + .position(|i| matches!(i, Instruction::LoopStart { .. })) + .expect("should have a LoopStart"); + + if let Instruction::LoopStart { params_index } = c.program.instructions[first_loop_idx] { + let params = c + .program + .instruction_data + .get_loop_params(params_index) + .unwrap(); + assert_eq!( + params.mode, + LoopMode::ForEach, + "outer loop should be ForEach" + ); + } + + // The innermost level has no where clause, so it should use Count + // instruction (direct count, no loop). + assert!( + c.program + .instructions + .iter() + .any(|i| matches!(i, Instruction::Count { .. })), + "innermost level without where should emit Count" + ); + + // Should also have an Add instruction to accumulate. + assert!( + c.program + .instructions + .iter() + .any(|i| matches!(i, Instruction::Add { .. })), + "should accumulate inner counts via Add" + ); + + // The result register should be valid. + assert!(result_reg < c.register_counter); + } + + #[test] + fn nested_count_with_where_emits_foreach_loops() { + let mut c = Compiler::new(); + let span = dummy_span(); + + // A simple where clause: { field: "type", equals: "someType" } + let where_clause = Constraint::Condition(alloc::boxed::Box::new(Condition { + span: dummy_span(), + lhs: crate::languages::azure_policy::ast::Lhs::Field(FieldNode { + span: dummy_span(), + kind: FieldKind::Type, + }), + operator: OperatorNode { + span: dummy_span(), + kind: OperatorKind::Equals, + }, + rhs: ValueOrExpr::Value(JsonValue::Str(dummy_span(), "someType".to_string())), + })); + + let _result_reg = c + .compile_count_nested(None, "a[*].b[*]", Some(&where_clause), "", &span) + .unwrap(); + + // With a where clause the innermost level should emit a loop (not + // a bare Count instruction). + let loop_starts: Vec<_> = c + .program + .instructions + .iter() + .filter(|i| matches!(i, Instruction::LoopStart { .. })) + .collect(); + assert!( + loop_starts.len() >= 2, + "nested count with where should emit at least 2 LoopStart instructions, got {}", + loop_starts.len() + ); + } + + // ----------------------------------------------------------------------- + // compile_count_nested_any — existence-pattern optimization for nested paths + // ----------------------------------------------------------------------- + + #[test] + fn nested_any_exists_true_emits_any_loops() { + let mut c = Compiler::new(); + let span = dummy_span(); + + let where_clause = Constraint::Condition(alloc::boxed::Box::new(Condition { + span: dummy_span(), + lhs: crate::languages::azure_policy::ast::Lhs::Field(FieldNode { + span: dummy_span(), + kind: FieldKind::Type, + }), + operator: OperatorNode { + span: dummy_span(), + kind: OperatorKind::Equals, + }, + rhs: ValueOrExpr::Value(JsonValue::Str(dummy_span(), "someType".to_string())), + })); + + let result = c + .compile_count_nested_any( + None, + "a[*].b[*]", + &where_clause, + "", + true, // exists = true → count > 0 + &span, + ) + .unwrap(); + assert!(result.is_some(), "nested any should return Some"); + + // All loops should be LoopMode::Any for the existence optimization. + for instr in &c.program.instructions { + if let Instruction::LoopStart { params_index } = instr { + let params = c + .program + .instruction_data + .get_loop_params(*params_index) + .unwrap(); + assert_eq!( + params.mode, + LoopMode::Any, + "existence pattern should use Any loops" + ); + } + } + } + + #[test] + fn nested_any_exists_false_emits_not() { + let mut c = Compiler::new(); + let span = dummy_span(); + + let where_clause = Constraint::Condition(alloc::boxed::Box::new(Condition { + span: dummy_span(), + lhs: crate::languages::azure_policy::ast::Lhs::Field(FieldNode { + span: dummy_span(), + kind: FieldKind::Type, + }), + operator: OperatorNode { + span: dummy_span(), + kind: OperatorKind::Equals, + }, + rhs: ValueOrExpr::Value(JsonValue::Str(dummy_span(), "someType".to_string())), + })); + + let result = c + .compile_count_nested_any( + None, + "a[*].b[*]", + &where_clause, + "", + false, // exists = false → count == 0 + &span, + ) + .unwrap(); + assert!(result.is_some()); + + // Should have a PolicyCondition with Not op for the negation. + assert!( + c.program.instructions.iter().any(|i| matches!( + i, + Instruction::PolicyCondition { op, .. } if *op == PolicyOp::Not + )), + "count == 0 pattern should negate with PolicyCondition::Not" + ); + } + + // ----------------------------------------------------------------------- + // try_compile_count_as_any — existence detection via operator + RHS + // ----------------------------------------------------------------------- + + /// Helper: build a Condition with count LHS, given operator and numeric RHS. + fn make_count_condition(count_node: CountNode, op: OperatorKind, rhs_number: i64) -> Condition { + Condition { + span: dummy_span(), + lhs: crate::languages::azure_policy::ast::Lhs::Count(count_node), + operator: OperatorNode { + span: dummy_span(), + kind: op, + }, + rhs: ValueOrExpr::Value(JsonValue::Number(dummy_span(), rhs_number.to_string())), + } + } + + fn make_value_count_with_where() -> CountNode { + CountNode::Value { + span: dummy_span(), + value: ValueOrExpr::Value(JsonValue::Array( + dummy_span(), + vec![ + JsonValue::Number(dummy_span(), "1".to_string()), + JsonValue::Number(dummy_span(), "2".to_string()), + JsonValue::Number(dummy_span(), "3".to_string()), + ], + )), + name: None, + where_: Some(alloc::boxed::Box::new(Constraint::Condition( + alloc::boxed::Box::new(Condition { + span: dummy_span(), + lhs: crate::languages::azure_policy::ast::Lhs::Value { + key_span: dummy_span(), + value: ValueOrExpr::Value(JsonValue::Number(dummy_span(), "1".to_string())), + }, + operator: OperatorNode { + span: dummy_span(), + kind: OperatorKind::Equals, + }, + rhs: ValueOrExpr::Value(JsonValue::Number(dummy_span(), "1".to_string())), + }), + ))), + } + } + + #[test] + fn any_optimization_greater_zero() { + let mut c = Compiler::new(); + let count_node = make_value_count_with_where(); + let condition = make_count_condition(count_node.clone(), OperatorKind::Greater, 0); + let result = c.try_compile_count_as_any(&count_node, &condition).unwrap(); + assert!( + result.is_some(), + "count > 0 should trigger Any optimization" + ); + + // The loop should use LoopMode::Any. + for instr in &c.program.instructions { + if let Instruction::LoopStart { params_index } = instr { + let params = c + .program + .instruction_data + .get_loop_params(*params_index) + .unwrap(); + assert_eq!(params.mode, LoopMode::Any); + } + } + } + + #[test] + fn any_optimization_equals_zero_negates() { + let mut c = Compiler::new(); + let count_node = make_value_count_with_where(); + let condition = make_count_condition(count_node.clone(), OperatorKind::Equals, 0); + let result = c.try_compile_count_as_any(&count_node, &condition).unwrap(); + assert!( + result.is_some(), + "count == 0 should trigger Any optimization" + ); + + // Should negate: PolicyCondition with Not. + assert!( + c.program.instructions.iter().any(|i| matches!( + i, + Instruction::PolicyCondition { op, .. } if *op == PolicyOp::Not + )), + "count == 0 should negate" + ); + } + + #[test] + fn any_optimization_not_triggered_for_equals_two() { + let mut c = Compiler::new(); + let count_node = make_value_count_with_where(); + let condition = make_count_condition(count_node.clone(), OperatorKind::Equals, 2); + let result = c.try_compile_count_as_any(&count_node, &condition).unwrap(); + assert!( + result.is_none(), + "count == 2 is not an existence pattern, should return None" + ); + } + + #[test] + fn any_optimization_no_where_uses_any_loop() { + let mut c = Compiler::new(); + let count_node = CountNode::Value { + span: dummy_span(), + value: ValueOrExpr::Value(JsonValue::Array(dummy_span(), vec![])), + name: None, + where_: None, + }; + let condition = make_count_condition(count_node.clone(), OperatorKind::Greater, 0); + let result = c.try_compile_count_as_any(&count_node, &condition).unwrap(); + assert!( + result.is_some(), + "without where clause, Any optimization should still apply for existence patterns" + ); + + // Verify it emitted an Any loop. + let has_any_loop = c.program.instructions.iter().any(|instr| { + if let Instruction::LoopStart { params_index } = instr { + let params = c + .program + .instruction_data + .get_loop_params(*params_index) + .unwrap(); + params.mode == LoopMode::Any + } else { + false + } + }); + assert!(has_any_loop, "should emit a LoopMode::Any loop"); + } + + // ----------------------------------------------------------------------- + // compile_count — value-based count loop + // ----------------------------------------------------------------------- + + #[test] + fn compile_value_count_without_where() { + let mut c = Compiler::new(); + let count_node = CountNode::Value { + span: dummy_span(), + value: ValueOrExpr::Value(JsonValue::Array( + dummy_span(), + vec![ + JsonValue::Number(dummy_span(), "1".to_string()), + JsonValue::Number(dummy_span(), "2".to_string()), + ], + )), + name: None, + where_: None, + }; + + let result_reg = c.compile_count(&count_node).unwrap(); + assert!(result_reg < c.register_counter); + + // Should emit a ForEach loop with Add to increment count. + let has_loop = c + .program + .instructions + .iter() + .any(|i| matches!(i, Instruction::LoopStart { .. })); + let has_add = c + .program + .instructions + .iter() + .any(|i| matches!(i, Instruction::Add { .. })); + assert!(has_loop, "value count should emit a loop"); + assert!(has_add, "value count should emit Add to increment"); + } + + #[test] + fn compile_value_count_with_where() { + let mut c = Compiler::new(); + let count_node = make_value_count_with_where(); + + let result_reg = c.compile_count(&count_node).unwrap(); + assert!(result_reg < c.register_counter); + + // Should have Guard instruction for the where clause. + assert!( + c.program.instructions.iter().any(|i| matches!( + i, + Instruction::Guard { + mode: GuardMode::Condition, + .. + } + )), + "count with where should emit Guard for where condition" + ); + } + + // ----------------------------------------------------------------------- + // classify_existence_pattern — direct coverage of all recognized patterns + // ----------------------------------------------------------------------- + + /// Helper to build a Condition with the given operator and numeric RHS + /// (LHS is irrelevant for classify_existence_pattern). + fn make_condition_for_classify(op: OperatorKind, rhs: i64) -> Condition { + Condition { + span: dummy_span(), + lhs: crate::languages::azure_policy::ast::Lhs::Field(FieldNode { + span: dummy_span(), + kind: FieldKind::Type, + }), + operator: OperatorNode { + span: dummy_span(), + kind: op, + }, + rhs: ValueOrExpr::Value(JsonValue::Number(dummy_span(), rhs.to_string())), + } + } + + #[test] + fn classify_existence_all_patterns() { + // "at least one" patterns → Some(true) + assert_eq!( + Compiler::classify_existence_pattern(&make_condition_for_classify( + OperatorKind::Greater, + 0 + )), + Some(true), + "> 0" + ); + assert_eq!( + Compiler::classify_existence_pattern(&make_condition_for_classify( + OperatorKind::GreaterOrEquals, + 1 + )), + Some(true), + ">= 1" + ); + assert_eq!( + Compiler::classify_existence_pattern(&make_condition_for_classify( + OperatorKind::NotEquals, + 0 + )), + Some(true), + "!= 0" + ); + + // "none" patterns → Some(false) + assert_eq!( + Compiler::classify_existence_pattern(&make_condition_for_classify( + OperatorKind::Equals, + 0 + )), + Some(false), + "== 0" + ); + assert_eq!( + Compiler::classify_existence_pattern(&make_condition_for_classify( + OperatorKind::Less, + 1 + )), + Some(false), + "< 1" + ); + assert_eq!( + Compiler::classify_existence_pattern(&make_condition_for_classify( + OperatorKind::LessOrEquals, + 0 + )), + Some(false), + "<= 0" + ); + + // Non-existence patterns → None + assert_eq!( + Compiler::classify_existence_pattern(&make_condition_for_classify( + OperatorKind::Equals, + 2 + )), + None, + "== 2" + ); + assert_eq!( + Compiler::classify_existence_pattern(&make_condition_for_classify( + OperatorKind::Greater, + 1 + )), + None, + "> 1" + ); + } + + // ----------------------------------------------------------------------- + // try_compile_count_as_any — nested no-where field count is skipped + // ----------------------------------------------------------------------- + + #[test] + fn any_optimization_skips_nested_no_where_field_count() { + // A nested wildcard field path without a where clause should NOT be + // optimised into Any loops because of Undefined-propagation semantics. + let mut c = Compiler::new(); + let count_node = CountNode::Field { + span: dummy_span(), + field: FieldNode { + span: dummy_span(), + kind: FieldKind::Alias("a[*].b[*]".to_string()), + }, + where_: None, + }; + let condition = make_count_condition(count_node.clone(), OperatorKind::Equals, 0); + let result = c.try_compile_count_as_any(&count_node, &condition).unwrap(); + assert!( + result.is_none(), + "nested no-where field count should fall back to generic path" + ); + } } diff --git a/src/languages/azure_policy/compiler/count_any.rs b/src/languages/azure_policy/compiler/count_any.rs deleted file mode 100644 index 03d17e8..0000000 --- a/src/languages/azure_policy/compiler/count_any.rs +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Existence-pattern optimization (count → Any loop). -//! -//! Stub — real implementation added in a later commit. diff --git a/src/languages/azure_policy/compiler/count_bindings.rs b/src/languages/azure_policy/compiler/count_bindings.rs deleted file mode 100644 index 2f573ff..0000000 --- a/src/languages/azure_policy/compiler/count_bindings.rs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -#![allow(dead_code)] - -//! Count-binding resolution and `current()` references. -//! -//! Stub — real implementation added in a later commit. - -use anyhow::{bail, Result}; - -use super::core::{Compiler, CountBinding}; - -impl Compiler { - pub(super) const fn resolve_count_binding( - &self, - _field_path: &str, - ) -> Result> { - _ = self.register_counter; - Ok(None) - } - - pub(super) fn compile_from_binding( - &mut self, - _binding: &CountBinding, - _field_path: &str, - span: &crate::lexer::Span, - ) -> Result { - let _ = self; - bail!(span.error("count binding compilation not yet implemented")) - } - - pub(super) fn compile_current_reference( - &mut self, - _key: &str, - span: &crate::lexer::Span, - ) -> Result { - let _ = self; - bail!(span.error("current() reference not yet implemented")) - } -} diff --git a/src/languages/azure_policy/compiler/mod.rs b/src/languages/azure_policy/compiler/mod.rs index 4f005cb..e407c66 100644 --- a/src/languages/azure_policy/compiler/mod.rs +++ b/src/languages/azure_policy/compiler/mod.rs @@ -8,9 +8,8 @@ //! - [`core`]: `Compiler` struct, main pipeline, register/emit helpers //! - [`conditions`]: constraint / condition / LHS compilation //! - [`conditions_wildcard`]: implicit allOf for unbound `[*]` fields -//! - [`count`]: `count` / `count.where` loops -//! - [`count_any`]: existence-pattern optimization (count → Any loop) -//! - [`count_bindings`]: count-binding resolution and `current()` references +//! - [`count`]: `count` / `count.where` loops, existence-pattern optimization, +//! count-binding resolution and `current()` references //! - [`expressions`]: template-expression and call-expression compilation //! - [`fields`]: field-kind and resource-path compilation //! - [`template_dispatch`]: ARM template function dispatch @@ -23,8 +22,6 @@ mod conditions; mod conditions_wildcard; mod core; mod count; -mod count_any; -mod count_bindings; mod effects; mod effects_modify_append; mod expressions; diff --git a/src/languages/azure_policy/compiler/utils.rs b/src/languages/azure_policy/compiler/utils.rs index 7a13103..d920681 100644 --- a/src/languages/azure_policy/compiler/utils.rs +++ b/src/languages/azure_policy/compiler/utils.rs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#![allow(dead_code, clippy::pattern_type_mismatch)] +#![allow(clippy::pattern_type_mismatch)] //! Free helper functions used by the Azure Policy compiler. @@ -43,9 +43,6 @@ pub(super) fn split_count_wildcard_path(path: &str) -> Result<(String, Option