Skip to content

Commit 82f1d28

Browse files
Copilotsebastienros
andcommitted
Add AllowTrailingQuestion option to support identifiers ending with ?
Co-authored-by: sebastienros <1165805+sebastienros@users.noreply.github.com>
1 parent 9653ad1 commit 82f1d28

File tree

4 files changed

+256
-1
lines changed

4 files changed

+256
-1
lines changed
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
using Fluid.Ast;
2+
using Fluid.Parser;
3+
using System.Linq;
4+
using System.Threading.Tasks;
5+
using Xunit;
6+
7+
namespace Fluid.Tests
8+
{
9+
public class TrailingQuestionTests
10+
{
11+
[Fact]
12+
public void ShouldNotParseTrailingQuestionByDefault()
13+
{
14+
var parser = new FluidParser();
15+
var result = parser.TryParse("{{ product.empty? }}", out var template, out var errors);
16+
17+
Assert.False(result);
18+
Assert.NotNull(errors);
19+
}
20+
21+
[Fact]
22+
public void ShouldParseTrailingQuestionWhenEnabled()
23+
{
24+
var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestion = true });
25+
var result = parser.TryParse("{{ product.empty? }}", out var template, out var errors);
26+
27+
Assert.True(result);
28+
Assert.Null(errors);
29+
}
30+
31+
[Fact]
32+
public void ShouldStripTrailingQuestionFromIdentifier()
33+
{
34+
var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestion = true });
35+
parser.TryParse("{{ product.empty? }}", out var template, out var errors);
36+
37+
var statements = ((FluidTemplate)template).Statements;
38+
var outputStatement = statements[0] as OutputStatement;
39+
Assert.NotNull(outputStatement);
40+
41+
var memberExpression = outputStatement.Expression as MemberExpression;
42+
Assert.NotNull(memberExpression);
43+
Assert.Equal(2, memberExpression.Segments.Count);
44+
45+
var firstSegment = memberExpression.Segments[0] as IdentifierSegment;
46+
Assert.NotNull(firstSegment);
47+
Assert.Equal("product", firstSegment.Identifier);
48+
49+
var secondSegment = memberExpression.Segments[1] as IdentifierSegment;
50+
Assert.NotNull(secondSegment);
51+
Assert.Equal("empty", secondSegment.Identifier); // Should NOT contain '?'
52+
}
53+
54+
[Fact]
55+
public async Task ShouldResolveIdentifierWithoutQuestionMark()
56+
{
57+
var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestion = true });
58+
parser.TryParse("{{ products.empty? }}", out var template, out var errors);
59+
60+
var context = new TemplateContext();
61+
var sampleObj = new { empty = true };
62+
context.Options.MemberAccessStrategy.Register(sampleObj.GetType());
63+
context.SetValue("products", sampleObj);
64+
65+
var result = await template.RenderAsync(context);
66+
Assert.Equal("true", result);
67+
}
68+
69+
[Theory]
70+
[InlineData("{{ a? }}", "a")]
71+
[InlineData("{{ product.quantity_price_breaks_configured? }}", "quantity_price_breaks_configured")]
72+
[InlineData("{{ collection.products.empty? }}", "empty")]
73+
public void ShouldStripTrailingQuestionFromVariousIdentifiers(string template, string expectedLastSegment)
74+
{
75+
var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestion = true });
76+
parser.TryParse(template, out var parsedTemplate, out var errors);
77+
78+
var statements = ((FluidTemplate)parsedTemplate).Statements;
79+
var outputStatement = statements[0] as OutputStatement;
80+
Assert.NotNull(outputStatement);
81+
82+
var memberExpression = outputStatement.Expression as MemberExpression;
83+
Assert.NotNull(memberExpression);
84+
85+
var lastSegment = memberExpression.Segments[^1] as IdentifierSegment;
86+
Assert.NotNull(lastSegment);
87+
Assert.Equal(expectedLastSegment, lastSegment.Identifier);
88+
}
89+
90+
[Fact]
91+
public void ShouldParseTrailingQuestionInIfStatement()
92+
{
93+
var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestion = true });
94+
var result = parser.TryParse("{% if collection.products.empty? %}No products{% endif %}", out var template, out var errors);
95+
96+
Assert.True(result);
97+
Assert.Null(errors);
98+
}
99+
100+
[Fact]
101+
public async Task ShouldEvaluateTrailingQuestionInIfStatement()
102+
{
103+
var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestion = true });
104+
parser.TryParse("{% if collection.products.empty? %}No products{% endif %}", out var template, out var errors);
105+
106+
var context = new TemplateContext();
107+
var products = new { empty = true };
108+
var collection = new { products };
109+
context.Options.MemberAccessStrategy.Register(products.GetType());
110+
context.Options.MemberAccessStrategy.Register(collection.GetType());
111+
context.SetValue("collection", collection);
112+
113+
var result = await template.RenderAsync(context);
114+
Assert.Equal("No products", result);
115+
}
116+
117+
[Fact]
118+
public void ShouldParseTrailingQuestionInFilterArgument()
119+
{
120+
var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestion = true });
121+
var result = parser.TryParse("{{ value | filter: item.empty? }}", out var template, out var errors);
122+
123+
Assert.True(result);
124+
Assert.Null(errors);
125+
}
126+
127+
[Fact]
128+
public void ShouldParseTrailingQuestionInAssignment()
129+
{
130+
var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestion = true });
131+
var result = parser.TryParse("{% assign x = product.empty? %}", out var template, out var errors);
132+
133+
Assert.True(result);
134+
Assert.Null(errors);
135+
}
136+
137+
[Fact]
138+
public async Task ShouldEvaluateTrailingQuestionInAssignment()
139+
{
140+
var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestion = true });
141+
parser.TryParse("{% assign x = product.empty? %}{{ x }}", out var template, out var errors);
142+
143+
var context = new TemplateContext();
144+
var product = new { empty = false };
145+
context.Options.MemberAccessStrategy.Register(product.GetType());
146+
context.SetValue("product", product);
147+
148+
var result = await template.RenderAsync(context);
149+
Assert.Equal("false", result);
150+
}
151+
152+
[Fact]
153+
public void ShouldNotAllowMultipleTrailingQuestions()
154+
{
155+
var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestion = true });
156+
var result = parser.TryParse("{{ product.empty?? }}", out var template, out var errors);
157+
158+
// Should fail because we only allow one trailing question mark
159+
Assert.False(result);
160+
}
161+
162+
[Fact]
163+
public void ShouldParseTrailingQuestionInForLoop()
164+
{
165+
var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestion = true });
166+
var result = parser.TryParse("{% for item in collection.items? %}{{ item }}{% endfor %}", out var template, out var errors);
167+
168+
Assert.True(result);
169+
Assert.Null(errors);
170+
}
171+
172+
[Fact]
173+
public async Task ShouldWorkWithMixedIdentifiers()
174+
{
175+
var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestion = true });
176+
parser.TryParse("{{ a.b? }}{{ c.d }}", out var template, out var errors);
177+
178+
var context = new TemplateContext();
179+
var objA = new { b = "value1" };
180+
var objC = new { d = "value2" };
181+
context.Options.MemberAccessStrategy.Register(objA.GetType());
182+
context.Options.MemberAccessStrategy.Register(objC.GetType());
183+
context.SetValue("a", objA);
184+
context.SetValue("c", objC);
185+
186+
var result = await template.RenderAsync(context);
187+
Assert.Equal("value1value2", result);
188+
}
189+
190+
[Fact]
191+
public void ShouldParseTrailingQuestionWithIndexer()
192+
{
193+
var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestion = true });
194+
var result = parser.TryParse("{{ items[0].empty? }}", out var template, out var errors);
195+
196+
Assert.True(result);
197+
Assert.Null(errors);
198+
}
199+
200+
[Theory]
201+
[InlineData("{{ a? | upcase }}")]
202+
[InlineData("{{ a.b? | append: '.txt' }}")]
203+
public void ShouldParseTrailingQuestionWithFilters(string templateText)
204+
{
205+
var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestion = true });
206+
var result = parser.TryParse(templateText, out var template, out var errors);
207+
208+
Assert.True(result);
209+
Assert.Null(errors);
210+
}
211+
212+
[Fact]
213+
public async Task ShouldRenderTrailingQuestionWithFilters()
214+
{
215+
var parser = new FluidParser(new FluidParserOptions { AllowTrailingQuestion = true });
216+
parser.TryParse("{{ text | upcase }}", out var template, out var errors);
217+
218+
var context = new TemplateContext();
219+
context.SetValue("text", "hello");
220+
221+
var result = await template.RenderAsync(context);
222+
Assert.Equal("HELLO", result);
223+
}
224+
}
225+
}

