diff --git a/compiler/src/emitter/conditional.rs b/compiler/src/emitter/conditional.rs index c7ce76c..8b3508e 100644 --- a/compiler/src/emitter/conditional.rs +++ b/compiler/src/emitter/conditional.rs @@ -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)) } diff --git a/compiler/src/emitter/context.rs b/compiler/src/emitter/context.rs index f0d2f10..0e0426f 100644 --- a/compiler/src/emitter/context.rs +++ b/compiler/src/emitter/context.rs @@ -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, diff --git a/compiler/src/emitter/expression.rs b/compiler/src/emitter/expression.rs index 6a4020a..9e202fc 100644 --- a/compiler/src/emitter/expression.rs +++ b/compiler/src/emitter/expression.rs @@ -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) @@ -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})) } diff --git a/compiler/tests/compiler_tests.rs b/compiler/tests/compiler_tests.rs index d414a5e..6aa5e57 100644 --- a/compiler/tests/compiler_tests.rs +++ b/compiler/tests/compiler_tests.rs @@ -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 = 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}" + ); +}