diff --git a/CURRENT_TASK.md b/CURRENT_TASK.md index be53a160..0a78a3b5 100644 --- a/CURRENT_TASK.md +++ b/CURRENT_TASK.md @@ -28,7 +28,7 @@ - **Phase 134 完了**: Plugin loader best-effort loading(決定的順序 + failure 集約 + 継続)を導入。 - **Phase 135 完了**: ConditionLoweringBox allocator SSOT(P0: 根治修正 + P1: contract_checks Fail-Fast 強化)。 - **Phase 136 完了**: MirBuilder Context SSOT 化(+ ValueId allocator 掃討)。 -- **Phase 137-1 完了**: Loop Canonicalizer(型/語彙の SSOT)を導入(routing/lowering には未介入)。 +- **Phase 137-2 完了**: Loop Canonicalizer(dev-only 観測)まで完了(既定挙動は不変)。 - **Phase 88 完了**: continue + 可変ステップ(i=i+const 差分)を dev-only fixture で固定、StepCalculator Box 抽出。 - **Phase 89 完了**: P0(ContinueReturn detector)+ P1(lowering 実装)完了。 - **Phase 90 完了**: ParseStringComposite + `Null` literal + ContinueReturn(同一値の複数 return-if)を dev-only fixture で固定。 @@ -53,15 +53,15 @@ ## 次の指示書(優先順位) -### P0: Loop Canonicalizer の Phase 2(dev-only 観測の導入) +### P0: Loop Canonicalizer の Phase 3(Skeleton→Decision の精密化) -**状態**: ✅ 設計 + Phase 1(型)完了、Phase 2 へ +**状態**: ✅ Phase 2 まで完了、Phase 3 へ -**目的**: `LoopSkeleton` / `Capability` / `RoutingDecision` を実際のループ入口で “観測できる” 状態にして、組み合わせ爆発の手前で理由付き Fail-Fast ができる基盤を作る(ただし既定挙動は不変)。 +**目的**: `LoopSkeleton` から `RoutingDecision`(chosen/missing_caps/notes)を安定に計算し、代表ケース(`skip_whitespace`)で “期待する選択” をテストで固定する(既定挙動は不変)。 SSOT: - `docs/development/current/main/design/loop-canonicalizer.md` -実装(Phase 1): +実装: - `src/mir/loop_canonicalizer/mod.rs` **次に触るSSOT**: diff --git a/docs/development/current/main/01-JoinIR-Selfhost-INDEX.md b/docs/development/current/main/01-JoinIR-Selfhost-INDEX.md index dbe4d346..a720187f 100644 --- a/docs/development/current/main/01-JoinIR-Selfhost-INDEX.md +++ b/docs/development/current/main/01-JoinIR-Selfhost-INDEX.md @@ -42,7 +42,7 @@ JoinIR の箱構造と責務、ループ/if の lowering パターンを把握 - `docs/development/current/main/design/joinir-design-map.md` 5. Loop Canonicalizer(設計 SSOT) - `docs/development/current/main/design/loop-canonicalizer.md` - - 実装(Phase 137-1): `src/mir/loop_canonicalizer/mod.rs` + - 実装(Phase 137-2): `src/mir/loop_canonicalizer/mod.rs` 6. MIR Builder(Context 分割の入口) - `src/mir/builder/README.md` 7. Scope/BindingId(shadowing・束縛同一性の段階移行) diff --git a/docs/development/current/main/10-Now.md b/docs/development/current/main/10-Now.md index d01665c9..5102b4f9 100644 --- a/docs/development/current/main/10-Now.md +++ b/docs/development/current/main/10-Now.md @@ -27,11 +27,11 @@ - Phase 136: MirBuilder の Context 分割を完了し、状態の SSOT を Context に一本化。 - 詳細: `docs/development/current/main/phases/phase-136/README.md` -## 2025‑12‑16:Phase 137‑1(短報) +## 2025‑12‑16:Phase 137‑2(短報) -- Loop Canonicalizer の Phase 1(型/語彙の SSOT)を導入(routing/lowering には未介入)。 +- Loop Canonicalizer の Phase 2(dev-only 観測)まで完了(既定挙動は不変)。 - 設計 SSOT: `docs/development/current/main/design/loop-canonicalizer.md` - - 実装: `src/mir/loop_canonicalizer/mod.rs` + - 実装: `src/mir/loop_canonicalizer/mod.rs`(+ 観測: `src/mir/builder/control_flow/joinir/routing.rs`) ## 2025‑12‑14:現状サマリ diff --git a/docs/development/current/main/design/loop-canonicalizer.md b/docs/development/current/main/design/loop-canonicalizer.md index c4921782..401ba321 100644 --- a/docs/development/current/main/design/loop-canonicalizer.md +++ b/docs/development/current/main/design/loop-canonicalizer.md @@ -1,6 +1,6 @@ # Loop Canonicalizer(設計 SSOT) -Status: Phase 1 done(型定義まで) +Status: Phase 2 done(dev-only 観測まで) Scope: ループ形の組み合わせ爆発を抑えるための “前処理” の設計(fixture/shape guard/fail-fast と整合) Related: - SSOT (契約/不変条件): `docs/development/current/main/joinir-architecture-overview.md` @@ -160,12 +160,12 @@ pub enum CarrierRole { ## 実装の入口(現状) -Phase 1(型定義のみ)の実装はここ: +実装(Phase 1–2)はここ: - `src/mir/loop_canonicalizer/mod.rs` 注意: -- ここは「型と語彙の SSOT」を置く場所で、routing/lowering にはまだ介入しない。 -- Phase 2 以降で `canonicalize(loop_ast) -> (LoopSkeleton, RoutingDecision)` を導入し、dev-only で観測から始める。 +- Phase 2 で `canonicalize_loop_expr(...) -> Result<(LoopSkeleton, RoutingDecision), String>` を導入し、JoinIR ループ入口で dev-only 観測できるようにした(既定挙動は不変)。 +- 観測ポイント(JoinIR ループ入口): `src/mir/builder/control_flow/joinir/routing.rs`(`joinir_dev_enabled()` 配下) ## Capability の語彙(Fail-Fast reason タグ) @@ -197,7 +197,7 @@ Canonicalizer の判定結果は `RoutingDecision` に集約し、以下に流 ```rust pub struct RoutingDecision { /// 選択された Pattern(None = Fail-Fast) - pub chosen: Option, + pub chosen: Option, /// 不足している Capability のリスト pub missing_caps: Vec<&'static str>, @@ -206,7 +206,7 @@ pub struct RoutingDecision { pub notes: Vec, /// error_tags への追記(contract_checks 用) - pub error_tags: Vec, + pub error_tags: Vec, } ``` diff --git a/docs/development/current/main/phases/phase-137/README.md b/docs/development/current/main/phases/phase-137/README.md index 1a036482..fe22ef08 100644 --- a/docs/development/current/main/phases/phase-137/README.md +++ b/docs/development/current/main/phases/phase-137/README.md @@ -1,7 +1,7 @@ # Phase 137: Loop Canonicalizer(前処理 SSOT) ## Status -- 状態: 🔶 進行中(Phase 1 完了) +- 状態: 🔶 進行中(Phase 2 完了) ## Goal - ループ形の組み合わせ爆発を抑えるため、`AST → LoopSkeleton → (capability/routing)` の前処理を SSOT 化する。 @@ -15,11 +15,17 @@ - `RoutingDecision` / capability tags(`CAP_MISSING_*`) - 注意: Phase 1 は「型と語彙」のみ。routing/lowering にはまだ介入しない。 -## Phase 2(次): dev-only 観測の導入 +## Phase 2(完了): dev-only 観測の導入 -- 目標: ループ入口で `LoopSkeleton` と `RoutingDecision` を生成し、dev-only で観測できるようにする。 -- 既定挙動: 不変(dev-only の観測から開始) -- スイッチ: 新しい env を増やさず、`joinir_dev_enabled()`(既存)配下で有効化する。 +- 入口: + - Canonicalize: `src/mir/loop_canonicalizer/mod.rs`(`canonicalize_loop_expr`) + - 観測ポイント: `src/mir/builder/control_flow/joinir/routing.rs`(`joinir_dev_enabled()` 配下) +- 既定挙動: 不変(dev-only 観測のみ) + +## Phase 3(次): Pattern 検出(Skeleton→Decision の精密化) + +- 目標: `skip_whitespace` を Skeleton から安定に識別し、`RoutingDecision.chosen` と `missing_caps` を期待通りに固定する。 +- 注意: routing/lowering の変更は dev-only の観測結果が固まってから。 ## SSOT diff --git a/src/mir/builder/control_flow/joinir/routing.rs b/src/mir/builder/control_flow/joinir/routing.rs index 96d98bc8..46df0e7c 100644 --- a/src/mir/builder/control_flow/joinir/routing.rs +++ b/src/mir/builder/control_flow/joinir/routing.rs @@ -142,9 +142,12 @@ impl MirBuilder { eprintln!("[loop_canonicalizer] Has exits: {}", skeleton.exits.has_any_exit()); eprintln!("[loop_canonicalizer] Decision: {}", if decision.is_success() { "SUCCESS" } else { "FAIL_FAST" }); + if let Some(pattern) = decision.chosen { + eprintln!("[loop_canonicalizer] Chosen pattern: {:?}", pattern); + } + eprintln!("[loop_canonicalizer] Missing caps: {:?}", decision.missing_caps); if decision.is_fail_fast() { eprintln!("[loop_canonicalizer] Reason: {}", decision.notes.join("; ")); - eprintln!("[loop_canonicalizer] Missing caps: {:?}", decision.missing_caps); } } Err(e) => { diff --git a/src/mir/loop_canonicalizer/mod.rs b/src/mir/loop_canonicalizer/mod.rs index b0f65440..a4ca500a 100644 --- a/src/mir/loop_canonicalizer/mod.rs +++ b/src/mir/loop_canonicalizer/mod.rs @@ -326,14 +326,116 @@ impl std::fmt::Display for CarrierRole { } // ============================================================================ -// Phase 2: Canonicalization Entry Point +// Phase 3: Pattern Recognition Helpers // ============================================================================ -/// Canonicalize a loop AST into LoopSkeleton (Phase 2: Minimal Implementation) +/// Try to extract skip_whitespace pattern from loop +/// +/// Pattern structure: +/// ``` +/// loop(cond) { +/// // ... optional body statements (Body) +/// if check_cond { +/// carrier = carrier + const +/// } else { +/// break +/// } +/// } +/// ``` +/// +/// Returns (carrier_name, delta, body_stmts) if pattern matches. +fn try_extract_skip_whitespace_pattern( + body: &[ASTNode], +) -> Option<(String, i64, Vec)> { + if body.is_empty() { + return None; + } + + // Last statement must be if-else with break + let last_stmt = &body[body.len() - 1]; + + let (then_body, else_body) = match last_stmt { + ASTNode::If { + then_body, + else_body: Some(else_body), + .. + } => (then_body, else_body), + _ => return None, + }; + + // Then branch must be single assignment: carrier = carrier + const + if then_body.len() != 1 { + return None; + } + + let (carrier_name, delta) = match &then_body[0] { + ASTNode::Assignment { target, value, .. } => { + // Extract target variable name + let target_name = match target.as_ref() { + ASTNode::Variable { name, .. } => name.clone(), + _ => return None, + }; + + // Value must be: target + const + match value.as_ref() { + ASTNode::BinaryOp { + operator: crate::ast::BinaryOperator::Add, + left, + right, + .. + } => { + // Left must be same variable + let left_name = match left.as_ref() { + ASTNode::Variable { name, .. } => name, + _ => return None, + }; + + if left_name != &target_name { + return None; + } + + // Right must be integer literal + let delta = match right.as_ref() { + ASTNode::Literal { + value: crate::ast::LiteralValue::Integer(n), + .. + } => *n, + _ => return None, + }; + + (target_name, delta) + } + _ => return None, + } + } + _ => return None, + }; + + // Else branch must be single break + if else_body.len() != 1 { + return None; + } + + match &else_body[0] { + ASTNode::Break { .. } => { + // Success! Extract body statements (all except last if) + let body_stmts = body[..body.len() - 1].to_vec(); + Some((carrier_name, delta, body_stmts)) + } + _ => None, + } +} + +// ============================================================================ +// Phase 3: Canonicalization Entry Point +// ============================================================================ + +/// Canonicalize a loop AST into LoopSkeleton (Phase 3: skip_whitespace pattern recognition) /// /// Currently supports only the skip_whitespace pattern: /// ``` /// loop(cond) { +/// // ... optional body statements /// if check_cond { /// carrier = carrier + step /// } else { @@ -363,66 +465,55 @@ pub fn canonicalize_loop_expr( _ => return Err(format!("Expected Loop node, got: {:?}", loop_expr)), }; - // Phase 2: Minimal implementation - detect skip_whitespace pattern only - // Pattern: loop(cond) { if check { update } else { break } } + // Phase 3: Try to extract skip_whitespace pattern + if let Some((carrier_name, delta, body_stmts)) = try_extract_skip_whitespace_pattern(body) { + // Build skeleton for skip_whitespace pattern + let mut skeleton = LoopSkeleton::new(span); - // Check for minimal pattern: single if-else with break - if body.len() != 1 { - return Ok(( - LoopSkeleton::new(span), - RoutingDecision::fail_fast( - vec![capability_tags::CAP_MISSING_SINGLE_BREAK], - format!("Phase 2: Only single-statement loops supported (got {} statements)", body.len()), - ), - )); + // Step 1: Header condition + skeleton.steps.push(SkeletonStep::HeaderCond { + expr: Box::new(condition.clone()), + }); + + // Step 2: Body statements (if any) + if !body_stmts.is_empty() { + skeleton.steps.push(SkeletonStep::Body { + stmts: body_stmts, + }); + } + + // Step 3: Update step + skeleton.steps.push(SkeletonStep::Update { + carrier_name: carrier_name.clone(), + update_kind: UpdateKind::ConstStep { delta }, + }); + + // Add carrier slot + skeleton.carriers.push(CarrierSlot { + name: carrier_name, + role: CarrierRole::Counter, + update_kind: UpdateKind::ConstStep { delta }, + }); + + // Set exit contract + skeleton.exits = ExitContract { + has_break: true, + has_continue: false, + has_return: false, + break_has_value: false, + }; + + // Success! Return Pattern3WithIfPhi + let decision = RoutingDecision::success(LoopPatternKind::Pattern3IfPhi); + return Ok((skeleton, decision)); } - // Check if it's an if-else statement - let _if_stmt = match &body[0] { - ASTNode::If { - condition: _if_cond, - then_body: _then_body, - else_body, - .. - } => { - // Must have else branch - if else_body.is_none() { - return Ok(( - LoopSkeleton::new(span), - RoutingDecision::fail_fast( - vec![capability_tags::CAP_MISSING_SINGLE_BREAK], - "Phase 2: If statement must have else branch".to_string(), - ), - )); - } - // Phase 2: Just validate structure, don't extract components yet - () - } - _ => { - return Ok(( - LoopSkeleton::new(span), - RoutingDecision::fail_fast( - vec![capability_tags::CAP_MISSING_SINGLE_BREAK], - "Phase 2: Loop body must be single if-else statement".to_string(), - ), - )); - } - }; - - // Build minimal skeleton - let mut skeleton = LoopSkeleton::new(span); - - // Add header condition - skeleton.steps.push(SkeletonStep::HeaderCond { - expr: Box::new(condition.clone()), - }); - - // For now, just mark as unsupported - full pattern detection will come in Phase 3 + // Pattern not recognized - fail fast Ok(( - skeleton, + LoopSkeleton::new(span), RoutingDecision::fail_fast( vec![capability_tags::CAP_MISSING_CONST_STEP], - "Phase 2: Pattern detection not yet implemented".to_string(), + "Phase 3: Loop does not match skip_whitespace pattern".to_string(), ), )) } @@ -534,22 +625,60 @@ mod tests { assert!(result.unwrap_err().contains("Expected Loop node")); } - #[test] - fn test_canonicalize_minimal_loop_structure() { - use crate::ast::LiteralValue; + // ============================================================================ + // Phase 3: skip_whitespace Pattern Tests + // ============================================================================ - // Build minimal loop: loop(true) { if true { } else { break } } + #[test] + fn test_skip_whitespace_pattern_recognition() { + use crate::ast::{BinaryOperator, LiteralValue}; + + // Build skip_whitespace pattern: loop(p < len) { if is_ws == 1 { p = p + 1 } else { break } } let loop_node = ASTNode::Loop { - condition: Box::new(ASTNode::Literal { - value: LiteralValue::Bool(true), + condition: Box::new(ASTNode::BinaryOp { + operator: BinaryOperator::Less, + left: Box::new(ASTNode::Variable { + name: "p".to_string(), + span: Span::unknown(), + }), + right: Box::new(ASTNode::Variable { + name: "len".to_string(), + span: Span::unknown(), + }), span: Span::unknown(), }), body: vec![ASTNode::If { - condition: Box::new(ASTNode::Literal { - value: LiteralValue::Bool(true), + condition: Box::new(ASTNode::BinaryOp { + operator: BinaryOperator::Equal, + left: Box::new(ASTNode::Variable { + name: "is_ws".to_string(), + span: Span::unknown(), + }), + right: Box::new(ASTNode::Literal { + value: LiteralValue::Integer(1), + span: Span::unknown(), + }), span: Span::unknown(), }), - then_body: vec![], + then_body: vec![ASTNode::Assignment { + target: Box::new(ASTNode::Variable { + name: "p".to_string(), + span: Span::unknown(), + }), + value: Box::new(ASTNode::BinaryOp { + operator: BinaryOperator::Add, + left: Box::new(ASTNode::Variable { + name: "p".to_string(), + span: Span::unknown(), + }), + right: Box::new(ASTNode::Literal { + value: LiteralValue::Integer(1), + span: Span::unknown(), + }), + span: Span::unknown(), + }), + span: Span::unknown(), + }], else_body: Some(vec![ASTNode::Break { span: Span::unknown(), }]), @@ -562,37 +691,106 @@ mod tests { assert!(result.is_ok()); let (skeleton, decision) = result.unwrap(); - // Should have header condition step - assert_eq!(skeleton.steps.len(), 1); + + // Verify success + assert!(decision.is_success()); + assert_eq!(decision.chosen, Some(LoopPatternKind::Pattern3IfPhi)); + assert_eq!(decision.missing_caps.len(), 0); + + // Verify skeleton structure + assert_eq!(skeleton.steps.len(), 2); // HeaderCond + Update assert!(matches!( skeleton.steps[0], SkeletonStep::HeaderCond { .. } )); + assert!(matches!( + skeleton.steps[1], + SkeletonStep::Update { .. } + )); - // Phase 2: Should fail-fast (pattern detection not implemented) - assert!(decision.is_fail_fast()); - assert!(decision.notes[0].contains("Pattern detection not yet implemented")); + // Verify carrier + assert_eq!(skeleton.carriers.len(), 1); + assert_eq!(skeleton.carriers[0].name, "p"); + assert_eq!(skeleton.carriers[0].role, CarrierRole::Counter); + match &skeleton.carriers[0].update_kind { + UpdateKind::ConstStep { delta } => assert_eq!(*delta, 1), + _ => panic!("Expected ConstStep update"), + } + + // Verify exit contract + assert!(skeleton.exits.has_break); + assert!(!skeleton.exits.has_continue); + assert!(!skeleton.exits.has_return); + assert!(!skeleton.exits.break_has_value); } #[test] - fn test_canonicalize_rejects_multi_statement_loop() { - use crate::ast::LiteralValue; + fn test_skip_whitespace_with_body_statements() { + use crate::ast::{BinaryOperator, LiteralValue}; - // Build loop with 2 statements + // Build pattern with body statements before the if: + // loop(p < len) { + // local ch = get_char(p) + // if is_ws { p = p + 1 } else { break } + // } let loop_node = ASTNode::Loop { - condition: Box::new(ASTNode::Literal { - value: LiteralValue::Bool(true), + condition: Box::new(ASTNode::BinaryOp { + operator: BinaryOperator::Less, + left: Box::new(ASTNode::Variable { + name: "p".to_string(), + span: Span::unknown(), + }), + right: Box::new(ASTNode::Variable { + name: "len".to_string(), + span: Span::unknown(), + }), span: Span::unknown(), }), body: vec![ - ASTNode::Print { - expression: Box::new(ASTNode::Literal { - value: LiteralValue::String("test".to_string()), + // Body statement + ASTNode::Assignment { + target: Box::new(ASTNode::Variable { + name: "ch".to_string(), + span: Span::unknown(), + }), + value: Box::new(ASTNode::FunctionCall { + name: "get_char".to_string(), + arguments: vec![ASTNode::Variable { + name: "p".to_string(), + span: Span::unknown(), + }], span: Span::unknown(), }), span: Span::unknown(), }, - ASTNode::Break { + // The if-else pattern + ASTNode::If { + condition: Box::new(ASTNode::Variable { + name: "is_ws".to_string(), + span: Span::unknown(), + }), + then_body: vec![ASTNode::Assignment { + target: Box::new(ASTNode::Variable { + name: "p".to_string(), + span: Span::unknown(), + }), + value: Box::new(ASTNode::BinaryOp { + operator: BinaryOperator::Add, + left: Box::new(ASTNode::Variable { + name: "p".to_string(), + span: Span::unknown(), + }), + right: Box::new(ASTNode::Literal { + value: LiteralValue::Integer(1), + span: Span::unknown(), + }), + span: Span::unknown(), + }), + span: Span::unknown(), + }], + else_body: Some(vec![ASTNode::Break { + span: Span::unknown(), + }]), span: Span::unknown(), }, ], @@ -602,16 +800,36 @@ mod tests { let result = canonicalize_loop_expr(&loop_node); assert!(result.is_ok()); - let (_, decision) = result.unwrap(); - assert!(decision.is_fail_fast()); - assert!(decision.notes[0].contains("Only single-statement loops supported")); + let (skeleton, decision) = result.unwrap(); + + // Verify success + assert!(decision.is_success()); + assert_eq!(decision.chosen, Some(LoopPatternKind::Pattern3IfPhi)); + + // Verify skeleton has Body step + assert_eq!(skeleton.steps.len(), 3); // HeaderCond + Body + Update + assert!(matches!( + skeleton.steps[0], + SkeletonStep::HeaderCond { .. } + )); + assert!(matches!(skeleton.steps[1], SkeletonStep::Body { .. })); + assert!(matches!( + skeleton.steps[2], + SkeletonStep::Update { .. } + )); + + // Verify body contains 1 statement + match &skeleton.steps[1] { + SkeletonStep::Body { stmts } => assert_eq!(stmts.len(), 1), + _ => panic!("Expected Body step"), + } } #[test] - fn test_canonicalize_rejects_if_without_else() { - use crate::ast::LiteralValue; + fn test_skip_whitespace_fails_without_else() { + use crate::ast::{BinaryOperator, LiteralValue}; - // Build loop with if (no else) + // Build pattern without else branch (should fail) let loop_node = ASTNode::Loop { condition: Box::new(ASTNode::Literal { value: LiteralValue::Bool(true), @@ -622,7 +840,25 @@ mod tests { value: LiteralValue::Bool(true), span: Span::unknown(), }), - then_body: vec![], + then_body: vec![ASTNode::Assignment { + target: Box::new(ASTNode::Variable { + name: "p".to_string(), + span: Span::unknown(), + }), + value: Box::new(ASTNode::BinaryOp { + operator: BinaryOperator::Add, + left: Box::new(ASTNode::Variable { + name: "p".to_string(), + span: Span::unknown(), + }), + right: Box::new(ASTNode::Literal { + value: LiteralValue::Integer(1), + span: Span::unknown(), + }), + span: Span::unknown(), + }), + span: Span::unknown(), + }], else_body: None, // No else branch span: Span::unknown(), }], @@ -634,6 +870,58 @@ mod tests { let (_, decision) = result.unwrap(); assert!(decision.is_fail_fast()); - assert!(decision.notes[0].contains("must have else branch")); + assert!(decision + .notes[0] + .contains("does not match skip_whitespace pattern")); } + + #[test] + fn test_skip_whitespace_fails_with_wrong_delta() { + use crate::ast::{BinaryOperator, LiteralValue}; + + // Build pattern with wrong update (p = p - 1 instead of p = p + 1) + let loop_node = ASTNode::Loop { + condition: Box::new(ASTNode::Literal { + value: LiteralValue::Bool(true), + span: Span::unknown(), + }), + body: vec![ASTNode::If { + condition: Box::new(ASTNode::Literal { + value: LiteralValue::Bool(true), + span: Span::unknown(), + }), + then_body: vec![ASTNode::Assignment { + target: Box::new(ASTNode::Variable { + name: "p".to_string(), + span: Span::unknown(), + }), + value: Box::new(ASTNode::BinaryOp { + operator: BinaryOperator::Subtract, // Wrong operator + left: Box::new(ASTNode::Variable { + name: "p".to_string(), + span: Span::unknown(), + }), + right: Box::new(ASTNode::Literal { + value: LiteralValue::Integer(1), + span: Span::unknown(), + }), + span: Span::unknown(), + }), + span: Span::unknown(), + }], + else_body: Some(vec![ASTNode::Break { + span: Span::unknown(), + }]), + span: Span::unknown(), + }], + span: Span::unknown(), + }; + + let result = canonicalize_loop_expr(&loop_node); + assert!(result.is_ok()); + + let (_, decision) = result.unwrap(); + assert!(decision.is_fail_fast()); + } + }