From 3bc3a09d5ff24b49eb425a33cf4a2201a8e6ad31 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 13:20:13 +0000 Subject: [PATCH 1/3] Add {% include path %} directive support to template parser Implements the include directive that inlines another template file's content at parse time, as if it were written directly in the template. - Parser: handles {% include path %} by resolving relative (to the current file directory) or root-relative (to project root) paths, reading the target file via an injectable fileReader delegate, and recursively parsing it into the AST. Includes circular include detection. - ClassGenerator: accepts fileDirectory, projectRoot, fileReader, and filePath to pass through to Parser. - FileGenerator: supplies the file's directory, project root (from MSBuildProjectDirectory MSBuild property), and a File.ReadAllText reader to ClassGenerator. - Tests: Parser unit tests for include resolution, relative/absolute paths, circular detection, and missing file errors. Integration tests via OutputGenerator with temp files, and end-to-end via sample GreetingWithInclude.html that includes GreetingFragment.html. https://claude.ai/code/session_01BY3CHmYq7DCxZNVmJFP7sy --- Strongbars.Generator/ClassGenerator.cs | 16 +- Strongbars.Generator/FileGenerator.cs | 33 +++- Strongbars.Generator/Parser.cs | 110 +++++++++++- Strongbars.Tests/FileGeneratorTests.cs | 69 ++++++++ Strongbars.Tests/ParserTests.cs | 162 ++++++++++++++++++ Strongbars.Tests/sample/GreetingFragment.html | 1 + .../sample/GreetingWithInclude.html | 1 + 7 files changed, 381 insertions(+), 11 deletions(-) create mode 100644 Strongbars.Tests/sample/GreetingFragment.html create mode 100644 Strongbars.Tests/sample/GreetingWithInclude.html diff --git a/Strongbars.Generator/ClassGenerator.cs b/Strongbars.Generator/ClassGenerator.cs index 59dd1eb..cdb7f8c 100644 --- a/Strongbars.Generator/ClassGenerator.cs +++ b/Strongbars.Generator/ClassGenerator.cs @@ -8,10 +8,22 @@ public static string GenerateFileContent( string visibility, string @namespace, string @class, - string fileContent + string fileContent, + string? fileDirectory = null, + string? projectRoot = null, + Func? fileReader = null, + string? filePath = null ) { - var rootToken = new Parser($"{@namespace}.{@class}", fileContent).Parse(); + var initialIncludedPaths = filePath != null ? new HashSet { filePath } : null; + var rootToken = new Parser( + $"{@namespace}.{@class}", + fileContent, + fileDirectory, + projectRoot, + fileReader, + initialIncludedPaths + ).Parse(); var variables = DeduplicateVariables(rootToken.GetVariables()); return $@" diff --git a/Strongbars.Generator/FileGenerator.cs b/Strongbars.Generator/FileGenerator.cs index 1bed3f8..f264b93 100644 --- a/Strongbars.Generator/FileGenerator.cs +++ b/Strongbars.Generator/FileGenerator.cs @@ -1,3 +1,4 @@ +using System.IO; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; @@ -10,7 +11,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { var globalOptions = context.AnalyzerConfigOptionsProvider.Select( static (provider, _) => - provider.GetGlobalOptionOrDefault("StrongbarsVisibility", "public") + ( + Visibility: provider.GetGlobalOptionOrDefault("StrongbarsVisibility", "public"), + ProjectDirectory: provider.GetGlobalOptionOrDefault( + "MSBuildProjectDirectory", + "" + ) + ) ); var additionalFiles = context @@ -38,7 +45,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) (spc, pair) => { var @namespace = ComputeNamespace(pair.Left.Namespace!, pair.Left.RecursiveDir); - var visibility = pair.Right; + var visibility = pair.Right.Visibility; + var projectDirectory = pair.Right.ProjectDirectory; var file = pair.Left.File; var filename = Path.GetFileNameWithoutExtension(file.Path); var text = file.GetText(); @@ -84,7 +92,20 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } var fileContent = text.ToString(); - var generator = new ClassGenerator(); + var fileDirectory = Path.GetDirectoryName(file.Path); + var projectRoot = string.IsNullOrEmpty(projectDirectory) ? null : projectDirectory; + + static string? FileReader(string path) + { + try + { + return File.ReadAllText(path); + } + catch + { + return null; + } + } try { @@ -94,7 +115,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) visibility, @namespace, @class, - fileContent + fileContent, + fileDirectory, + projectRoot, + FileReader, + file.Path ) ); } diff --git a/Strongbars.Generator/Parser.cs b/Strongbars.Generator/Parser.cs index 204b35a..1f223ce 100644 --- a/Strongbars.Generator/Parser.cs +++ b/Strongbars.Generator/Parser.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Text.RegularExpressions; using Strongbars.Abstractions; @@ -7,11 +8,26 @@ public class Parser { public string TemplateName { get; } public string FileContent { get; } + private readonly string? _fileDirectory; + private readonly string? _projectRoot; + private readonly Func? _fileReader; + private readonly HashSet? _includedPaths; - public Parser(string templateName, string fileContent) + public Parser( + string templateName, + string fileContent, + string? fileDirectory = null, + string? projectRoot = null, + Func? fileReader = null, + HashSet? includedPaths = null + ) { TemplateName = templateName; FileContent = fileContent; + _fileDirectory = fileDirectory; + _projectRoot = projectRoot; + _fileReader = fileReader; + _includedPaths = includedPaths; } public ITemplateNode Parse() @@ -149,17 +165,99 @@ private ITemplateNode ParseMatch(MatchCollection matches, ref int index) var conditionalStatementMatch = ConditionalStartRegex.Match(content); if (conditionalStatementMatch.Success) return ParseConditional(conditionalStatementMatch, matches, ref index); - else + var includeMatch = IncludeRegex.Match(content); + if (includeMatch.Success) + { + index++; + return ParseInclude(includeMatch.Groups[1].Value.Trim(), match, index); + } + throw new ParserError( + TemplateName, + FileContent, + match, + index, + $"'{content}' is not a valid expression" + ); + } + + throw new ParserError(TemplateName, FileContent, match, index, "invalid token"); + } + + private ITemplateNode ParseInclude(string includePath, Match match, int matchIndex) + { + string resolvedPath; + if (includePath.StartsWith("/")) + { + if (string.IsNullOrEmpty(_projectRoot)) throw new ParserError( TemplateName, FileContent, match, - index, - $"'{content}' is not a valid expression" + matchIndex, + $"Cannot resolve root-relative include '{includePath}': project root is unavailable" ); + resolvedPath = Path.GetFullPath(Path.Combine(_projectRoot, includePath.TrimStart('/'))); + } + else + { + if (_fileDirectory == null) + throw new ParserError( + TemplateName, + FileContent, + match, + matchIndex, + $"Cannot resolve include '{includePath}': file directory is unavailable" + ); + resolvedPath = Path.GetFullPath(Path.Combine(_fileDirectory, includePath)); } - throw new ParserError(TemplateName, FileContent, match, index, "invalid token"); + if (_includedPaths?.Contains(resolvedPath) == true) + throw new ParserError( + TemplateName, + FileContent, + match, + matchIndex, + $"Circular include detected for '{resolvedPath}'" + ); + + string? content; + try + { + content = _fileReader != null ? _fileReader(resolvedPath) : File.ReadAllText(resolvedPath); + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + throw new ParserError( + TemplateName, + FileContent, + match, + matchIndex, + $"Could not read include file '{resolvedPath}': {ex.Message}" + ); + } + + if (content == null) + throw new ParserError( + TemplateName, + FileContent, + match, + matchIndex, + $"Include file not found: '{resolvedPath}'" + ); + + var newIncludedPaths = new HashSet(_includedPaths ?? new HashSet()) + { + resolvedPath, + }; + var includedDir = Path.GetDirectoryName(resolvedPath); + return new Parser( + resolvedPath, + content, + includedDir, + _projectRoot, + _fileReader, + newIncludedPaths + ).Parse(); } private string DiffSinceLastMatch(MatchCollection matches, int index) @@ -173,6 +271,8 @@ private string DiffSinceLastMatch(MatchCollection matches, int index) return FileContent.Substring(endOfLastMatch, match.Index - endOfLastMatch); } + public static Regex IncludeRegex = new Regex(@"^include\s+(.+)$", RegexOptions.Compiled); + public static Regex ConditionalStartRegex = new Regex( @"(if|unless)\s+([a-zA-Z]\w*)", RegexOptions.Compiled diff --git a/Strongbars.Tests/FileGeneratorTests.cs b/Strongbars.Tests/FileGeneratorTests.cs index 3ce0e3e..eb01e9f 100644 --- a/Strongbars.Tests/FileGeneratorTests.cs +++ b/Strongbars.Tests/FileGeneratorTests.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.IO; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; using Strongbars.Tests.Utils; @@ -468,6 +469,74 @@ public void ElseBranchDoesNotAffectTemplatesWithoutElse() ); } + [Test] + public void IncludeDirective_InlinesIncludedFileVariables() + { + Assert.That( + GreetingWithInclude.Variables, + Is.EquivalentTo([ + new Variable("name", VariableType.TemplateArgument, array: false, optional: false), + ]) + ); + } + + [Test] + public void IncludeDirective_RendersIncludedContent() + { + Assert.That( + new GreetingWithInclude(name: "World").Render(), + Is.EqualTo("Hello World!").IgnoreWhiteSpace + ); + } + + [Test] + public void IncludeDirective_ViaOutputGenerator_InlinesContent() + { + var tempDir = Path.GetTempPath(); + var partialPath = Path.Combine(tempDir, $"partial_{Guid.NewGuid():N}.html"); + File.WriteAllText(partialPath, "{{city}}"); + var mainPath = Path.Combine(tempDir, $"main_{Guid.NewGuid():N}.html"); + try + { + var textOptions = new Dictionary + { + [new TestAdditionalText( + mainPath, + $"{{% include {Path.GetFileName(partialPath)} %}}" + )] = new TestAnalyzerConfigOptions( + new Dictionary + { + ["build_metadata.AdditionalFiles.StrongbarsNamespace"] = "TestNs", + }.ToImmutableDictionary() + ), + }; + + var (diagnostics, source) = OutputGenerator.GetGeneratedOutput( + TestAnalyzerConfigOptions.Empty, + textOptions + ); + + Assert.That(diagnostics, Is.Empty); + Assert.That(source, Is.Not.Null); + Assert.That(source, Does.Contain("TemplateArgument city")); + } + finally + { + File.Delete(partialPath); + } + } + + [Test] + public void IncludeDirective_MissingFile_ProducesSB003Diagnostic() + { + var (diagnostics, _) = GenerateFromTemplate( + "Bad", + "{% include /missing/file.html %}" + ); + + Assert.That(diagnostics, Has.One.Matches(d => d.Id == "SB003")); + } + private static (ImmutableArray, string?) GenerateFromTemplate( string templateName, string templateContent diff --git a/Strongbars.Tests/ParserTests.cs b/Strongbars.Tests/ParserTests.cs index e1c1129..3956f14 100644 --- a/Strongbars.Tests/ParserTests.cs +++ b/Strongbars.Tests/ParserTests.cs @@ -199,4 +199,166 @@ public void ParserError_IncludesMatchPosition() Assert.That(ex!.Match.Index, Is.EqualTo(6)); // position of "{{ 123bad }}" Assert.That(ex.Match.Length, Is.GreaterThan(0)); } + + // ── Include directives ──────────────────────────────────────────────────── + + private static ITemplateNode ParseWithIncludes( + string content, + string fileDirectory, + Dictionary files + ) => + new Parser( + "test", + content, + fileDirectory: fileDirectory, + fileReader: path => files.TryGetValue(path, out var c) ? c : null + ).Parse(); + + [Test] + public void Include_InlinesIncludedTemplateContent() + { + var files = new Dictionary + { + ["/root/partial.html"] = "hello world", + }; + + var node = ParseWithIncludes("{% include partial.html %}", "/root", files); + + Assert.That(node, Is.InstanceOf()); + Assert.That(((LiteralTemplateNode)node).Content, Is.EqualTo("hello world")); + } + + [Test] + public void Include_RelativePath_ResolvesFromFileDirectory() + { + var files = new Dictionary + { + ["/root/partials/header.html"] = "HEADER", + }; + + var node = ParseWithIncludes("{% include partials/header.html %}", "/root", files); + + Assert.That(node, Is.InstanceOf()); + Assert.That(((LiteralTemplateNode)node).Content, Is.EqualTo("HEADER")); + } + + [Test] + public void Include_DotDotPath_ResolvesFromFileDirectory() + { + var files = new Dictionary + { + ["/root/shared.html"] = "SHARED", + }; + + var node = ParseWithIncludes( + "{% include ../shared.html %}", + "/root/subdir", + files + ); + + Assert.That(node, Is.InstanceOf()); + Assert.That(((LiteralTemplateNode)node).Content, Is.EqualTo("SHARED")); + } + + [Test] + public void Include_AbsolutePath_ResolvesFromProjectRoot() + { + var files = new Dictionary + { + ["/projectroot/partials/nav.html"] = "NAV", + }; + + var node = new Parser( + "test", + "{% include /partials/nav.html %}", + fileDirectory: "/projectroot/pages", + projectRoot: "/projectroot", + fileReader: path => files.TryGetValue(path, out var c) ? c : null + ).Parse(); + + Assert.That(node, Is.InstanceOf()); + Assert.That(((LiteralTemplateNode)node).Content, Is.EqualTo("NAV")); + } + + [Test] + public void Include_IncludedVariablesAreExposedInGetVariables() + { + var files = new Dictionary + { + ["/root/partial.html"] = "{{name}}", + }; + + var node = ParseWithIncludes("{% include partial.html %}", "/root", files); + var variables = node.GetVariables().ToArray(); + + Assert.That(variables, Has.Length.EqualTo(1)); + Assert.That(variables[0].Name, Is.EqualTo("name")); + } + + [Test] + public void Include_InsideConditional_Works() + { + var files = new Dictionary + { + ["/root/body.html"] = "BODY", + }; + + var node = ParseWithIncludes( + "{% if active %}{% include body.html %}{% end %}", + "/root", + files + ); + + var composite = (CompositeTemplateNode)node; + var cond = (ConditionalTemplateNode)composite.Nodes.Single(); + var ifTrue = (CompositeTemplateNode)cond.IfTrue; + Assert.That(ifTrue.Nodes, Has.Count.GreaterThan(0)); + } + + [Test] + public void Include_CircularInclude_ThrowsParserError() + { + var files = new Dictionary + { + ["/root/a.html"] = "{% include b.html %}", + ["/root/b.html"] = "{% include a.html %}", + }; + + var ex = Assert.Throws( + () => + new Parser( + "/root/a.html", + "{% include a.html %}", + fileDirectory: "/root", + fileReader: path => files.TryGetValue(path, out var c) ? c : null, + includedPaths: new HashSet { "/root/a.html" } + ).Parse() + ); + + Assert.That(ex!.Reason, Does.Contain("Circular include")); + } + + [Test] + public void Include_FileNotFound_ThrowsParserError() + { + var ex = Assert.Throws( + () => + new Parser( + "test", + "{% include missing.html %}", + fileDirectory: "/root", + fileReader: _ => null + ).Parse() + ); + + Assert.That(ex!.Reason, Does.Contain("Include file not found")); + } + + [Test] + public void Include_WithoutFileDirectory_ThrowsParserError() + { + var ex = Assert.Throws(() => Parse("{% include something.html %}")); + + Assert.That(ex!.Reason, Does.Contain("file directory is unavailable")); + } } diff --git a/Strongbars.Tests/sample/GreetingFragment.html b/Strongbars.Tests/sample/GreetingFragment.html new file mode 100644 index 0000000..966eba8 --- /dev/null +++ b/Strongbars.Tests/sample/GreetingFragment.html @@ -0,0 +1 @@ +{{name}} diff --git a/Strongbars.Tests/sample/GreetingWithInclude.html b/Strongbars.Tests/sample/GreetingWithInclude.html new file mode 100644 index 0000000..9386b5f --- /dev/null +++ b/Strongbars.Tests/sample/GreetingWithInclude.html @@ -0,0 +1 @@ +Hello {% include GreetingFragment.html %}! From 5c416880a77609a478eb646804cc1bb72a6ebbdd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 13:42:36 +0000 Subject: [PATCH 2/3] Split test files into focused classes by feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ParserTests.cs → ParserTests (literals, variables, GetVariables) + ParserConditionalTests (if/unless/else blocks) + ParserErrorTests (invalid syntax, error positions) + ParserIncludeTests (include directive) FileGeneratorTests.cs → FileGeneratorConfigurationTests (namespace, visibility) + FileGeneratorRenderingTests (variables, arrays, optionals) + FileGeneratorConditionalTests (if/unless/else rendering) + FileGeneratorIncludeTests (include directive) + FileGeneratorDiagnosticsTests (SB003 diagnostics, deduplication) https://claude.ai/code/session_01BY3CHmYq7DCxZNVmJFP7sy --- .../FileGeneratorConditionalTests.cs | 138 +++++ .../FileGeneratorDiagnosticsTests.cs | 116 ++++ Strongbars.Tests/FileGeneratorIncludeTests.cs | 97 ++++ .../FileGeneratorRenderingTests.cs | 206 +++++++ Strongbars.Tests/FileGeneratorTests.cs | 513 +----------------- Strongbars.Tests/ParserConditionalTests.cs | 74 +++ Strongbars.Tests/ParserErrorTests.cs | 42 ++ Strongbars.Tests/ParserIncludeTests.cs | 169 ++++++ Strongbars.Tests/ParserTests.cs | 274 +--------- 9 files changed, 845 insertions(+), 784 deletions(-) create mode 100644 Strongbars.Tests/FileGeneratorConditionalTests.cs create mode 100644 Strongbars.Tests/FileGeneratorDiagnosticsTests.cs create mode 100644 Strongbars.Tests/FileGeneratorIncludeTests.cs create mode 100644 Strongbars.Tests/FileGeneratorRenderingTests.cs create mode 100644 Strongbars.Tests/ParserConditionalTests.cs create mode 100644 Strongbars.Tests/ParserErrorTests.cs create mode 100644 Strongbars.Tests/ParserIncludeTests.cs diff --git a/Strongbars.Tests/FileGeneratorConditionalTests.cs b/Strongbars.Tests/FileGeneratorConditionalTests.cs new file mode 100644 index 0000000..4212624 --- /dev/null +++ b/Strongbars.Tests/FileGeneratorConditionalTests.cs @@ -0,0 +1,138 @@ +using Strongbars.Abstractions; +using Strongbars.Tests.Output; + +namespace Strongbars.Tests; + +public class FileGeneratorConditionalTests +{ + [Test] + public void ConditionalVariablesHaveCorrectMetadata() + { + Assert.That( + Message.Variables, + Is.EquivalentTo([ + new Variable("urgent", VariableType.Bool, array: false, optional: false), + new Variable( + "message", + VariableType.TemplateArgument, + array: false, + optional: false + ), + ]) + ); + } + + [Test] + public void ConditionalRendersContentWhenTrue() + { + var template = new Message(urgent: true, message: "Hello!"); + + Assert.That(template.Render(), Does.Contain("urgent")); + Assert.That(template.Render(), Does.Contain("Hello!")); + } + + [Test] + public void ConditionalRendersEmptyWhenFalse() + { + var template = new Message(urgent: false, message: "Hello!"); + + Assert.That(template.Render(), Does.Not.Contain("urgent")); + Assert.That(template.Render(), Does.Contain("Hello!")); + } + + [Test] + public void ConditionalFullRenderMatchesExpected() + { + Assert.That( + new Message(urgent: true, message: "Buy now!").Render(), + Is.EqualTo(@"
Buy now!
").IgnoreWhiteSpace + ); + Assert.That( + new Message(urgent: false, message: "Buy now!").Render(), + Is.EqualTo(@"
Buy now!
").IgnoreWhiteSpace + ); + } + + [Test] + public void UnlessVariablesHaveCorrectMetadata() + { + Assert.That( + Status.Variables, + Is.EquivalentTo([ + new Variable("inactive", VariableType.Bool, array: false, optional: false), + new Variable("label", VariableType.TemplateArgument, array: false, optional: false), + ]) + ); + } + + [Test] + public void UnlessRendersContentWhenFalse() + { + var template = new Status(inactive: false, label: "Online"); + + Assert.That(template.Render(), Does.Contain("active")); + Assert.That(template.Render(), Does.Contain("Online")); + } + + [Test] + public void UnlessRendersEmptyWhenTrue() + { + var template = new Status(inactive: true, label: "Offline"); + + Assert.That(template.Render(), Does.Not.Contain("active")); + Assert.That(template.Render(), Does.Contain("Offline")); + } + + [Test] + public void UnlessFullRenderMatchesExpected() + { + Assert.That( + new Status(inactive: false, label: "Online").Render(), + Is.EqualTo(@"Online").IgnoreWhiteSpace + ); + Assert.That( + new Status(inactive: true, label: "Offline").Render(), + Is.EqualTo(@"Offline").IgnoreWhiteSpace + ); + } + + [Test] + public void IfElseRendersElseBranchWhenFalse() + { + Assert.That( + new Toggle(enabled: true, label: "Go").Render(), + Is.EqualTo(@"").IgnoreWhiteSpace + ); + Assert.That( + new Toggle(enabled: false, label: "Go").Render(), + Is.EqualTo(@"").IgnoreWhiteSpace + ); + } + + [Test] + public void UnlessElseRendersElseBranchWhenTrue() + { + Assert.That( + new Subscription(premium: false).Render(), + Is.EqualTo("

