diff --git a/docs/decisions.md b/docs/decisions.md index ad3691f..19ecf70 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -21,13 +21,15 @@ catalog/WIR. The shared global table is not a local-variable ABI. To avoid suspension or re-entry aliasing, the slice rejects player context, functions, methods, parameters, closures, recursive calls, and any external Workshop action in the -rule body (including actions that may suspend or restart rules). Uninitialized -locals, arrays, objects, structs, references, `foreach`, and unsupported value -shapes remain structured `HI018` failures and produce empty WIR. The local map -is scoped to one rule lowering and uses the common runtime-global allocator, but -that does not make the slot safe for overlapping activations. The slice therefore -provides no evidence for the remaining #31 runtime strategies or for advancing -the support matrix. +rule body (including actions that may suspend or restart rules). A bounded +lowerable-array subset is materialized with the same generated slot strategy; +object/reference elements, uninitialized locals, and unsupported value shapes +remain structured `HI018` failures and produce empty WIR. The local maps are +scoped to one rule lowering and use the common runtime-global allocator, but +that does not make the slots safe for overlapping activations. The slice +therefore provides no evidence for the remaining #31 runtime strategies or +for advancing the support matrix. `Value::Array` emit/parse equivalence remains +a canonical workshop-rs gap and is not reimplemented in del-rs. --- @@ -307,5 +309,19 @@ observable semantics. Synthetic helper-name collisions also fail closed. This is a DEL adapter policy over the released WIR forms, not a new provider-local WIR node or a claim of live Workshop-client execution. +## #31 global lowerable-array local storage (2026-08-18) + +Global-rule local variables whose types and initializers are representable by +canonical WIR values may use the same generated global storage as scalar +locals. This slice extends the value set to nested arrays of scalar values and +lowers array literals, variable references, indexes, and assignments through +the existing `Array`, `GlobalVariable`, and `valueInArray` forms. It does not +define object identity, reference generations, member layout, or a local table. + +Array elements that require object/reference semantics, player-context locals, +and re-entrant bodies fail closed with `HI018`. The choice is deliberately +limited to WIR values already supplied by `workshop-rs`; no provider-local +collection or runtime-layout contract is introduced. + The support matrix remains `lowering-dependent`; these tests are implementation evidence for the bounded adapter slice, not end-to-end Workshop execution proof. diff --git a/docs/limitations.md b/docs/limitations.md index cbe9952..f07fb64 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -36,6 +36,14 @@ plus a short list of evidence-backed approximation areas. actions. Player-context iteration, non-scalar/reference binders, and re-entrant bodies still fail closed with `HI018`; this is not a claim that canonical `workshop-rs` WIR is missing a provider-local `Foreach` node. +- Global-rule local storage now also accepts lowerable array values: the local + is materialized as a generated global slot and uses canonical WIR `Array`, + global-variable, and array-index values. Player-context locals, arrays with + object/reference elements, uninitialized locals, suspending external actions, + and re-entrant storage remain `HI018` gaps. The released workshop-rs + emitter/parser currently does not round-trip `Value::Array` equivalently + (it reparses the emitted form as `Call("array", ...)`); that canonical gap + remains owned by workshop-rs and is not bypassed here. - Switch lowering materializes an unstable scrutinee into one generated global helper slot before emitting repeated case comparisons in global rules. Player-context dynamic switches and recursive contexts fail closed with diff --git a/src/workshop.rs b/src/workshop.rs index 0c764a4..8960b1f 100644 --- a/src/workshop.rs +++ b/src/workshop.rs @@ -395,6 +395,7 @@ struct Lowerer<'a> { out: wir::Program, global_vars: HashMap, rule_local_globals: HashMap, + rule_local_arrays: HashSet, player_vars: HashMap, subroutines: HashMap, diagnostics: Vec, @@ -423,6 +424,7 @@ impl<'a> Lowerer<'a> { out, global_vars: HashMap::new(), rule_local_globals: HashMap::new(), + rule_local_arrays: HashSet::new(), player_vars: HashMap::new(), subroutines: HashMap::new(), diagnostics: Vec::new(), @@ -657,6 +659,7 @@ impl<'a> Lowerer<'a> { | wir::Event::Player { .. } ); self.rule_local_globals.clear(); + self.rule_local_arrays.clear(); if matches!(&event, wir::Event::Global) { self.prepare_global_rule_locals(rule); } @@ -670,6 +673,7 @@ impl<'a> Lowerer<'a> { if self.has_new_errors(diagnostic_count) { self.player_context = previous_player_context; self.rule_local_globals.clear(); + self.rule_local_arrays.clear(); return; } self.out.rules.push(wir::Rule { @@ -683,6 +687,7 @@ impl<'a> Lowerer<'a> { }); self.player_context = previous_player_context; self.rule_local_globals.clear(); + self.rule_local_arrays.clear(); } fn lower_subroutine(&mut self, fid: HirFuncId) { @@ -813,15 +818,73 @@ impl<'a> Lowerer<'a> { if self.block_contains_nonscalar_local_write(&rule.body) { self.unsupported( rule.span, - "global-rule local storage accepts only scalar value expressions", + "global-rule local storage accepts only scalar or lowerable-array value expressions", ); return; } for var in locals { + if self.block_contains_array_local_declaration(&rule.body, var) { + self.rule_local_arrays.insert(var); + } self.materialize_rule_local(var); } } + fn block_contains_array_local_declaration( + &self, + block: &crate::hir::HirBlock, + var: HirVarId, + ) -> bool { + block + .stmts + .iter() + .any(|stmt| self.stmt_contains_array_local_declaration(stmt, var)) + } + + fn stmt_contains_array_local_declaration(&self, stmt: &HirStmt, var: HirVarId) -> bool { + match &stmt.kind { + HirStmtKind::VarDecl { + var: declared, + init, + } => { + *declared == var + && init.is_some_and(|expr| { + self.hir + .expr(expr) + .is_some_and(|expr| matches!(expr.ty, Type::Array(_))) + }) + } + HirStmtKind::Block(block) => self.block_contains_array_local_declaration(block, var), + HirStmtKind::If { then, els, .. } => { + self.stmt_contains_array_local_declaration(then, var) + || els + .as_deref() + .is_some_and(|stmt| self.stmt_contains_array_local_declaration(stmt, var)) + } + HirStmtKind::While { body, .. } + | HirStmtKind::AutoFor { body, .. } + | HirStmtKind::Foreach { body, .. } => { + self.stmt_contains_array_local_declaration(body, var) + } + HirStmtKind::For { + init, step, body, .. + } => { + init.as_deref() + .is_some_and(|stmt| self.stmt_contains_array_local_declaration(stmt, var)) + || step + .as_deref() + .is_some_and(|stmt| self.stmt_contains_array_local_declaration(stmt, var)) + || self.stmt_contains_array_local_declaration(body, var) + } + HirStmtKind::Switch { arms, .. } => arms.iter().any(|arm| { + arm.stmts + .iter() + .any(|stmt| self.stmt_contains_array_local_declaration(stmt, var)) + }), + _ => false, + } + } + fn block_contains_nonscalar_local_write(&self, block: &crate::hir::HirBlock) -> bool { block .stmts @@ -834,7 +897,18 @@ impl<'a> Lowerer<'a> { HirStmtKind::VarDecl { var, init } => { self.hir.vars.get(*var as usize).is_some_and(|var| { var.storage == StorageIntent::Local - && init.is_none_or(|expr| !self.expr_is_scalar_value(expr)) + && init.is_none_or(|expr| { + let is_array = matches!(var.ty, Type::Array(_)) + || self + .hir + .expr(expr) + .is_some_and(|expr| matches!(expr.ty, Type::Array(_))); + if is_array { + !self.expr_is_lowerable_value(expr) + } else { + !self.expr_is_scalar_value(expr) + } + }) }) } HirStmtKind::Assign { target, value, .. } => { @@ -842,7 +916,7 @@ impl<'a> Lowerer<'a> { self.hir.expr(*target).map(|expr| &expr.kind), Some(HirExprKind::VarRef { var }) if self.hir.vars.get(*var as usize).is_some_and(|var| var.storage == StorageIntent::Local) - ) && !self.expr_is_scalar_value(*value) + ) && !self.expr_is_supported_local_write(*target, *value) } HirStmtKind::Block(block) => self.block_contains_nonscalar_local_write(block), HirStmtKind::If { then, els, .. } => { @@ -900,6 +974,87 @@ impl<'a> Lowerer<'a> { } } + fn expr_is_supported_local_write(&self, target: HirExprId, value: HirExprId) -> bool { + let Some(HirExprKind::VarRef { var }) = self.hir.expr(target).map(|expr| &expr.kind) else { + return false; + }; + let Some(variable) = self.hir.vars.get(*var as usize) else { + return false; + }; + if matches!(variable.ty, Type::Array(_)) || self.rule_local_arrays.contains(var) { + self.expr_is_lowerable_value(value) + } else { + self.expr_is_scalar_value(value) + } + } + + fn is_wir_value_type(ty: &Type) -> bool { + matches!( + ty, + Type::Number | Type::String | Type::Bool | Type::Null | Type::Any + ) || matches!(ty, Type::Array(inner) if Self::is_wir_value_type(inner)) + } + + fn expr_is_lowerable_value(&self, id: HirExprId) -> bool { + let Some(expr) = self.hir.expr(id) else { + return false; + }; + if !Self::is_wir_value_type(&expr.ty) { + return false; + } + match &expr.kind { + HirExprKind::Literal(_) | HirExprKind::External { .. } => true, + HirExprKind::VarRef { var } => self + .hir + .vars + .get(*var as usize) + .is_some_and(|variable| Self::is_wir_value_type(&variable.ty)), + HirExprKind::ArrayLit { elems } => elems + .iter() + .all(|element| self.expr_is_lowerable_value(*element)), + HirExprKind::Index { base, index } => { + self.expr_is_lowerable_value(*base) && self.expr_is_lowerable_value(*index) + } + HirExprKind::Binary { lhs, rhs, .. } => { + self.expr_is_lowerable_value(*lhs) && self.expr_is_lowerable_value(*rhs) + } + HirExprKind::Unary { operand, .. } + | HirExprKind::Convert { from: operand, .. } + | HirExprKind::Cast { expr: operand, .. } => self.expr_is_lowerable_value(*operand), + HirExprKind::Ternary { cond, then, els } => { + self.expr_is_lowerable_value(*cond) + && self.expr_is_lowerable_value(*then) + && self.expr_is_lowerable_value(*els) + } + HirExprKind::Call { target, args } => { + let args_lowerable = args.iter().all(|arg| match arg { + HirArg::Pos(value) | HirArg::Named { value, .. } => { + self.expr_is_lowerable_value(*value) + } + }); + let target_lowerable = match target { + CallTarget::External { .. } => true, + CallTarget::BuiltinArrayMethod { base, .. } => { + self.expr_is_lowerable_value(*base) + } + _ => false, + }; + args_lowerable && target_lowerable + } + HirExprKind::Member { .. } + | HirExprKind::Assign { .. } + | HirExprKind::Postfix { .. } + | HirExprKind::FunctionValue { .. } + | HirExprKind::New { .. } + | HirExprKind::StructLit { .. } + | HirExprKind::EnumCtor { .. } + | HirExprKind::StrInterp { .. } + | HirExprKind::Async { .. } + | HirExprKind::This { .. } + | HirExprKind::Error => false, + } + } + fn collect_local_declarations(&self, block: &crate::hir::HirBlock, locals: &mut Vec) { for stmt in &block.stmts { self.collect_local_declarations_stmt(stmt, locals); @@ -1150,15 +1305,12 @@ impl<'a> Lowerer<'a> { }; if hir_var.storage != StorageIntent::Local || hir_var.semantics != crate::hir::ValueSemantics::Value - || !matches!( - hir_var.ty, - Type::Number | Type::String | Type::Bool | Type::Null | Type::Any - ) + || (!Self::is_wir_value_type(&hir_var.ty) && !self.rule_local_arrays.contains(&var)) { self.unsupported( hir_var.span, format!( - "rule-local variable '{}' is outside the scalar value storage slice", + "rule-local variable '{}' is outside the scalar or lowerable-array value storage slice", hir_var.name ), ); diff --git a/tests/workshop_lowering.rs b/tests/workshop_lowering.rs index 61d30c7..c2b50be 100644 --- a/tests/workshop_lowering.rs +++ b/tests/workshop_lowering.rs @@ -177,6 +177,10 @@ rule: "local" Event.OngoingGlobal { .get(workshop_rs::wir::GlobalVarId::from_index(0)) .unwrap(); assert_eq!(variable.name, "__del_rule_local_0"); + assert!(variable.span.is_some() && variable.name_span.is_some()); + let span = variable.span.unwrap(); + assert_eq!((span.start.line, span.start.col), (3, 12)); + assert_eq!((span.end.line, span.end.col), (3, 17)); assert_eq!(variable.index, 0); let rule = program .rules @@ -222,12 +226,213 @@ rule: "local" Event.OngoingGlobal { } #[test] -fn rule_local_storage_outside_global_scalar_slice_fails_closed() { +fn global_rule_array_local_storage_materializes_value_reference_and_assignment() { + let (program, diagnostics) = lower( + r#" +rule: "array" Event.OngoingGlobal { + Number[] local = [1, 2]; + local = local; + local = [local[0], 3]; +} +"#, + ); + assert!( + diagnostics.iter().all(|diagnostic| !diagnostic.is_error()), + "{diagnostics:?}" + ); + program.validate().expect("structurally valid WIR"); + let variable = program + .global_variables + .get(workshop_rs::wir::GlobalVarId::from_index(0)) + .unwrap(); + assert_eq!(variable.name, "__del_rule_local_0"); + let span = variable.span.unwrap(); + assert_eq!((span.start.line, span.start.col), (3, 14)); + assert_eq!((span.end.line, span.end.col), (3, 19)); + let rule = program + .rules + .iter() + .find(|rule| rule.name == "array") + .expect("array rule"); + assert_eq!(rule.actions.len(), 3); + let workshop_rs::wir::Action::SetGlobalVariable { + variable: local, + value: initial, + .. + } = program.actions.get(rule.actions[0]).unwrap() + else { + panic!("array declaration must set a global variable") + }; + assert_eq!(*local, workshop_rs::wir::GlobalVarId::from_index(0)); + let workshop_rs::wir::Value::Array(elements) = &program.values.get(*initial).unwrap().value + else { + panic!("array declaration must use canonical WIR Array") + }; + assert_eq!(elements.len(), 2); + + let workshop_rs::wir::Action::SetGlobalVariable { + variable: assigned, + value: reference, + .. + } = program.actions.get(rule.actions[1]).unwrap() + else { + panic!("array assignment must set a global variable") + }; + assert_eq!(*assigned, *local); + assert!(matches!( + program.values.get(*reference).unwrap().value, + workshop_rs::wir::Value::GlobalVariable(id) if id == *local + )); + + let workshop_rs::wir::Action::SetGlobalVariable { + value: reassigned, .. + } = program.actions.get(rule.actions[2]).unwrap() + else { + panic!("array reassignment must set a global variable") + }; + let workshop_rs::wir::Value::Array(elements) = &program.values.get(*reassigned).unwrap().value + else { + panic!("array reassignment must use canonical WIR Array") + }; + assert!(matches!( + program.values.get(elements[0]).unwrap().value, + workshop_rs::wir::Value::Call { ref name, ref args } + if name == "valueInArray" + && matches!(program.values.get(args[0]).unwrap().value, + workshop_rs::wir::Value::GlobalVariable(id) if id == *local) + )); + let catalog = workshop_rs::catalog::Catalog::builtin().unwrap(); + let locale = workshop_rs::catalog::Locale::new("en-US"); + let emitted = workshop_rs::emitter::emit(&program, &catalog, &locale).unwrap(); + let reparsed = workshop_rs::parser::parse(&emitted, &catalog, &locale).unwrap(); + assert_eq!(reparsed.rules.len(), 1); + assert_eq!( + reparsed + .rules + .get(workshop_rs::wir::RuleId::from_index(0)) + .unwrap() + .actions + .len(), + 3 + ); + // workshop-rs currently reparses emitted Array values as the provider + // call `array(...)`; keep this evidence local until that canonical gap is + // fixed in workshop-rs rather than weakening del-rs ownership boundaries. +} + +#[test] +fn global_rule_foreach_reads_local_array_collection() { + let (program, diagnostics) = lower( + r#" +rule: "foreach-local" Event.OngoingGlobal { + Number[] local = [1, 2]; + foreach (Number value in local) { } +} +"#, + ); + assert!( + diagnostics.iter().all(|diagnostic| !diagnostic.is_error()), + "{diagnostics:?}" + ); + program.validate().expect("structurally valid WIR"); + let dump = program.dump(); + assert!(dump.contains("countOf"), "{dump}"); + assert!(dump.contains("valueInArray"), "{dump}"); + assert!(dump.contains("__del_foreach_collection_"), "{dump}"); + let rule = program + .rules + .iter() + .find(|rule| rule.name == "foreach-local") + .expect("foreach rule"); + assert_eq!(rule.actions.len(), 4); + let workshop_rs::wir::Action::SetGlobalVariable { + variable: local, + value: initial, + .. + } = program.actions.get(rule.actions[0]).unwrap() + else { + panic!("local array initialization must be the first action") + }; + assert!(matches!( + program.values.get(*initial).unwrap().value, + workshop_rs::wir::Value::Array(_) + )); + let workshop_rs::wir::Action::SetGlobalVariable { + variable: collection_slot, + value: collection, + .. + } = program.actions.get(rule.actions[1]).unwrap() + else { + panic!("foreach must materialize its collection after local initialization") + }; + assert_eq!( + program.global_variables.get(*local).unwrap().name, + "__del_rule_local_0" + ); + let local_span = program.global_variables.get(*local).unwrap().span.unwrap(); + assert_eq!((local_span.start.line, local_span.start.col), (3, 14)); + assert_eq!((local_span.end.line, local_span.end.col), (3, 19)); + assert_eq!( + program.global_variables.get(*collection_slot).unwrap().name, + "__del_foreach_collection_2" + ); + let collection_span = program + .global_variables + .get(*collection_slot) + .unwrap() + .span + .unwrap(); + assert_eq!( + (collection_span.start.line, collection_span.start.col), + (4, 30) + ); + assert_eq!((collection_span.end.line, collection_span.end.col), (4, 35)); + assert!(matches!( + program.values.get(*collection).unwrap().value, + workshop_rs::wir::Value::GlobalVariable(id) if id == *local + )); + let workshop_rs::wir::Action::SetGlobalVariable { + variable: index_slot, + value: index, + .. + } = program.actions.get(rule.actions[2]).unwrap() + else { + panic!("foreach must initialize its index after the collection") + }; + assert_eq!( + program.global_variables.get(*index_slot).unwrap().name, + "__del_foreach_index_3" + ); + let index_span = program + .global_variables + .get(*index_slot) + .unwrap() + .span + .unwrap(); + assert_eq!((index_span.start.line, index_span.start.col), (4, 5)); + assert_eq!((index_span.end.line, index_span.end.col), (4, 40)); + assert!(matches!( + program.values.get(*index).unwrap().value, + workshop_rs::wir::Value::Number { .. } + )); + assert!(matches!( + program.actions.get(rule.actions[3]), + Some(workshop_rs::wir::Action::While { .. }) + )); + assert!(program + .global_variables + .iter() + .filter(|variable| variable.name.starts_with("__del_foreach_")) + .all(|variable| variable.span.is_some() && variable.name_span.is_some())); +} + +#[test] +fn rule_local_storage_outside_global_array_slice_fails_closed() { let (program, diagnostics) = lower( r#" rule: "unsupported" Event.OngoingPlayer { - define local = 1; - local = 2; + Number[] local = [1]; + local = [2]; } "#, ); @@ -244,16 +449,85 @@ rule: "unsupported" Event.OngoingPlayer { let (program, diagnostics) = lower( r#" +class Box { } rule: "array" Event.OngoingGlobal { - define local = [1]; - local = [2]; + Box[] local = [new Box()]; +} +"#, + ); + assert!(program.rules.is_empty()); + assert!( + diagnostics.iter().any(|diagnostic| { + diagnostic.code == "HI018" + && diagnostic + .message + .contains("scalar or lowerable-array value expressions") + }), + "{diagnostics:?}" + ); +} + +#[test] +fn global_rule_vector_array_local_fails_closed() { + let (program, diagnostics) = lower( + r#" +rule: "vector-array" Event.OngoingGlobal { + Vector[] local = [Vector(1, 2, 3)]; +} +"#, + ); + assert!(program.rules.is_empty()); + assert!( + diagnostics.iter().any(|diagnostic| { + diagnostic.code == "HI018" + && diagnostic + .message + .contains("scalar or lowerable-array value expressions") + }), + "{diagnostics:?}" + ); +} + +#[test] +fn global_rule_array_local_without_initializer_fails_closed() { + let (program, diagnostics) = lower( + r#" +rule: "uninitialized-array" Event.OngoingGlobal { + Number[] local; + local = [1]; } "#, ); assert!(program.rules.is_empty()); assert!( diagnostics.iter().any(|diagnostic| { - diagnostic.code == "HI018" && diagnostic.message.contains("scalar value expressions") + diagnostic.code == "HI018" + && diagnostic + .message + .contains("scalar or lowerable-array value expressions") + }), + "{diagnostics:?}" + ); +} + +#[test] +fn non_reentrant_global_array_local_storage_fails_closed() { + let (program, diagnostics) = lower( + r#" +void Reenter() "reenter" { } +rule: "non-reentrant" Event.OngoingGlobal { + Number[] local = [1]; + Reenter(); +} +"#, + ); + assert!(program.rules.is_empty()); + assert!( + diagnostics.iter().any(|diagnostic| { + diagnostic.code == "HI018" + && diagnostic + .message + .contains("non-recursive, non-reentrant rule body") }), "{diagnostics:?}" ); @@ -282,6 +556,29 @@ rule: "suspending-local" Event.OngoingGlobal { ); } +#[test] +fn global_rule_array_local_storage_rejects_suspending_external_actions() { + let (program, diagnostics) = lower( + r#" +rule: "suspending-array-local" Event.OngoingGlobal { + Number[] local = [1]; + Wait(1); + local = [2]; +} +"#, + ); + assert!(program.rules.is_empty()); + assert!( + diagnostics.iter().any(|diagnostic| { + diagnostic.code == "HI018" + && diagnostic + .message + .contains("non-recursive, non-reentrant rule body") + }), + "{diagnostics:?}" + ); +} + #[test] fn global_rule_local_storage_rejects_synthetic_name_collisions() { let (program, diagnostics) = lower(