test(joinir): Phase 104 read_digits loop(true) parity

This commit is contained in:
nyash-codex
2025-12-17 18:29:27 +09:00
parent ce501113a7
commit 950560a3d9
20 changed files with 954 additions and 30 deletions

View File

@ -19,6 +19,9 @@ pub(in crate::mir::builder) mod trace;
// Phase 140-P4-A: Re-export for loop_canonicalizer SSOT (crate-wide visibility)
pub(crate) use patterns::{detect_skip_whitespace_pattern, SkipWhitespaceInfo};
// Phase 104: Re-export read_digits(loop(true)) detection for loop_canonicalizer
pub(crate) use patterns::{detect_read_digits_loop_true_pattern, ReadDigitsLoopTrueInfo};
// Phase 142-P1: Re-export continue pattern detection for loop_canonicalizer
pub(crate) use patterns::{detect_continue_pattern, ContinuePatternInfo};

View File

@ -1043,6 +1043,115 @@ pub fn detect_skip_whitespace_pattern(body: &[ASTNode]) -> Option<SkipWhitespace
}
}
// ============================================================================
// Phase 104: loop(true) + break-only digits (read_digits_from)
// ============================================================================
/// loop(true) + break-only digits pattern information
#[derive(Debug, Clone, PartialEq)]
pub struct ReadDigitsLoopTrueInfo {
/// Counter variable name (e.g., "i")
pub carrier_name: String,
/// Constant step increment (currently only supports +1)
pub delta: i64,
/// Body statements before the digit-check if (may include `ch = substring(...)`, `if ch==\"\" { break }`, etc.)
pub body_stmts: Vec<ASTNode>,
}
/// Detect read_digits_from-like pattern in loop body (loop(true) expected at callsite)
///
/// Recognized minimal shape (JsonCursorBox/MiniJsonLoader):
/// ```text
/// loop(true) {
/// local ch = s.substring(i, i+1)
/// if ch == "" { break }
/// if is_digit(ch) { out = out + ch; i = i + 1 } else { break }
/// }
/// ```
///
/// Contract (Phase 104 minimal):
/// - Last statement is `if ... { ... } else { break }`
/// - Then branch contains an update `i = i + 1`
/// - Then branch may contain other updates (e.g., `out = out + ch`)
pub fn detect_read_digits_loop_true_pattern(body: &[ASTNode]) -> Option<ReadDigitsLoopTrueInfo> {
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,
};
// Else branch must be single break
if else_body.len() != 1 || !matches!(else_body[0], ASTNode::Break { .. }) {
return None;
}
// Then branch must include `i = i + 1` (allow other statements too)
let mut carrier_name: Option<String> = None;
let mut delta: Option<i64> = None;
for stmt in then_body {
let (name, d) = match stmt {
ASTNode::Assignment { target, value, .. } => {
let target_name = match target.as_ref() {
ASTNode::Variable { name, .. } => name.clone(),
_ => continue,
};
match value.as_ref() {
ASTNode::BinaryOp {
operator: BinaryOperator::Add,
left,
right,
..
} => {
let left_name = match left.as_ref() {
ASTNode::Variable { name, .. } => name,
_ => continue,
};
if left_name != &target_name {
continue;
}
let const_val = match right.as_ref() {
ASTNode::Literal {
value: LiteralValue::Integer(n),
..
} => *n,
_ => continue,
};
(target_name, const_val)
}
_ => continue,
}
}
_ => continue,
};
// Phase 104 minimal: only accept +1 step
if d == 1 {
carrier_name = Some(name);
delta = Some(1);
break;
}
}
let carrier_name = carrier_name?;
let delta = delta?;
let body_stmts = body[..body.len() - 1].to_vec();
Some(ReadDigitsLoopTrueInfo {
carrier_name,
delta,
body_stmts,
})
}
// ============================================================================
// Phase 91 P5b (Escape Sequence Handling) Pattern
// ============================================================================

View File