Free tier

").IgnoreWhiteSpace + ); + Assert.That( + new Subscription(premium: true).Render(), + Is.EqualTo("

Premium member

").IgnoreWhiteSpace + ); + } + + [Test] + public void ElseBranchDoesNotAffectTemplatesWithoutElse() + { + // Existing templates without {% else %} must still behave identically + Assert.That( + new Message(urgent: false, message: "All good").Render(), + Is.EqualTo(@"
All good
").IgnoreWhiteSpace + ); + Assert.That( + new Status(inactive: true, label: "Offline").Render(), + Is.EqualTo(@"Offline").IgnoreWhiteSpace + ); + } +} diff --git a/Strongbars.Tests/FileGeneratorDiagnosticsTests.cs b/Strongbars.Tests/FileGeneratorDiagnosticsTests.cs new file mode 100644 index 0000000..a7883ec --- /dev/null +++ b/Strongbars.Tests/FileGeneratorDiagnosticsTests.cs @@ -0,0 +1,116 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Strongbars.Tests.Utils; + +namespace Strongbars.Tests; + +public class FileGeneratorDiagnosticsTests +{ + [Test] + public void InvalidVariableNameProducesSB003Diagnostic() + { + var (diagnostics, _) = GenerateFromTemplate("Bad", "{{ 123invalid }}"); + + Assert.That(diagnostics, Has.One.Matches(d => d.Id == "SB003")); + Assert.That( + diagnostics.Single(d => d.Id == "SB003").GetMessage(), + Does.Contain("not a valid variable name") + ); + } + + [Test] + public void InvalidExpressionProducesSB003Diagnostic() + { + var (diagnostics, _) = GenerateFromTemplate("Bad", "{% badkeyword %}"); + + Assert.That(diagnostics, Has.One.Matches(d => d.Id == "SB003")); + Assert.That( + diagnostics.Single(d => d.Id == "SB003").GetMessage(), + Does.Contain("not a valid expression") + ); + } + + [Test] + public void UnclosedConditionalProducesSB003Diagnostic() + { + var (diagnostics, _) = GenerateFromTemplate("Bad", "{% if foo %}no closing end tag"); + + Assert.That(diagnostics, Has.One.Matches(d => d.Id == "SB003")); + Assert.That( + diagnostics.Single(d => d.Id == "SB003").GetMessage(), + Does.Contain("Conditional doesnt end") + ); + } + + [Test] + public void SB003DiagnosticIsError() + { + var (diagnostics, _) = GenerateFromTemplate("Bad", "{{ 123invalid }}"); + + Assert.That( + diagnostics.Single(d => d.Id == "SB003").Severity, + Is.EqualTo(DiagnosticSeverity.Error) + ); + } + + [Test] + public void SameVariableUsedTwiceGeneratesSingleConstructorParameter() + { + var (diagnostics, source) = GenerateFromTemplate( + "Tmpl", + "Hello {{name}}, your name is {{name}} again." + ); + + Assert.That(diagnostics, Is.Empty); + Assert.That(source, Is.Not.Null); + // Constructor must not have duplicate `name` parameters + Assert.That(source, Does.Not.Match(@"TemplateArgument name,\s*TemplateArgument name")); + } + + [Test] + public void RequiredVariableOverridesOptionalWhenSameNameUsedBoth() + { + // {{name}} is required, {{name?}} is optional — result should be required (no ?) + var (diagnostics, source) = GenerateFromTemplate("Tmpl", "{{name}} and {{name?}}"); + + Assert.That(diagnostics, Is.Empty); + Assert.That(source, Is.Not.Null); + // The field should be non-nullable (TemplateArgument, not TemplateArgument?) + Assert.That(source, Does.Contain("TemplateArgument name")); + Assert.That(source, Does.Not.Contain("TemplateArgument? name")); + } + + [Test] + public void TypeConflictBetweenBoolAndTemplateArgumentProducesSB003Diagnostic() + { + // {% if items %} treats `items` as Bool; {{..items}} treats it as TemplateArgument array + var (diagnostics, _) = GenerateFromTemplate( + "Tmpl", + "{% if items %}yes{% end %}{{..items}}" + ); + + Assert.That(diagnostics, Has.One.Matches(d => d.Id == "SB003")); + Assert.That( + diagnostics.Single(d => d.Id == "SB003").GetMessage(), + Does.Contain("conflicting types") + ); + } + + private static (ImmutableArray, string?) GenerateFromTemplate( + string templateName, + string templateContent + ) + { + var textOptions = new Dictionary + { + [new TestAdditionalText(templateName, templateContent)] = new TestAnalyzerConfigOptions( + new Dictionary + { + ["build_metadata.AdditionalFiles.StrongbarsNamespace"] = "TestNs", + }.ToImmutableDictionary() + ), + }; + return OutputGenerator.GetGeneratedOutput(TestAnalyzerConfigOptions.Empty, textOptions); + } +} diff --git a/Strongbars.Tests/FileGeneratorIncludeTests.cs b/Strongbars.Tests/FileGeneratorIncludeTests.cs new file mode 100644 index 0000000..b61f668 --- /dev/null +++ b/Strongbars.Tests/FileGeneratorIncludeTests.cs @@ -0,0 +1,97 @@ +using System.Collections.Immutable; +using System.IO; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Strongbars.Abstractions; +using Strongbars.Tests.Output; +using Strongbars.Tests.Utils; + +namespace Strongbars.Tests; + +public class FileGeneratorIncludeTests +{ + [Test] + public void IncludeDirective_InlinesIncludedFileVariables() + { + Assert.That( + GreetingWithInclude.Variables, + Is.EquivalentTo([ + new Variable("name", VariableType.TemplateArgument, array: false, optional: false), + ]) + ); + } + + [Test] + public void IncludeDirective_RendersIncludedContent() + { + Assert.That( + new GreetingWithInclude(name: "World").Render(), + Is.EqualTo("Hello World!").IgnoreWhiteSpace + ); + } + + [Test] + public void IncludeDirective_ViaOutputGenerator_InlinesContent() + { + var tempDir = Path.GetTempPath(); + var partialPath = Path.Combine(tempDir, $"partial_{Guid.NewGuid():N}.html"); + File.WriteAllText(partialPath, "{{city}}"); + var mainPath = Path.Combine(tempDir, $"main_{Guid.NewGuid():N}.html"); + try + { + var textOptions = new Dictionary + { + [new TestAdditionalText( + mainPath, + $"{{% include {Path.GetFileName(partialPath)} %}}" + )] = new TestAnalyzerConfigOptions( + new Dictionary + { + ["build_metadata.AdditionalFiles.StrongbarsNamespace"] = "TestNs", + }.ToImmutableDictionary() + ), + }; + + var (diagnostics, source) = OutputGenerator.GetGeneratedOutput( + TestAnalyzerConfigOptions.Empty, + textOptions + ); + + Assert.That(diagnostics, Is.Empty); + Assert.That(source, Is.Not.Null); + Assert.That(source, Does.Contain("TemplateArgument city")); + } + finally + { + File.Delete(partialPath); + } + } + + [Test] + public void IncludeDirective_MissingFile_ProducesSB003Diagnostic() + { + var (diagnostics, _) = GenerateFromTemplate( + "Bad", + "{% include /missing/file.html %}" + ); + + Assert.That(diagnostics, Has.One.Matches(d => d.Id == "SB003")); + } + + private static (ImmutableArray, string?) GenerateFromTemplate( + string templateName, + string templateContent + ) + { + var textOptions = new Dictionary + { + [new TestAdditionalText(templateName, templateContent)] = new TestAnalyzerConfigOptions( + new Dictionary + { + ["build_metadata.AdditionalFiles.StrongbarsNamespace"] = "TestNs", + }.ToImmutableDictionary() + ), + }; + return OutputGenerator.GetGeneratedOutput(TestAnalyzerConfigOptions.Empty, textOptions); + } +} diff --git a/Strongbars.Tests/FileGeneratorRenderingTests.cs b/Strongbars.Tests/FileGeneratorRenderingTests.cs new file mode 100644 index 0000000..38885fa --- /dev/null +++ b/Strongbars.Tests/FileGeneratorRenderingTests.cs @@ -0,0 +1,206 @@ +using Strongbars.Abstractions; +using Strongbars.Tests.Output; +using List = Strongbars.Tests.Output.List; + +namespace Strongbars.Tests; + +public class FileGeneratorRenderingTests +{ + [Test] + public void HasExpectedArgs() + { + Assert.That( + Name.Variables, + Is.EquivalentTo([ + new Variable("firstName", VariableType.TemplateArgument, false, false), + new Variable("lastName", VariableType.TemplateArgument, false, false), + ]) + ); + } + + [Test] + public void GeneratedClassGivesExpectedRender() + { + var template = new Name(firstName: "Bob", lastName: "Smith"); + + Assert.That(template.Render(), Is.EqualTo("