Fluid/FluidParser.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ public class FluidParser
4545
protected static readonly Parser<string> BinaryOr = Terms.Text("or");
4646
protected static readonly Parser<string> BinaryAnd = Terms.Text("and");
4747

48-
protected static readonly Parser<string> Identifier = SkipWhiteSpace(new IdentifierParser()).Then(x => x.ToString());
48+
protected readonly Parser<string> Identifier;
4949

5050
protected readonly Parser<IReadOnlyList<FilterArgument>> ArgumentsList;
5151
protected readonly Parser<IReadOnlyList<FunctionCallArgument>> FunctionCallArgumentsList;
@@ -95,6 +95,8 @@ public FluidParser(FluidParserOptions parserOptions)
9595
TagEnd = NoInlineTagEnd;
9696
}
9797

98+
Identifier = SkipWhiteSpace(new IdentifierParser(parserOptions.AllowTrailingQuestion)).Then(x => x.ToString());
99+
98100
String.Name = "String";
99101
Number.Name = "Number";
100102

Fluid/FluidParserOptions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,10 @@ public class FluidParserOptions
1919
/// Gets whether the inline liquid tag is allowed in templates. Default is <c>false</c>.
2020
/// </summary>
2121
public bool AllowLiquidTag { get; set; }
22+
23+
/// <summary>
24+
/// Gets whether identifiers can end with a question mark (`?`), which will be stripped during parsing. Default is <c>false</c>.
25+
/// </summary>
26+
public bool AllowTrailingQuestion { get; set; }
2227
}
2328
}

