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
25 changes: 25 additions & 0 deletions src/Waffle.Core/Interpreter/EnvValue.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
// (c) DeNA Co., Ltd.

using System;
using System.Collections.Generic;

namespace Waffle.Interpreter;

/// <summary>
Expand Down Expand Up @@ -37,3 +40,25 @@ private EnvValue(byte tag, int i, object? obj)
/// </summary>
internal object? AsObject() => _tag == 1 ? _int : _obj;
}

internal static class EnvLookup
{
/// <summary>
/// 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 <c>For</c>/<c>ForEach</c> block ended — which the C# compiler accepts but
/// Waffle cannot satisfy at runtime.
/// </summary>
public static EnvValue GetLoopVariable(this Dictionary<int, EnvValue> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ public class IntIteratorProxy(int parentId) : IBlockContent, IResolvableTo<int>
/// <inheritdoc/>
public int Resolve(Dictionary<int, EnvValue> env)
{
return env[parentId].AsInt();
return env.GetLoopVariable(parentId).AsInt();
}

/// <inheritdoc/>
public EvalResult Evaluate(Dictionary<int, EnvValue> env, int alignment, string? format)
{
return EvalResult.Create(env[parentId].AsInt(), alignment, format);
return EvalResult.Create(env.GetLoopVariable(parentId).AsInt(), alignment, format);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public class IteratorProxy<T>(int parentId) : IBlockContent, IResolvableTo<T>
public EvalResult Evaluate(Dictionary<int, EnvValue> 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<T> value types; render as empty string.
if (it is null)
Expand Down Expand Up @@ -49,7 +49,7 @@ public EvalResult Evaluate(Dictionary<int, EnvValue> env, int alignment, string?
/// <inheritdoc/>
public T Resolve(Dictionary<int, EnvValue> env)
{
var it = env[_parentId].AsObject();
var it = env.GetLoopVariable(_parentId).AsObject();

if (it is null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public class NullableRefIteratorProxy<T>(int parentId) : IBlockContent, IResolva
/// <inheritdoc/>
public EvalResult Evaluate(Dictionary<int, EnvValue> 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)
Expand All @@ -42,7 +42,7 @@ public EvalResult Evaluate(Dictionary<int, EnvValue> env, int alignment, string?
/// <inheritdoc/>
public T? Resolve(Dictionary<int, EnvValue> env)
{
var it = env[parentId].AsObject();
var it = env.GetLoopVariable(parentId).AsObject();
return (T?)it;
}
}
66 changes: 66 additions & 0 deletions tests/Waffle.Core.Test/WaffleSyntaxTest.LoopVariableScope.cs
Original file line number Diff line number Diff line change
@@ -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<InvalidOperationException>(() =>
{
_ = 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<InvalidOperationException>(() =>
{
_ = 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<string?> items = ["a", null, "c"];

var ex = Assert.Throws<InvalidOperationException>(() =>
{
_ = Render($$"""
{{ForEachNullable(items, out var x)}}
{{x}}
{{End}}
{{x}}
""");
});

Assert.That(ex!.Message, Does.Contain("For/ForEach"));
Assert.That(ex.Message, Does.Contain("End"));
}
}