diff --git a/crates/workshop-rs/src/emitter.rs b/crates/workshop-rs/src/emitter.rs index d6ebb3a..02eb72f 100644 --- a/crates/workshop-rs/src/emitter.rs +++ b/crates/workshop-rs/src/emitter.rs @@ -22,6 +22,68 @@ use crate::settings::table::{self, KeyKind, PathPart}; use crate::settings::{Settings as SettingsTree, SettingsNode}; use crate::wir; +/// The number of native Workshop actions emitted by a canonical WIR action +/// sequence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ActionLayout { + /// The action count in the canonical native action stream. + pub width: usize, +} + +/// Errors returned while querying canonical native action layout. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ActionLayoutError { + /// The WIR does not satisfy its structural invariants. + InvalidWIR(crate::wir::error::IrError), + /// Canonical emission could not expand the requested actions. + Emission(WorkshopError), +} + +impl std::fmt::Display for ActionLayoutError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidWIR(error) => write!(f, "invalid WIR: {error}"), + Self::Emission(error) => write!(f, "action layout emission failed: {error}"), + } + } +} + +impl std::error::Error for ActionLayoutError {} + +/// Query the native Workshop action width of a validated WIR action sequence. +/// +/// The sequence is expanded using the same recursive action implementation as +/// [`emit`]. Every action in the sequence is treated as non-rule-final, which +/// is the canonical stream contract needed for relative action offsets. The +/// returned width counts native Workshop action lines, including structural +/// headers and terminators. +pub fn action_width( + program: &wir::Program, + catalog: &Catalog, + locale: &Locale, + actions: &[wir::ActionId], +) -> std::result::Result { + program.validate().map_err(ActionLayoutError::InvalidWIR)?; + let mut emitter = Emitter { + program, + catalog, + locale: locale.clone(), + fallback: None, + fallback_ids: Vec::new(), + force_hero_constructors: false, + out: String::new(), + line_count: 0, + }; + for action in actions { + emitter + .action(*action, 0, false) + .map_err(ActionLayoutError::Emission)?; + } + Ok(ActionLayout { + width: emitter.line_count, + }) +} + /// Emission options: opt-in fallback for missing target-locale mappings. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct EmitOptions { @@ -84,6 +146,7 @@ fn emit_with_options_inner( force_hero_constructors, fallback_ids: Vec::new(), out: String::new(), + line_count: 0, }; emitter.run()?; Ok(EmitOutput { @@ -102,6 +165,7 @@ struct Emitter<'a> { fallback_ids: Vec, force_hero_constructors: bool, out: String, + line_count: usize, } impl Emitter<'_> { @@ -1617,6 +1681,7 @@ impl Emitter<'_> { } self.out.push_str(text); self.out.push('\n'); + self.line_count += 1; Ok(()) } } diff --git a/crates/workshop-rs/src/lib.rs b/crates/workshop-rs/src/lib.rs index 0408971..3117270 100644 --- a/crates/workshop-rs/src/lib.rs +++ b/crates/workshop-rs/src/lib.rs @@ -14,7 +14,8 @@ //! validated Workshop IR; //! * [`wir`] — the Workshop IR model (locale-independent semantic //! representation) with its arena/source/settings support; -//! * [`emitter`] — deterministic localized Workshop emission, failing +//! * [`emitter`] — deterministic localized Workshop emission and canonical +//! native action-layout queries, failing //! explicitly on missing target-locale mappings (opt-in fallback); //! * [`detect`] — Workshop client-language detection and explicit override; //! * [`validate`] — catalog-backed validation of canonical builtin references; diff --git a/crates/workshop-rs/tests/action_layout.rs b/crates/workshop-rs/tests/action_layout.rs new file mode 100644 index 0000000..2a078ed --- /dev/null +++ b/crates/workshop-rs/tests/action_layout.rs @@ -0,0 +1,173 @@ +use workshop_rs::catalog::{Catalog, Locale}; +use workshop_rs::emitter; +use workshop_rs::wir::{self, Action, Event, Value, ValueNode}; + +fn program_with_structured_actions() -> (wir::Program, Vec) { + let mut program = wir::Program::default(); + let global = program.global_variables.push(wir::WorkshopVariable { + name: "index".into(), + index: 0, + span: None, + name_span: None, + }); + let player = program.player_variables.push(wir::WorkshopVariable { + name: "index".into(), + index: 0, + span: None, + name_span: None, + }); + let condition = program.values.push(ValueNode::new(Value::Bool(true), None)); + let number = program.values.push(ValueNode::new( + Value::Number { + value: 0.0, + text: "0".into(), + }, + None, + )); + fn leaf(program: &mut wir::Program, number: wir::ValueId) -> wir::ActionId { + program.actions.push(Action::Call { + name: "wait".into(), + args: vec![number], + span: None, + }) + } + let if_body = leaf(&mut program, number); + let else_body = leaf(&mut program, number); + let if_action = program.actions.push(Action::If { + branches: vec![wir::IfBranch { + condition, + body: vec![if_body], + }], + else_body: Some(vec![else_body]), + span: None, + }); + let while_body = leaf(&mut program, number); + let while_action = program.actions.push(Action::While { + condition, + body: vec![while_body], + span: None, + }); + let for_global_body = leaf(&mut program, number); + let for_global = program.actions.push(Action::ForGlobalVariable { + variable: global, + start: number, + stop: number, + step: number, + body: vec![for_global_body], + span: None, + target_span: None, + }); + let nested_if_body = if_action; + let for_player = program.actions.push(Action::ForPlayerVariable { + player: program + .values + .push(ValueNode::new(Value::EventPlayer, None)), + variable: player, + start: number, + stop: number, + step: number, + body: vec![nested_if_body], + span: None, + }); + let trailing_leaf = leaf(&mut program, number); + let actions = vec![ + if_action, + while_action, + for_global, + for_player, + trailing_leaf, + ]; + program.rules.push(wir::Rule { + name: "layout".into(), + span: None, + name_span: None, + disabled: false, + event: Event::Global, + conditions: vec![], + actions: actions.clone(), + }); + (program, actions) +} + +#[test] +fn structured_action_widths_count_native_expansion() { + let (program, actions) = program_with_structured_actions(); + let catalog = Catalog::builtin().unwrap(); + let locale = Locale::new("en-US"); + + assert_eq!( + emitter::action_width(&program, &catalog, &locale, &actions[..1]) + .unwrap() + .width, + 5 + ); + assert_eq!( + emitter::action_width(&program, &catalog, &locale, &actions[1..2]) + .unwrap() + .width, + 3 + ); + assert_eq!( + emitter::action_width(&program, &catalog, &locale, &actions[2..3]) + .unwrap() + .width, + 3 + ); + assert_eq!( + emitter::action_width(&program, &catalog, &locale, &actions[3..4]) + .unwrap() + .width, + 7 + ); +} + +#[test] +fn layout_matches_canonical_emission_for_a_nested_sequence() { + let (program, actions) = program_with_structured_actions(); + let catalog = Catalog::builtin().unwrap(); + let locale = Locale::new("en-US"); + let emitted = emitter::emit(&program, &catalog, &locale).unwrap(); + let action_text = emitted + .split_once("actions {\n") + .unwrap() + .1 + .split_once("\n }") + .unwrap() + .0; + let emitted_width = action_text + .lines() + .filter(|line| !line.trim().is_empty()) + .count(); + let layout = emitter::action_width(&program, &catalog, &locale, &actions).unwrap(); + assert_eq!(layout.width, emitted_width); + assert_eq!(layout.width, 19); +} + +#[test] +fn invalid_layout_requests_fail_explicitly() { + let mut program = wir::Program::default(); + let dangling = wir::ActionId::from_index(0); + program.rules.push(wir::Rule { + name: "invalid".into(), + span: None, + name_span: None, + disabled: false, + event: Event::Global, + conditions: vec![], + actions: vec![dangling], + }); + let error = emitter::action_width( + &program, + &Catalog::builtin().unwrap(), + &Locale::new("en-US"), + &[dangling], + ) + .unwrap_err(); + assert!(matches!( + error, + emitter::ActionLayoutError::InvalidWIR(wir::error::IrError::DanglingReference { + what: "action", + id: 0 + }) + )); +} diff --git a/docs/README.md b/docs/README.md index a2c29f5..82066db 100644 --- a/docs/README.md +++ b/docs/README.md @@ -39,6 +39,9 @@ belong in documentation. - [Implementation role](implementation-role.md): standalone Workshop implementation identity, canonical ownership, dependency direction from `opy-rs` / `del-rs` / Wright, and consumer-driven evolution rules. +- [Canonical action layout](action-layout.md): native Workshop action-width + queries for validated WIR sequences, including structured action expansion + and explicit layout errors. - [ADR-0001: Workshop Catalog, Locale, Provenance, and Version Boundaries](adr/0001-catalog-boundaries.md): semantic-code/catalog separation, locale-independent identities, missing-mapping behavior, and version identity. diff --git a/docs/action-layout.md b/docs/action-layout.md new file mode 100644 index 0000000..dc18252 --- /dev/null +++ b/docs/action-layout.md @@ -0,0 +1,19 @@ +# Canonical action layout + +`workshop_rs::emitter::action_width` reports the number of native Workshop +action lines produced by a WIR action sequence. The result includes structural +headers and terminators, so a structured action can have a width greater than +one. + +The query validates the complete WIR first and then expands the requested +sequence through the same authoritative action implementation used by +`emitter::emit`. Consumers do not need to reproduce emitter expansion rules or +inspect emitted text. Each requested action is treated as non-rule-final; this +is the action-stream contract for calculating relative native action offsets. + +The query is source-language-neutral. Source-language lowering, target +placement, and control-flow policy remain in the consuming compiler. + +Malformed WIR and emission failures are returned as +`ActionLayoutError::InvalidWIR` or `ActionLayoutError::Emission`; they are +never converted into a zero or partial width.