@ -0,0 +1,211 @@
//! LoopTrueCounterExtractorBox - loop(true) からの loop counter 抽出Pattern2専用
//!
//! Phase 104: read_digits_from 形loop(true) + break-onlyを Pattern2 で扱うため、
//! `condition` 側に loop var が無いケースで body から loop counter例: iを一意に確定する。
//!
//! SSOT/Fail-Fast:
//! - 目的は「曖昧な loop(true) を通さない」こと。
//! - 1変数・+1 だけを許可し、取りこぼしは理由付き Err にする。
use crate::ast::{ASTNode, BinaryOperator, LiteralValue};
use crate::mir::ValueId;
use std::collections::BTreeMap;
pub(crate) struct LoopTrueCounterExtractorBox;
impl LoopTrueCounterExtractorBox {
pub(crate) fn is_loop_true(condition: &ASTNode) -> bool {
matches!(
condition,
ASTNode::Literal {
value: LiteralValue::Bool(true),
..
}
)
}
/// Extract a unique loop counter variable from loop(true) body.
///
/// Current supported shape (Phase 104 minimal):
/// - There exists an assignment `i = i + 1` somewhere in the body (including nested if).
/// - There exists a substring read using that counter: `s.substring(i, i + 1)` (same `i`).
///
/// Fail-Fast (returns Err) when:
/// - No counter candidate found
/// - Multiple different candidates found
/// - Candidate not found in `variable_map` (loop-outer var required)
/// - Substring pattern not found (guards against accidental matches)
pub(crate) fn extract_loop_counter_from_body(
body: &[ASTNode],
variable_map: &BTreeMap<String, ValueId>,
) -> Result<(String, ValueId), String> {
let mut candidates: Vec<String> = Vec::new();
fn walk(node: &ASTNode, out: &mut Vec<String>) {
match node {
ASTNode::Assignment { target, value, .. } => {
if let (Some(name), true) = (
extract_var_name(target.as_ref()),
is_self_plus_const_one(value.as_ref(), target.as_ref()),
) {
out.push(name);
}
}
ASTNode::If {
condition: _,
then_body,
else_body,
..
} => {
for s in then_body {
walk(s, out);
}
if let Some(eb) = else_body {
for s in eb {
walk(s, out);
}
}
}
ASTNode::Loop { body, .. } => {
for s in body {
walk(s, out);
}
}
_ => {}
}
}
fn extract_var_name(n: &ASTNode) -> Option<String> {
match n {
ASTNode::Variable { name, .. } => Some(name.clone()),
_ => None,
}
}
fn is_self_plus_const_one(value: &ASTNode, target: &ASTNode) -> bool {
let target_name = match extract_var_name(target) {
Some(n) => n,
None => return false,
};
match value {
ASTNode::BinaryOp {
operator: BinaryOperator::Add,
left,
right,
..
} => {
let left_is_var = matches!(left.as_ref(), ASTNode::Variable { name, .. } if name == &target_name);
let right_is_one = matches!(
right.as_ref(),
ASTNode::Literal {
value: LiteralValue::Integer(1),
..
}
);
left_is_var && right_is_one
}
_ => false,
}
}
for stmt in body {
walk(stmt, &mut candidates);
}
candidates.sort();
candidates.dedup();
let loop_var_name = match candidates.len() {
0 => {
return Err(
"[phase104/loop-true-counter] Cannot find unique counter update `i = i + 1` in loop(true) body"
.to_string(),
);
}
1 => candidates[0].clone(),
_ => {
return Err(format!(
"[phase104/loop-true-counter] Multiple counter candidates found in loop(true) body: {:?}",
candidates
));
}
};
let host_id = variable_map.get(&loop_var_name).copied().ok_or_else(|| {
format!(
"[phase104/loop-true-counter] Counter '{}' not found in variable_map (loop-outer var required)",
loop_var_name
)
})?;
if !has_substring_read(body, &loop_var_name) {
return Err(format!(
"[phase104/loop-true-counter] Counter '{}' found, but missing substring pattern `s.substring({}, {} + 1)`",
loop_var_name, loop_var_name, loop_var_name
));
}
Ok((loop_var_name, host_id))
}
}
fn has_substring_read(body: &[ASTNode], counter: &str) -> bool {
fn walk(node: &ASTNode, counter: &str) -> bool {
match node {
ASTNode::Assignment { value, .. } => walk(value.as_ref(), counter),
ASTNode::Local {
initial_values, ..
} => initial_values
.iter()
.filter_map(|v| v.as_ref())
.any(|v| walk(v.as_ref(), counter)),
ASTNode::MethodCall {
object: _,
method,
arguments,
..
} => {
if method == "substring" && arguments.len() == 2 {
let a0 = &arguments[0];
let a1 = &arguments[1];
let a0_ok = matches!(a0, ASTNode::Variable { name, .. } if name == counter);
let a1_ok = matches!(
a1,
ASTNode::BinaryOp {
operator: BinaryOperator::Add,
left,
right,
..
} if matches!(left.as_ref(), ASTNode::Variable { name, .. } if name == counter)
&& matches!(right.as_ref(), ASTNode::Literal { value: LiteralValue::Integer(1), .. })
);
if a0_ok && a1_ok {
return true;
}
}
// Search recursively in args
arguments.iter().any(|a| walk(a, counter))
}
ASTNode::BinaryOp { left, right, .. } => walk(left.as_ref(), counter) || walk(right.as_ref(), counter),
ASTNode::If {
condition,
then_body,
else_body,
..
} => {
walk(condition.as_ref(), counter)
|| then_body.iter().any(|s| walk(s, counter))
|| else_body
.as_ref()
.map(|eb| eb.iter().any(|s| walk(s, counter)))
.unwrap_or(false)
}
ASTNode::Loop { body, condition, .. } => {
walk(condition.as_ref(), counter) || body.iter().any(|s| walk(s, counter))
}
_ => false,
}
}
body.iter().any(|s| walk(s, counter))
}

