diff --git a/Fluid.Tests/IncludeStatementTests.cs b/Fluid.Tests/IncludeStatementTests.cs index 7fc9d22c..213c51e5 100644 --- a/Fluid.Tests/IncludeStatementTests.cs +++ b/Fluid.Tests/IncludeStatementTests.cs @@ -4,6 +4,7 @@ using System.Text.Encodings.Web; using System.Threading.Tasks; using Fluid.Ast; +using Fluid.Parser; using Fluid.Tests.Mocks; using Fluid.Values; using Xunit; @@ -251,6 +252,32 @@ public void RenderTag_With_Default_Name() Assert.Equal("Product: Draft 151cm ", result); } + [Fact] + public void Increment_Is_Isolated_Between_Renders() + { + var fileProvider = new MockFileProvider(); + fileProvider.Add("incr.liquid", "{% increment %}"); + + var options = new TemplateOptions() { FileProvider = fileProvider, MemberAccessStrategy = UnsafeMemberAccessStrategy.Instance }; + var context = new TemplateContext(options); + _parser.TryParse("{% increment %}{% increment %}{% render 'incr' %}", out var template, out var error); + Assert.Null(error); + var result = template.Render(context); + + Assert.Equal("010", result); + } + + [Fact] + public void RenderTagCantUseDynamicName() + { + var fileProvider = new MockFileProvider(); + var options = new TemplateOptions() { FileProvider = fileProvider, MemberAccessStrategy = UnsafeMemberAccessStrategy.Instance }; + var context = new TemplateContext(options); + var result = _parser.TryParse("{% assign name = 'snippet' %}{% render name %}", out var template, out var error); + Assert.False(result); + Assert.Contains(ErrorMessages.ExpectedStringRender, error); + } + [Fact] public void IncludeTag_For_Loop() { diff --git a/Fluid.Tests/TemplateTests.cs b/Fluid.Tests/TemplateTests.cs index d200dbe6..28c8cb3c 100644 --- a/Fluid.Tests/TemplateTests.cs +++ b/Fluid.Tests/TemplateTests.cs @@ -592,6 +592,15 @@ public Task IncrementDoesntAffectVariable(string source, string expected) return CheckAsync(source, expected); } + [Theory] + [InlineData("{% increment %}{% increment %}{% increment %}", "012")] + [InlineData("{% decrement %}{% decrement %}{% decrement %}", "0-1-2")] + [InlineData("{% increment %}{% decrement %}{% increment %}", "0-10")] + public Task IncrementCanBeUsedWithoutIdentifier(string source, string expected) + { + return CheckAsync(source, expected); + } + [Fact] public async Task ModelIsUsedAsFallback() { diff --git a/Fluid/Ast/IncludeStatement.cs b/Fluid/Ast/IncludeStatement.cs index 20227326..84d6a5ce 100644 --- a/Fluid/Ast/IncludeStatement.cs +++ b/Fluid/Ast/IncludeStatement.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Text.Encodings.Web; +using System.Threading; using System.Threading.Tasks; namespace Fluid.Ast @@ -12,8 +13,8 @@ public class IncludeStatement : Statement { public const string ViewExtension = ".liquid"; private readonly FluidParser _parser; - private IFluidTemplate _template; - private string _identifier; + private volatile CachedTemplate _cachedTemplate; + private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1); public IncludeStatement(FluidParser parser, Expression path, Expression with = null, Expression @for = null, string alias = null, IList assignStatements = null) { @@ -42,31 +43,46 @@ public override async ValueTask WriteToAsync(TextWriter writer, Text relativePath += ViewExtension; } - if (_template == null || !string.Equals(_identifier, System.IO.Path.GetFileNameWithoutExtension(relativePath), StringComparison.OrdinalIgnoreCase)) + if (_cachedTemplate == null || !string.Equals(_cachedTemplate.Name, System.IO.Path.GetFileNameWithoutExtension(relativePath), StringComparison.Ordinal)) { - var fileProvider = context.Options.FileProvider; + await _semaphore.WaitAsync(); - var fileInfo = fileProvider.GetFileInfo(relativePath); - - if (fileInfo == null || !fileInfo.Exists) + try { - throw new FileNotFoundException(relativePath); - } + if (_cachedTemplate == null || !string.Equals(_cachedTemplate.Name, System.IO.Path.GetFileNameWithoutExtension(relativePath), StringComparison.Ordinal)) + { - var content = ""; + var fileProvider = context.Options.FileProvider; - using (var stream = fileInfo.CreateReadStream()) - using (var streamReader = new StreamReader(stream)) - { - content = await streamReader.ReadToEndAsync(); - } + var fileInfo = fileProvider.GetFileInfo(relativePath); - if (!_parser.TryParse(content, out _template, out var errors)) + if (fileInfo == null || !fileInfo.Exists) + { + throw new FileNotFoundException(relativePath); + } + + var content = ""; + + using (var stream = fileInfo.CreateReadStream()) + using (var streamReader = new StreamReader(stream)) + { + content = await streamReader.ReadToEndAsync(); + } + + if (!_parser.TryParse(content, out var template, out var errors)) + { + throw new ParseException(errors); + } + + var identifier = System.IO.Path.GetFileNameWithoutExtension(relativePath); + + _cachedTemplate = new CachedTemplate(template, identifier); + } + } + finally { - throw new ParseException(errors); + _semaphore.Release(); } - - _identifier = System.IO.Path.GetFileNameWithoutExtension(relativePath); } try @@ -77,9 +93,9 @@ public override async ValueTask WriteToAsync(TextWriter writer, Text { var with = await With.EvaluateAsync(context); - context.SetValue(Alias ?? _identifier, with); + context.SetValue(Alias ?? _cachedTemplate.Name, with); - await _template.RenderAsync(writer, encoder, context); + await _cachedTemplate.Template.RenderAsync(writer, encoder, context); } else if (AssignStatements != null) { @@ -89,7 +105,7 @@ public override async ValueTask WriteToAsync(TextWriter writer, Text await AssignStatements[i].WriteToAsync(writer, encoder, context); } - await _template.RenderAsync(writer, encoder, context); + await _cachedTemplate.Template.RenderAsync(writer, encoder, context); } else if (For != null) { @@ -109,7 +125,7 @@ public override async ValueTask WriteToAsync(TextWriter writer, Text var item = list[i]; - context.SetValue(Alias ?? _identifier, item); + context.SetValue(Alias ?? _cachedTemplate.Name, item); // Set helper variables forloop.Index = i + 1; @@ -119,7 +135,7 @@ public override async ValueTask WriteToAsync(TextWriter writer, Text forloop.First = i == 0; forloop.Last = i == length - 1; - await _template.RenderAsync(writer, encoder, context); + await _cachedTemplate.Template.RenderAsync(writer, encoder, context); // Restore the forloop property after every statement in case it replaced it, // for instance if it contains a nested for loop @@ -134,7 +150,7 @@ public override async ValueTask WriteToAsync(TextWriter writer, Text else { // no with, for or assignments, e.g. {% include 'products' %} - await _template.RenderAsync(writer, encoder, context); + await _cachedTemplate.Template.RenderAsync(writer, encoder, context); } } finally @@ -144,5 +160,8 @@ public override async ValueTask WriteToAsync(TextWriter writer, Text return Completion.Normal; } + + private record class CachedTemplate(IFluidTemplate Template, string Name); + } } diff --git a/Fluid/Ast/IncrementStatement.cs b/Fluid/Ast/IncrementStatement.cs index 67f963b0..3b56d1fa 100644 --- a/Fluid/Ast/IncrementStatement.cs +++ b/Fluid/Ast/IncrementStatement.cs @@ -10,7 +10,7 @@ public class IncrementStatement : Statement public const string Prefix = "$$incdec$$$"; public IncrementStatement(string identifier) { - Identifier = identifier; + Identifier = identifier ?? ""; } public string Identifier { get; } diff --git a/Fluid/Ast/RenderStatement.cs b/Fluid/Ast/RenderStatement.cs index 84196814..087ab53a 100644 --- a/Fluid/Ast/RenderStatement.cs +++ b/Fluid/Ast/RenderStatement.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Text.Encodings.Web; using System.Threading.Tasks; +using System.Threading; namespace Fluid.Ast { @@ -15,10 +16,10 @@ public class RenderStatement : Statement { public const string ViewExtension = ".liquid"; private readonly FluidParser _parser; - private IFluidTemplate _template; - private string _identifier; + private volatile CachedTemplate _cachedTemplate; + private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1); - public RenderStatement(FluidParser parser, Expression path, Expression with = null, Expression @for = null, string alias = null, IList assignStatements = null) + public RenderStatement(FluidParser parser, string path, Expression with = null, Expression @for = null, string alias = null, IList assignStatements = null) { _parser = parser; Path = path; @@ -28,7 +29,7 @@ public RenderStatement(FluidParser parser, Expression path, Expression with = nu AssignStatements = assignStatements; } - public Expression Path { get; } + public string Path { get; } public IList AssignStatements { get; } public Expression With { get; } public Expression For { get; } @@ -38,38 +39,52 @@ public override async ValueTask WriteToAsync(TextWriter writer, Text { context.IncrementSteps(); - var relativePath = (await Path.EvaluateAsync(context)).ToStringValue(); + var relativePath = Path; if (!relativePath.EndsWith(ViewExtension, StringComparison.OrdinalIgnoreCase)) { relativePath += ViewExtension; } - if (_template == null || !string.Equals(_identifier, System.IO.Path.GetFileNameWithoutExtension(relativePath), StringComparison.OrdinalIgnoreCase)) + if (_cachedTemplate == null || !string.Equals(_cachedTemplate.Name, System.IO.Path.GetFileNameWithoutExtension(relativePath), StringComparison.Ordinal)) { - var fileProvider = context.Options.FileProvider; + await _semaphore.WaitAsync(); - var fileInfo = fileProvider.GetFileInfo(relativePath); - - if (fileInfo == null || !fileInfo.Exists) + try { - throw new FileNotFoundException(relativePath); - } + if (_cachedTemplate == null || !string.Equals(_cachedTemplate.Name, System.IO.Path.GetFileNameWithoutExtension(relativePath), StringComparison.Ordinal)) + { + var fileProvider = context.Options.FileProvider; - var content = ""; + var fileInfo = fileProvider.GetFileInfo(relativePath); - using (var stream = fileInfo.CreateReadStream()) - using (var streamReader = new StreamReader(stream)) - { - content = await streamReader.ReadToEndAsync(); - } + if (fileInfo == null || !fileInfo.Exists) + { + throw new FileNotFoundException(relativePath); + } + + var content = ""; + + using (var stream = fileInfo.CreateReadStream()) + using (var streamReader = new StreamReader(stream)) + { + content = await streamReader.ReadToEndAsync(); + } + + if (!_parser.TryParse(content, out var template, out var errors)) + { + throw new ParseException(errors); + } + + var identifier = System.IO.Path.GetFileNameWithoutExtension(relativePath); - if (!_parser.TryParse(content, out _template, out var errors)) + _cachedTemplate = new CachedTemplate(template, identifier); + } + } + finally { - throw new ParseException(errors); + _semaphore.Release(); } - - _identifier = System.IO.Path.GetFileNameWithoutExtension(relativePath); } context.EnterChildScope(); @@ -84,8 +99,8 @@ public override async ValueTask WriteToAsync(TextWriter writer, Text context.LocalScope = new Scope(context.RootScope); previousScope.CopyTo(context.LocalScope); - context.SetValue(Alias ?? _identifier, with); - await _template.RenderAsync(writer, encoder, context); + context.SetValue(Alias ?? _cachedTemplate.Name, with); + await _cachedTemplate.Template.RenderAsync(writer, encoder, context); } else if (AssignStatements != null) { @@ -98,7 +113,7 @@ public override async ValueTask WriteToAsync(TextWriter writer, Text context.LocalScope = new Scope(context.RootScope); previousScope.CopyTo(context.LocalScope); - await _template.RenderAsync(writer, encoder, context); + await _cachedTemplate.Template.RenderAsync(writer, encoder, context); } else if (For != null) { @@ -121,7 +136,7 @@ public override async ValueTask WriteToAsync(TextWriter writer, Text var item = list[i]; - context.SetValue(Alias ?? _identifier, item); + context.SetValue(Alias ?? _cachedTemplate.Name, item); // Set helper variables forloop.Index = i + 1; @@ -131,7 +146,7 @@ public override async ValueTask WriteToAsync(TextWriter writer, Text forloop.First = i == 0; forloop.Last = i == length - 1; - await _template.RenderAsync(writer, encoder, context); + await _cachedTemplate.Template.RenderAsync(writer, encoder, context); // Restore the forloop property after every statement in case it replaced it, // for instance if it contains a nested for loop @@ -148,7 +163,7 @@ public override async ValueTask WriteToAsync(TextWriter writer, Text context.LocalScope = new Scope(context.RootScope); previousScope.CopyTo(context.LocalScope); - await _template.RenderAsync(writer, encoder, context); + await _cachedTemplate.Template.RenderAsync(writer, encoder, context); } } finally @@ -159,5 +174,8 @@ public override async ValueTask WriteToAsync(TextWriter writer, Text return Completion.Normal; } + + private record class CachedTemplate (IFluidTemplate Template, string Name); + } } diff --git a/Fluid/FluidParser.cs b/Fluid/FluidParser.cs index 2d943292..368581b3 100644 --- a/Fluid/FluidParser.cs +++ b/Fluid/FluidParser.cs @@ -250,10 +250,15 @@ public FluidParser() .Then(x => new CycleStatement(x.Item1, x.Item2)) .ElseError("Invalid 'cycle' tag") ; - var DecrementTag = Identifier.AndSkip(TagEnd) + var DecrementTag = ZeroOrOne(Identifier).AndSkip(TagEnd) .Then(x => new DecrementStatement(x)) .ElseError("Invalid 'decrement' tag") ; + var IncrementTag = ZeroOrOne(Identifier).AndSkip(TagEnd) + .Then(x => new IncrementStatement(x)) + .ElseError("Invalid 'increment' tag") + ; + var IncludeTag = OneOf( Primary.AndSkip(Comma).And(Separated(Comma, Identifier.AndSkip(Colon).And(Primary).Then(static x => new AssignStatement(x.Item1, x.Item2)))).Then(x => new IncludeStatement(this, x.Item1, null, null, null, x.Item2)), Primary.AndSkip(Terms.Text("with")).And(Primary).And(ZeroOrOne(Terms.Text("as").SkipAnd(Identifier))).Then(x => new IncludeStatement(this, x.Item1, with: x.Item2, alias: x.Item3)), @@ -263,18 +268,19 @@ public FluidParser() .Then(x => x) .ElseError("Invalid 'include' tag") ; + + var StringAfterRender = String.ElseError(ExpectedStringRender); + var RenderTag = OneOf( - Primary.AndSkip(Comma).And(Separated(Comma, Identifier.AndSkip(Colon).And(Primary).Then(static x => new AssignStatement(x.Item1, x.Item2)))).Then(x => new RenderStatement(this, x.Item1, null, null, null, x.Item2)), - Primary.AndSkip(Terms.Text("with")).And(Primary).And(ZeroOrOne(Terms.Text("as").SkipAnd(Identifier))).Then(x => new RenderStatement(this, x.Item1, with: x.Item2, alias: x.Item3)), - Primary.AndSkip(Terms.Text("for")).And(Primary).And(ZeroOrOne(Terms.Text("as").SkipAnd(Identifier))).Then(x => new RenderStatement(this, x.Item1, @for: x.Item2, alias: x.Item3)), - Primary.Then(x => new RenderStatement(this, x)) + StringAfterRender.AndSkip(Comma).And(Separated(Comma, Identifier.AndSkip(Colon).And(Primary).Then(static x => new AssignStatement(x.Item1, x.Item2)))).Then(x => new RenderStatement(this, x.Item1.ToString(), null, null, null, x.Item2)), + StringAfterRender.AndSkip(Terms.Text("with")).And(Primary).And(ZeroOrOne(Terms.Text("as").SkipAnd(Identifier))).Then(x => new RenderStatement(this, x.Item1.ToString(), with: x.Item2, alias: x.Item3)), + StringAfterRender.AndSkip(Terms.Text("for")).And(Primary).And(ZeroOrOne(Terms.Text("as").SkipAnd(Identifier))).Then(x => new RenderStatement(this, x.Item1.ToString(), @for: x.Item2, alias: x.Item3)), + StringAfterRender.Then(x => new RenderStatement(this, x.ToString())) ).AndSkip(TagEnd) .Then(x => x) .ElseError("Invalid 'render' tag") ; - - var IncrementTag = Identifier.AndSkip(TagEnd).Then(x => new IncrementStatement(x)).ElseError("Invalid 'increment' tag"); var RawTag = TagEnd.SkipAnd(AnyCharBefore(CreateTag("endraw"), consumeDelimiter: true, failOnEof: true).Then(x => new RawStatement(x))).ElseError("Not end tag found for {% raw %}"); var AssignTag = Identifier.ElseError(IdentifierAfterAssign).AndSkip(Equal.ElseError(EqualAfterAssignIdentifier)).And(FilterExpression).AndSkip(TagEnd.ElseError(ExpectedTagEnd)).Then(x => new AssignStatement(x.Item1, x.Item2)); var IfTag = LogicalExpression diff --git a/Fluid/IsExternalInit.cs b/Fluid/IsExternalInit.cs new file mode 100644 index 00000000..fe0c7ea0 --- /dev/null +++ b/Fluid/IsExternalInit.cs @@ -0,0 +1,10 @@ +#if NETCOREAPP3_1 || NETSTANDARD +using System.ComponentModel; + +// Fix for: error CS0518: Predefined type 'System.Runtime.CompilerServices.IsExternalInit' is not defined or imported +namespace System.Runtime.CompilerServices +{ + [EditorBrowsable(EditorBrowsableState.Never)] + internal class IsExternalInit { } +} +#endif diff --git a/Fluid/Parser/ErrorMessages.cs b/Fluid/Parser/ErrorMessages.cs index 511e8f9f..129e4d37 100644 --- a/Fluid/Parser/ErrorMessages.cs +++ b/Fluid/Parser/ErrorMessages.cs @@ -7,7 +7,8 @@ public static class ErrorMessages public const string IdentifierAfterTagStart = "An identifier was expected after '{%'"; public const string LogicalExpressionStartsFilter = "A value was expected"; public const string IdentifierAfterPipe = "An identifier was expected after the '|' sign"; - public const string ExpectedTagEnd = "'%}' was expected"; - public const string ExpectedOutputEnd = "'}}' was expected"; + public const string ExpectedTagEnd = "End of tag '%}' was expected"; + public const string ExpectedOutputEnd = "End of tag '}}' was expected"; + public const string ExpectedStringRender = "A quoted string value is required for the render tag"; } }