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
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ jobs:
dotnet nuget push "nupkg/Waffle.Core.$TAG_NAME.nupkg" --source "$PKG_SOURCE" --api-key "$NUGET_API_KEY" --skip-duplicate
dotnet nuget push "nupkg/Waffle.Bakery.$TAG_NAME.nupkg" --source "$PKG_SOURCE" --api-key "$NUGET_API_KEY" --skip-duplicate
dotnet nuget push "nupkg/Waffle.ModelProxy.$TAG_NAME.nupkg" --source "$PKG_SOURCE" --api-key "$NUGET_API_KEY" --skip-duplicate
dotnet nuget push "nupkg/Waffle.Analyzer.$TAG_NAME.nupkg" --source "$PKG_SOURCE" --api-key "$NUGET_API_KEY" --skip-duplicate
- name: Create Release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 # zizmor: ignore[superfluous-actions]
with:
Expand All @@ -52,3 +53,4 @@ jobs:
nupkg/Waffle.Core.${{env.TAG_NAME}}.nupkg
nupkg/Waffle.Bakery.${{env.TAG_NAME}}.nupkg
nupkg/Waffle.ModelProxy.${{env.TAG_NAME}}.nupkg
nupkg/Waffle.Analyzer.${{env.TAG_NAME}}.nupkg
27 changes: 20 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
[![Core](https://img.shields.io/nuget/vpre/Waffle.Core.svg?label=Waffle.Core&logo=nuget)](https://www.nuget.org/packages/Waffle.Core)
[![ModelProxy](https://img.shields.io/nuget/vpre/Waffle.ModelProxy.svg?label=Waffle.ModelProxy&logo=nuget)](https://www.nuget.org/packages/Waffle.ModelProxy)
[![Bakery](https://img.shields.io/nuget/vpre/Waffle.Bakery.svg?label=Waffle.Bakery&logo=nuget)](https://www.nuget.org/packages/Waffle.Bakery)
[![Analyzer](https://img.shields.io/nuget/vpre/Waffle.Analyzer.svg?label=Waffle.Analyzer&logo=nuget)](https://www.nuget.org/packages/Waffle.Analyzer)
![.NET Standard 2.0](https://img.shields.io/badge/.NET%20Standard-2.0-blueviolet)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

Expand Down Expand Up @@ -54,11 +55,12 @@ feature-rich DSL, or Razor/Liquid compatibility — another engine may be a bett

## Packages

| Package | Description |
|------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
| Package | Description |
|----------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
| [**Waffle.Core**](src/Waffle.Core/README.md) | Core engine: `WaffleSyntax` API, `TemplateInterpreter`, block AST, lazy-resolution pipeline |
| [**Waffle.ModelProxy**](src/Waffle.ModelProxy/README.md) | Incremental Source Generator — generates `{Type}Proxy` wrappers so model members are accessible as Waffle tokens without `.To()` lambdas |
| [**Waffle.Bakery**](src/Waffle.Bakery/README.md) | Batch-execution framework for running multiple templates and collecting their outputs |
| [**Waffle.Bakery**](src/Waffle.Bakery/README.md) | Batch-execution framework for running multiple templates and collecting their outputs |
| [**Waffle.Analyzer**](src/Waffle.Analyzer/README.md) | Roslyn Analyzer — detects template syntax errors (unmatched blocks, out-of-scope variables, etc.) at compile time |

## Installation

Expand All @@ -74,14 +76,24 @@ To also access model members cleanly inside loop bodies, add **Waffle.ModelProxy

```xml
<ItemGroup>
<PackageReference Include="Waffle.Core" Version="1.x"/>
<PackageReference Include="Waffle.ModelProxy" Version="1.x">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<OutputItemType>Analyzer</OutputItemType>
</PackageReference>
</ItemGroup>
```

To catch template syntax errors (unmatched `End`, out-of-scope loop variables, misplaced `Elif`/`Else`, etc.) at compile time, add **Waffle.Analyzer**:

```xml
<ItemGroup>
<PackageReference Include="Waffle.Analyzer" Version="1.x">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<OutputItemType>Analyzer</OutputItemType>
</PackageReference>
</ItemGroup>
```

## Quick Start

Import the static API and call `Render`:
Expand Down Expand Up @@ -280,9 +292,10 @@ The consuming project should support **C# 11 or later** (for interpolated string
|---------------------------------------------------------|---------------------------------------------------------------------------------------------------|
| [Syntax Reference](docs/syntax-reference.md) | Complete reference for all `WaffleSyntax` commands, whitespace control, lazy resolution, and more |
| [Recipes](docs/recipes.md) | Practical patterns — comma-separated lists, multi-line parameters, nested loops, and more |
| [Waffle.Core README](src/Waffle.Core/README.md) | Architecture, key types, extensibility points, and Source Generator integration guide |
| [Waffle.ModelProxy README](src/Waffle.ModelProxy/README.md) | `[ModelProxy]` setup, generated API, and advanced proxy usage |
| [Waffle.Bakery README](src/Waffle.Bakery/README.md) | Batch-execution framework, template registration, custom contexts |
| [Waffle.Core README](src/Waffle.Core/README.md) | Architecture, key types, extensibility points, and Source Generator integration guide |
| [Waffle.ModelProxy README](src/Waffle.ModelProxy/README.md) | `[ModelProxy]` setup, generated API, and advanced proxy usage |
| [Waffle.Bakery README](src/Waffle.Bakery/README.md) | Batch-execution framework, template registration, custom contexts |
| [Waffle.Analyzer README](src/Waffle.Analyzer/README.md) | Compile-time diagnostic rules and their descriptions |

## Building from Source

Expand Down
12 changes: 12 additions & 0 deletions Waffle.sln
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Waffle.Core.Benchmark.Test"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Waffle.Bakery.Test", "tests\Waffle.Bakery.Test\Waffle.Bakery.Test.csproj", "{B611C504-FD3D-456D-BBF8-DA366BA58BBF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Waffle.Analyzer", "src\Waffle.Analyzer\Waffle.Analyzer.csproj", "{1F2E3D4C-5B6A-7980-8870-9A0B1C2D3E4F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Waffle.Analyzer.Test", "tests\Waffle.Analyzer.Test\Waffle.Analyzer.Test.csproj", "{2E3D4C5B-6A79-8089-9978-0A1B2C3D4E5F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -54,5 +58,13 @@ Global
{B611C504-FD3D-456D-BBF8-DA366BA58BBF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B611C504-FD3D-456D-BBF8-DA366BA58BBF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B611C504-FD3D-456D-BBF8-DA366BA58BBF}.Release|Any CPU.Build.0 = Release|Any CPU
{1F2E3D4C-5B6A-7980-8870-9A0B1C2D3E4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1F2E3D4C-5B6A-7980-8870-9A0B1C2D3E4F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1F2E3D4C-5B6A-7980-8870-9A0B1C2D3E4F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1F2E3D4C-5B6A-7980-8870-9A0B1C2D3E4F}.Release|Any CPU.Build.0 = Release|Any CPU
{2E3D4C5B-6A79-8089-9978-0A1B2C3D4E5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2E3D4C5B-6A79-8089-9978-0A1B2C3D4E5F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2E3D4C5B-6A79-8089-9978-0A1B2C3D4E5F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2E3D4C5B-6A79-8089-9978-0A1B2C3D4E5F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<OutputItemType>Analyzer</OutputItemType>
</ProjectReference>
<ProjectReference Include="..\..\src\Waffle.Analyzer\Waffle.Analyzer.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<OutputItemType>Analyzer</OutputItemType>
</ProjectReference>
</ItemGroup>

</Project>
227 changes: 227 additions & 0 deletions src/Waffle.Analyzer/Analyzer/TemplateBlockWalker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
// (c) DeNA Co., Ltd.

using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace Waffle.Analyzer;

internal sealed class TemplateBlockWalker
{
private readonly SemanticModel _semanticModel;
private readonly Stack<BlockFrame> _blockStack = new();
private readonly HashSet<ILocalSymbol> _closedVariables = new(SymbolEqualityComparer.Default);

internal TemplateBlockWalker(SemanticModel semanticModel)
{
_semanticModel = semanticModel;
}

internal void Analyze(InterpolatedStringExpressionSyntax interpolated, Action<Diagnostic> report)
{
foreach (var content in interpolated.Contents)
{
if (content is not InterpolationSyntax interpolation) continue;

var expr = interpolation.Expression;
var command = ClassifySymbol(_semanticModel.GetSymbolInfo(expr).Symbol);

if (command.IsOpeningBlock)
{
var ownedVars = ExtractOutVarSymbols(expr);
// A variable reused via "out i" (without var) may still be in _closedVariables
// from a previous block. Remove it so references inside this block are not
// falsely flagged as WAF003.
foreach (var v in ownedVars)
_closedVariables.Remove(v);
_blockStack.Push(new BlockFrame(interpolation, command, ownedVars));
}
else if (command is WaffleCommand.End)
{
if (_blockStack.Count > 0)
{
var frame = _blockStack.Pop();
foreach (var v in frame.OwnedVariables)
_closedVariables.Add(v);
}
else
{
// WAF002: End with no matching open block
report(Diagnostic.Create(
WaffleDiagnostics.UnexpectedEnd,
interpolation.GetLocation()));
}
}
else if (command is WaffleCommand.Elif or WaffleCommand.Else)
{
if (_blockStack.Count == 0 || _blockStack.Peek().Command != WaffleCommand.If)
{
// WAF004: Elif/Else outside an If block
report(Diagnostic.Create(
WaffleDiagnostics.ElifElseOutsideIf,
interpolation.GetLocation(),
command.ToString()));
}
else if (command is WaffleCommand.Else)
{
var topFrame = _blockStack.Peek();
if (topFrame.HasElse)
{
// WAF005: second Else in the same If block
report(Diagnostic.Create(
WaffleDiagnostics.MultipleElse,
interpolation.GetLocation()));
}
else
{
topFrame.HasElse = true;
}
}
else // Elif
{
if (_blockStack.Peek().HasElse)
{
// WAF006: Elif after Else
report(Diagnostic.Create(
WaffleDiagnostics.ElifAfterElse,
interpolation.GetLocation()));
}
}
}
else if (command is WaffleCommand.Break or WaffleCommand.Continue)
{
if (!_blockStack.Any(f => f.Command.IsIterationBlock))
{
// WAF007: Break/Continue outside any For/ForEach block
report(Diagnostic.Create(
WaffleDiagnostics.BreakContinueOutsideIteration,
interpolation.GetLocation(),
command.ToString()));
}
}
else // WaffleCommand.None — non-block commands or user expression
{
// A non-block command (e.g. Let) may rebind a closed variable via "out x".
// Remove those from _closedVariables before checking, so that both the "out x"
// position itself and subsequent references within this interpolation are not
// falsely flagged.
RemoveReboundOutVarsFromClosedSet(expr);
CheckForOutOfScopeVariables(expr, report);
}
}

// Any unclosed block at the end of the template → WAF001
foreach (var frame in _blockStack)
{
report(Diagnostic.Create(
WaffleDiagnostics.MissingEnd,
frame.OpeningNode.GetLocation(),
frame.Command.ToString()));
}
}

private void CheckForOutOfScopeVariables(ExpressionSyntax expr, Action<Diagnostic> report)
{
foreach (var id in expr.DescendantNodesAndSelf().OfType<IdentifierNameSyntax>())
{
// Skip identifiers in "out x" position — they are being written to, not read.
if (id.Parent is ArgumentSyntax { RefOrOutKeyword: { RawKind: (int)SyntaxKind.OutKeyword } })
continue;

if (_semanticModel.GetSymbolInfo(id).Symbol is ILocalSymbol local
&& _closedVariables.Contains(local))
{
report(Diagnostic.Create(
WaffleDiagnostics.OutOfScopeVariable,
id.GetLocation(),
id.Identifier.Text));
}
}
}

private void RemoveReboundOutVarsFromClosedSet(ExpressionSyntax expr)
{
if (expr is not InvocationExpressionSyntax invocation) return;

foreach (var argument in invocation.ArgumentList.Arguments)
{
if (!argument.RefOrOutKeyword.IsKind(SyntaxKind.OutKeyword)) continue;
// out var x — new declaration, not a rebind of a closed variable
if (argument.Expression is DeclarationExpressionSyntax) continue;
// out x — rebind; remove from closed set so subsequent references are not flagged
if (_semanticModel.GetSymbolInfo(argument.Expression).Symbol is ILocalSymbol local)
_closedVariables.Remove(local);
}
}

private ImmutableArray<ILocalSymbol> ExtractOutVarSymbols(ExpressionSyntax expr)
{
if (expr is not InvocationExpressionSyntax invocation)
return ImmutableArray<ILocalSymbol>.Empty;

var builder = ImmutableArray.CreateBuilder<ILocalSymbol>();

foreach (var argument in invocation.ArgumentList.Arguments)
{
if (!argument.RefOrOutKeyword.IsKind(SyntaxKind.OutKeyword)) continue;

if (argument.Expression is DeclarationExpressionSyntax decl
&& decl.Designation is SingleVariableDesignationSyntax designation
&& _semanticModel.GetDeclaredSymbol(designation) is ILocalSymbol declared)
{
// out var i — new declaration
builder.Add(declared);
}
else if (_semanticModel.GetSymbolInfo(argument.Expression).Symbol is ILocalSymbol reused)
{
// out i — rebinding an existing variable to a new block
builder.Add(reused);
}
}

return builder.ToImmutable();
}

/// <summary>Resolves a symbol to its Waffle command classification in a single pass.</summary>
private static WaffleCommand ClassifySymbol(ISymbol? symbol) => symbol switch
{
IMethodSymbol m when WaffleTemplateAnalyzer.IsWaffleSyntaxType(m.ContainingType) => m.Name switch
{
"For" => WaffleCommand.For,
"Forr" => WaffleCommand.Forr,
"ForEach" => WaffleCommand.ForEach,
"ForEachNullable" => WaffleCommand.ForEachNullable,
"If" => WaffleCommand.If,
"Elif" => WaffleCommand.Elif,
_ => WaffleCommand.None,
},
IFieldSymbol f when WaffleTemplateAnalyzer.IsWaffleSyntaxType(f.ContainingType) => f.Name switch
{
"End" => WaffleCommand.End,
"Else" => WaffleCommand.Else,
"Break" => WaffleCommand.Break,
"Continue" => WaffleCommand.Continue,
_ => WaffleCommand.None,
},
_ => WaffleCommand.None,
};

private sealed class BlockFrame
{
internal BlockFrame(InterpolationSyntax openingNode, WaffleCommand command,
ImmutableArray<ILocalSymbol> ownedVariables)
{
OpeningNode = openingNode;
Command = command;
OwnedVariables = ownedVariables;
}

internal InterpolationSyntax OpeningNode { get; }
internal WaffleCommand Command { get; }
internal ImmutableArray<ILocalSymbol> OwnedVariables { get; }

/// <summary>True once Else has been seen in this If block.</summary>
internal bool HasElse { get; set; }
}
}
41 changes: 41 additions & 0 deletions src/Waffle.Analyzer/Analyzer/WaffleCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// (c) DeNA Co., Ltd.

namespace Waffle.Analyzer;

internal enum WaffleCommand
{
None,

// Opening block commands — push a frame onto the stack
For,
Forr,
ForEach,
ForEachNullable,
If,

// Block terminator
End,

// Mid-If commands — valid only inside an open If frame
Elif,
Else,

// Iteration control — valid only inside a For/ForEach frame
Break,
Continue,
}

internal static class WaffleCommandExtensions
{
extension(WaffleCommand cmd)
{
internal bool IsOpeningBlock =>
cmd is WaffleCommand.For or WaffleCommand.Forr
or WaffleCommand.ForEach or WaffleCommand.ForEachNullable
or WaffleCommand.If;

internal bool IsIterationBlock =>
cmd is WaffleCommand.For or WaffleCommand.Forr
or WaffleCommand.ForEach or WaffleCommand.ForEachNullable;
}
}
Loading