View File

@ -54,6 +54,8 @@ pub(in crate::mir::builder) mod policies; // Phase 93/94: Pattern routing polici
pub(in crate::mir::builder) mod body_local_policy; // Phase 92 P3: promotion vs slot routing
pub(in crate::mir::builder) mod escape_pattern_recognizer; // Phase 91 P5b
pub(in crate::mir::builder) mod common_init;
pub(in crate::mir::builder) mod loop_true_counter_extractor; // Phase 104: loop(true) counter extraction for Pattern2
pub(in crate::mir::builder) mod read_digits_break_condition_box; // Phase 104: break cond normalization for read_digits(loop(true))
pub(in crate::mir::builder) mod condition_env_builder;
pub(in crate::mir::builder) mod conversion_pipeline;
pub(in crate::mir::builder) mod exit_binding;
@ -79,6 +81,9 @@ pub(in crate::mir::builder) use router::{route_loop_pattern, LoopPatternContext}
// Phase 140-P4-A: Re-export for loop_canonicalizer SSOT (crate-wide visibility)
pub(crate) use ast_feature_extractor::{detect_skip_whitespace_pattern, SkipWhitespaceInfo};
// Phase 104: Re-export read_digits(loop(true)) detection for loop_canonicalizer
pub(crate) use ast_feature_extractor::{detect_read_digits_loop_true_pattern, ReadDigitsLoopTrueInfo};
// Phase 142-P1: Re-export continue pattern detection for loop_canonicalizer
pub(crate) use ast_feature_extractor::{detect_continue_pattern, ContinuePatternInfo};

View File

