diff --git a/Fluid.Tests/TrailingQuestionTests.cs b/Fluid.Tests/TrailingQuestionTests.cs new file mode 100644 index 00000000..693a7f6b --- /dev/null +++ b/Fluid.Tests/TrailingQuestionTests.cs @@ -0,0 +1,295 @@ +using Fluid.Ast; +using Fluid.Parser; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace Fluid.Tests +{ + public class TrailingQuestionTests + { + [Fact] + public void ShouldNotParseTrailingQuestionByDefault() + { + var parser = new FluidParser(); + var result = parser.TryParse("{{ product.empty? }}", out var template, out var errors); + + Assert.False(result); + Assert.NotNull(errors); + } + + [Fact] + public void ShouldParseTrailingQuestionWhenEnabled() + { + var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestionMark = true }); + var result = parser.TryParse("{{ product.empty? }}", out var template, out var errors); + + Assert.True(result); + Assert.Null(errors); + } + + [Fact] + public void ShouldStripTrailingQuestionFromIdentifier() + { + var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestionMark = true }); + parser.TryParse("{{ product.empty? }}", out var template, out var errors); + + var statements = ((FluidTemplate)template).Statements; + var outputStatement = statements[0] as OutputStatement; + Assert.NotNull(outputStatement); + + var memberExpression = outputStatement.Expression as MemberExpression; + Assert.NotNull(memberExpression); + Assert.Equal(2, memberExpression.Segments.Count); + + var firstSegment = memberExpression.Segments[0] as IdentifierSegment; + Assert.NotNull(firstSegment); + Assert.Equal("product", firstSegment.Identifier); + + var secondSegment = memberExpression.Segments[1] as IdentifierSegment; + Assert.NotNull(secondSegment); + Assert.Equal("empty", secondSegment.Identifier); // Should NOT contain '?' + } + + [Fact] + public async Task ShouldResolveIdentifierWithoutQuestionMark() + { + var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestionMark = true }); + parser.TryParse("{{ products.empty? }}", out var template, out var errors); + + var context = new TemplateContext(); + var sampleObj = new { empty = true }; + context.Options.MemberAccessStrategy.Register(sampleObj.GetType()); + context.SetValue("products", sampleObj); + + var result = await template.RenderAsync(context); + Assert.Equal("true", result); + } + + [Theory] + [InlineData("{{ a? }}", "a")] + [InlineData("{{ product.quantity_price_breaks_configured? }}", "quantity_price_breaks_configured")] + [InlineData("{{ collection.products.empty? }}", "empty")] + public void ShouldStripTrailingQuestionFromVariousIdentifiers(string template, string expectedLastSegment) + { + var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestionMark = true }); + parser.TryParse(template, out var parsedTemplate, out var errors); + + var statements = ((FluidTemplate)parsedTemplate).Statements; + var outputStatement = statements[0] as OutputStatement; + Assert.NotNull(outputStatement); + + var memberExpression = outputStatement.Expression as MemberExpression; + Assert.NotNull(memberExpression); + + var lastSegment = memberExpression.Segments[^1] as IdentifierSegment; + Assert.NotNull(lastSegment); + Assert.Equal(expectedLastSegment, lastSegment.Identifier); + } + + [Fact] + public void ShouldParseTrailingQuestionInIfStatement() + { + var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestionMark = true }); + var result = parser.TryParse("{% if collection.products.empty? %}No products{% endif %}", out var template, out var errors); + + Assert.True(result); + Assert.Null(errors); + } + + [Fact] + public async Task ShouldEvaluateTrailingQuestionInIfStatement() + { + var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestionMark = true }); + parser.TryParse("{% if collection.products.empty? %}No products{% endif %}", out var template, out var errors); + + var context = new TemplateContext(); + var products = new { empty = true }; + var collection = new { products }; + context.Options.MemberAccessStrategy.Register(products.GetType()); + context.Options.MemberAccessStrategy.Register(collection.GetType()); + context.SetValue("collection", collection); + + var result = await template.RenderAsync(context); + Assert.Equal("No products", result); + } + + [Fact] + public void ShouldParseTrailingQuestionInFilterArgument() + { + var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestionMark = true }); + var result = parser.TryParse("{{ value | filter: item.empty? }}", out var template, out var errors); + + Assert.True(result); + Assert.Null(errors); + } + + [Fact] + public void ShouldParseTrailingQuestionInAssignment() + { + var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestionMark = true }); + var result = parser.TryParse("{% assign x = product.empty? %}", out var template, out var errors); + + Assert.True(result); + Assert.Null(errors); + } + + [Fact] + public async Task ShouldEvaluateTrailingQuestionInAssignment() + { + var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestionMark = true }); + parser.TryParse("{% assign x = product.empty? %}{{ x }}", out var template, out var errors); + + var context = new TemplateContext(); + var product = new { empty = false }; + context.Options.MemberAccessStrategy.Register(product.GetType()); + context.SetValue("product", product); + + var result = await template.RenderAsync(context); + Assert.Equal("false", result); + } + + [Fact] + public void ShouldNotAllowMultipleTrailingQuestions() + { + var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestionMark = true }); + var result = parser.TryParse("{{ product.empty?? }}", out var template, out var errors); + + // Should fail because we only allow one trailing question mark + Assert.False(result); + } + + [Fact] + public void ShouldParseTrailingQuestionInForLoop() + { + var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestionMark = true }); + var result = parser.TryParse("{% for item in collection.items? %}{{ item }}{% endfor %}", out var template, out var errors); + + Assert.True(result); + Assert.Null(errors); + } + + [Fact] + public async Task ShouldWorkWithMixedIdentifiers() + { + var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestionMark = true }); + parser.TryParse("{{ a.b? }}{{ c.d }}", out var template, out var errors); + + var context = new TemplateContext(); + var objA = new { b = "value1" }; + var objC = new { d = "value2" }; + context.Options.MemberAccessStrategy.Register(objA.GetType()); + context.Options.MemberAccessStrategy.Register(objC.GetType()); + context.SetValue("a", objA); + context.SetValue("c", objC); + + var result = await template.RenderAsync(context); + Assert.Equal("value1value2", result); + } + + [Fact] + public void ShouldParseTrailingQuestionWithIndexer() + { + var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestionMark = true }); + var result = parser.TryParse("{{ items[0].empty? }}", out var template, out var errors); + + Assert.True(result); + Assert.Null(errors); + } + + [Theory] + [InlineData("{{ a? | upcase }}")] + [InlineData("{{ a.b? | append: '.txt' }}")] + public void ShouldParseTrailingQuestionWithFilters(string templateText) + { + var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestionMark = true }); + var result = parser.TryParse(templateText, out var template, out var errors); + + Assert.True(result); + Assert.Null(errors); + } + + [Fact] + public async Task ShouldRenderTrailingQuestionWithFilters() + { + var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestionMark = true }); + parser.TryParse("{{ text | upcase }}", out var template, out var errors); + + var context = new TemplateContext(); + context.SetValue("text", "hello"); + + var result = await template.RenderAsync(context); + Assert.Equal("HELLO", result); + } + + [Fact] + public void ShouldSupportTrailingQuestionOnIntermediateIdentifiers() + { + var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestionMark = true }); + parser.TryParse("{{ a?.b.c }}", out var template, out var errors); + + var statements = ((FluidTemplate)template).Statements; + var outputStatement = statements[0] as OutputStatement; + Assert.NotNull(outputStatement); + + var memberExpression = outputStatement.Expression as MemberExpression; + Assert.NotNull(memberExpression); + Assert.Equal(3, memberExpression.Segments.Count); + + var firstSegment = memberExpression.Segments[0] as IdentifierSegment; + Assert.NotNull(firstSegment); + Assert.Equal("a", firstSegment.Identifier); // Should NOT contain '?' + + var secondSegment = memberExpression.Segments[1] as IdentifierSegment; + Assert.NotNull(secondSegment); + Assert.Equal("b", secondSegment.Identifier); + + var thirdSegment = memberExpression.Segments[2] as IdentifierSegment; + Assert.NotNull(thirdSegment); + Assert.Equal("c", thirdSegment.Identifier); + } + + [Fact] + public async Task ShouldResolveIntermediateIdentifiersWithTrailingQuestion() + { + var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestionMark = true }); + parser.TryParse("{{ obj?.nested.value }}", out var template, out var errors); + + var context = new TemplateContext(); + var nested = new { value = "test" }; + var obj = new { nested }; + context.Options.MemberAccessStrategy.Register(nested.GetType()); + context.Options.MemberAccessStrategy.Register(obj.GetType()); + context.SetValue("obj", obj); + + var result = await template.RenderAsync(context); + Assert.Equal("test", result); + } + + [Theory] + [InlineData("{{ a?.b.c }}", new[] { "a", "b", "c" })] + [InlineData("{{ a.b?.c }}", new[] { "a", "b", "c" })] + [InlineData("{{ a?.b?.c }}", new[] { "a", "b", "c" })] + [InlineData("{{ a?.b?.c? }}", new[] { "a", "b", "c" })] + public void ShouldStripTrailingQuestionFromAllSegments(string template, string[] expectedIdentifiers) + { + var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestionMark = true }); + parser.TryParse(template, out var parsedTemplate, out var errors); + + var statements = ((FluidTemplate)parsedTemplate).Statements; + var outputStatement = statements[0] as OutputStatement; + Assert.NotNull(outputStatement); + + var memberExpression = outputStatement.Expression as MemberExpression; + Assert.NotNull(memberExpression); + Assert.Equal(expectedIdentifiers.Length, memberExpression.Segments.Count); + + for (int i = 0; i < expectedIdentifiers.Length; i++) + { + var segment = memberExpression.Segments[i] as IdentifierSegment; + Assert.NotNull(segment); + Assert.Equal(expectedIdentifiers[i], segment.Identifier); + } + } + } +} diff --git a/Fluid/FluidParser.cs b/Fluid/FluidParser.cs index 8bad133e..a87f613f 100644 --- a/Fluid/FluidParser.cs +++ b/Fluid/FluidParser.cs @@ -45,7 +45,7 @@ public class FluidParser protected static readonly Parser BinaryOr = Terms.Text("or"); protected static readonly Parser BinaryAnd = Terms.Text("and"); - protected static readonly Parser Identifier = SkipWhiteSpace(new IdentifierParser()).Then(x => x.ToString()); + protected readonly Parser Identifier; protected readonly Parser> ArgumentsList; protected readonly Parser> FunctionCallArgumentsList; @@ -95,6 +95,8 @@ public FluidParser(FluidParserOptions parserOptions) TagEnd = NoInlineTagEnd; } + Identifier = SkipWhiteSpace(new IdentifierParser(parserOptions.AllowTrailingQuestionMark)).Then(x => x.ToString()); + String.Name = "String"; Number.Name = "Number"; diff --git a/Fluid/FluidParserOptions.cs b/Fluid/FluidParserOptions.cs index 56af6bc2..650ba395 100644 --- a/Fluid/FluidParserOptions.cs +++ b/Fluid/FluidParserOptions.cs @@ -19,5 +19,10 @@ public class FluidParserOptions /// Gets whether the inline liquid tag is allowed in templates. Default is false. /// public bool AllowLiquidTag { get; set; } + + /// + /// Gets whether identifiers can end with a question mark (`?`), which will be stripped during parsing. Default is false. + /// + public bool AllowTrailingQuestionMark { get; set; } } } diff --git a/Fluid/Parser/IdentifierParser.cs b/Fluid/Parser/IdentifierParser.cs index 15478e11..fa975483 100644 --- a/Fluid/Parser/IdentifierParser.cs +++ b/Fluid/Parser/IdentifierParser.cs @@ -7,12 +7,19 @@ namespace Fluid.Parser public sealed class IdentifierParser : Parser, ISeekable { public const string StartChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"; + private readonly bool _stripTrailingQuestion; + public bool CanSeek => true; public char[] ExpectedChars { get; } = StartChars.ToCharArray(); public bool SkipWhitespace => false; + public IdentifierParser(bool stripTrailingQuestion = false) + { + _stripTrailingQuestion = stripTrailingQuestion; + } + public override bool Parse(ParseContext context, ref ParseResult result) { context.EnterParser(this); @@ -45,6 +52,8 @@ public override bool Parse(ParseContext context, ref ParseResult resul cursor.Advance(); + var hasTrailingQuestion = false; + while (!cursor.Eof) { current = cursor.Current; @@ -64,6 +73,13 @@ public override bool Parse(ParseContext context, ref ParseResult resul { lastIsDash = false; } + else if (_stripTrailingQuestion && current == '?' && !hasTrailingQuestion) + { + // Allow one trailing '?' if the option is enabled + hasTrailingQuestion = true; + nonDigits++; + lastIsDash = false; + } else { break; @@ -85,6 +101,13 @@ public override bool Parse(ParseContext context, ref ParseResult resul cursor.ResetPosition(lastDashPosition); } + // Strip trailing '?' from the result if enabled and present + if (_stripTrailingQuestion && hasTrailingQuestion) + { + nonDigits--; + end--; + } + if (nonDigits == 0) { // Invalid identifier, only digits