Skip to content
Merged
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
65 changes: 65 additions & 0 deletions crates/workshop-rs/src/emitter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActionLayout, ActionLayoutError> {
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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -102,6 +165,7 @@ struct Emitter<'a> {
fallback_ids: Vec<String>,
force_hero_constructors: bool,
out: String,
line_count: usize,
}

impl Emitter<'_> {
Expand Down Expand Up @@ -1617,6 +1681,7 @@ impl Emitter<'_> {
}
self.out.push_str(text);
self.out.push('\n');
self.line_count += 1;
Ok(())
}
}
Expand Down
3 changes: 2 additions & 1 deletion crates/workshop-rs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
173 changes: 173 additions & 0 deletions crates/workshop-rs/tests/action_layout.rs
Original file line number Diff line number Diff line change
@@ -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<wir::ActionId>) {
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
})
));
}
3 changes: 3 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions docs/action-layout.md
Original file line number Diff line number Diff line change
@@ -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.
Loading