Hello Bob Smith

").IgnoreWhiteSpace); + } + + [Test] + public void ToStringWorks() + { + var template = new Name(firstName: "Bobby", lastName: "Smith"); + + Assert.That((string)template, Is.EqualTo("

Hello Bobby Smith

").IgnoreWhiteSpace); + } + + [Test] + public void IgnoresNewlinesInVariableBrackets() + { + var template = new Paragraph("Test"); + + Assert.That(template.Render(), Is.EqualTo("

\n Test\n

").IgnoreWhiteSpace); + } + + [Test] + public void ListHasConstructors() + { + Assert.That( + typeof(List) + .GetConstructors() + .Select(con => con.GetParameters().Select(p => p.ParameterType).ToArray()), + Is.EquivalentTo( + new[] + { + new[] { typeof(IEnumerable) }, + new[] { typeof(IEnumerable) }, + } + ) + ); + } + + [Test] + public void ListHasConstructorsTest2() + { + Assert.That( + typeof(TwoLists) + .GetConstructors() + .Select(con => con.GetParameters().Select(p => p.ParameterType).ToArray()), + Is.EquivalentTo( + new[] + { + new[] + { + typeof(TemplateArgument), + typeof(IEnumerable), + typeof(TemplateArgument), + typeof(IEnumerable), + }, + new[] + { + typeof(TemplateArgument), + typeof(IEnumerable), + typeof(TemplateArgument), + typeof(IEnumerable), + }, + new[] + { + typeof(TemplateArgument), + typeof(IEnumerable), + typeof(TemplateArgument), + typeof(IEnumerable), + }, + new[] + { + typeof(TemplateArgument), + typeof(IEnumerable), + typeof(TemplateArgument), + typeof(IEnumerable), + }, + } + ) + ); + } + + [Test] + public void ListSample() + { + var template = new List([new ListItem("alpha"), new ListItem("omega")]); + + Assert.That( + template.Render(), + Is.EqualTo("
  • alpha
  • omega
