diff --git a/Directory.Packages.props b/Directory.Packages.props index f93bac92..24d821e6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,12 +11,12 @@ - + - + diff --git a/Fluid.Tests/ParserTests.cs b/Fluid.Tests/ParserTests.cs index c1984de0..2ac1c835 100644 --- a/Fluid.Tests/ParserTests.cs +++ b/Fluid.Tests/ParserTests.cs @@ -987,7 +987,8 @@ public void ShouldParseLiquidTag() | upcase %}"; - Assert.True(_parser.TryParse(source, out var template, out var errors), errors); + var parser = new FluidParser(new FluidParserOptions { AllowLiquidTag = true }); + Assert.True(parser.TryParse(source, out var template, out var errors), errors); var rendered = template.Render(); Assert.Contains("WELCOME TO THE LIQUID TAG", rendered); } @@ -1003,7 +1004,8 @@ public void ShouldParseLiquidTagWithBlocks() %} "; - Assert.True(_parser.TryParse(source, out var template, out var errors), errors); + var parser = new FluidParser(new FluidParserOptions { AllowLiquidTag = true }); + Assert.True(parser.TryParse(source, out var template, out var errors), errors); var rendered = template.Render(); Assert.Contains("WELCOME TO THE LIQUID TAG", rendered); } @@ -1091,11 +1093,11 @@ public void ShouldParseLiquidTagWithDifferentSpaces(string spaces) {% liquid for c in (1..3) echo c - endforSPACE%}SPACE{{chars}}SPACE - """.Replace("SPACE", spaces); + endfor[SPACE]%}[SPACE]{{chars}}[SPACE] + """.Replace("[SPACE]", spaces); - var _parser = new FluidParser(); - Assert.True(_parser.TryParse(source, out var template, out var errors), errors); + var parser = new FluidParser(new FluidParserOptions { AllowLiquidTag = true }); + Assert.True(parser.TryParse(source, out var template, out var errors), errors); var rendered = template.Render(); Assert.Contains("123", rendered); } diff --git a/Fluid/FluidParser.cs b/Fluid/FluidParser.cs index b4818c55..66c4d981 100644 --- a/Fluid/FluidParser.cs +++ b/Fluid/FluidParser.cs @@ -56,11 +56,25 @@ public class FluidParser protected readonly Deferred> KnownTagsList = Deferred>(); protected readonly Deferred> AnyTagsList = Deferred>(); - protected static readonly Parser OutputStart = TagParsers.OutputTagStart(); - protected static readonly Parser OutputEnd = TagParsers.OutputTagEnd(true); - protected static readonly Parser TagStart = TagParsers.TagStart(); - protected static readonly Parser TagStartSpaced = TagParsers.TagStart(true); - protected static readonly Parser TagEnd = TagParsers.TagEnd(true); + internal const string WhiteSpaceChars = "\t\n\v\f\r \u0085             \u2028\u2029   "; + + protected static readonly Parser InlineOutputStart = TagParsers.OutputTagStart(); + protected static readonly Parser InlineOutputEnd = TagParsers.OutputTagEnd(); + protected static readonly Parser InlineTagStart = TagParsers.TagStart(); + protected static readonly Parser InlineTagEnd = TagParsers.TagEnd(); + + protected static readonly Parser NoInlineOutputStart = NonInlineLiquidTagParsers.OutputTagStart(); + protected static readonly Parser NoInlineOutputEnd = Literals.AnyOf(WhiteSpaceChars, minSize: 0).SkipAnd(NonInlineLiquidTagParsers.OutputTagEnd()); + protected static readonly Parser NoInlineTagStart = NonInlineLiquidTagParsers.TagStart(); + protected static readonly Parser NoInlineTagEnd = Literals.AnyOf(WhiteSpaceChars, minSize: 0).SkipAnd(NonInlineLiquidTagParsers.TagEnd()); + + protected readonly Parser OutputStart = InlineOutputStart; + protected readonly Parser OutputEnd = InlineOutputEnd; + protected readonly Parser TagStart = InlineTagStart; + protected readonly Parser TagEnd = InlineTagEnd; + + protected static readonly Parser RawOutputStart = NonInlineLiquidTagParsers.OutputTagStart(); + protected static readonly Parser RawTagStart = NonInlineLiquidTagParsers.TagStart(); protected static readonly LiteralExpression EmptyKeyword = new LiteralExpression(EmptyValue.Instance); protected static readonly LiteralExpression BlankKeyword = new LiteralExpression(BlankValue.Instance); @@ -73,6 +87,14 @@ public FluidParser() : this(new()) public FluidParser(FluidParserOptions parserOptions) { + if (!parserOptions.AllowLiquidTag) + { + OutputStart = NoInlineOutputStart; + OutputEnd = NoInlineOutputEnd; + TagStart = NoInlineTagStart; + TagEnd = NoInlineTagEnd; + } + String.Name = "String"; Number.Name = "Number"; @@ -574,7 +596,7 @@ public FluidParser(FluidParserOptions parserOptions) Grammar = KnownTagsList; } - public static Parser CreateTag(string tagName) => TagStart.SkipAnd(Terms.Text(tagName)).AndSkip(TagEnd); + public Parser CreateTag(string tagName) => TagStart.SkipAnd(Terms.Text(tagName)).AndSkip(TagEnd); public void RegisterIdentifierTag(string tagName, Func> render) { diff --git a/Fluid/FluidParserOptions.cs b/Fluid/FluidParserOptions.cs index fe1f1727..56af6bc2 100644 --- a/Fluid/FluidParserOptions.cs +++ b/Fluid/FluidParserOptions.cs @@ -14,5 +14,10 @@ public class FluidParserOptions /// Gets whether parentheses are allowed in templates. Default is false. /// public bool AllowParentheses { get; set; } + + /// + /// Gets whether the inline liquid tag is allowed in templates. Default is false. + /// + public bool AllowLiquidTag { get; set; } } } diff --git a/Fluid/Parser/TagParsers.cs b/Fluid/Parser/TagParsers.cs index 1096d1a5..d7796470 100644 --- a/Fluid/Parser/TagParsers.cs +++ b/Fluid/Parser/TagParsers.cs @@ -15,10 +15,10 @@ public struct ForModifier public readonly struct TagResult { - public static readonly TagResult TagOpen = new TagResult(true, false); - public static readonly TagResult TagOpenTrim = new TagResult(true, true); - public static readonly TagResult TagClose = new TagResult(false, false); - public static readonly TagResult TagCloseTrim = new TagResult(false, true); + public static readonly TagResult TagOpen = new(true, false); + public static readonly TagResult TagOpenTrim = new(true, true); + public static readonly TagResult TagClose = new(false, false); + public static readonly TagResult TagCloseTrim = new(false, true); public TagResult(bool open, bool trim) { @@ -32,28 +32,17 @@ public TagResult(bool open, bool trim) public static class TagParsers { - public static Parser TagStart(bool skipWhiteSpace = false) => new TagStartParser(skipWhiteSpace); - public static Parser TagEnd(bool skipWhiteSpace = false) => new TagEndParser(skipWhiteSpace); - public static Parser OutputTagStart(bool skipWhiteSpace = false) => new OutputTagStartParser(skipWhiteSpace); - public static Parser OutputTagEnd(bool skipWhiteSpace = false) => new OutputTagEndParser(skipWhiteSpace); + public static Parser TagStart() => new TagStartParser(); + public static Parser TagEnd() => new TagEndParser(); + public static Parser OutputTagStart() => new OutputTagStartParser(); + public static Parser OutputTagEnd() => new OutputTagEndParser(); private sealed class TagStartParser : Parser { - private readonly bool _skipWhiteSpace; - public TagStartParser(bool skipWhiteSpace = false) - { - _skipWhiteSpace = skipWhiteSpace; - } - public override bool Parse(ParseContext context, ref ParseResult result) { context.EnterParser(this); - if (_skipWhiteSpace) - { - context.SkipWhiteSpace(); - } - var start = context.Scanner.Cursor.Position; var p = (FluidParseContext)context; @@ -66,7 +55,11 @@ public override bool Parse(ParseContext context, ref ParseResult resu return true; } +#if NET6_0_OR_GREATER + if (context.Scanner.ReadText("{%")) +#else if (context.Scanner.ReadChar('{') && context.Scanner.ReadChar('%')) +#endif { var trim = context.Scanner.ReadChar('-'); @@ -98,21 +91,12 @@ public override bool Parse(ParseContext context, ref ParseResult resu } /// - /// Search for `%}`, `-%}` or `-}` to close a tag. + /// Search for `%}`, `-%}` to close a tag. /// Also, if the tag is inside a `liquid` tag, it will only look for a new line to close the tag. /// - private sealed class TagEndParser : Parser, ISeekable + private sealed class TagEndParser : Parser { - private readonly bool _skipWhiteSpace; - - public bool CanSeek { get; set; } = true; - public bool SkipWhitespace { get; set; } = false; - public char[] ExpectedChars { get; set; } = ['\r', '\n', '}', '-', '%', ' ', '\t']; - - public TagEndParser(bool skipWhiteSpace = false) - { - _skipWhiteSpace = skipWhiteSpace; - } + public bool SkipWhitespace { get; set; } = true; public override bool Parse(ParseContext context, ref ParseResult result) { @@ -120,7 +104,9 @@ public override bool Parse(ParseContext context, ref ParseResult resu var newLineIsPresent = false; - if (_skipWhiteSpace) + var start = context.Scanner.Cursor.Position; + + if (SkipWhitespace) { if (p.InsideLiquidTag) { @@ -146,7 +132,6 @@ public override bool Parse(ParseContext context, ref ParseResult resu } } - var start = context.Scanner.Cursor.Position; bool trim; if (p.InsideLiquidTag) @@ -160,7 +145,11 @@ public override bool Parse(ParseContext context, ref ParseResult resu { trim = context.Scanner.ReadChar('-'); +#if NET6_0_OR_GREATER + if (context.Scanner.ReadText("%}")) +#else if (context.Scanner.ReadChar('%') && context.Scanner.ReadChar('}')) +#endif { p.StripNextTextSpanStatement = trim; p.PreviousTextSpanStatement = null; @@ -169,7 +158,7 @@ public override bool Parse(ParseContext context, ref ParseResult resu context.Scanner.Cursor.ResetPosition(start); - result.Set(start.Offset, start.Offset, TagResult.TagClose); + result.Set(start.Offset, context.Scanner.Cursor.Offset, trim ? TagResult.TagCloseTrim : TagResult.TagClose); return true; } @@ -180,7 +169,11 @@ public override bool Parse(ParseContext context, ref ParseResult resu trim = context.Scanner.ReadChar('-'); +#if NET6_0_OR_GREATER + if (context.Scanner.ReadText("%}")) +#else if (context.Scanner.ReadChar('%') && context.Scanner.ReadChar('}')) +#endif { p.StripNextTextSpanStatement = trim; p.PreviousTextSpanStatement = null; @@ -200,23 +193,197 @@ public override bool Parse(ParseContext context, ref ParseResult resu private sealed class OutputTagStartParser : Parser { - public OutputTagStartParser(bool skipWhiteSpace = false) + public override bool Parse(ParseContext context, ref ParseResult result) + { + var start = context.Scanner.Cursor.Position; + +#if NET6_0_OR_GREATER + if (context.Scanner.ReadText("{{")) +#else + if (context.Scanner.ReadChar('{') && context.Scanner.ReadChar('{')) +#endif + { + var trim = context.Scanner.ReadChar('-'); + + var p = (FluidParseContext)context; + + if (p.PreviousTextSpanStatement != null) + { + if (trim) + { + p.PreviousTextSpanStatement.StripRight = true; + } + + p.PreviousTextSpanStatement.NextIsOutput = true; + + p.PreviousTextSpanStatement = null; + } + + + result.Set(start.Offset, context.Scanner.Cursor.Offset, trim ? TagResult.TagOpenTrim : TagResult.TagOpen); + return true; + } + else + { + context.Scanner.Cursor.ResetPosition(start); + return false; + } + } + } + + private sealed class OutputTagEndParser : Parser + { + public override bool Parse(ParseContext context, ref ParseResult result) + { + context.EnterParser(this); + + var start = context.Scanner.Cursor.Position; + + context.SkipWhiteSpace(); + + var trim = context.Scanner.ReadChar('-'); + +#if NET6_0_OR_GREATER + if (context.Scanner.ReadText("}}")) +#else + if (context.Scanner.ReadChar('}') && context.Scanner.ReadChar('}')) +#endif + { + var p = (FluidParseContext)context; + + p.StripNextTextSpanStatement = trim; + p.PreviousTextSpanStatement = null; + p.PreviousIsTag = false; + p.PreviousIsOutput = true; + + result.Set(start.Offset, context.Scanner.Cursor.Offset, trim ? TagResult.TagCloseTrim : TagResult.TagClose); + + + context.ExitParser(this); + return true; + } + else + { + context.Scanner.Cursor.ResetPosition(start); + + context.ExitParser(this); + return false; + } + } + } + } + + public static class NonInlineLiquidTagParsers + { + public static Parser TagStart() => new TagStartParser(); + public static Parser TagEnd() => new TagEndParser(); + public static Parser OutputTagStart() => new OutputTagStartParser(); + public static Parser OutputTagEnd() => new OutputTagEndParser(); + + private sealed class TagStartParser : Parser, ISeekable + { + public bool CanSeek => true; + public char[] ExpectedChars { get; set; } = ['{']; + public bool SkipWhitespace { get; } = false; + + public override bool Parse(ParseContext context, ref ParseResult result) { - SkipWhitespace = skipWhiteSpace; + context.EnterParser(this); + + var start = context.Scanner.Cursor.Position; + + var p = (FluidParseContext)context; + +#if NET6_0_OR_GREATER + if (context.Scanner.ReadText("{%")) +#else + if (context.Scanner.ReadChar('{') && context.Scanner.ReadChar('%')) +#endif + { + var trim = context.Scanner.ReadChar('-'); + + if (p.PreviousTextSpanStatement != null) + { + if (trim) + { + p.PreviousTextSpanStatement.StripRight = true; + } + + p.PreviousTextSpanStatement.NextIsTag = true; + + p.PreviousTextSpanStatement = null; + } + + result.Set(start.Offset, context.Scanner.Cursor.Offset, trim ? TagResult.TagOpenTrim : TagResult.TagOpen); + + context.ExitParser(this); + return true; + } + else + { + context.Scanner.Cursor.ResetPosition(start); + + context.ExitParser(this); + return false; + } } + } - public bool SkipWhitespace { get; } + private sealed class TagEndParser : Parser, ISeekable + { + public bool CanSeek => true; + public char[] ExpectedChars { get; set; } = ['-', '%']; + public bool SkipWhitespace { get; set; } = false; public override bool Parse(ParseContext context, ref ParseResult result) { + var p = (FluidParseContext)context; + var start = context.Scanner.Cursor.Position; - if (SkipWhitespace) + bool trim; + + trim = context.Scanner.ReadChar('-'); + +#if NET6_0_OR_GREATER + if (context.Scanner.ReadText("%}")) +#else + if (context.Scanner.ReadChar('%') && context.Scanner.ReadChar('}')) +#endif + { + p.StripNextTextSpanStatement = trim; + p.PreviousTextSpanStatement = null; + p.PreviousIsTag = true; + p.PreviousIsOutput = false; + + result.Set(start.Offset, context.Scanner.Cursor.Offset, trim ? TagResult.TagCloseTrim : TagResult.TagClose); + return true; + } + else { - context.SkipWhiteSpace(); + context.Scanner.Cursor.ResetPosition(start); + return false; } + } + } + private sealed class OutputTagStartParser : Parser, ISeekable + { + public bool CanSeek => true; + + public char[] ExpectedChars { get; set; } = ['{']; + + public bool SkipWhitespace { get; } = false; + + public override bool Parse(ParseContext context, ref ParseResult result) + { + var start = context.Scanner.Cursor.Position; + +#if NET6_0_OR_GREATER + if (context.Scanner.ReadText("{{")) +#else if (context.Scanner.ReadChar('{') && context.Scanner.ReadChar('{')) +#endif { var trim = context.Scanner.ReadChar('-'); @@ -248,31 +415,25 @@ public override bool Parse(ParseContext context, ref ParseResult resu private sealed class OutputTagEndParser : Parser, ISeekable { - public OutputTagEndParser(bool skipWhiteSpace = false) - { - SkipWhitespace = skipWhiteSpace; - } - public bool CanSeek => true; public char[] ExpectedChars { get; set; } = ['-', '}']; - public bool SkipWhitespace { get; } + public bool SkipWhitespace { get; } = false; public override bool Parse(ParseContext context, ref ParseResult result) { context.EnterParser(this); - if (SkipWhitespace) - { - context.SkipWhiteSpace(); - } - var start = context.Scanner.Cursor.Position; var trim = context.Scanner.ReadChar('-'); +#if NET6_0_OR_GREATER + if (context.Scanner.ReadText("}}")) +#else if (context.Scanner.ReadChar('}') && context.Scanner.ReadChar('}')) +#endif { var p = (FluidParseContext)context; @@ -297,4 +458,5 @@ public override bool Parse(ParseContext context, ref ParseResult resu } } } + } diff --git a/README.md b/README.md index 684f78e4..3a216f5d 100644 --- a/README.md +++ b/README.md @@ -1130,43 +1130,43 @@ Run it locally to analyze the time it takes to execute specific templates. #### Results Fluid is faster and allocates less memory than all other well-known .NET Liquid parsers. -For parsing, Fluid is 20% faster than the second, Scriban, allocating 2 times less memory. -For rendering, Fluid is 30% faster than the second, Handlebars, allocating half the memory, and 5 times faster than Scriban. +For parsing, Fluid is 30% faster than the second best, Scriban, allocating half the memory. +For rendering, Fluid is 20% faster than the second best, Handlebars, and allocating half the memory. Compared to DotLiquid, Fluid renders 10 times faster, and allocates 34 times less memory. ``` text -BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.2314) +BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.3476) 12th Gen Intel Core i7-1260P, 1 CPU, 16 logical and 12 physical cores -.NET SDK 9.0.100 - [Host] : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX2 - ShortRun : .NET 9.0.0 (9.0.24.52809), X64 RyuJIT AVX2 +.NET SDK 9.0.201 + [Host] : .NET 9.0.3 (9.0.325.11113), X64 RyuJIT AVX2 + ShortRun : .NET 9.0.3 (9.0.325.11113), X64 RyuJIT AVX2 Job=ShortRun IterationCount=3 LaunchCount=1 WarmupCount=3 -| Method | Mean | Error | StdDev | Ratio | Allocated | Alloc Ratio | -|------------------- |--------------:|--------------:|------------:|---------:|------------:|------------:| -| Fluid_Parse | 2.622 us | 1.4586 us | 0.0800 us | 1.00 | 2.83 KB | 1.00 | -| Scriban_Parse | 3.149 us | 0.8304 us | 0.0455 us | 1.20 | 7.14 KB | 2.53 | -| DotLiquid_Parse | 6.133 us | 1.5094 us | 0.0827 us | 2.34 | 16.21 KB | 5.73 | -| LiquidNet_Parse | 23.112 us | 6.0582 us | 0.3321 us | 8.82 | 62.04 KB | 21.94 | -| Handlebars_Parse | 2,662.991 us | 4,830.0818 us | 264.7531 us | 1,016.17 | 155.42 KB | 54.95 | -| | | | | | | | -| Fluid_ParseBig | 10.642 us | 2.0982 us | 0.1150 us | 1.00 | 11.66 KB | 1.00 | -| Scriban_ParseBig | 18.546 us | 14.2197 us | 0.7794 us | 1.74 | 32.07 KB | 2.75 | -| DotLiquid_ParseBig | 25.980 us | 8.1228 us | 0.4452 us | 2.44 | 94.36 KB | 8.10 | -| LiquidNet_ParseBig | 11,175.713 us | 5,605.1094 us | 307.2350 us | 1,050.22 | 28542.56 KB | 2,448.69 | -| | | | | | | | -| Fluid_Render | 127.984 us | 46.8250 us | 2.5666 us | 1.00 | 95.87 KB | 1.00 | -| Scriban_Render | 601.083 us | 86.9414 us | 4.7656 us | 4.70 | 498.66 KB | 5.20 | -| DotLiquid_Render | 1,248.906 us | 231.9350 us | 12.7131 us | 9.76 | 3270.3 KB | 34.11 | -| LiquidNet_Render | 903.463 us | 2,324.0151 us | 127.3871 us | 7.06 | 3126.47 KB | 32.61 | -| Handlebars_Render | 170.182 us | 30.0175 us | 1.6454 us | 1.33 | 194.92 KB | 2.03 | -``` - -Tested on November 24, 2024 with -- Scriban 5.12.0 -- DotLiquid 2.2.692 +| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio | +|------------------- |--------------:|-------------:|------------:|---------:|--------:|----------:|---------:|--------:|------------:|------------:| +| Fluid_Parse | 2.418 us | 1.770 us | 0.0970 us | 1.00 | 0.05 | 0.3090 | - | - | 2.84 KB | 1.00 | +| Scriban_Parse | 3.156 us | 1.316 us | 0.0722 us | 1.31 | 0.05 | 0.7744 | 0.0267 | - | 7.14 KB | 2.51 | +| DotLiquid_Parse | 6.216 us | 8.672 us | 0.4753 us | 2.57 | 0.19 | 1.7548 | 0.0229 | - | 16.15 KB | 5.68 | +| LiquidNet_Parse | 23.808 us | 3.388 us | 0.1857 us | 9.86 | 0.34 | 6.7444 | 0.6104 | - | 62.04 KB | 21.82 | +| Handlebars_Parse | 2,504.618 us | 6,081.515 us | 333.3484 us | 1,036.88 | 124.65 | 15.6250 | - | - | 153.81 KB | 54.09 | +| | | | | | | | | | | | +| Fluid_ParseBig | 11.383 us | 10.116 us | 0.5545 us | 1.00 | 0.06 | 1.2817 | 0.0305 | - | 11.86 KB | 1.00 | +| Scriban_ParseBig | 17.779 us | 5.888 us | 0.3227 us | 1.56 | 0.07 | 3.4790 | 0.4883 | - | 32.07 KB | 2.70 | +| DotLiquid_ParseBig | 25.596 us | 3.955 us | 0.2168 us | 2.25 | 0.09 | 10.2539 | 0.4578 | - | 94.24 KB | 7.95 | +| LiquidNet_ParseBig | 11,786.566 us | 6,521.074 us | 357.4421 us | 1,037.03 | 50.83 | 3093.7500 | 15.6250 | - | 28542.55 KB | 2,406.75 | +| | | | | | | | | | | | +| Fluid_Render | 134.037 us | 101.115 us | 5.5425 us | 1.00 | 0.05 | 10.2539 | 0.4883 | - | 95.88 KB | 1.00 | +| Scriban_Render | 633.523 us | 269.921 us | 14.7953 us | 4.73 | 0.19 | 68.3594 | 68.3594 | 68.3594 | 498.68 KB | 5.20 | +| DotLiquid_Render | 1,402.392 us | 918.007 us | 50.3191 us | 10.47 | 0.49 | 361.3281 | 150.3906 | 29.2969 | 3272.13 KB | 34.13 | +| LiquidNet_Render | 871.168 us | 726.955 us | 39.8469 us | 6.51 | 0.34 | 339.8438 | 160.1563 | - | 3126.39 KB | 32.61 | +| Handlebars_Render | 161.000 us | 64.468 us | 3.5337 us | 1.20 | 0.05 | 20.9961 | 3.4180 | - | 194.92 KB | 2.03 | +``` + +Tested on 3/22/2025 with +- Scriban 6.0.0 +- DotLiquid 2.3.107 - Liquid.NET 0.10.0 - Handlebars.Net 2.1.6 diff --git a/Versions.props b/Versions.props index 0efa3b81..c2e25f31 100644 --- a/Versions.props +++ b/Versions.props @@ -1,12 +1,18 @@ - - 9.0.0 - 8.0.5 + + 6.0.1 + + + + 6.0.1 + + + + 8.0.0 9.0.0 - 9.0.2