Skip to content
Open
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
22 changes: 2 additions & 20 deletions compiler/src/emitter/conditional.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,8 @@ fn emit_condition(
Condition::FunctionCall(name) => {
out.push(json!({"f()": name}));
}
Condition::Expression(Expression::Variable(name))
if scope.resolve_choice_label(name).is_some() =>
{
// Labels are stored as absolute paths now
out.push(json!({"CNT?": scope.resolve_choice_label(name).unwrap()}));
}
Condition::Expression(Expression::Variable(name))
if context.qualified_choice_labels.contains_key(name) =>
{
out.push(json!({"CNT?": context.qualified_choice_labels[name]}));
}
Condition::Expression(Expression::Variable(name))
if context.top_flow_names.contains(name) || scope.child_flow_names.contains(name) =>
{
out.push(json!({"CNT?": scope.resolve_divert_target(name, context)}));
}
// Fully-qualified path like knot.stitch.label — treat as CNT? visit count
Condition::Expression(Expression::Variable(name)) if name.contains('.') => {
out.push(json!({"CNT?": name}));
}
// Labels, flow names and read-count paths all resolve through the
// shared variable ladder in `emit_expression_ctx`.
Condition::Expression(expression) => {
emit_expression_ctx(expression, out, Some(context), Some(scope))
}
Expand Down
31 changes: 31 additions & 0 deletions compiler/src/emitter/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,37 @@ impl EmitScope {
self.choice_label_targets.get(label).map(String::as_str)
}

/// Deep-search the enclosing knot for a weave label with this bare name.
/// Mirrors inklecate's ancestry walk (`Path.TryGetChildFromContext`): a bare
/// name used anywhere in a knot may address a label in any of its stitches.
fn resolve_knot_choice_label<'ctx>(
&self,
name: &str,
context: &'ctx EmitContext,
) -> Option<&'ctx str> {
let knot_prefix = format!("{}.", self.top_flow_name.as_deref()?);
let suffix = format!(".{name}");
context
.qualified_choice_labels
.iter()
.find(|(key, _)| key.starts_with(&knot_prefix) && key.ends_with(&suffix))
.map(|(_, path)| path.as_str())
}

/// Resolve a `stitch.label` reference made from elsewhere in the same knot
/// by qualifying it with the enclosing knot name.
fn resolve_knot_qualified_choice_label<'ctx>(
&self,
name: &str,
context: &'ctx EmitContext,
) -> Option<&'ctx str> {
let knot = self.top_flow_name.as_deref()?;
context
.qualified_choice_labels
.get(&format!("{knot}.{name}"))
.map(String::as_str)
}

fn resolve_qualified_choice_label(
&self,
target: &str,
Expand Down
11 changes: 10 additions & 1 deletion compiler/src/emitter/expression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,10 @@ fn emit_expression_ctx(
{
out.push(json!({"CNT?": path}))
} else if name.contains('.') {
out.push(json!({"CNT?": name}))
let knot_qualified = scope
.zip(context)
.and_then(|(s, ctx)| s.resolve_knot_qualified_choice_label(name, ctx));
out.push(json!({"CNT?": knot_qualified.unwrap_or(name)}))
} else if let (Some(s), Some(ctx)) = (scope, context)
&& (ctx.top_flow_names.contains(name)
|| s.child_flow_names.contains(name)
Expand All @@ -301,6 +304,12 @@ fn emit_expression_ctx(
out.push(json!({"CNT?": s.resolve_divert_target(name, ctx)}))
} else if context.is_some_and(|ctx| ctx.top_flow_names.contains(name)) {
out.push(json!({"CNT?": name}))
} else if let (Some(s), Some(ctx)) = (scope, context)
&& !ctx.global_variables.contains(name)
&& !s.temp_param_names.contains(name)
&& let Some(path) = s.resolve_knot_choice_label(name, ctx)
{
out.push(json!({"CNT?": path}))
} else {
out.push(json!({"VAR?": name}))
}
Expand Down
61 changes: 61 additions & 0 deletions compiler/tests/compiler_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,64 @@ Response.
"response gather must not be nested inside options: {json}"
);
}

/// A weave label referenced from a *different* stitch of the same knot must
/// resolve as a read count, mirroring inklecate's ancestry walk
/// (`Path.TryGetChildFromContext` finds a labelled weave point in any stitch of
/// the enclosing knot). Covers the bare, stitch-qualified and fully-qualified
/// reference styles.
#[test]
fn cross_stitch_label_reference_resolves_as_read_count() {
let ink = r#"
-> k.s1
== k ==
= s1
* (lbl) [pick me]
Picked.
-> s2
= s2
* {lbl} [bare]
-> DONE
* {s1.lbl} [stitch-qualified]
-> DONE
* {k.s1.lbl} [fully-qualified]
-> DONE
"#;

let json = Compiler::new().compile(ink).unwrap();
assert!(
!json.contains("VAR?"),
"label references must compile to read counts, not variable reads: {json}"
);

let mut story = Story::new(&json).unwrap();
while story.can_continue() {
story.cont().unwrap();
}
story.choose_choice_index(0).unwrap();
while story.can_continue() {
story.cont().unwrap();
}
let choices: Vec<String> = story
.get_current_choices()
.iter()
.map(|choice| choice.text.clone())
.collect();
assert_eq!(
choices,
vec!["bare", "stitch-qualified", "fully-qualified"],
"all three reference styles must see the visited label"
);
}

/// A global variable must not be shadowed by a same-named weave label in
/// another stitch of the enclosing knot.
#[test]
fn global_variable_wins_over_cross_stitch_label() {
let ink = "VAR count = 0\n== k ==\n= s1\n* (count) [x]\n -> DONE\n= s2\n{count}\n-> DONE\n";
let json = Compiler::new().compile(ink).unwrap();
assert!(
json.contains(r#"{"VAR?":"count"}"#),
"global must resolve as a variable read: {json}"
);
}