").IgnoreWhiteSpace + ); + } + + [Test] + public void SupportIEnumerable() + { + var enumerable = Enumerable.Repeat(new ListItem("a"), 3); + var template = new List(enumerable); + + Assert.That( + template.Render(), + Is.EqualTo("
  • a
  • a
  • a
").IgnoreWhiteSpace + ); + } + + [Test] + public void SupportIEnumerableOfString() + { + var enumerable = Enumerable.Repeat("a", 5); + var template = new List(enumerable); + + Assert.That(template.Render(), Is.EqualTo("
    aaaaa
").IgnoreWhiteSpace); + } + + [Test] + public void ListSampleParams() + { + var template = new List(new ListItem("alpha"), new ListItem("omega")); + + Assert.That( + template.Render(), + Is.EqualTo("
  • alpha
  • omega
").IgnoreWhiteSpace + ); + } + + [Test] + public void SupportArray() + { + Assert.That( + List.Variables, + Is.EquivalentTo([new Variable("items", VariableType.TemplateArgument, true, false)]) + ); + } + + [Test] + public void SupportOptional() + { + Assert.That( + TemplateWithOptional.Variables, + Is.EquivalentTo([ + new Variable( + name: "items", + type: VariableType.TemplateArgument, + array: true, + optional: true + ), + new Variable( + name: "blah", + type: VariableType.TemplateArgument, + array: false, + optional: true + ), + ]) + ); + } + + [Test] + public void OptionalIsOkayWithNullString() + { + string? nullable = null; + Assert.That(new OnlyOptional(nullable).Render(), Is.EqualTo("").IgnoreWhiteSpace); + } + + [Test] + public void OptionalSample() + { + Assert.That( + new TemplateWithOptional(null, (IEnumerable?)null).Render(), + Is.EqualTo("").IgnoreWhiteSpace + ); + Assert.That( + new TemplateWithOptional(blah: "a", items: (IEnumerable?)null).Render(), + Is.EqualTo("a").IgnoreWhiteSpace + ); + Assert.That( + new TemplateWithOptional(blah: null, items: (IEnumerable?)[]).Render(), + Is.EqualTo("").IgnoreWhiteSpace + ); + Assert.That( + new TemplateWithOptional(blah: null, items: ["a", "b", "cde"]).Render(), + Is.EqualTo("abcde").IgnoreWhiteSpace + ); + } +} diff --git a/Strongbars.Tests/FileGeneratorTests.cs b/Strongbars.Tests/FileGeneratorTests.cs index eb01e9f..ae28e98 100644 --- a/Strongbars.Tests/FileGeneratorTests.cs +++ b/Strongbars.Tests/FileGeneratorTests.cs @@ -1,16 +1,11 @@ -using System.Collections.Immutable; -using System.IO; +using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; using Strongbars.Tests.Utils; namespace Strongbars.Tests; -using Strongbars.Abstractions; -using Strongbars.Tests.Output; -using List = Strongbars.Tests.Output.List; - -public class FileGeneratorTests +public class FileGeneratorConfigurationTests { [Test] public void ProducesExpectedFile_WithDefaults() @@ -139,508 +134,4 @@ public void NoSubdirectoryLeavesNamespaceUnchanged() Assert.That(output, Does.Contain("namespace Pages")); Assert.That(output, Does.Not.Contain("namespace Pages.")); } - - [Test] - public void HasExpectedArgs() - { - Assert.That( - Name.Variables, - Is.EquivalentTo([ - new Variable("firstName", VariableType.TemplateArgument, false, false), - new Variable("lastName", VariableType.TemplateArgument, false, false), - ]) - ); - } - - [Test] - public void GeneratedClassGivesExpectedRender() - { - var template = new Name(firstName: "Bob", lastName: "Smith"); - - Assert.That(template.Render(), Is.EqualTo("

Hello Bob Smith

").IgnoreWhiteSpace); - } - - [Test] - public void ToStringWorks() - { - var template = new Name(firstName: "Bobby", lastName: "Smith"); - - Assert.That((string)template, Is.EqualTo("

Hello Bobby Smith

").IgnoreWhiteSpace); - } - - [Test] - public void IgnoresNewlinesInVariableBrackets() - { - var template = new Paragraph("Test"); - - Assert.That(template.Render(), Is.EqualTo("

\n Test\n

").IgnoreWhiteSpace); - } - - [Test] - public void ListHasConstructors() - { - Assert.That( - typeof(List) - .GetConstructors() - .Select(con => con.GetParameters().Select(p => p.ParameterType).ToArray()), - Is.EquivalentTo( - new[] - { - new[] { typeof(IEnumerable) }, - new[] { typeof(IEnumerable) }, - } - ) - ); - } - - [Test] - public void ListHasConstructorsTest2() - { - Assert.That( - typeof(TwoLists) - .GetConstructors() - .Select(con => con.GetParameters().Select(p => p.ParameterType).ToArray()), - Is.EquivalentTo( - new[] - { - new[] - { - typeof(TemplateArgument), - typeof(IEnumerable), - typeof(TemplateArgument), - typeof(IEnumerable), - }, - new[] - { - typeof(TemplateArgument), - typeof(IEnumerable), - typeof(TemplateArgument), - typeof(IEnumerable), - }, - new[] - { - typeof(TemplateArgument), - typeof(IEnumerable), - typeof(TemplateArgument), - typeof(IEnumerable), - }, - new[] - { - typeof(TemplateArgument), - typeof(IEnumerable), - typeof(TemplateArgument), - typeof(IEnumerable), - }, - } - ) - ); - } - - [Test] - public void ListSample() - { - var template = new List([new ListItem("alpha"), new ListItem("omega")]); - - Assert.That( - template.Render(), - Is.EqualTo("
  • alpha
  • omega