Fluid/Parser/IdentifierParser.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,19 @@ namespace Fluid.Parser
77
public sealed class IdentifierParser : Parser<TextSpan>, ISeekable
88
{
99
public const string StartChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
10+
private readonly bool _stripTrailingQuestion;
11+
1012
public bool CanSeek => true;
1113

1214
public char[] ExpectedChars { get; } = StartChars.ToCharArray();
1315

1416
public bool SkipWhitespace => false;
1517

18+
public IdentifierParser(bool stripTrailingQuestion = false)
19+
{
20+
_stripTrailingQuestion = stripTrailingQuestion;
21+
}
22+
1623
public override bool Parse(ParseContext context, ref ParseResult<TextSpan> result)
1724
{
1825
context.EnterParser(this);
@@ -45,6 +52,8 @@ public override bool Parse(ParseContext context, ref ParseResult<TextSpan> resul
4552

4653
cursor.Advance();
4754

55+
var hasTrailingQuestion = false;
56+
4857
while (!cursor.Eof)
4958
{
5059
current = cursor.Current;
@@ -64,6 +73,13 @@ public override bool Parse(ParseContext context, ref ParseResult<TextSpan> resul
6473
{
6574
lastIsDash = false;
6675
}
76+
else if (_stripTrailingQuestion && current == '?' && !hasTrailingQuestion)
77+
{
78+
// Allow one trailing '?' if the option is enabled
79+
hasTrailingQuestion = true;
80+
nonDigits++;
81+
lastIsDash = false;
82+
}
6783
else
6884
{
6985
break;
@@ -85,6 +101,13 @@ public override bool Parse(ParseContext context, ref ParseResult<TextSpan> resul
85101
cursor.ResetPosition(lastDashPosition);
86102
}
87103

104+
// Strip trailing '?' from the result if enabled and present
105+
if (_stripTrailingQuestion && hasTrailingQuestion)
106+
{
107+
nonDigits--;
108+
end--;
109+
}
110+
88111
if (nonDigits == 0)
89112
{
90113
// Invalid identifier, only digits

0 commit comments

Comments
 (0)