@ -496,16 +496,67 @@ fn prepare_pattern2_inputs(
}
}
// Break condition extraction
// Break condition extraction (SSOT + Phase 104 extension)
//
// Use the analyzer SSOT to produce "break when <cond> is true" as an owned AST node:
// SSOT (BreakConditionAnalyzer):
// - `if cond { break }` -> `cond`
// - `if cond { ... } else { break }` -> `!cond`
use crate::mir::loop_pattern_detection::break_condition_analyzer::BreakConditionAnalyzer;
let break_condition_node = BreakConditionAnalyzer::extract_break_condition_node(body)
.map_err(|_| "[cf_loop/pattern2] Failed to extract break condition from loop body".to_string())?;
//
// Phase 104 (read_digits loop(true)):
// - multiple breaks exist; normalize as:
// `break_when_true := (ch == \"\") || !(is_digit(ch))`
let (break_condition_node, phase104_recipe_and_allowed) = {
use super::loop_true_counter_extractor::LoopTrueCounterExtractorBox;
use crate::ast::{BinaryOperator, Span, UnaryOperator};
use crate::mir::join_ir::lowering::common::condition_only_emitter::{BreakSemantics, ConditionOnlyRecipe};
use crate::mir::loop_pattern_detection::break_condition_analyzer::BreakConditionAnalyzer;
Ok(Pattern2Inputs {
if LoopTrueCounterExtractorBox::is_loop_true(condition)
&& super::ast_feature_extractor::detect_read_digits_loop_true_pattern(body).is_some()
{
let (ch_var, eos_cond, digit_literals) =
super::read_digits_break_condition_box::ReadDigitsBreakConditionBox::extract_eos_and_digit_set(body)
.map_err(|e| format!("[cf_loop/pattern2] {}", e))?;
// ConditionOnly derived slot: "is ch a digit?" (computed each iteration from body-local `ch`)
let is_digit_name = "__phase104_is_digit".to_string();
let recipe = ConditionOnlyRecipe {
name: is_digit_name.clone(),
original_var: ch_var.clone(),
whitespace_chars: digit_literals,
break_semantics: BreakSemantics::WhenMatch,
};
let break_on_not_digit = ASTNode::UnaryOp {
operator: UnaryOperator::Not,
operand: Box::new(ASTNode::Variable {
name: is_digit_name.clone(),
span: Span::unknown(),
}),
span: Span::unknown(),
};
let break_when_true = ASTNode::BinaryOp {
operator: BinaryOperator::Or,
left: Box::new(eos_cond),
right: Box::new(break_on_not_digit),
span: Span::unknown(),
};
(
break_when_true,
Some((ch_var, is_digit_name, recipe)),
)
} else {
(
BreakConditionAnalyzer::extract_break_condition_node(body)
.map_err(|_| "[cf_loop/pattern2] Failed to extract break condition from loop body".to_string())?,
None,
)
}
};
let mut inputs = Pattern2Inputs {
loop_var_name,
loop_var_id,
carrier_info,
@ -520,7 +571,20 @@ fn prepare_pattern2_inputs(
break_condition_node,
condition_only_recipe: None, // Phase 93 P0: Will be set by apply_trim_and_normalize
body_local_derived_recipe: None, // Phase 94: Will be set after normalization
})
};
// Phase 104: read_digits(loop(true)) wires derived is-digit slot + allow-list in one place.
if let Some((ch_var, is_digit_name, recipe)) = phase104_recipe_and_allowed {
use crate::mir::join_ir::lowering::common::body_local_slot::ReadOnlyBodyLocalSlotBox;
inputs.condition_only_recipe = Some(recipe);
inputs.allowed_body_locals_for_conditions = vec![ch_var.clone(), is_digit_name];
inputs.read_only_body_local_slot = Some(ReadOnlyBodyLocalSlotBox::extract_single(
&[ch_var],
body,
)?);
}
Ok(inputs)
}
fn promote_and_prepare_carriers(
@ -533,6 +597,7 @@ fn promote_and_prepare_carriers(
) -> Result<(), String> {
use crate::mir::join_ir::lowering::digitpos_condition_normalizer::DigitPosConditionNormalizer;
use crate::mir::loop_pattern_detection::loop_condition_scope::LoopConditionScopeBox;
use super::loop_true_counter_extractor::LoopTrueCounterExtractorBox;
let cond_scope = LoopConditionScopeBox::analyze(
&inputs.loop_var_name,
@ -550,6 +615,11 @@ fn promote_and_prepare_carriers(
.collect();
if cond_scope.has_loop_body_local() {
// Phase 104: read_digits(loop(true)) pre-wires slot/allow-list/recipe in prepare_pattern2_inputs.
// Do not run promotion heuristics here.
if !(LoopTrueCounterExtractorBox::is_loop_true(condition)
&& super::ast_feature_extractor::detect_read_digits_loop_true_pattern(body).is_some())
{
match classify_for_pattern2(
builder,
&inputs.loop_var_name,
@ -682,6 +752,7 @@ fn promote_and_prepare_carriers(
}
PolicyDecision::None => {}
}
}
}
log.log(
@ -798,8 +869,13 @@ fn apply_trim_and_normalize(
let log = Pattern2DebugLog::new(verbose);
let mut alloc_join_value = || inputs.join_value_space.alloc_param();
let effective_break_condition = if let Some(trim_result) =
super::trim_loop_lowering::TrimLoopLowerer::try_lower_trim_like_loop(
// Phase 104: read_digits(loop(true)) uses a digit-guard OR-chain which resembles
// "trim-like" patterns; do not route through TrimLoopLowerer.
let is_phase104_read_digits = super::loop_true_counter_extractor::LoopTrueCounterExtractorBox::is_loop_true(condition)
&& super::ast_feature_extractor::detect_read_digits_loop_true_pattern(body).is_some();
let effective_break_condition = if !is_phase104_read_digits {
if let Some(trim_result) = super::trim_loop_lowering::TrimLoopLowerer::try_lower_trim_like_loop(
builder,
&inputs.scope,
condition,
@ -834,6 +910,9 @@ fn apply_trim_and_normalize(
),
);
trim_result.condition
} else {
inputs.break_condition_node.clone()
}
} else {
inputs.break_condition_node.clone()
};
@ -926,7 +1005,9 @@ fn collect_body_local_variables(
/// - ConditionalStep updates are supported (if skeleton provides them)
pub(crate) fn can_lower(builder: &MirBuilder, ctx: &super::router::LoopPatternContext) -> bool {
use super::common_init::CommonPatternInitializer;
use super::loop_true_counter_extractor::LoopTrueCounterExtractorBox;
use crate::mir::loop_pattern_detection::LoopPatternKind;
let trace_enabled = trace::trace().is_joinir_enabled() || crate::config::env::joinir_dev_enabled();
// Basic pattern check
if ctx.pattern_kind != LoopPatternKind::Pattern2Break {
@ -971,17 +1052,44 @@ pub(crate) fn can_lower(builder: &MirBuilder, ctx: &super::router::LoopPatternCo
}
// Phase 188/Refactor: Use common carrier update validation
// Extracts loop variable for dummy carrier creation (not used but required by API)
let loop_var_name = match builder.extract_loop_variable_from_condition(ctx.condition) {
Ok(name) => name,
Err(_) => return false,
// Phase 104: Support loop(true) by extracting the counter from the body.
let loop_var_name = if LoopTrueCounterExtractorBox::is_loop_true(ctx.condition) {
match LoopTrueCounterExtractorBox::extract_loop_counter_from_body(
ctx.body,
&builder.variable_ctx.variable_map,
) {
Ok((name, _host_id)) => name,
Err(e) => {
if trace_enabled {
trace::trace().debug("pattern2/can_lower", &format!("reject loop(true): {}", e));
}
return false;
}
}
} else {
match builder.extract_loop_variable_from_condition(ctx.condition) {
Ok(name) => name,
Err(e) => {
if trace_enabled {
trace::trace().debug("pattern2/can_lower", &format!("reject loop(cond): {}", e));
}
return false;
}
}
};
CommonPatternInitializer::check_carrier_updates_allowed(
let ok = CommonPatternInitializer::check_carrier_updates_allowed(
ctx.body,
&loop_var_name,
&builder.variable_ctx.variable_map,
)
);
if !ok && trace_enabled {
trace::trace().debug(
"pattern2/can_lower",
"reject: carrier updates contain complex RHS (UpdateRhs::Other)",
);
}
ok
}
/// Phase 194: Lowering function for Pattern 2

View File

@ -45,6 +45,7 @@ use crate::mir::ValueId;
use std::collections::{BTreeMap, BTreeSet}; // Phase 222.5-D: HashMap → BTreeMap for determinism
use super::common_init::CommonPatternInitializer;
use super::loop_true_counter_extractor::LoopTrueCounterExtractorBox;
use super::loop_scope_shape_builder::LoopScopeShapeBuilder;
/// Phase 179-B: Unified Pattern Pipeline Context
@ -260,12 +261,26 @@ pub(crate) fn build_pattern_context(
variant: PatternVariant,
) -> Result<PatternPipelineContext, String> {
// Step 1: Common initialization (all patterns)
let (loop_var_name, loop_var_id, carrier_info) = CommonPatternInitializer::initialize_pattern(
builder,
condition,
&builder.variable_ctx.variable_map,
None, // No exclusions for now (Pattern 2/4 will filter carriers later)
)?;
//
// Phase 104: Pattern2 now supports `loop(true)` by extracting the counter from the body.
// This path must be strict and conservative to avoid "accidental" routing.
let (loop_var_name, loop_var_id, carrier_info) = if variant == PatternVariant::Pattern2
&& LoopTrueCounterExtractorBox::is_loop_true(condition)
{
let (name, host_id) = LoopTrueCounterExtractorBox::extract_loop_counter_from_body(
body,
&builder.variable_ctx.variable_map,
)?;
let carrier_info = CarrierInfo::from_variable_map(name.clone(), &builder.variable_ctx.variable_map)?;
(name, host_id, carrier_info)
} else {
CommonPatternInitializer::initialize_pattern(
builder,
condition,
&builder.variable_ctx.variable_map,
None, // No exclusions for now (Pattern 2/4 will filter carriers later)
)?
};
// Step 2: Build LoopScopeShape
let loop_scope = match variant {

View File

@ -0,0 +1,179 @@
//! ReadDigitsBreakConditionBox (Phase 104)
//!
//! Responsibility (analysis only):
//! - For `loop(true)` read_digits_from shape, extract:
//! - the EOS break condition (`if ch == "" { break }`)
//! - the digit literal set used in the final `if <is_digit> { ... } else { break }`
use crate::ast::{ASTNode, BinaryOperator, LiteralValue};
pub(crate) struct ReadDigitsBreakConditionBox;
impl ReadDigitsBreakConditionBox {
pub(crate) fn extract_eos_and_digit_set(
body: &[ASTNode],
) -> Result<(String, ASTNode, Vec<String>), String> {
if body.is_empty() {
return Err("[phase104/read-digits] empty loop body".to_string());
}
let (digit_cond, has_else_break) = match &body[body.len() - 1] {
ASTNode::If {
condition,
else_body: Some(else_body),
..
} => (
condition.as_ref().clone(),
else_body.len() == 1 && matches!(else_body[0], ASTNode::Break { .. }),
),
_ => return Err("[phase104/read-digits] last statement is not if-else".to_string()),
};
if !has_else_break {
return Err("[phase104/read-digits] last if does not have `else { break }`".to_string());
}
let (ch_var, eos_cond) = find_eos_break_condition(body).ok_or_else(|| {
"[phase104/read-digits] missing `if ch == \"\" { break }` guard".to_string()
})?;
let mut digit_literals: Vec<String> = Vec::new();
let mut digit_var_name: Option<String> = None;
collect_eq_string_literals(&digit_cond, &mut digit_literals, &mut digit_var_name)?;
let digit_var_name = digit_var_name.ok_or_else(|| {
"[phase104/read-digits] digit condition does not reference a variable".to_string()
})?;
if digit_var_name != ch_var {
return Err(format!(
"[phase104/read-digits] digit condition var '{}' != eos var '{}'",
digit_var_name, ch_var
));
}
digit_literals.sort();
digit_literals.dedup();
if digit_literals.is_empty() {
return Err("[phase104/read-digits] digit condition has no string literals".to_string());
}
// Phase 104 minimal: require the canonical digit set.
let expected: Vec<String> = (0..=9).map(|d| d.to_string()).collect();
if digit_literals != expected {
return Err(format!(
"[phase104/read-digits] digit condition literal set mismatch: got={:?}, expected={:?}",
digit_literals, expected
));
}
Ok((ch_var, eos_cond, digit_literals))
}
}
fn find_eos_break_condition(body: &[ASTNode]) -> Option<(String, ASTNode)> {
for stmt in body {
let (cond, then_body, else_body) = match stmt {
ASTNode::If {
condition,
then_body,
else_body,
..
} => (condition, then_body, else_body),
_ => continue,
};
if else_body.is_some() {
continue;
}
if then_body.len() != 1 || !matches!(then_body[0], ASTNode::Break { .. }) {
continue;
}
if let Some(var_name) = ch_eq_empty_var(cond.as_ref()) {
return Some((var_name, cond.as_ref().clone()));
}
}
None
}
fn ch_eq_empty_var(cond: &ASTNode) -> Option<String> {
match cond {
ASTNode::BinaryOp {
operator: BinaryOperator::Equal,
left,
right,
..
} => {
let name = match left.as_ref() {
ASTNode::Variable { name, .. } => name.clone(),
_ => return None,
};
match right.as_ref() {
ASTNode::Literal {
value: LiteralValue::String(s),
..
} if s.is_empty() => Some(name),
_ => None,
}
}
_ => None,
}
}
fn collect_eq_string_literals(
cond: &ASTNode,
out: &mut Vec<String>,
var_name: &mut Option<String>,
) -> Result<(), String> {
match cond {
ASTNode::BinaryOp {
operator: BinaryOperator::Or,
left,
right,
..
} => {
collect_eq_string_literals(left.as_ref(), out, var_name)?;
collect_eq_string_literals(right.as_ref(), out, var_name)?;
Ok(())
}
ASTNode::BinaryOp {
operator: BinaryOperator::Equal,
left,
right,
..
} => {
let (name, lit) = match (left.as_ref(), right.as_ref()) {
(ASTNode::Variable { name, .. }, ASTNode::Literal { value: LiteralValue::String(s), .. }) => {
(name.clone(), s.clone())
}
(ASTNode::Literal { value: LiteralValue::String(s), .. }, ASTNode::Variable { name, .. }) => {
(name.clone(), s.clone())
}
_ => {
return Err("[phase104/read-digits] digit condition must be OR of `ch == \"d\"`".to_string());
}
};
if lit.len() != 1 || !lit.chars().all(|c| c.is_ascii_digit()) {
return Err(format!(
"[phase104/read-digits] non-digit literal in digit condition: {:?}",
lit
));
}
match var_name.as_deref() {
None => *var_name = Some(name),
Some(prev) if prev == name => {}
Some(prev) => {
return Err(format!(
"[phase104/read-digits] mixed variable names in digit condition: '{}' vs '{}'",
prev, name
));
}
}
out.push(lit);
Ok(())
}
_ => Err("[phase104/read-digits] digit condition must be OR-chain of equality checks".to_string()),
}
}

View File

@ -57,6 +57,9 @@ pub(in crate::mir::builder) mod utils;
// Phase 140-P4-A: Re-export for loop_canonicalizer SSOT (crate-wide visibility)
pub(crate) use joinir::{detect_skip_whitespace_pattern, SkipWhitespaceInfo};
// Phase 104: Re-export read_digits(loop(true)) detection for loop_canonicalizer
pub(crate) use joinir::{detect_read_digits_loop_true_pattern, ReadDigitsLoopTrueInfo};
// Phase 142-P1: Re-export continue pattern detection for loop_canonicalizer
pub(crate) use joinir::{detect_continue_pattern, ContinuePatternInfo};