").IgnoreWhiteSpace - ); - } - - [Test] - public void SupportIEnumerable() - { - var enumerable = Enumerable.Repeat(new ListItem("a"), 3); - var template = new List(enumerable); - - Assert.That( - template.Render(), - Is.EqualTo("
  • a
  • a
  • a
").IgnoreWhiteSpace - ); - } - - [Test] - public void SupportIEnumerableOfString() - { - var enumerable = Enumerable.Repeat("a", 5); - var template = new List(enumerable); - - Assert.That(template.Render(), Is.EqualTo("
    aaaaa
").IgnoreWhiteSpace); - } - - [Test] - public void ListSampleParams() - { - var template = new List(new ListItem("alpha"), new ListItem("omega")); - - Assert.That( - template.Render(), - Is.EqualTo("
  • alpha
  • omega
").IgnoreWhiteSpace - ); - } - - [Test] - public void SupportArray() - { - Assert.That( - List.Variables, - Is.EquivalentTo([new Variable("items", VariableType.TemplateArgument, true, false)]) - ); - } - - [Test] - public void SupportOptional() - { - Assert.That( - TemplateWithOptional.Variables, - Is.EquivalentTo([ - new Variable( - name: "items", - type: VariableType.TemplateArgument, - array: true, - optional: true - ), - new Variable( - name: "blah", - type: VariableType.TemplateArgument, - array: false, - optional: true - ), - ]) - ); - } - - [Test] - public void OptionalIsOkayWithNullString() - { - string? nullable = null; - Assert.That(new OnlyOptional(nullable).Render(), Is.EqualTo("").IgnoreWhiteSpace); - } - - [Test] - public void OptionalSample() - { - Assert.That( - new TemplateWithOptional(null, (IEnumerable?)null).Render(), - Is.EqualTo("").IgnoreWhiteSpace - ); - Assert.That( - new TemplateWithOptional(blah: "a", items: (IEnumerable?)null).Render(), - Is.EqualTo("a").IgnoreWhiteSpace - ); - Assert.That( - new TemplateWithOptional(blah: null, items: (IEnumerable?)[]).Render(), - Is.EqualTo("").IgnoreWhiteSpace - ); - Assert.That( - new TemplateWithOptional(blah: null, items: ["a", "b", "cde"]).Render(), - Is.EqualTo("abcde").IgnoreWhiteSpace - ); - } - - [Test] - public void ConditionalVariablesHaveCorrectMetadata() - { - Assert.That( - Message.Variables, - Is.EquivalentTo([ - new Variable("urgent", VariableType.Bool, array: false, optional: false), - new Variable( - "message", - VariableType.TemplateArgument, - array: false, - optional: false - ), - ]) - ); - } - - [Test] - public void ConditionalRendersContentWhenTrue() - { - var template = new Message(urgent: true, message: "Hello!"); - - Assert.That(template.Render(), Does.Contain("urgent")); - Assert.That(template.Render(), Does.Contain("Hello!")); - } - - [Test] - public void ConditionalRendersEmptyWhenFalse() - { - var template = new Message(urgent: false, message: "Hello!"); - - Assert.That(template.Render(), Does.Not.Contain("urgent")); - Assert.That(template.Render(), Does.Contain("Hello!")); - } - - [Test] - public void ConditionalFullRenderMatchesExpected() - { - Assert.That( - new Message(urgent: true, message: "Buy now!").Render(), - Is.EqualTo(@"
Buy now!
").IgnoreWhiteSpace - ); - Assert.That( - new Message(urgent: false, message: "Buy now!").Render(), - Is.EqualTo(@"
Buy now!
").IgnoreWhiteSpace - ); - } - - [Test] - public void UnlessVariablesHaveCorrectMetadata() - { - Assert.That( - Status.Variables, - Is.EquivalentTo([ - new Variable("inactive", VariableType.Bool, array: false, optional: false), - new Variable("label", VariableType.TemplateArgument, array: false, optional: false), - ]) - ); - } - - [Test] - public void UnlessRendersContentWhenFalse() - { - var template = new Status(inactive: false, label: "Online"); - - Assert.That(template.Render(), Does.Contain("active")); - Assert.That(template.Render(), Does.Contain("Online")); - } - - [Test] - public void UnlessRendersEmptyWhenTrue() - { - var template = new Status(inactive: true, label: "Offline"); - - Assert.That(template.Render(), Does.Not.Contain("active")); - Assert.That(template.Render(), Does.Contain("Offline")); - } - - [Test] - public void UnlessFullRenderMatchesExpected() - { - Assert.That( - new Status(inactive: false, label: "Online").Render(), - Is.EqualTo(@"Online").IgnoreWhiteSpace - ); - Assert.That( - new Status(inactive: true, label: "Offline").Render(), - Is.EqualTo(@"Offline").IgnoreWhiteSpace - ); - } - - [Test] - public void IfElseRendersElseBranchWhenFalse() - { - Assert.That( - new Toggle(enabled: true, label: "Go").Render(), - Is.EqualTo(@"").IgnoreWhiteSpace - ); - Assert.That( - new Toggle(enabled: false, label: "Go").Render(), - Is.EqualTo(@"").IgnoreWhiteSpace - ); - } - - [Test] - public void UnlessElseRendersElseBranchWhenTrue() - { - Assert.That( - new Subscription(premium: false).Render(), - Is.EqualTo("

Free tier

").IgnoreWhiteSpace - ); - Assert.That( - new Subscription(premium: true).Render(), - Is.EqualTo("

Premium member

