diff --git a/src/Waffle.Core/Interpreter/EnvValue.cs b/src/Waffle.Core/Interpreter/EnvValue.cs
index 6e3cd0d..f67d804 100644
--- a/src/Waffle.Core/Interpreter/EnvValue.cs
+++ b/src/Waffle.Core/Interpreter/EnvValue.cs
@@ -1,5 +1,8 @@
// (c) DeNA Co., Ltd.
+using System;
+using System.Collections.Generic;
+
namespace Waffle.Interpreter;
///
@@ -37,3 +40,25 @@ private EnvValue(byte tag, int i, object? obj)
///
internal object? AsObject() => _tag == 1 ? _int : _obj;
}
+
+internal static class EnvLookup
+{
+ ///
+ /// Retrieves a loop-variable value from the evaluation environment, throwing a user-friendly
+ /// exception when the key is absent. Absence indicates the loop variable was referenced after
+ /// its corresponding For/ForEach block ended — which the C# compiler accepts but
+ /// Waffle cannot satisfy at runtime.
+ ///
+ public static EnvValue GetLoopVariable(this Dictionary env, int parentId)
+ {
+ if (!env.TryGetValue(parentId, out var v))
+ {
+ throw new InvalidOperationException(
+ "Loop variable is accessed outside the scope of its For/ForEach block. " +
+ "C# allows referencing a loop variable declared with `out var` after the `End` command, " +
+ "but Waffle evaluates the template at runtime and the variable no longer exists at that point. " +
+ "Move the reference inside the corresponding For/ForEach ... End block.");
+ }
+ return v;
+ }
+}
diff --git a/src/Waffle.Core/Interpreter/Interpolation/Proxy/IntIteratorProxy.cs b/src/Waffle.Core/Interpreter/Interpolation/Proxy/IntIteratorProxy.cs
index 75a644a..79ab102 100644
--- a/src/Waffle.Core/Interpreter/Interpolation/Proxy/IntIteratorProxy.cs
+++ b/src/Waffle.Core/Interpreter/Interpolation/Proxy/IntIteratorProxy.cs
@@ -14,12 +14,12 @@ public class IntIteratorProxy(int parentId) : IBlockContent, IResolvableTo
///
public int Resolve(Dictionary env)
{
- return env[parentId].AsInt();
+ return env.GetLoopVariable(parentId).AsInt();
}
///
public EvalResult Evaluate(Dictionary env, int alignment, string? format)
{
- return EvalResult.Create(env[parentId].AsInt(), alignment, format);
+ return EvalResult.Create(env.GetLoopVariable(parentId).AsInt(), alignment, format);
}
}
diff --git a/src/Waffle.Core/Interpreter/Interpolation/Proxy/IteratorProxy.cs b/src/Waffle.Core/Interpreter/Interpolation/Proxy/IteratorProxy.cs
index 890c42e..6b5a2e2 100644
--- a/src/Waffle.Core/Interpreter/Interpolation/Proxy/IteratorProxy.cs
+++ b/src/Waffle.Core/Interpreter/Interpolation/Proxy/IteratorProxy.cs
@@ -20,7 +20,7 @@ public class IteratorProxy(int parentId) : IBlockContent, IResolvableTo
public EvalResult Evaluate(Dictionary env, int alignment, string? format)
{
// The actual iterator value is stored under the corresponding block's ID in the environment.
- var it = env[_parentId].AsObject();
+ var it = env.GetLoopVariable(_parentId).AsObject();
// null is a valid value for reference types and Nullable value types; render as empty string.
if (it is null)
@@ -49,7 +49,7 @@ public EvalResult Evaluate(Dictionary env, int alignment, string?
///
public T Resolve(Dictionary env)
{
- var it = env[_parentId].AsObject();
+ var it = env.GetLoopVariable(_parentId).AsObject();
if (it is null)
{
diff --git a/src/Waffle.Core/Interpreter/Interpolation/Proxy/NullableRefIteratorProxy.cs b/src/Waffle.Core/Interpreter/Interpolation/Proxy/NullableRefIteratorProxy.cs
index 7b2ba4f..45daac5 100644
--- a/src/Waffle.Core/Interpreter/Interpolation/Proxy/NullableRefIteratorProxy.cs
+++ b/src/Waffle.Core/Interpreter/Interpolation/Proxy/NullableRefIteratorProxy.cs
@@ -18,7 +18,7 @@ public class NullableRefIteratorProxy(int parentId) : IBlockContent, IResolva
///
public EvalResult Evaluate(Dictionary env, int alignment, string? format)
{
- var it = env[parentId].AsObject();
+ var it = env.GetLoopVariable(parentId).AsObject();
// null element: render as empty string.
if (it is null)
@@ -42,7 +42,7 @@ public EvalResult Evaluate(Dictionary env, int alignment, string?
///
public T? Resolve(Dictionary env)
{
- var it = env[parentId].AsObject();
+ var it = env.GetLoopVariable(parentId).AsObject();
return (T?)it;
}
}
diff --git a/tests/Waffle.Core.Test/WaffleSyntaxTest.LoopVariableScope.cs b/tests/Waffle.Core.Test/WaffleSyntaxTest.LoopVariableScope.cs
new file mode 100644
index 0000000..1c7d81d
--- /dev/null
+++ b/tests/Waffle.Core.Test/WaffleSyntaxTest.LoopVariableScope.cs
@@ -0,0 +1,66 @@
+// (c) DeNA Co., Ltd.
+
+namespace Waffle.Core.Test;
+
+public partial class WaffleSyntaxTest
+{
+ // Loop variables declared with `out var` inside For/ForEach are still in scope
+ // for the C# compiler after `End`, but Waffle removes them from the runtime
+ // environment when the block ends. Accessing them afterwards must throw a
+ // user-friendly InvalidOperationException rather than a raw KeyNotFoundException.
+
+ [Test]
+ public void For_LoopVariable_UsedAfterEnd_ThrowsFriendlyError()
+ {
+ var ex = Assert.Throws(() =>
+ {
+ _ = Render($$"""
+ {{For(0, 3, out var i)}}
+ {{i}}
+ {{End}}
+ {{i}}
+ """);
+ });
+
+ Assert.That(ex!.Message, Does.Contain("For/ForEach"));
+ Assert.That(ex.Message, Does.Contain("End"));
+ }
+
+ [Test]
+ public void ForEach_LoopVariable_UsedAfterEnd_ThrowsFriendlyError()
+ {
+ var items = new[] { "a", "b", "c" };
+
+ var ex = Assert.Throws(() =>
+ {
+ _ = Render($$"""
+ {{ForEach(items, out var x)}}
+ {{x}}
+ {{End}}
+ {{x}}
+ """);
+ });
+
+ Assert.That(ex!.Message, Does.Contain("For/ForEach"));
+ Assert.That(ex.Message, Does.Contain("End"));
+ }
+
+ [Test]
+ public void ForEachNullable_LoopVariable_UsedAfterEnd_ThrowsFriendlyError()
+ {
+ IEnumerable items = ["a", null, "c"];
+
+ var ex = Assert.Throws(() =>
+ {
+ _ = Render($$"""
+ {{ForEachNullable(items, out var x)}}
+ {{x}}
+ {{End}}
+ {{x}}
+ """);
+ });
+
+ Assert.That(ex!.Message, Does.Contain("For/ForEach"));
+ Assert.That(ex.Message, Does.Contain("End"));
+ }
+}