Skip to content
Closed
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
16 changes: 14 additions & 2 deletions Strongbars.Generator/ClassGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string?>? fileReader = null,
string? filePath = null
)
{
var rootToken = new Parser($"{@namespace}.{@class}", fileContent).Parse();
var initialIncludedPaths = filePath != null ? new HashSet<string> { filePath } : null;
var rootToken = new Parser(
$"{@namespace}.{@class}",
fileContent,
fileDirectory,
projectRoot,
fileReader,
initialIncludedPaths
).Parse();
var variables = DeduplicateVariables(rootToken.GetVariables());

return $@"
Expand Down
33 changes: 29 additions & 4 deletions Strongbars.Generator/FileGenerator.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.IO;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
{
Expand All @@ -94,7 +115,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
visibility,
@namespace,
@class,
fileContent
fileContent,
fileDirectory,
projectRoot,
FileReader,
file.Path
)
);
}
Expand Down
111 changes: 106 additions & 5 deletions Strongbars.Generator/Parser.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.IO;
using System.Text.RegularExpressions;
using Strongbars.Abstractions;

Expand All @@ -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<string, string?>? _fileReader;
private readonly HashSet<string>? _includedPaths;

public Parser(string templateName, string fileContent)
public Parser(
string templateName,
string fileContent,
string? fileDirectory = null,
string? projectRoot = null,
Func<string, string?>? fileReader = null,
HashSet<string>? includedPaths = null
)
{
TemplateName = templateName;
FileContent = fileContent;
_fileDirectory = fileDirectory;
_projectRoot = projectRoot;
_fileReader = fileReader;
_includedPaths = includedPaths;
}

public ITemplateNode Parse()
Expand Down Expand Up @@ -149,17 +165,100 @@ 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<string>(_includedPaths ?? new HashSet<string>())
{
resolvedPath,
};
var includedDir = Path.GetDirectoryName(resolvedPath);
return new Parser(
resolvedPath,
content,
includedDir,
_projectRoot,
_fileReader,
newIncludedPaths
).Parse();
}

private string DiffSinceLastMatch(MatchCollection matches, int index)
Expand All @@ -173,6 +272,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
Expand Down
138 changes: 138 additions & 0 deletions Strongbars.Tests/FileGeneratorConditionalTests.cs
Original file line number Diff line number Diff line change
@@ -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(@"<div class=""message urgent"">Buy now!</div>").IgnoreWhiteSpace
);
Assert.That(
new Message(urgent: false, message: "Buy now!").Render(),
Is.EqualTo(@"<div class=""message "">Buy now!</div>").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(@"<span class=""status active"">Online</span>").IgnoreWhiteSpace
);
Assert.That(
new Status(inactive: true, label: "Offline").Render(),
Is.EqualTo(@"<span class=""status "">Offline</span>").IgnoreWhiteSpace
);
}

[Test]
public void IfElseRendersElseBranchWhenFalse()
{
Assert.That(
new Toggle(enabled: true, label: "Go").Render(),
Is.EqualTo(@"<button class=""enabled"">Go</button>").IgnoreWhiteSpace
);
Assert.That(
new Toggle(enabled: false, label: "Go").Render(),
Is.EqualTo(@"<button class=""disabled"">Go</button>").IgnoreWhiteSpace
);
}

[Test]
public void UnlessElseRendersElseBranchWhenTrue()
{
Assert.That(
new Subscription(premium: false).Render(),
Is.EqualTo("<p>Free tier</p>").IgnoreWhiteSpace
);
Assert.That(
new Subscription(premium: true).Render(),
Is.EqualTo("<p>Premium member</p>").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(@"<div class=""message "">All good</div>").IgnoreWhiteSpace
);
Assert.That(
new Status(inactive: true, label: "Offline").Render(),
Is.EqualTo(@"<span class=""status "">Offline</span>").IgnoreWhiteSpace
);
}
}
Loading
Loading