").IgnoreWhiteSpace - ); - } - - [Test] - public void ElseBranchDoesNotAffectTemplatesWithoutElse() - { - // Existing templates without {% else %} must still behave identically - Assert.That( - new Message(urgent: false, message: "All good").Render(), - Is.EqualTo(@"
All good
").IgnoreWhiteSpace - ); - Assert.That( - new Status(inactive: true, label: "Offline").Render(), - Is.EqualTo(@"Offline").IgnoreWhiteSpace - ); - } - - [Test] - public void IncludeDirective_InlinesIncludedFileVariables() - { - Assert.That( - GreetingWithInclude.Variables, - Is.EquivalentTo([ - new Variable("name", VariableType.TemplateArgument, array: false, optional: false), - ]) - ); - } - - [Test] - public void IncludeDirective_RendersIncludedContent() - { - Assert.That( - new GreetingWithInclude(name: "World").Render(), - Is.EqualTo("Hello World!").IgnoreWhiteSpace - ); - } - - [Test] - public void IncludeDirective_ViaOutputGenerator_InlinesContent() - { - var tempDir = Path.GetTempPath(); - var partialPath = Path.Combine(tempDir, $"partial_{Guid.NewGuid():N}.html"); - File.WriteAllText(partialPath, "{{city}}"); - var mainPath = Path.Combine(tempDir, $"main_{Guid.NewGuid():N}.html"); - try - { - var textOptions = new Dictionary - { - [new TestAdditionalText( - mainPath, - $"{{% include {Path.GetFileName(partialPath)} %}}" - )] = new TestAnalyzerConfigOptions( - new Dictionary - { - ["build_metadata.AdditionalFiles.StrongbarsNamespace"] = "TestNs", - }.ToImmutableDictionary() - ), - }; - - var (diagnostics, source) = OutputGenerator.GetGeneratedOutput( - TestAnalyzerConfigOptions.Empty, - textOptions - ); - - Assert.That(diagnostics, Is.Empty); - Assert.That(source, Is.Not.Null); - Assert.That(source, Does.Contain("TemplateArgument city")); - } - finally - { - File.Delete(partialPath); - } - } - - [Test] - public void IncludeDirective_MissingFile_ProducesSB003Diagnostic() - { - var (diagnostics, _) = GenerateFromTemplate( - "Bad", - "{% include /missing/file.html %}" - ); - - Assert.That(diagnostics, Has.One.Matches(d => d.Id == "SB003")); - } - - private static (ImmutableArray, string?) GenerateFromTemplate( - string templateName, - string templateContent - ) - { - var textOptions = new Dictionary - { - [new TestAdditionalText(templateName, templateContent)] = new TestAnalyzerConfigOptions( - new Dictionary - { - ["build_metadata.AdditionalFiles.StrongbarsNamespace"] = "TestNs", - }.ToImmutableDictionary() - ), - }; - return OutputGenerator.GetGeneratedOutput(TestAnalyzerConfigOptions.Empty, textOptions); - } - - [Test] - public void InvalidVariableNameProducesSB003Diagnostic() - { - var (diagnostics, _) = GenerateFromTemplate("Bad", "{{ 123invalid }}"); - - Assert.That(diagnostics, Has.One.Matches(d => d.Id == "SB003")); - Assert.That( - diagnostics.Single(d => d.Id == "SB003").GetMessage(), - Does.Contain("not a valid variable name") - ); - } - - [Test] - public void InvalidExpressionProducesSB003Diagnostic() - { - var (diagnostics, _) = GenerateFromTemplate("Bad", "{% badkeyword %}"); - - Assert.That(diagnostics, Has.One.Matches(d => d.Id == "SB003")); - Assert.That( - diagnostics.Single(d => d.Id == "SB003").GetMessage(), - Does.Contain("not a valid expression") - ); - } - - [Test] - public void UnclosedConditionalProducesSB003Diagnostic() - { - var (diagnostics, _) = GenerateFromTemplate("Bad", "{% if foo %}no closing end tag"); - - Assert.That(diagnostics, Has.One.Matches(d => d.Id == "SB003")); - Assert.That( - diagnostics.Single(d => d.Id == "SB003").GetMessage(), - Does.Contain("Conditional doesnt end") - ); - } - - [Test] - public void SB003DiagnosticIsError() - { - var (diagnostics, _) = GenerateFromTemplate("Bad", "{{ 123invalid }}"); - - Assert.That( - diagnostics.Single(d => d.Id == "SB003").Severity, - Is.EqualTo(DiagnosticSeverity.Error) - ); - } - - [Test] - public void SameVariableUsedTwiceGeneratesSingleConstructorParameter() - { - var (diagnostics, source) = GenerateFromTemplate( - "Tmpl", - "Hello {{name}}, your name is {{name}} again." - ); - - Assert.That(diagnostics, Is.Empty); - Assert.That(source, Is.Not.Null); - // Constructor must not have duplicate `name` parameters - Assert.That(source, Does.Not.Match(@"TemplateArgument name,\s*TemplateArgument name")); - } - - [Test] - public void RequiredVariableOverridesOptionalWhenSameNameUsedBoth() - { - // {{name}} is required, {{name?}} is optional — result should be required (no ?) - var (diagnostics, source) = GenerateFromTemplate("Tmpl", "{{name}} and {{name?}}"); - - Assert.That(diagnostics, Is.Empty); - Assert.That(source, Is.Not.Null); - // The field should be non-nullable (TemplateArgument, not TemplateArgument?) - Assert.That(source, Does.Contain("TemplateArgument name")); - Assert.That(source, Does.Not.Contain("TemplateArgument? name")); - } - - [Test] - public void TypeConflictBetweenBoolAndTemplateArgumentProducesSB003Diagnostic() - { - // {% if items %} treats `items` as Bool; {{..items}} treats it as TemplateArgument array - var (diagnostics, _) = GenerateFromTemplate( - "Tmpl", - "{% if items %}yes{% end %}{{..items}}" - ); - - Assert.That(diagnostics, Has.One.Matches(d => d.Id == "SB003")); - Assert.That( - diagnostics.Single(d => d.Id == "SB003").GetMessage(), - Does.Contain("conflicting types") - ); - } } diff --git a/Strongbars.Tests/ParserConditionalTests.cs b/Strongbars.Tests/ParserConditionalTests.cs new file mode 100644 index 0000000..0ed4360 --- /dev/null +++ b/Strongbars.Tests/ParserConditionalTests.cs @@ -0,0 +1,74 @@ +using Strongbars.Abstractions; +using Strongbars.Generator; + +namespace Strongbars.Tests; + +public class ParserConditionalTests +{ + private static ITemplateNode Parse(string content) => new Parser("test", content).Parse(); + + [Test] + public void IfBlock_ProducesConditionalNode() + { + var node = (CompositeTemplateNode)Parse("{% if active %}yes{% end %}"); + var cond = (ConditionalTemplateNode)node.Nodes.Single(); + + Assert.That(cond.Conditional.Name, Is.EqualTo("active")); + Assert.That(cond.Conditional.Type, Is.EqualTo(VariableType.Bool)); + } + + [Test] + public void IfBlock_IfTrueContainsLiteral() + { + var node = (CompositeTemplateNode)Parse("{% if active %}yes{% end %}"); + var cond = (ConditionalTemplateNode)node.Nodes.Single(); + var ifTrue = (CompositeTemplateNode)cond.IfTrue; + + Assert.That(ifTrue.Nodes.OfType().Single().Content, Is.EqualTo("yes")); + } + + [Test] + public void IfBlock_IfFalseIsEmptyComposite() + { + var node = (CompositeTemplateNode)Parse("{% if active %}yes{% end %}"); + var cond = (ConditionalTemplateNode)node.Nodes.Single(); + var ifFalse = (CompositeTemplateNode)cond.IfFalse; + + Assert.That(ifFalse.Nodes, Is.Empty); + } + + [Test] + public void IfElseBlock_BothBranchesPopulated() + { + var node = (CompositeTemplateNode)Parse("{% if enabled %}on{% else %}off{% end %}"); + var cond = (ConditionalTemplateNode)node.Nodes.Single(); + + var ifTrue = (CompositeTemplateNode)cond.IfTrue; + Assert.That(ifTrue.Nodes.OfType().Single().Content, Is.EqualTo("on")); + + var ifFalse = (CompositeTemplateNode)cond.IfFalse; + Assert.That( + ifFalse.Nodes.OfType().Single().Content, + Is.EqualTo("off") + ); + } + + [Test] + public void UnlessBlock_InvertsIfTrueAndIfFalse() + { + // {% unless x %}body{% end %}: condition=x, IfTrue="" (shown when x=true), IfFalse="body" + var node = (CompositeTemplateNode)Parse("{% unless x %}body{% end %}"); + var cond = (ConditionalTemplateNode)node.Nodes.Single(); + + // IfTrue (rendered when condition is true) is empty for "unless" + var ifTrue = (CompositeTemplateNode)cond.IfTrue; + Assert.That(ifTrue.Nodes, Is.Empty); + + // IfFalse (rendered when condition is false) contains the body + var ifFalse = (CompositeTemplateNode)cond.IfFalse; + Assert.That( + ifFalse.Nodes.OfType().Single().Content, + Is.EqualTo("body") + ); + } +} diff --git a/Strongbars.Tests/ParserErrorTests.cs b/Strongbars.Tests/ParserErrorTests.cs new file mode 100644 index 0000000..0151056 --- /dev/null +++ b/Strongbars.Tests/ParserErrorTests.cs @@ -0,0 +1,42 @@ +using Strongbars.Generator; + +namespace Strongbars.Tests; + +public class ParserErrorTests +{ + private static void Parse(string content) => new Parser("test", content).Parse(); + + [Test] + public void InvalidVariableName_ThrowsParserError() + { + var ex = Assert.Throws(() => Parse("{{ 123bad }}")); + + Assert.That(ex!.Reason, Does.Contain("not a valid variable name")); + Assert.That(ex.TemplateName, Is.EqualTo("test")); + } + + [Test] + public void InvalidExpression_ThrowsParserError() + { + var ex = Assert.Throws(() => Parse("{% notakeyword %}")); + + Assert.That(ex!.Reason, Does.Contain("not a valid expression")); + } + + [Test] + public void UnclosedConditional_ThrowsParserError() + { + var ex = Assert.Throws(() => Parse("{% if x %}body without end")); + + Assert.That(ex!.Reason, Does.Contain("Conditional doesnt end")); + } + + [Test] + public void ParserError_IncludesMatchPosition() + { + var ex = Assert.Throws(() => Parse("hello {{ 123bad }} world")); + + Assert.That(ex!.Match.Index, Is.EqualTo(6)); // position of "{{ 123bad }}" + Assert.That(ex.Match.Length, Is.GreaterThan(0)); + } +} diff --git a/Strongbars.Tests/ParserIncludeTests.cs b/Strongbars.Tests/ParserIncludeTests.cs new file mode 100644 index 0000000..fce59ae --- /dev/null +++ b/Strongbars.Tests/ParserIncludeTests.cs @@ -0,0 +1,169 @@ +using Strongbars.Abstractions; +using Strongbars.Generator; + +namespace Strongbars.Tests; + +public class ParserIncludeTests +{ + private static ITemplateNode Parse(string content) => new Parser("test", content).Parse(); + + private static ITemplateNode ParseWithIncludes( + string content, + string fileDirectory, + Dictionary files + ) => + new Parser( + "test", + content, + fileDirectory: fileDirectory, + fileReader: path => files.TryGetValue(path, out var c) ? c : null + ).Parse(); + + [Test] + public void Include_InlinesIncludedTemplateContent() + { + var files = new Dictionary + { + ["/root/partial.html"] = "hello world", + }; + + var node = ParseWithIncludes("{% include partial.html %}", "/root", files); + + Assert.That(node, Is.InstanceOf()); + Assert.That(((LiteralTemplateNode)node).Content, Is.EqualTo("hello world")); + } + + [Test] + public void Include_RelativePath_ResolvesFromFileDirectory() + { + var files = new Dictionary + { + ["/root/partials/header.html"] = "HEADER", + }; + + var node = ParseWithIncludes("{% include partials/header.html %}", "/root", files); + + Assert.That(node, Is.InstanceOf()); + Assert.That(((LiteralTemplateNode)node).Content, Is.EqualTo("HEADER")); + } + + [Test] + public void Include_DotDotPath_ResolvesFromFileDirectory() + { + var files = new Dictionary + { + ["/root/shared.html"] = "SHARED", + }; + + var node = ParseWithIncludes( + "{% include ../shared.html %}", + "/root/subdir", + files + ); + + Assert.That(node, Is.InstanceOf()); + Assert.That(((LiteralTemplateNode)node).Content, Is.EqualTo("SHARED")); + } + + [Test] + public void Include_AbsolutePath_ResolvesFromProjectRoot() + { + var files = new Dictionary + { + ["/projectroot/partials/nav.html"] = "NAV", + }; + + var node = new Parser( + "test", + "{% include /partials/nav.html %}", + fileDirectory: "/projectroot/pages", + projectRoot: "/projectroot", + fileReader: path => files.TryGetValue(path, out var c) ? c : null + ).Parse(); + + Assert.That(node, Is.InstanceOf()); + Assert.That(((LiteralTemplateNode)node).Content, Is.EqualTo("NAV")); + } + + [Test] + public void Include_IncludedVariablesAreExposedInGetVariables() + { + var files = new Dictionary + { + ["/root/partial.html"] = "{{name}}", + }; + + var node = ParseWithIncludes("{% include partial.html %}", "/root", files); + var variables = node.GetVariables().ToArray(); + + Assert.That(variables, Has.Length.EqualTo(1)); + Assert.That(variables[0].Name, Is.EqualTo("name")); + } + + [Test] + public void Include_InsideConditional_Works() + { + var files = new Dictionary + { + ["/root/body.html"] = "BODY", + }; + + var node = ParseWithIncludes( + "{% if active %}{% include body.html %}{% end %}", + "/root", + files + ); + + var composite = (CompositeTemplateNode)node; + var cond = (ConditionalTemplateNode)composite.Nodes.Single(); + var ifTrue = (CompositeTemplateNode)cond.IfTrue; + Assert.That(ifTrue.Nodes, Has.Count.GreaterThan(0)); + } + + [Test] + public void Include_CircularInclude_ThrowsParserError() + { + var files = new Dictionary + { + ["/root/a.html"] = "{% include b.html %}", + ["/root/b.html"] = "{% include a.html %}", + }; + + var ex = Assert.Throws( + () => + new Parser( + "/root/a.html", + "{% include a.html %}", + fileDirectory: "/root", + fileReader: path => files.TryGetValue(path, out var c) ? c : null, + includedPaths: new HashSet { "/root/a.html" } + ).Parse() + ); + + Assert.That(ex!.Reason, Does.Contain("Circular include")); + } + + [Test] + public void Include_FileNotFound_ThrowsParserError() + { + var ex = Assert.Throws( + () => + new Parser( + "test", + "{% include missing.html %}", + fileDirectory: "/root", + fileReader: _ => null + ).Parse() + ); + + Assert.That(ex!.Reason, Does.Contain("Include file not found")); + } + + [Test] + public void Include_WithoutFileDirectory_ThrowsParserError() + { + var ex = Assert.Throws(() => Parse("{% include something.html %}")); + + Assert.That(ex!.Reason, Does.Contain("file directory is unavailable")); + } +} diff --git a/Strongbars.Tests/ParserTests.cs b/Strongbars.Tests/ParserTests.cs index 3956f14..1a6339b 100644 --- a/Strongbars.Tests/ParserTests.cs +++ b/Strongbars.Tests/ParserTests.cs @@ -4,15 +4,12 @@ namespace Strongbars.Tests; /// -/// Unit tests for in isolation, verifying the AST it builds -/// and that is thrown for invalid templates. +/// Unit tests for in isolation, verifying the AST it builds. /// public class ParserTests { private static ITemplateNode Parse(string content) => new Parser("test", content).Parse(); - // ── Literal-only templates ──────────────────────────────────────────────── - [Test] public void PlainText_ReturnsSingleLiteralNode() { @@ -31,8 +28,6 @@ public void EmptyTemplate_ReturnsSingleLiteralNode() Assert.That(((LiteralTemplateNode)node).Content, Is.EqualTo("")); } - // ── Variable nodes ──────────────────────────────────────────────────────── - [Test] public void SingleVariable_ReturnsCompositeWithVariableNode() { @@ -86,75 +81,6 @@ public void MultipleVariables_AllParsed() Assert.That(variables[1].Variable.Name, Is.EqualTo("second")); } - // ── Conditional nodes ───────────────────────────────────────────────────── - - [Test] - public void IfBlock_ProducesConditionalNode() - { - var node = (CompositeTemplateNode)Parse("{% if active %}yes{% end %}"); - var cond = (ConditionalTemplateNode)node.Nodes.Single(); - - Assert.That(cond.Conditional.Name, Is.EqualTo("active")); - Assert.That(cond.Conditional.Type, Is.EqualTo(VariableType.Bool)); - } - - [Test] - public void IfBlock_IfTrueContainsLiteral() - { - var node = (CompositeTemplateNode)Parse("{% if active %}yes{% end %}"); - var cond = (ConditionalTemplateNode)node.Nodes.Single(); - var ifTrue = (CompositeTemplateNode)cond.IfTrue; - - Assert.That(ifTrue.Nodes.OfType().Single().Content, Is.EqualTo("yes")); - } - - [Test] - public void IfBlock_IfFalseIsEmptyComposite() - { - var node = (CompositeTemplateNode)Parse("{% if active %}yes{% end %}"); - var cond = (ConditionalTemplateNode)node.Nodes.Single(); - var ifFalse = (CompositeTemplateNode)cond.IfFalse; - - Assert.That(ifFalse.Nodes, Is.Empty); - } - - [Test] - public void IfElseBlock_BothBranchesPopulated() - { - var node = (CompositeTemplateNode)Parse("{% if enabled %}on{% else %}off{% end %}"); - var cond = (ConditionalTemplateNode)node.Nodes.Single(); - - var ifTrue = (CompositeTemplateNode)cond.IfTrue; - Assert.That(ifTrue.Nodes.OfType().Single().Content, Is.EqualTo("on")); - - var ifFalse = (CompositeTemplateNode)cond.IfFalse; - Assert.That( - ifFalse.Nodes.OfType().Single().Content, - Is.EqualTo("off") - ); - } - - [Test] - public void UnlessBlock_InvertsIfTrueAndIfFalse() - { - // {% unless x %}body{% end %}: condition=x, IfTrue="" (shown when x=true), IfFalse="body" - var node = (CompositeTemplateNode)Parse("{% unless x %}body{% end %}"); - var cond = (ConditionalTemplateNode)node.Nodes.Single(); - - // IfTrue (rendered when condition is true) is empty for "unless" - var ifTrue = (CompositeTemplateNode)cond.IfTrue; - Assert.That(ifTrue.Nodes, Is.Empty); - - // IfFalse (rendered when condition is false) contains the body - var ifFalse = (CompositeTemplateNode)cond.IfFalse; - Assert.That( - ifFalse.Nodes.OfType().Single().Content, - Is.EqualTo("body") - ); - } - - // ── GetVariables ────────────────────────────────────────────────────────── - [Test] public void GetVariables_ReturnsAllVariablesIncludingConditionals() { @@ -163,202 +89,4 @@ public void GetVariables_ReturnsAllVariablesIncludingConditionals() Assert.That(names, Is.EquivalentTo(new[] { "active", "message" })); } - - // ── Error cases ─────────────────────────────────────────────────────────── - - [Test] - public void InvalidVariableName_ThrowsParserError() - { - var ex = Assert.Throws(() => Parse("{{ 123bad }}")); - - Assert.That(ex!.Reason, Does.Contain("not a valid variable name")); - Assert.That(ex.TemplateName, Is.EqualTo("test")); - } - - [Test] - public void InvalidExpression_ThrowsParserError() - { - var ex = Assert.Throws(() => Parse("{% notakeyword %}")); - - Assert.That(ex!.Reason, Does.Contain("not a valid expression")); - } - - [Test] - public void UnclosedConditional_ThrowsParserError() - { - var ex = Assert.Throws(() => Parse("{% if x %}body without end")); - - Assert.That(ex!.Reason, Does.Contain("Conditional doesnt end")); - } - - [Test] - public void ParserError_IncludesMatchPosition() - { - var ex = Assert.Throws(() => Parse("hello {{ 123bad }} world")); - - Assert.That(ex!.Match.Index, Is.EqualTo(6)); // position of "{{ 123bad }}" - Assert.That(ex.Match.Length, Is.GreaterThan(0)); - } - - // ── Include directives ──────────────────────────────────────────────────── - - private static ITemplateNode ParseWithIncludes( - string content, - string fileDirectory, - Dictionary files - ) => - new Parser( - "test", - content, - fileDirectory: fileDirectory, - fileReader: path => files.TryGetValue(path, out var c) ? c : null - ).Parse(); - - [Test] - public void Include_InlinesIncludedTemplateContent() - { - var files = new Dictionary - { - ["/root/partial.html"] = "hello world", - }; - - var node = ParseWithIncludes("{% include partial.html %}", "/root", files); - - Assert.That(node, Is.InstanceOf()); - Assert.That(((LiteralTemplateNode)node).Content, Is.EqualTo("hello world")); - } - - [Test] - public void Include_RelativePath_ResolvesFromFileDirectory() - { - var files = new Dictionary - { - ["/root/partials/header.html"] = "HEADER", - }; - - var node = ParseWithIncludes("{% include partials/header.html %}", "/root", files); - - Assert.That(node, Is.InstanceOf()); - Assert.That(((LiteralTemplateNode)node).Content, Is.EqualTo("HEADER")); - } - - [Test] - public void Include_DotDotPath_ResolvesFromFileDirectory() - { - var files = new Dictionary - { - ["/root/shared.html"] = "SHARED", - }; - - var node = ParseWithIncludes( - "{% include ../shared.html %}", - "/root/subdir", - files - ); - - Assert.That(node, Is.InstanceOf()); - Assert.That(((LiteralTemplateNode)node).Content, Is.EqualTo("SHARED")); - } - - [Test] - public void Include_AbsolutePath_ResolvesFromProjectRoot() - { - var files = new Dictionary - { - ["/projectroot/partials/nav.html"] = "NAV", - }; - - var node = new Parser( - "test", - "{% include /partials/nav.html %}", - fileDirectory: "/projectroot/pages", - projectRoot: "/projectroot", - fileReader: path => files.TryGetValue(path, out var c) ? c : null - ).Parse(); - - Assert.That(node, Is.InstanceOf()); - Assert.That(((LiteralTemplateNode)node).Content, Is.EqualTo("NAV")); - } - - [Test] - public void Include_IncludedVariablesAreExposedInGetVariables() - { - var files = new Dictionary - { - ["/root/partial.html"] = "{{name}}", - }; - - var node = ParseWithIncludes("{% include partial.html %}", "/root", files); - var variables = node.GetVariables().ToArray(); - - Assert.That(variables, Has.Length.EqualTo(1)); - Assert.That(variables[0].Name, Is.EqualTo("name")); - } - - [Test] - public void Include_InsideConditional_Works() - { - var files = new Dictionary - { - ["/root/body.html"] = "BODY", - }; - - var node = ParseWithIncludes( - "{% if active %}{% include body.html %}{% end %}", - "/root", - files - ); - - var composite = (CompositeTemplateNode)node; - var cond = (ConditionalTemplateNode)composite.Nodes.Single(); - var ifTrue = (CompositeTemplateNode)cond.IfTrue; - Assert.That(ifTrue.Nodes, Has.Count.GreaterThan(0)); - } - - [Test] - public void Include_CircularInclude_ThrowsParserError() - { - var files = new Dictionary - { - ["/root/a.html"] = "{% include b.html %}", - ["/root/b.html"] = "{% include a.html %}", - }; - - var ex = Assert.Throws( - () => - new Parser( - "/root/a.html", - "{% include a.html %}", - fileDirectory: "/root", - fileReader: path => files.TryGetValue(path, out var c) ? c : null, - includedPaths: new HashSet { "/root/a.html" } - ).Parse() - ); - - Assert.That(ex!.Reason, Does.Contain("Circular include")); - } - - [Test] - public void Include_FileNotFound_ThrowsParserError() - { - var ex = Assert.Throws( - () => - new Parser( - "test", - "{% include missing.html %}", - fileDirectory: "/root", - fileReader: _ => null - ).Parse() - ); - - Assert.That(ex!.Reason, Does.Contain("Include file not found")); - } - - [Test] - public void Include_WithoutFileDirectory_ThrowsParserError() - { - var ex = Assert.Throws(() => Parse("{% include something.html %}")); - - Assert.That(ex!.Reason, Does.Contain("file directory is unavailable")); - } } From 2fa3683b9cdb72419b47489efb70c6860b277e9a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 13:46:33 +0000 Subject: [PATCH 3/3] Fix include test assertions and apply CSharpier formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Include tag is a {%...%} match, so Parse() always wraps the result in a CompositeTemplateNode. Tests expecting a bare LiteralTemplateNode were wrong — fixed to unwrap the single node from the composite. Also applies CSharpier formatting across all modified files. https://claude.ai/code/session_01BY3CHmYq7DCxZNVmJFP7sy --- Strongbars.Generator/Parser.cs | 3 +- Strongbars.Tests/FileGeneratorIncludeTests.cs | 15 ++-- Strongbars.Tests/ParserIncludeTests.cs | 89 ++++++++----------- 3 files changed, 46 insertions(+), 61 deletions(-) diff --git a/Strongbars.Generator/Parser.cs b/Strongbars.Generator/Parser.cs index 1f223ce..8b75d01 100644 --- a/Strongbars.Generator/Parser.cs +++ b/Strongbars.Generator/Parser.cs @@ -223,7 +223,8 @@ private ITemplateNode ParseInclude(string includePath, Match match, int matchInd string? content; try { - content = _fileReader != null ? _fileReader(resolvedPath) : File.ReadAllText(resolvedPath); + content = + _fileReader != null ? _fileReader(resolvedPath) : File.ReadAllText(resolvedPath); } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { diff --git a/Strongbars.Tests/FileGeneratorIncludeTests.cs b/Strongbars.Tests/FileGeneratorIncludeTests.cs index b61f668..cea09c5 100644 --- a/Strongbars.Tests/FileGeneratorIncludeTests.cs +++ b/Strongbars.Tests/FileGeneratorIncludeTests.cs @@ -41,10 +41,12 @@ public void IncludeDirective_ViaOutputGenerator_InlinesContent() { var textOptions = new Dictionary { - [new TestAdditionalText( - mainPath, - $"{{% include {Path.GetFileName(partialPath)} %}}" - )] = new TestAnalyzerConfigOptions( + [ + new TestAdditionalText( + mainPath, + $"{{% include {Path.GetFileName(partialPath)} %}}" + ) + ] = new TestAnalyzerConfigOptions( new Dictionary { ["build_metadata.AdditionalFiles.StrongbarsNamespace"] = "TestNs", @@ -70,10 +72,7 @@ [new TestAdditionalText( [Test] public void IncludeDirective_MissingFile_ProducesSB003Diagnostic() { - var (diagnostics, _) = GenerateFromTemplate( - "Bad", - "{% include /missing/file.html %}" - ); + var (diagnostics, _) = GenerateFromTemplate("Bad", "{% include /missing/file.html %}"); Assert.That(diagnostics, Has.One.Matches(d => d.Id == "SB003")); } diff --git a/Strongbars.Tests/ParserIncludeTests.cs b/Strongbars.Tests/ParserIncludeTests.cs index fce59ae..f8ffb0b 100644 --- a/Strongbars.Tests/ParserIncludeTests.cs +++ b/Strongbars.Tests/ParserIncludeTests.cs @@ -22,56 +22,49 @@ Dictionary files [Test] public void Include_InlinesIncludedTemplateContent() { - var files = new Dictionary - { - ["/root/partial.html"] = "hello world", - }; + var files = new Dictionary { ["/root/partial.html"] = "hello world" }; - var node = ParseWithIncludes("{% include partial.html %}", "/root", files); + var node = (CompositeTemplateNode)ParseWithIncludes( + "{% include partial.html %}", + "/root", + files + ); - Assert.That(node, Is.InstanceOf()); - Assert.That(((LiteralTemplateNode)node).Content, Is.EqualTo("hello world")); + Assert.That(((LiteralTemplateNode)node.Nodes.Single()).Content, Is.EqualTo("hello world")); } [Test] public void Include_RelativePath_ResolvesFromFileDirectory() { - var files = new Dictionary - { - ["/root/partials/header.html"] = "HEADER", - }; + var files = new Dictionary { ["/root/partials/header.html"] = "HEADER" }; - var node = ParseWithIncludes("{% include partials/header.html %}", "/root", files); + var node = (CompositeTemplateNode)ParseWithIncludes( + "{% include partials/header.html %}", + "/root", + files + ); - Assert.That(node, Is.InstanceOf()); - Assert.That(((LiteralTemplateNode)node).Content, Is.EqualTo("HEADER")); + Assert.That(((LiteralTemplateNode)node.Nodes.Single()).Content, Is.EqualTo("HEADER")); } [Test] public void Include_DotDotPath_ResolvesFromFileDirectory() { - var files = new Dictionary - { - ["/root/shared.html"] = "SHARED", - }; + var files = new Dictionary { ["/root/shared.html"] = "SHARED" }; - var node = ParseWithIncludes( + var node = (CompositeTemplateNode)ParseWithIncludes( "{% include ../shared.html %}", "/root/subdir", files ); - Assert.That(node, Is.InstanceOf()); - Assert.That(((LiteralTemplateNode)node).Content, Is.EqualTo("SHARED")); + Assert.That(((LiteralTemplateNode)node.Nodes.Single()).Content, Is.EqualTo("SHARED")); } [Test] public void Include_AbsolutePath_ResolvesFromProjectRoot() { - var files = new Dictionary - { - ["/projectroot/partials/nav.html"] = "NAV", - }; + var files = new Dictionary { ["/projectroot/partials/nav.html"] = "NAV" }; var node = new Parser( "test", @@ -81,17 +74,14 @@ public void Include_AbsolutePath_ResolvesFromProjectRoot() fileReader: path => files.TryGetValue(path, out var c) ? c : null ).Parse(); - Assert.That(node, Is.InstanceOf()); - Assert.That(((LiteralTemplateNode)node).Content, Is.EqualTo("NAV")); + var composite = (CompositeTemplateNode)node; + Assert.That(((LiteralTemplateNode)composite.Nodes.Single()).Content, Is.EqualTo("NAV")); } [Test] public void Include_IncludedVariablesAreExposedInGetVariables() { - var files = new Dictionary - { - ["/root/partial.html"] = "{{name}}", - }; + var files = new Dictionary { ["/root/partial.html"] = "{{name}}" }; var node = ParseWithIncludes("{% include partial.html %}", "/root", files); var variables = node.GetVariables().ToArray(); @@ -103,10 +93,7 @@ public void Include_IncludedVariablesAreExposedInGetVariables() [Test] public void Include_InsideConditional_Works() { - var files = new Dictionary - { - ["/root/body.html"] = "BODY", - }; + var files = new Dictionary { ["/root/body.html"] = "BODY" }; var node = ParseWithIncludes( "{% if active %}{% include body.html %}{% end %}", @@ -129,15 +116,14 @@ public void Include_CircularInclude_ThrowsParserError() ["/root/b.html"] = "{% include a.html %}", }; - var ex = Assert.Throws( - () => - new Parser( - "/root/a.html", - "{% include a.html %}", - fileDirectory: "/root", - fileReader: path => files.TryGetValue(path, out var c) ? c : null, - includedPaths: new HashSet { "/root/a.html" } - ).Parse() + var ex = Assert.Throws(() => + new Parser( + "/root/a.html", + "{% include a.html %}", + fileDirectory: "/root", + fileReader: path => files.TryGetValue(path, out var c) ? c : null, + includedPaths: new HashSet { "/root/a.html" } + ).Parse() ); Assert.That(ex!.Reason, Does.Contain("Circular include")); @@ -146,14 +132,13 @@ public void Include_CircularInclude_ThrowsParserError() [Test] public void Include_FileNotFound_ThrowsParserError() { - var ex = Assert.Throws( - () => - new Parser( - "test", - "{% include missing.html %}", - fileDirectory: "/root", - fileReader: _ => null - ).Parse() + var ex = Assert.Throws(() => + new Parser( + "test", + "{% include missing.html %}", + fileDirectory: "/root", + fileReader: _ => null + ).Parse() ); Assert.That(ex!.Reason, Does.Contain("Include file not found"));