From 377784ecb7847d2e2741adc3a32cd2c0706f139e Mon Sep 17 00:00:00 2001 From: "hiroki.harada" Date: Thu, 28 May 2026 21:38:35 +0900 Subject: [PATCH] throw friendly error when loop variable is out of scope --- src/Waffle.Core/Interpreter/EnvValue.cs | 25 +++++++ .../Interpolation/Proxy/IntIteratorProxy.cs | 4 +- .../Interpolation/Proxy/IteratorProxy.cs | 4 +- .../Proxy/NullableRefIteratorProxy.cs | 4 +- .../WaffleSyntaxTest.LoopVariableScope.cs | 66 +++++++++++++++++++ 5 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 tests/Waffle.Core.Test/WaffleSyntaxTest.LoopVariableScope.cs 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")); + } +}