Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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.
8 changes: 8 additions & 0 deletions docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
168 changes: 160 additions & 8 deletions src/workshop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ struct Lowerer<'a> {
out: wir::Program,
global_vars: HashMap<HirVarId, wir::GlobalVarId>,
rule_local_globals: HashMap<HirVarId, wir::GlobalVarId>,
rule_local_arrays: HashSet<HirVarId>,
player_vars: HashMap<HirVarId, wir::PlayerVarId>,
subroutines: HashMap<HirFuncId, wir::SubroutineId>,
diagnostics: Vec<Diagnostic>,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
}
Expand All @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -834,15 +897,26 @@ 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, .. } => {
matches!(
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, .. } => {
Expand Down Expand Up @@ -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<HirVarId>) {
for stmt in &block.stmts {
self.collect_local_declarations_stmt(stmt, locals);
Expand Down Expand Up @@ -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
),
);
Expand Down
Loading