feat(joinir): detect mutable accumulator pattern (AST-only, spec minimal)

- Implement MutableAccumulatorAnalyzer in loop_pattern_detection/
- Detect shape: target = target + x (Add only, x ∈ {Var, Literal})
- Does NOT check read-only (delegated to ScopeManager)
- Multiple assignments → return None (not our pattern, let other code handle it)
- 6 unit tests covering OK/NG cases
- Error prefix: [joinir/mutable-acc-spec]

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
nyash-codex
2025-12-17 06:10:38 +09:00
parent b45035597d
commit 8a40b5700a
2 changed files with 421 additions and 0 deletions

View File

@ -746,4 +746,8 @@ pub mod binding_map_provider;
// Phase 100 P1-2: Pinned Local Analyzer
pub mod pinned_local_analyzer;
// Phase 100 P2-1: Mutable Accumulator Analyzer
pub mod mutable_accumulator_analyzer;
pub use binding_map_provider::BindingMapProvider;

View File

@ -0,0 +1,417 @@
//! Phase 100 P2-1: Mutable Accumulator Analyzer
//!
//! Pure AST box for detecting accumulator patterns (s = s + x form only).
//! This analyzer ONLY detects the shape from AST, without checking:
//! - Whether target is loop-outer or loop-local (delegated to ScopeManager)
//! - Whether RHS is read-only (delegated to ScopeManager)
//!
//! Minimal spec: `target = target + x` where x ∈ {Variable, Literal}
use crate::ast::{ASTNode, BinaryOperator};
/// RHS expression kind in accumulator pattern
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RhsExprKind {
/// Variable reference (e.g., "ch")
Var,
/// Literal value (e.g., "\"hello\"" or "42")
Literal,
}
/// Accumulator specification detected from AST
#[derive(Debug, Clone, PartialEq)]
pub struct MutableAccumulatorSpec {
/// Target variable name (e.g., "out")
pub target_name: String,
/// RHS expression kind
pub rhs_expr_kind: RhsExprKind,
/// RHS variable name or literal string representation
pub rhs_var_or_lit: String,
/// Binary operator (currently only Add supported)
pub op: BinaryOperator,
}
/// Analyzer for detecting accumulator patterns in loop body AST.
///
/// # Responsibility
///
/// Detects accumulator **shape** from AST only. Does NOT check:
/// - Whether target is loop-outer or loop-local
/// - Whether RHS is read-only
///
/// These checks are delegated to Pattern2 (ScopeManager).
pub struct MutableAccumulatorAnalyzer;
impl MutableAccumulatorAnalyzer {
/// Analyzes loop body AST to detect accumulator pattern.
///
/// # Arguments
///
/// * `loop_body` - AST nodes of the loop body
///
/// # Returns
///
/// * `Ok(Some(spec))` - Accumulator pattern detected
/// * `Ok(None)` - No accumulator pattern found
/// * `Err(String)` - Pattern violation (Fail-Fast)
///
/// # Fail-Fast Cases
///
/// - Multiple assignments to the same variable
/// - Assignment form not accumulator pattern (e.g., reversed operands)
/// - RHS contains function call (not supported)
/// - Operator not Add (other ops not supported yet)
pub fn analyze(loop_body: &[ASTNode]) -> Result<Option<MutableAccumulatorSpec>, String> {
// Collect all assignments in loop body
let assignments = collect_assignments(loop_body);
if assignments.is_empty() {
return Ok(None); // No assignments, no accumulator
}
// Check for multiple assignments to the same variable
// Note: We're looking for ONE accumulator variable with ONE assignment
// If there are multiple assignments to the same variable, it's not the simple
// accumulator pattern we support, so return None (not an error)
let mut assignment_counts = std::collections::BTreeMap::new();
for (target_name, _, _) in &assignments {
*assignment_counts.entry(target_name.clone()).or_insert(0) += 1;
}
// If any variable has multiple assignments, this is not the simple accumulator
// pattern we're looking for. Return None to allow other patterns to handle it.
for (_target_name, count) in &assignment_counts {
if *count > 1 {
return Ok(None); // Not our pattern, let other code handle it
}
}
// Try to find accumulator pattern: target = target + x
for (target_name, value_node, _span) in assignments {
// Check if value is BinaryOp
if let ASTNode::BinaryOp {
operator,
left,
right,
..
} = value_node
{
// Check operator is Add
if operator != &BinaryOperator::Add {
return Err(format!(
"[joinir/mutable-acc-spec] Operator '{}' not supported (only '+' allowed)",
operator
));
}
// Check left operand is Variable with same name as target
if let ASTNode::Variable { name, .. } = left.as_ref() {
if name == &target_name {
// Check RHS is Variable or Literal (no MethodCall)
match right.as_ref() {
ASTNode::Variable { name: rhs_name, .. } => {
return Ok(Some(MutableAccumulatorSpec {
target_name,
rhs_expr_kind: RhsExprKind::Var,
rhs_var_or_lit: rhs_name.clone(),
op: BinaryOperator::Add,
}));
}
ASTNode::Literal { value, .. } => {
return Ok(Some(MutableAccumulatorSpec {
target_name,
rhs_expr_kind: RhsExprKind::Literal,
rhs_var_or_lit: format!("{:?}", value),
op: BinaryOperator::Add,
}));
}
ASTNode::MethodCall { .. } | ASTNode::Call { .. } => {
return Err(
"[joinir/mutable-acc-spec] RHS contains function call (not supported)"
.to_string(),
);
}
_ => {
return Err(format!(
"[joinir/mutable-acc-spec] RHS expression type not supported: {:?}",
right
));
}
}
} else {
// Left operand is different variable (reversed operands)
return Err(
"[joinir/mutable-acc-spec] Assignment form not accumulator pattern (required: target = target + x)"
.to_string(),
);
}
} else {
// Left operand is not Variable
return Err(
"[joinir/mutable-acc-spec] Assignment form not accumulator pattern (required: target = target + x)"
.to_string(),
);
}
} else {
// Value is not BinaryOp, check if it's a potential mutation
// If the assignment is to a candidate variable, it's a non-accumulator mutation
return Err(format!(
"[joinir/mutable-acc-spec] Assignment to '{}' is not accumulator form (required: target = target + x)",
target_name
));
}
}
Ok(None) // No accumulator pattern found
}
}
/// Collects all assignments in loop body AST.
///
/// Returns: Vec<(target_name, value_node, span)>
fn collect_assignments<'a>(nodes: &'a [ASTNode]) -> Vec<(String, &'a ASTNode, crate::ast::Span)> {
let mut assignments = Vec::new();
for node in nodes {
collect_assignments_in_node(node, &mut assignments);
}
assignments
}
/// Recursively collects assignments from a single AST node.
fn collect_assignments_in_node<'a>(
node: &'a ASTNode,
assignments: &mut Vec<(String, &'a ASTNode, crate::ast::Span)>,
) {
match node {
ASTNode::Assignment {
target, value, span, ..
} => {
if let ASTNode::Variable { name, .. } = target.as_ref() {
assignments.push((name.clone(), value.as_ref(), span.clone()));
}
}
ASTNode::If {
then_body,
else_body,
..
} => {
for node in then_body {
collect_assignments_in_node(node, assignments);
}
if let Some(else_stmts) = else_body {
for node in else_stmts {
collect_assignments_in_node(node, assignments);
}
}
}
ASTNode::Loop { body, .. } => {
for node in body {
collect_assignments_in_node(node, assignments);
}
}
// Other nodes don't contain assignments
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::{LiteralValue, Span};
#[test]
fn test_mutable_accumulator_spec_simple() {
// Build AST for: out = out + ch
let loop_body = vec![ASTNode::Assignment {
target: Box::new(ASTNode::Variable {
name: "out".to_string(),
span: Span::unknown(),
}),
value: Box::new(ASTNode::BinaryOp {
operator: BinaryOperator::Add,
left: Box::new(ASTNode::Variable {
name: "out".to_string(),
span: Span::unknown(),
}),
right: Box::new(ASTNode::Variable {
name: "ch".to_string(),
span: Span::unknown(),
}),
span: Span::unknown(),
}),
span: Span::unknown(),
}];
let result = MutableAccumulatorAnalyzer::analyze(&loop_body).unwrap();
assert!(result.is_some());
let spec = result.unwrap();
assert_eq!(spec.target_name, "out");
assert_eq!(spec.rhs_expr_kind, RhsExprKind::Var);
assert_eq!(spec.rhs_var_or_lit, "ch");
assert_eq!(spec.op, BinaryOperator::Add);
}
#[test]
fn test_mutable_accumulator_spec_ng_reversed() {
// Build AST for: out = ch + out (reversed operands)
let loop_body = vec![ASTNode::Assignment {
target: Box::new(ASTNode::Variable {
name: "out".to_string(),
span: Span::unknown(),
}),
value: Box::new(ASTNode::BinaryOp {
operator: BinaryOperator::Add,
left: Box::new(ASTNode::Variable {
name: "ch".to_string(),
span: Span::unknown(),
}),
right: Box::new(ASTNode::Variable {
name: "out".to_string(),
span: Span::unknown(),
}),
span: Span::unknown(),
}),
span: Span::unknown(),
}];
let result = MutableAccumulatorAnalyzer::analyze(&loop_body);
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("Assignment form not accumulator pattern"));
}
#[test]
fn test_mutable_accumulator_spec_ng_multiple() {
// Build AST for: out = out + ch; out = out + "x"
// Expected: Ok(None) because multiple assignments means not our simple pattern
let loop_body = vec![
ASTNode::Assignment {
target: Box::new(ASTNode::Variable {
name: "out".to_string(),
span: Span::unknown(),
}),
value: Box::new(ASTNode::BinaryOp {
operator: BinaryOperator::Add,
left: Box::new(ASTNode::Variable {
name: "out".to_string(),
span: Span::unknown(),
}),
right: Box::new(ASTNode::Variable {
name: "ch".to_string(),
span: Span::unknown(),
}),
span: Span::unknown(),
}),
span: Span::unknown(),
},
ASTNode::Assignment {
target: Box::new(ASTNode::Variable {
name: "out".to_string(),
span: Span::unknown(),
}),
value: Box::new(ASTNode::BinaryOp {
operator: BinaryOperator::Add,
left: Box::new(ASTNode::Variable {
name: "out".to_string(),
span: Span::unknown(),
}),
right: Box::new(ASTNode::Literal {
value: LiteralValue::String("x".to_string()),
span: Span::unknown(),
}),
span: Span::unknown(),
}),
span: Span::unknown(),
},
];
let result = MutableAccumulatorAnalyzer::analyze(&loop_body);
assert!(result.is_ok());
assert!(result.unwrap().is_none(), "Multiple assignments should return None, not error");
}
#[test]
fn test_mutable_accumulator_spec_ng_method_call() {
// Build AST for: out = out + f()
let loop_body = vec![ASTNode::Assignment {
target: Box::new(ASTNode::Variable {
name: "out".to_string(),
span: Span::unknown(),
}),
value: Box::new(ASTNode::BinaryOp {
operator: BinaryOperator::Add,
left: Box::new(ASTNode::Variable {
name: "out".to_string(),
span: Span::unknown(),
}),
right: Box::new(ASTNode::MethodCall {
object: Box::new(ASTNode::Variable {
name: "obj".to_string(),
span: Span::unknown(),
}),
method: "f".to_string(),
arguments: vec![],
span: Span::unknown(),
}),
span: Span::unknown(),
}),
span: Span::unknown(),
}];
let result = MutableAccumulatorAnalyzer::analyze(&loop_body);
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("RHS contains function call (not supported)"));
}
#[test]
fn test_no_accumulator_when_no_assignment() {
// Build AST for: local x = 1
let loop_body = vec![ASTNode::Local {
variables: vec!["x".to_string()],
initial_values: vec![Some(Box::new(ASTNode::Literal {
value: LiteralValue::Integer(1),
span: Span::unknown(),
}))],
span: Span::unknown(),
}];
let result = MutableAccumulatorAnalyzer::analyze(&loop_body).unwrap();
assert!(result.is_none());
}
#[test]
fn test_accumulator_with_literal_rhs() {
// Build AST for: count = count + 1
let loop_body = vec![ASTNode::Assignment {
target: Box::new(ASTNode::Variable {
name: "count".to_string(),
span: Span::unknown(),
}),
value: Box::new(ASTNode::BinaryOp {
operator: BinaryOperator::Add,
left: Box::new(ASTNode::Variable {
name: "count".to_string(),
span: Span::unknown(),
}),
right: Box::new(ASTNode::Literal {
value: LiteralValue::Integer(1),
span: Span::unknown(),
}),
span: Span::unknown(),
}),
span: Span::unknown(),
}];
let result = MutableAccumulatorAnalyzer::analyze(&loop_body).unwrap();
assert!(result.is_some());
let spec = result.unwrap();
assert_eq!(spec.target_name, "count");
assert_eq!(spec.rhs_expr_kind, RhsExprKind::Literal);
assert_eq!(spec.op, BinaryOperator::Add);
}
}