feat(azure-policy): add policy rule and policy definition parsers (#660)

Extend the Azure Policy parser to handle complete policyRule and
policyDefinition JSON structures, not just standalone constraints.

Policy rule parser (policy_rule.rs):
- Parse top-level { "if": ..., "then": ... } objects
- Extract effect kind (deny, audit, append, modify, etc.) into typed AST
- Parse "details" structurally when it is an object to pull out
  existenceCondition as a first-class Constraint; fall back to opaque
  JSON for non-object details (e.g. append array form)
- Detect duplicate/missing keys for "if", "then", "effect", "details"

Policy definition parser (policy_definition.rs):
- Handle both wrapped ARM envelope ({ "properties": { ... } }) and
  unwrapped (properties-level keys at top level) forms
- Type-extract displayName, description, mode, metadata, parameters,
  and policyRule; everything else goes into extra
- Parse parameter definitions with type, defaultValue, allowedValues,
  and metadata; detect duplicate parameter names
- Duplicate key detection throughout

Grammar documentation (docs/azure-policy/azurepolicy.ebnf):
- Add formal EBNF grammar covering policy-rule, then-block,
  constraints, conditions, all 19 operators, count expressions,
  JSON values, and ARM template expressions

Test harness changes:
- Add parse_level field to YAML test cases: "constraint" (default),
  "policy_rule", or "policy_definition"
- Un-skip three parse_errors cases that needed policy_rule-level parsing
- Add policy_rule.yaml with 12 cases covering all 9 effect kinds,
  existenceCondition, parameterized effects, complex conditions, and
  extra key handling
- Add policy_definition.yaml with wrapped, unwrapped, parameterized,
  missing-policyRule, and duplicate-key error cases
This commit is contained in:
Anand Krishnamoorthi
2026-04-06 11:36:24 -05:00
committed by GitHub
parent 687be2850b
commit 8f740e2f6f
11 changed files with 1311 additions and 40 deletions

View File

@@ -87,7 +87,7 @@ pub enum EffectKind {
DenyAction,
Manual,
/// An effect value that wasn't recognized (may be a parameterized expression).
/// Use [`EffectNode::raw`] to get the original text.
/// The original text can be retrieved from [`EffectNode::raw`].
Other,
}
@@ -283,13 +283,18 @@ pub struct PolicyDefinition {
/// Optional `metadata` (kept as raw JSON).
pub metadata: Option<JsonValue>,
/// Parameter definitions as an ordered list; lookups should match `ParameterDefinition::name`.
/// Parameter definitions as an ordered list; each entry includes its parameter name.
pub parameters: Vec<ParameterDefinition>,
/// The parsed `policyRule`.
pub policy_rule: PolicyRule,
/// Any other top-level fields not handled above (e.g., `id`, `name`, `type`, `policyType`).
/// Any unrecognized fields collected during parsing.
///
/// In the **wrapped** form this includes both envelope-level keys
/// (e.g., `id`, `name`, `type`) and unrecognized keys inside the
/// inner `properties` object. In the **unwrapped** form it contains
/// only unrecognized top-level keys.
pub extra: Vec<ObjectEntry>,
}

View File

@@ -18,18 +18,6 @@ use super::core::{CountInner, EntryValue, Parser};
use super::error::ParseError;
use super::parse_operator_kind;
/// Set `slot` to `val`, returning a [`ParseError::DuplicateKey`] if it was already set.
fn set_once<T>(slot: &mut Option<T>, val: T, key: &str, span: &Span) -> Result<(), ParseError> {
if slot.is_some() {
return Err(ParseError::DuplicateKey {
span: span.clone(),
key: String::from(key),
});
}
*slot = Some(val);
Ok(())
}
impl<'source> Parser<'source> {
/// Parse a constraint (a JSON object: logical combinator or leaf condition).
pub fn parse_constraint(&mut self) -> Result<Constraint, ParseError> {
@@ -191,7 +179,7 @@ impl<'source> Parser<'source> {
expected: "JSON value for 'field'",
});
};
set_once(&mut field, (key_span.clone(), jv), &key, &key_span)?;
Self::set_once(&mut field, (key_span.clone(), jv), &key, &key_span)?;
}
"value" => {
let EntryValue::Json(jv) = entry_value else {
@@ -200,7 +188,7 @@ impl<'source> Parser<'source> {
expected: "JSON value for 'value'",
});
};
set_once(&mut value, (key_span.clone(), jv), &key, &key_span)?;
Self::set_once(&mut value, (key_span.clone(), jv), &key, &key_span)?;
}
"count" => {
let EntryValue::CountInner(ci) = entry_value else {
@@ -209,7 +197,7 @@ impl<'source> Parser<'source> {
expected: "object for 'count'",
});
};
set_once(&mut count, (key_span.clone(), ci), &key, &key_span)?;
Self::set_once(&mut count, (key_span.clone(), ci), &key, &key_span)?;
}
_ => {
if let Some(op_kind) = parse_operator_kind(&key.to_lowercase()) {
@@ -288,19 +276,19 @@ impl<'source> Parser<'source> {
match key_lower.as_str() {
"field" => {
let jv = self.parse_json_value()?;
set_once(&mut field, (key_span.clone(), jv), &key_lower, &key_span)?;
Self::set_once(&mut field, (key_span.clone(), jv), &key_lower, &key_span)?;
}
"value" => {
let jv = self.parse_json_value()?;
set_once(&mut value, (key_span.clone(), jv), &key_lower, &key_span)?;
Self::set_once(&mut value, (key_span.clone(), jv), &key_lower, &key_span)?;
}
"name" => {
let jv = self.parse_json_value()?;
set_once(&mut name, (key_span.clone(), jv), &key_lower, &key_span)?;
Self::set_once(&mut name, (key_span.clone(), jv), &key_lower, &key_span)?;
}
"where" => {
let c = self.parse_constraint()?;
set_once(&mut where_, c, &key_lower, &key_span)?;
Self::set_once(&mut where_, c, &key_lower, &key_span)?;
}
_ => {
return Err(ParseError::UnrecognizedKey {

View File

@@ -346,6 +346,27 @@ impl<'source> Parser<'source> {
})
}
// ========================================================================
// Duplicate-key guard
// ========================================================================
/// Set `slot` to `val`, returning [`ParseError::DuplicateKey`] if already set.
pub(super) fn set_once<T>(
slot: &mut Option<T>,
val: T,
key: &str,
span: &Span,
) -> Result<(), ParseError> {
if slot.is_some() {
return Err(ParseError::DuplicateKey {
span: span.clone(),
key: String::from(key),
});
}
*slot = Some(val);
Ok(())
}
// ========================================================================
// Conversion helpers
// ========================================================================

View File

@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Recursive-descent JSON parser for Azure Policy rule constraints.
//! Custom recursive-descent JSON parser for Azure Policy rules.
//!
//! Parses Azure Policy JSON directly from [`Lexer`] tokens, building span-annotated
//! AST nodes in a single pass. No intermediate `serde_json::Value` is created.
@@ -9,19 +9,34 @@
//! The parser is policy-aware: when parsing JSON objects, it dispatches on key names
//! (`allOf`, `anyOf`, `not`, `field`, `value`, `count`, operator names) to build
//! the appropriate AST nodes.
//!
//! ## Usage
//!
//! ```ignore
//! use regorus::Source;
//! use regorus::languages::azure_policy::parser;
//!
//! let json = r#"{ "if": { "field": "type", "equals": "Microsoft.Compute/virtualMachines" },
//! "then": { "effect": "deny" } }"#;
//! let source = Source::from_contents("policy.json".into(), json.into())?;
//! let rule = parser::parse_policy_rule(&source)?;
//! ```
mod constraint;
mod core;
mod error;
mod policy_definition;
mod policy_rule;
pub(super) use self::core::json_unescape;
pub use error::ParseError;
use alloc::string::ToString as _;
use crate::lexer::{Source, TokenKind};
use super::ast::{Constraint, FieldKind, OperatorKind};
use super::ast::{Constraint, FieldKind, OperatorKind, PolicyDefinition, PolicyRule};
use super::expr::ExprParser;
use self::core::Parser;
@@ -30,12 +45,56 @@ use self::core::Parser;
// Public API
// ============================================================================
/// Parse an Azure Policy rule from a JSON source.
///
/// The source should contain a complete `policyRule` JSON object:
/// ```json
/// {
/// "if": { "field": "type", "equals": "Microsoft.Compute/virtualMachines" },
/// "then": { "effect": "deny" }
/// }
/// ```
///
/// Returns a span-annotated [`PolicyRule`] AST.
pub fn parse_policy_rule(source: &Source) -> Result<PolicyRule, ParseError> {
let mut parser = Parser::new(source)?;
let rule = parser.parse_policy_rule()?;
if parser.tok.0 != TokenKind::Eof {
return Err(ParseError::UnexpectedToken {
span: parser.tok.1.clone(),
expected: "end of input",
});
}
Ok(rule)
}
/// Parse a full Azure Policy definition from a JSON source.
///
/// Accepts two forms:
/// 1. **Wrapped**: `{ "properties": { "policyRule": ..., ... }, "id": ..., ... }`
/// 2. **Unwrapped**: `{ "displayName": ..., "policyRule": ..., ... }`
///
/// Returns a [`PolicyDefinition`] with typed fields for known properties
/// and a catch-all list of `extra` entries for everything else.
pub fn parse_policy_definition(source: &Source) -> Result<PolicyDefinition, ParseError> {
let mut parser = Parser::new(source)?;
let defn = parser.parse_policy_definition()?;
if parser.tok.0 != TokenKind::Eof {
return Err(ParseError::UnexpectedToken {
span: parser.tok.1.clone(),
expected: "end of input",
});
}
Ok(defn)
}
/// Parse a standalone constraint from a JSON source.
///
/// A constraint is one of:
/// - Logical combinator: `{ "allOf": [...] }`, `{ "anyOf": [...] }`, `{ "not": {...} }`
/// - Leaf condition: `{ "field": "...", "equals": "..." }`
/// - Count condition: `{ "count": { "field": "..." }, "greater": 0 }`
/// Useful for parsing just the `"if"` part of a policy rule.
pub fn parse_constraint(source: &Source) -> Result<Constraint, ParseError> {
let mut parser = Parser::new(source)?;
let constraint = parser.parse_constraint()?;

View File

@@ -0,0 +1,459 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Parsing for the full Azure Policy definition envelope.
//!
//! Handles the outer `{ "properties": { ... } }` wrapper, extracting typed
//! fields (`displayName`, `description`, `mode`, `parameters`, `policyRule`)
//! and collecting everything else into `extra`.
use alloc::collections::BTreeSet;
use alloc::string::String;
use alloc::vec::Vec;
use crate::lexer::Span;
use crate::languages::azure_policy::ast::{
JsonValue, ObjectEntry, ParameterDefinition, PolicyDefinition, PolicyRule,
};
use super::core::Parser;
use super::error::ParseError;
impl<'source> Parser<'source> {
/// Parse a full Azure Policy definition JSON.
///
/// Accepts two forms:
/// 1. **Wrapped**: `{ "properties": { ... }, ... }` — the standard ARM resource format.
/// 2. **Unwrapped**: `{ "displayName": ..., "policyRule": ..., ... }` — just the
/// `properties` contents directly.
///
/// In the wrapped form, fields outside `properties` (like `id`, `name`, `type`)
/// are collected into `extra`.
///
/// Detection: if the top-level object has a `"properties"` key whose value
/// is a JSON object, the wrapped path is taken. (In practice, the ARM
/// resource envelope always uses `"properties"` to wrap the definition body.)
pub fn parse_policy_definition(&mut self) -> Result<PolicyDefinition, ParseError> {
let open = self.expect_symbol("{")?;
// Determine whether this looks like the wrapped ARM resource form by
// scanning top-level keys as they are parsed.
//
// Because JSON keys can appear in any order, we handle the general case:
// iterate all top-level keys; if we encounter "properties" whose value is
// an object, take the wrapped path. Keys outside "properties" go into
// `extra`.
//
// For the unwrapped form, all top-level keys are treated as
// properties-level fields.
let mut display_name: Option<String> = None;
let mut description: Option<String> = None;
let mut mode: Option<String> = None;
let mut metadata: Option<JsonValue> = None;
let mut parameters: Vec<ParameterDefinition> = Vec::new();
let mut policy_rule: Option<PolicyRule> = None;
let mut extra = Vec::new();
// Track whether we found a "properties" object (wrapped form).
let mut found_properties = false;
// Track whether we have seen any "properties" key (for duplicate detection).
let mut seen_properties = false;
// Track seen recognized keys for duplicate detection (unwrapped path).
let mut seen_keys: Vec<String> = Vec::new();
if self.token_text() != "}" {
loop {
let (key_span, key) = self.expect_string()?;
self.expect_symbol(":")?;
match key.to_lowercase().as_str() {
"properties" if seen_properties => {
return Err(ParseError::DuplicateKey {
span: key_span,
key,
});
}
"properties" => {
seen_properties = true;
if self.token_text() == "{" {
// Wrapped form: parse inner properties object structurally.
found_properties = true;
self.parse_properties_fields(
&mut display_name,
&mut description,
&mut mode,
&mut metadata,
&mut parameters,
&mut policy_rule,
&mut extra,
&mut seen_keys,
)?;
} else {
// Unwrapped form: "properties" is just a regular key.
Self::handle_properties_key(
self,
key_span,
&key,
&mut display_name,
&mut description,
&mut mode,
&mut metadata,
&mut parameters,
&mut policy_rule,
&mut extra,
&mut seen_keys,
)?;
}
}
_ if !found_properties => {
// Unwrapped form: dispatch on properties-level keys.
Self::handle_properties_key(
self,
key_span,
&key,
&mut display_name,
&mut description,
&mut mode,
&mut metadata,
&mut parameters,
&mut policy_rule,
&mut extra,
&mut seen_keys,
)?;
}
_ => {
// Wrapped form: keys outside "properties" go to extra.
let value = self.parse_json_value()?;
extra.push(ObjectEntry {
key_span,
key,
value,
});
}
}
if self.token_text() == "," {
self.advance()?;
} else {
break;
}
}
}
let close = self.expect_symbol("}")?;
let span = Span {
source: open.source.clone(),
line: open.line,
col: open.col,
start: open.start,
end: close.end,
};
let policy_rule = policy_rule.ok_or_else(|| ParseError::MissingKey {
span: span.clone(),
key: "policyRule",
})?;
Ok(PolicyDefinition {
span,
display_name,
description,
mode,
metadata,
parameters,
policy_rule,
extra,
})
}
/// Parse the interior of a `"properties": { ... }` object, dispatching
/// recognized keys to their typed parsers.
#[allow(clippy::too_many_arguments)]
fn parse_properties_fields(
&mut self,
display_name: &mut Option<String>,
description: &mut Option<String>,
mode: &mut Option<String>,
metadata: &mut Option<JsonValue>,
parameters: &mut Vec<ParameterDefinition>,
policy_rule: &mut Option<PolicyRule>,
extra: &mut Vec<ObjectEntry>,
seen_keys: &mut Vec<String>,
) -> Result<(), ParseError> {
self.expect_symbol("{")?;
if self.token_text() != "}" {
loop {
let (key_span, key) = self.expect_string()?;
self.expect_symbol(":")?;
Self::handle_properties_key(
self,
key_span,
&key,
display_name,
description,
mode,
metadata,
parameters,
policy_rule,
extra,
seen_keys,
)?;
if self.token_text() == "," {
self.advance()?;
} else {
break;
}
}
}
self.expect_symbol("}")?;
Ok(())
}
/// Handle a single properties-level key, dispatching to the appropriate
/// typed parser or collecting into `extra`.
#[allow(clippy::too_many_arguments)]
fn handle_properties_key(
&mut self,
key_span: Span,
key: &str,
display_name: &mut Option<String>,
description: &mut Option<String>,
mode: &mut Option<String>,
metadata: &mut Option<JsonValue>,
parameters: &mut Vec<ParameterDefinition>,
policy_rule: &mut Option<PolicyRule>,
extra: &mut Vec<ObjectEntry>,
seen_keys: &mut Vec<String>,
) -> Result<(), ParseError> {
let lk = key.to_lowercase();
// Reject duplicate recognized property keys regardless of value type.
let recognized = matches!(
lk.as_str(),
"displayname" | "description" | "mode" | "metadata" | "parameters" | "policyrule"
);
if recognized {
if seen_keys.contains(&lk) {
return Err(ParseError::DuplicateKey {
span: key_span,
key: key.into(),
});
}
seen_keys.push(lk.clone());
}
match lk.as_str() {
"displayname" => {
let value = self.parse_json_value()?;
assign_string_or_extra(value, display_name, key_span, key, extra);
}
"description" => {
let value = self.parse_json_value()?;
assign_string_or_extra(value, description, key_span, key, extra);
}
"mode" => {
let value = self.parse_json_value()?;
assign_string_or_extra(value, mode, key_span, key, extra);
}
"metadata" => {
*metadata = Some(self.parse_json_value()?);
}
"parameters" => {
// Parameters must be a JSON object; if not, push to extra.
if self.token_text() == "{" {
*parameters = self.parse_parameter_definitions()?;
} else {
let value = self.parse_json_value()?;
extra.push(ObjectEntry {
key_span,
key: key.into(),
value,
});
}
}
"policyrule" => {
// Parse the policyRule directly from the token stream!
*policy_rule = Some(self.parse_policy_rule()?);
}
_ => {
let value = self.parse_json_value()?;
extra.push(ObjectEntry {
key_span,
key: key.into(),
value,
});
}
}
Ok(())
}
/// Parse a `"parameters": { ... }` object into a list of
/// [`ParameterDefinition`], consuming tokens directly from the stream.
fn parse_parameter_definitions(&mut self) -> Result<Vec<ParameterDefinition>, ParseError> {
self.expect_symbol("{")?;
let mut defs = Vec::new();
let mut seen_names = BTreeSet::new();
if self.token_text() != "}" {
loop {
let param = self.parse_single_parameter()?;
if !seen_names.insert(param.name.to_lowercase()) {
return Err(ParseError::DuplicateKey {
span: param.name_span.clone(),
key: param.name.clone(),
});
}
defs.push(param);
if self.token_text() == "," {
self.advance()?;
} else {
break;
}
}
}
self.expect_symbol("}")?;
Ok(defs)
}
/// Parse a single `"paramName": { ... }` entry.
fn parse_single_parameter(&mut self) -> Result<ParameterDefinition, ParseError> {
let (name_span, name) = self.expect_string()?;
self.expect_symbol(":")?;
// Parameter definitions must be JSON objects.
if self.token_text() != "{" {
return Err(ParseError::Custom {
span: name_span,
message: alloc::format!("expected object for parameter '{}'", name),
});
}
let open = self.expect_symbol("{")?;
let mut param_type: Option<String> = None;
let mut default_value: Option<JsonValue> = None;
let mut allowed_values: Option<Vec<JsonValue>> = None;
let mut metadata: Option<JsonValue> = None;
let mut extra = Vec::new();
// Track seen recognized keys for duplicate detection.
let mut seen_type = false;
let mut seen_default_value = false;
let mut seen_allowed_values = false;
let mut seen_metadata = false;
if self.token_text() != "}" {
loop {
let (ks, k) = self.expect_string()?;
self.expect_symbol(":")?;
match k.to_lowercase().as_str() {
"type" => {
if seen_type {
return Err(ParseError::DuplicateKey { span: ks, key: k });
}
seen_type = true;
let value = self.parse_json_value()?;
assign_string_or_extra(value, &mut param_type, ks, &k, &mut extra);
}
"defaultvalue" => {
if seen_default_value {
return Err(ParseError::DuplicateKey { span: ks, key: k });
}
seen_default_value = true;
default_value = Some(self.parse_json_value()?);
}
"allowedvalues" => {
if seen_allowed_values {
return Err(ParseError::DuplicateKey { span: ks, key: k });
}
seen_allowed_values = true;
let value = self.parse_json_value()?;
if let JsonValue::Array(_, items) = value {
allowed_values = Some(items);
} else {
extra.push(ObjectEntry {
key_span: ks,
key: k,
value,
});
}
}
"metadata" => {
if seen_metadata {
return Err(ParseError::DuplicateKey { span: ks, key: k });
}
seen_metadata = true;
metadata = Some(self.parse_json_value()?);
}
_ => {
let value = self.parse_json_value()?;
extra.push(ObjectEntry {
key_span: ks,
key: k,
value,
});
}
}
if self.token_text() == "," {
self.advance()?;
} else {
break;
}
}
}
let close = self.expect_symbol("}")?;
let span = Span {
source: open.source.clone(),
line: open.line,
col: open.col,
start: open.start,
end: close.end,
};
Ok(ParameterDefinition {
span,
name,
name_span,
param_type,
default_value,
allowed_values,
metadata,
extra,
})
}
}
// ============================================================================
// Helpers
// ============================================================================
/// If `value` is a plain string, assign it to `target`; otherwise push
/// the entry to `extra` so callers don't have to repeat this pattern.
fn assign_string_or_extra(
value: JsonValue,
target: &mut Option<String>,
key_span: Span,
key: &str,
extra: &mut Vec<ObjectEntry>,
) {
if let JsonValue::Str(_, ref s) = value {
*target = Some(s.clone());
} else {
extra.push(ObjectEntry {
key_span,
key: key.into(),
value,
});
}
}

View File

@@ -0,0 +1,213 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! Top-level policy rule parsing: `parse_policy_rule` and `parse_then_block`.
use crate::lexer::Span;
use crate::languages::azure_policy::ast::{
Constraint, EffectKind, EffectNode, JsonValue, ObjectEntry, PolicyRule, ThenBlock,
};
use super::core::Parser;
use super::error::ParseError;
impl<'source> Parser<'source> {
/// Parse the top-level `policyRule` object.
pub fn parse_policy_rule(&mut self) -> Result<PolicyRule, ParseError> {
let open = self.expect_symbol("{")?;
let mut condition: Option<Constraint> = None;
let mut then_block: Option<ThenBlock> = None;
if self.token_text() != "}" {
loop {
let (key_span, key) = self.expect_string()?;
self.expect_symbol(":")?;
match key.to_lowercase().as_str() {
"if" => {
let c = self.parse_constraint()?;
Self::set_once(&mut condition, c, "if", &key_span)?;
}
"then" => {
let tb = self.parse_then_block()?;
Self::set_once(&mut then_block, tb, "then", &key_span)?;
}
_ => {
let _ = self.parse_json_value()?;
}
}
if self.token_text() == "," {
self.advance()?;
} else {
break;
}
}
}
let close = self.expect_symbol("}")?;
let span = Span {
source: open.source.clone(),
line: open.line,
col: open.col,
start: open.start,
end: close.end,
};
let condition = condition.ok_or_else(|| ParseError::MissingKey {
span: span.clone(),
key: "if",
})?;
let then_block = then_block.ok_or_else(|| ParseError::MissingKey {
span: span.clone(),
key: "then",
})?;
Ok(PolicyRule {
span,
condition,
then_block,
})
}
/// Parse the `"then"` block.
fn parse_then_block(&mut self) -> Result<ThenBlock, ParseError> {
let open = self.expect_symbol("{")?;
let mut effect: Option<EffectNode> = None;
let mut details: Option<JsonValue> = None;
let mut existence_condition: Option<Constraint> = None;
if self.token_text() != "}" {
loop {
let (key_span, key) = self.expect_string()?;
self.expect_symbol(":")?;
match key.to_lowercase().as_str() {
"effect" => {
let (val_span, val_text) = self.expect_string()?;
let kind = match val_text.to_lowercase().as_str() {
"deny" => EffectKind::Deny,
"audit" => EffectKind::Audit,
"append" => EffectKind::Append,
"auditifnotexists" => EffectKind::AuditIfNotExists,
"deployifnotexists" => EffectKind::DeployIfNotExists,
"disabled" => EffectKind::Disabled,
"modify" => EffectKind::Modify,
"denyaction" => EffectKind::DenyAction,
"manual" => EffectKind::Manual,
_ => EffectKind::Other,
};
let node = EffectNode {
span: val_span,
kind,
raw: val_text,
};
Self::set_once(&mut effect, node, "effect", &key_span)?;
}
"details" => {
// If details is an object, parse structurally to
// recognize existenceCondition inline. Otherwise
// (e.g., append's array form), parse as opaque JSON.
if self.token_text() == "{" {
let (det, ec) = self.parse_then_details()?;
Self::set_once(&mut details, det, "details", &key_span)?;
existence_condition = ec;
} else {
let det = self.parse_json_value()?;
Self::set_once(&mut details, det, "details", &key_span)?;
}
}
_ => {
let _ = self.parse_json_value()?;
}
}
if self.token_text() == "," {
self.advance()?;
} else {
break;
}
}
}
let close = self.expect_symbol("}")?;
let span = Span {
source: open.source.clone(),
line: open.line,
col: open.col,
start: open.start,
end: close.end,
};
let effect = effect.ok_or_else(|| ParseError::MissingKey {
span: span.clone(),
key: "effect",
})?;
Ok(ThenBlock {
span,
effect,
details,
existence_condition,
})
}
/// Parse the `"details"` object, extracting `existenceCondition` as a
/// structured [`Constraint`] directly from the token stream.
///
/// All other entries are collected into a generic `JsonValue::Object`.
fn parse_then_details(&mut self) -> Result<(JsonValue, Option<Constraint>), ParseError> {
let open = self.expect_symbol("{")?;
let mut entries = alloc::vec::Vec::new();
let mut existence_condition: Option<Constraint> = None;
if self.token_text() != "}" {
loop {
let (key_span, key) = self.expect_string()?;
self.expect_symbol(":")?;
if key.eq_ignore_ascii_case("existenceCondition") {
// Parse the constraint directly from the token stream and
// expose it separately via `ThenBlock::existence_condition`.
// Intentionally omit the `existenceCondition` entry from
// the `details` JsonValue since that field is returned
// separately on the AST node.
Self::set_once(
&mut existence_condition,
self.parse_constraint()?,
"existenceCondition",
&key_span,
)?;
} else {
let value = self.parse_json_value()?;
entries.push(ObjectEntry {
key_span,
key,
value,
});
}
if self.token_text() == "," {
self.advance()?;
} else {
break;
}
}
}
let close = self.expect_symbol("}")?;
let span = Span {
source: open.source.clone(),
line: open.line,
col: open.col,
start: open.start,
end: close.end,
};
Ok((JsonValue::Object(span, entries), existence_condition))
}
}