diff --git a/appveyor.yml b/appveyor.yml index 51babf570..f55a0c999 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,23 +1,26 @@ # Build script for dotliquid is presently stored securely on AppVeyor. # Below is ignored and kept for informational purposes only -version: 2.2.{build} +version: 2.4.{build} image: Visual Studio 2022 configuration: Release -assembly_info: +dotnet_csproj: patch: true - file: AssemblyInfo.* + file: '**\*.csproj' + version: '{version}' + version_prefix: '{version}' + package_version: '{version}' assembly_version: '{version}' - assembly_file_version: '{version}' - assembly_informational_version: '{version}-$(APPVEYOR_REPO_COMMIT)' + file_version: '{version}' + informational_version: '{version}-$(APPVEYOR_REPO_COMMIT)' install: - cmd: >- choco install opencover.portable -y choco install codecov -y - choco install dotnetcore-runtime.install --version=1.1.13 --allow-downgrade -y + dotnet --list-runtimes | find "Microsoft.NETCore.App 3.1" >nul - choco install dotnetcore-runtime.install --version=2.1.30 -y + if %errorlevel% neq 0 ( choco install dotnetcore-runtime.install --version=3.1.32 -y ) dotnet restore src/DotLiquid.sln cache: @@ -32,7 +35,7 @@ after_build: - ps: >- New-Item -Path build\pkg -ItemType Directory - nuget pack src/DotLiquid/DotLiquid.nuspec -Symbols -SymbolPackageFormat snupkg -Version "$($env:APPVEYOR_BUILD_VERSION)" -OutputDirectory build\pkg + dotnet pack src\DotLiquid\DotLiquid.csproj --configuration Release --include-symbols -p:SymbolPackageFormat=snupkg --output build\pkg --no-build test_script: - cmd: >- opencover.console -target:"C:\Program Files\dotnet\dotnet.exe" -targetargs:"test src\DotLiquid.Tests\DotLiquid.Tests.csproj /clp:ErrorsOnly" -output:TestsCoverage.xml -filter:"+[DotLiquid]*" -register:user -returntargetcode -oldstyle diff --git a/src/DotLiquid.Tests/BlockTests.cs b/src/DotLiquid.Tests/BlockTests.cs index a4e322ef6..bdfc69543 100644 --- a/src/DotLiquid.Tests/BlockTests.cs +++ b/src/DotLiquid.Tests/BlockTests.cs @@ -69,6 +69,7 @@ public void TestWithBlock() public void TestWithCustomTag() { Template.RegisterTag("testtag"); + Assert.That(Template.GetTagType("testtag"), Is.EqualTo(typeof(Block))); Assert.DoesNotThrow(() => Template.Parse("{% testtag %} {% endtesttag %}")); } diff --git a/src/DotLiquid.Tests/ConditionTests.cs b/src/DotLiquid.Tests/ConditionTests.cs index a1ff5ee57..ac3405ca6 100644 --- a/src/DotLiquid.Tests/ConditionTests.cs +++ b/src/DotLiquid.Tests/ConditionTests.cs @@ -512,7 +512,6 @@ public void TestExpandoHasValue() testDictionary.type = "cleaning"; _context["dictionary"] = testDictionary; - AssertEvaluatesTrue("dictionary", "hasvalue", "'Vacuum'"); AssertEvaluatesFalse("dictionary", "hasvalue", "'title'"); } diff --git a/src/DotLiquid.Tests/ContextTests.cs b/src/DotLiquid.Tests/ContextTests.cs index 88c7dfcf2..c0bf81b90 100644 --- a/src/DotLiquid.Tests/ContextTests.cs +++ b/src/DotLiquid.Tests/ContextTests.cs @@ -4,6 +4,7 @@ using System.Dynamic; using System.Globalization; using System.Linq; +using System.Threading; using DotLiquid.Exceptions; using Newtonsoft.Json; using NUnit.Framework; @@ -236,7 +237,7 @@ public void TestVariables() [Test] public void TestVariablesArray() { - List list = new List { 1, 2, 3, 4, 5 }; + var list = new List { 1, 2, 3, 4, 5 }; _context["list"] = list; Assert.That(_context["list"], Is.EqualTo(list)); Assert.That(_context["list[0]"], Is.EqualTo(1)); @@ -244,7 +245,7 @@ public void TestVariablesArray() Assert.That(_context["list[12]"], Is.Null); Assert.That(_context["list[-12]"], Is.Null); - List emptyList = new List(); + var emptyList = new List(); _context["empty_list"] = emptyList; Assert.That(_context["empty_list"], Is.EqualTo(emptyList)); Assert.That(_context["empty_list[0]"], Is.Null); @@ -394,12 +395,19 @@ public void TestAddFilter() Context context = new Context(CultureInfo.InvariantCulture); context.AddFilters(new[] { typeof(TestFilters) }); Assert.That(context.Invoke("hi", new List { "hi?" }), Is.EqualTo("hi? hi!")); - context.SyntaxCompatibilityLevel = SyntaxCompatibility.DotLiquid22; - Assert.That(context.Invoke("hi", new List { "hi?" }), Is.EqualTo("hi? hi!")); - + context = new Context(CultureInfo.InvariantCulture); Assert.That(context.Invoke("hi", new List { "hi?" }), Is.EqualTo("hi?")); - context.SyntaxCompatibilityLevel = SyntaxCompatibility.DotLiquid22; + } + + [Test] + public void TestAddFilter_NotFoundException() + { + var context = new Context(CultureInfo.InvariantCulture) { SyntaxCompatibilityLevel = SyntaxCompatibility.DotLiquid22 }; + context.AddFilters(new[] { typeof(TestFilters) }); + Assert.That(context.Invoke("hi", new List { "hi?" }), Is.EqualTo("hi? hi!")); + + context = new Context(CultureInfo.InvariantCulture) { SyntaxCompatibilityLevel = SyntaxCompatibility.DotLiquid22 }; Assert.Throws(() => context.Invoke("hi", new List { "hi?" })); } @@ -412,15 +420,32 @@ public void TestAddContextFilter() context.AddFilters(new[] { typeof(TestContextFilters) }); Assert.That(context.Invoke("hi", new List { "hi?" }), Is.EqualTo("hi? hi from King Kong!")); - context.SyntaxCompatibilityLevel = SyntaxCompatibility.DotLiquid22; - Assert.That(context.Invoke("hi", new List { "hi?" }), Is.EqualTo("hi? hi from King Kong!")); context = new Context(CultureInfo.InvariantCulture) { SyntaxCompatibilityLevel = SyntaxCompatibility.DotLiquid20 }; Assert.That(context.Invoke("hi", new List { "hi?" }), Is.EqualTo("hi?")); - context.SyntaxCompatibilityLevel = SyntaxCompatibility.DotLiquid22; + } + + [Test] + public void TestAddContextFilter_NotFoundException() + { + // This test differs from TestAddFilter only in that the Hi method within this class has a Context parameter in addition to the input string + Context context = new Context(CultureInfo.InvariantCulture) { SyntaxCompatibilityLevel = SyntaxCompatibility.DotLiquid22 }; + context["name"] = "King Kong"; + context.AddFilters(new[] { typeof(TestContextFilters) }); + Assert.That(context.Invoke("hi", new List { "hi?" }), Is.EqualTo("hi? hi from King Kong!")); + + context = new Context(CultureInfo.InvariantCulture) { SyntaxCompatibilityLevel = SyntaxCompatibility.DotLiquid22 }; Assert.Throws(() => context.Invoke("hi", new List { "hi?" })); } + [Test] + public void TestSyntaxCompatibilityReadonly() + { + Context context = new Context(CultureInfo.InvariantCulture) { SyntaxCompatibilityLevel = SyntaxCompatibility.DotLiquid20 }; + context.AddFilters(new[] { typeof(TestFilters) }); + Assert.Throws(() => context.SyntaxCompatibilityLevel = SyntaxCompatibility.DotLiquid22); + } + [Test] public void TestOverrideGlobalFilter() { @@ -1063,6 +1088,15 @@ public void TestConstructor() Assert.That(context.CurrentCulture.Name, Is.EqualTo("jp-JP")); } + // + [Test] + public void TestConstructorNullHandling() + { + Assert.Throws(() => { + _ = new Context(environments: null, outerScope: new Hash(), registers: new Hash(), errorsOutputMode: ErrorsOutputMode.Display, maxIterations: 1, formatProvider: CultureInfo.CurrentCulture, cancellationToken: CancellationToken.None); + }); + } + /// /// The expectation is that a Context is created with a CultureInfo, however, /// the parameter is defined as an IFormatProvider so this is not enforced by diff --git a/src/DotLiquid.Tests/CultureHelper.cs b/src/DotLiquid.Tests/CultureHelper.cs index 7697ffc06..90fba249a 100644 --- a/src/DotLiquid.Tests/CultureHelper.cs +++ b/src/DotLiquid.Tests/CultureHelper.cs @@ -1,19 +1,14 @@ -using System; +using System; using System.Globalization; -namespace DotLiquid +namespace DotLiquid.Tests { internal static class CultureHelper { public static IDisposable SetCulture(string name) { var scope = new CultureScope(CultureInfo.CurrentCulture); - -#if CORE - CultureInfo.CurrentCulture = new CultureInfo(name); -#else System.Threading.Thread.CurrentThread.CurrentCulture = new CultureInfo(name); -#endif return scope; } @@ -28,11 +23,7 @@ public CultureScope(CultureInfo culture) public void Dispose() { -#if CORE - CultureInfo.CurrentCulture = this.culture; -#else System.Threading.Thread.CurrentThread.CurrentCulture = this.culture; -#endif } } } diff --git a/src/DotLiquid.Tests/DotLiquid.Tests.csproj b/src/DotLiquid.Tests/DotLiquid.Tests.csproj index 1bd2c7f78..f989abbec 100644 --- a/src/DotLiquid.Tests/DotLiquid.Tests.csproj +++ b/src/DotLiquid.Tests/DotLiquid.Tests.csproj @@ -1,16 +1,13 @@ - net461;net6.0;netcoreapp1.0;netcoreapp2.0 + net462;netcoreapp3.1;net6.0 DotLiquid.Tests ../Formosatek-OpenSource.snk true true DotLiquid.Tests true - $(PackageTargetFallback);portable-net451+win8;dnxcore5 - 1.0.16 - 2.0.9 false false false @@ -32,48 +29,35 @@ - - - - - + + + + + + + + - - - - - + + - - - + - + + - - $(DefineConstants);CORE - - - - - - - - - - diff --git a/src/DotLiquid.Tests/DropTests.cs b/src/DotLiquid.Tests/DropTests.cs index 3d51f625b..10299936a 100755 --- a/src/DotLiquid.Tests/DropTests.cs +++ b/src/DotLiquid.Tests/DropTests.cs @@ -145,7 +145,6 @@ public IEnumerator GetEnumerator() } } -#if !CORE internal class DataRowDrop : Drop { private readonly System.Data.DataRow _dataRow; @@ -162,7 +161,6 @@ public override object BeforeMethod(string method) return null; } } -#endif internal class CamelCaseDrop : Drop { @@ -172,6 +170,11 @@ public int ProductID } } + internal class MethodDrop : Drop + { + public int ProductID() => 1; + } + internal static class ProductFilter { public static string ProductText(object input) @@ -375,7 +378,6 @@ public void TestNullCatchAll() Assert.That(Template.Parse("{{ nulldrop.a_method }}").Render(Hash.FromAnonymousObject(new { nulldrop = new NullDrop() })), Is.EqualTo("")); } -#if !CORE [Test] public void TestDataRowDrop() { @@ -390,7 +392,6 @@ public void TestDataRowDrop() Template tpl = Template.Parse(" {{ row.column1 }} "); Assert.That(tpl.Render(Hash.FromAnonymousObject(new { row = new DataRowDrop(dataRow) })), Is.EqualTo(" Hello ")); } -#endif [Test] public void TestRubyNamingConventionPrintsHelpfulErrorIfMissingPropertyWouldMatchCSharpNamingConvention() @@ -420,5 +421,45 @@ public void TestTypeResolutionDuplicateNames() localVariables: Hash.FromAnonymousObject(new { value = new ConflictingChildDrop() })); }); } + + [Test] + public void TestDropRootKeys() + { + Helper.AssertTemplateResult( + expected: "1", + template: "{{ product_id }}", + localVariables: new CamelCaseDrop(), + namingConvention: new RubyNamingConvention()); + } + + [Test] + public void TestDropRootMethods() + { + Helper.AssertTemplateResult( + expected: "1", + template: "{{ product_id }}", + localVariables: new MethodDrop(), + namingConvention: new RubyNamingConvention()); + } + + [Test] + public void TestDropRootCatchall() + { + var dataTable = new System.Data.DataTable(); + dataTable.Columns.Add("Column1"); + dataTable.Columns.Add("Column2"); + + var dataRow = dataTable.NewRow(); + dataRow["Column1"] = "Hello"; + dataRow["Column2"] = "World"; + + Template tpl = Template.Parse(""); + Helper.AssertTemplateResult( + expected: " Hello ", + template: " {{ column1 }} ", + localVariables: new DataRowDrop(dataRow), + namingConvention: new RubyNamingConvention()); + + } } } diff --git a/src/DotLiquid.Tests/Embedded/golden_rules.json b/src/DotLiquid.Tests/Embedded/golden_rules.json index 5e798eba5..2b6de160e 100644 --- a/src/DotLiquid.Tests/Embedded/golden_rules.json +++ b/src/DotLiquid.Tests/Embedded/golden_rules.json @@ -8,9 +8,7 @@ "liquid.golden.liquid_tag", // not yet implemented "liquid.golden.reject_filter", // not yet implemented "liquid.golden.render_tag", // not yet implemented - "liquid.golden.replace_last_filter", // not yet implemented - "liquid.golden.sum_filter", // not yet implemented - "liquid.golden.truncatewords_filter" // implemented as truncate_words so these will fail for now + "liquid.golden.sum_filter" // not yet implemented ], "skipped_tests": [ // Test assumes that a parsed date is UTC, DotLiquid assumes system timezone @@ -35,6 +33,7 @@ "liquid.golden.strip_filter - right padded", "liquid.golden.strip_newlines_filter - reference implementation test 1", "liquid.golden.strip_newlines_filter - reference implementation test 2", + "liquid.golden.truncatewords_filter - reference implementation test 3", // Default filter allow_false attribute is not yet implemented "liquid.golden.default_filter - allow false", "liquid.golden.default_filter - allow false from context", @@ -46,13 +45,6 @@ "liquid.golden.default_filter - render a default given a literal false with 'allow false' equal to true", // DotLiquid #457 "liquid.golden.date_filter - timestamp string", - // DotLiquid #549 - "liquid.golden.first_filter - first of a string", - "liquid.golden.for_tag - loop over a string literal", - "liquid.golden.for_tag - loop over a string variable", - "liquid.golden.last_filter - last of a string", - "liquid.golden.special - first of a string", - "liquid.golden.special - last of a string", // Non-integer arguments for numeric methods "liquid.golden.at_least_filter - argument string not a number", "liquid.golden.at_least_filter - left value not a number negative argument", @@ -94,21 +86,14 @@ "liquid.golden.plus_filter - undefined argument", "liquid.golden.plus_filter - undefined left value", "liquid.golden.remove_filter - undefined argument", - "liquid.golden.remove_last_filter - undefined argument", - "liquid.golden.remove_last_filter - undefined left value", "liquid.golden.replace_filter - undefined first argument", - "liquid.golden.replace_first_filter - undefined first argument", - "liquid.golden.replace_first_filter - undefined second argument", - "liquid.golden.replace_last_filter - undefined first argument", - "liquid.golden.replace_last_filter - undefined left value", - "liquid.golden.replace_last_filter - undefined second argument", "liquid.golden.slice_filter - undefined first argument", "liquid.golden.slice_filter - undefined second argument", "liquid.golden.sort_natural_filter - argument is undefined", "liquid.golden.times_filter - undefined argument", "liquid.golden.times_filter - undefined left value", "liquid.golden.truncate_filter - undefined first argument", - "liquid.golden.truncate_filter - undefined second argument", + "liquid.golden.truncatewords_filter - undefined first argument", "liquid.golden.where_filter - both arguments are undefined", "liquid.golden.where_filter - first argument is undefined", "liquid.golden.where_filter - both arguments are undefined", @@ -122,7 +107,6 @@ // Contains Not Implemented Tags/Filters "liquid.golden.whitespace_control - don't suppress whitespace only blocks containing echo", // Unsorted Exceptions - "liquid.golden.whitespace_control - white space control with carriage return, newline and spaces", "liquid.golden.compact_filter - array of objects with key property", "liquid.golden.concat_filter - left value is not array-like", "liquid.golden.concat_filter - nested left value gets flattened", @@ -186,9 +170,6 @@ "liquid.golden.range_objects - whitespace before and after dots, for loop", "liquid.golden.range_objects - whitespace before dots", "liquid.golden.range_objects - whitespace before start", - "liquid.golden.remove_last_filter - argument not a string", - "liquid.golden.remove_last_filter - not a string", - "liquid.golden.remove_last_filter - remove substrings", "liquid.golden.replace_filter - left value is an object", "liquid.golden.reverse_filter - array of things", "liquid.golden.reverse_filter - left value not an array", @@ -207,8 +188,6 @@ "liquid.golden.special - last of a object", "liquid.golden.split_filter - argument is a newline", "liquid.golden.split_filter - argument is a single space", - "liquid.golden.split_filter - empty string and empty argument", - "liquid.golden.split_filter - empty string and single char argument", "liquid.golden.strip_newlines_filter - newline and other whitespace", "liquid.golden.tablerow_tag - break from a tablerow loop inside a for loop", "liquid.golden.tablerow_tag - break from a tablerow loop", @@ -238,7 +217,11 @@ "liquid.golden.modulo_filter - integer value and float arg": "0", "liquid.golden.modulo_filter - string value and argument": "0", "liquid.golden.plus_filter - integer value and float arg": "12", - + // With Rails/ActiveSupport these work similar to DotLiquid + "liquid.golden.first_filter - first of a string": "h", + "liquid.golden.last_filter - last of a string": "o", + "liquid.golden.special - first of a string": "h", + "liquid.golden.special - last of a string": "o", // These operators are implemented by DotLiquid but not Ruby "liquid.golden.if_tag - endswith is not a valid operator": "TRUE", "liquid.golden.if_tag - haskey is not a valid operator": "TRUE", diff --git a/src/DotLiquid.Tests/ExtendedFilterTests.cs b/src/DotLiquid.Tests/ExtendedFilterTests.cs index 7be75ae12..1ee5e8880 100644 --- a/src/DotLiquid.Tests/ExtendedFilterTests.cs +++ b/src/DotLiquid.Tests/ExtendedFilterTests.cs @@ -37,11 +37,10 @@ public void TestTitleize() [Test] public void TestUpcaseFirst() { - var context = _context; - Assert.That(ExtendedFilters.UpcaseFirst(context: context, input: null), Is.EqualTo(null)); - Assert.That(ExtendedFilters.UpcaseFirst(context: context, input: ""), Is.EqualTo("")); - Assert.That(ExtendedFilters.UpcaseFirst(context: context, input: " "), Is.EqualTo(" ")); - Assert.That(ExtendedFilters.UpcaseFirst(context: context, input: " my boss is Mr. Doe."), Is.EqualTo(" My boss is Mr. Doe.")); + Assert.That(ExtendedFilters.UpcaseFirst(input: null), Is.EqualTo(null)); + Assert.That(ExtendedFilters.UpcaseFirst(input: ""), Is.EqualTo("")); + Assert.That(ExtendedFilters.UpcaseFirst(input: " "), Is.EqualTo(" ")); + Assert.That(ExtendedFilters.UpcaseFirst(input: " my boss is Mr. Doe."), Is.EqualTo(" My boss is Mr. Doe.")); Helper.AssertTemplateResult( expected: "My great title", @@ -53,5 +52,31 @@ public void TestRegexReplace() { Assert.That(actual: ExtendedFilters.RegexReplace(input: "a A A a", pattern: "[Aa]", replacement: "b"), Is.EqualTo(expected: "b b b b")); } + + [Test] + public void TestRubySplit() + { + Assert.That(ExtendedFilters.RubySplit("This is a sentence", " "), Is.EqualTo(new[] { "This", "is", "a", "sentence" }).AsCollection); + + // A string with no pattern should be split into a string[], as required for the Liquid Reverse filter + Assert.That(ExtendedFilters.RubySplit("YMCA", null), Is.EqualTo(new[] { "Y", "M", "C", "A" }).AsCollection); + Assert.That(ExtendedFilters.RubySplit("YMCA", ""), Is.EqualTo(new[] { "Y", "M", "C", "A" }).AsCollection); + Assert.That(ExtendedFilters.RubySplit(" ", ""), Is.EqualTo(new[] { " " }).AsCollection); + } + + [Test] + public void TestRubySplitWhitespace() + { + Assert.Multiple(() => + { + Assert.That(ExtendedFilters.RubySplit(" one two three four ", " "), Is.EqualTo(new[] { "one", "two", "three", "four" }).AsCollection); + Assert.That(ExtendedFilters.RubySplit("one two\tthree\nfour", " "), Is.EqualTo(new[] { "one", "two", "three", "four" }).AsCollection); + Assert.That(ExtendedFilters.RubySplit("one two\tthree\nfour", "\n"), Is.EqualTo(new[] { "one two\tthree", "four" }).AsCollection); + + Assert.That(ExtendedFilters.RubySplit("abracadabra", "ab"), Is.EqualTo(new[] { "", "racad", "ra" }).AsCollection); + Assert.That(ExtendedFilters.RubySplit("aaabcdaaa", "a"), Is.EqualTo(new[] { "", "", "", "bcd" }).AsCollection); + Assert.That(ExtendedFilters.RubySplit("", "a"), Has.Exactly(0).Items); + }); + } } } diff --git a/src/DotLiquid.Tests/FileSystemTests.cs b/src/DotLiquid.Tests/FileSystemTests.cs index 429fbc26e..0e824cbda 100644 --- a/src/DotLiquid.Tests/FileSystemTests.cs +++ b/src/DotLiquid.Tests/FileSystemTests.cs @@ -74,7 +74,7 @@ public void TestLocalWithBracketsInPath() [Test] public void TestEmbeddedResource() { - var assembly = typeof(FileSystemTests).GetTypeInfo().Assembly; + var assembly = typeof(FileSystemTests).Assembly; EmbeddedFileSystem fileSystem = new EmbeddedFileSystem(assembly, "DotLiquid.Tests.Embedded"); foreach (var validPath in validPaths) Assert.That( diff --git a/src/DotLiquid.Tests/Filters/StandardFiltersTestsBase.cs b/src/DotLiquid.Tests/Filters/StandardFiltersTestsBase.cs new file mode 100644 index 000000000..7d8a23681 --- /dev/null +++ b/src/DotLiquid.Tests/Filters/StandardFiltersTestsBase.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; + +namespace DotLiquid.Tests.Filters +{ + [TestFixture] + public abstract class StandardFiltersTestsBase + { + public abstract SyntaxCompatibility SyntaxCompatibilityLevel { get; } + public abstract CapitalizeDelegate Capitalize { get; } + public abstract MathDelegate Divide { get; } + public abstract MathDelegate Plus { get; } + public abstract MathDelegate Minus { get; } + public abstract MathDelegate Modulo { get; } + public abstract RemoveFirstDelegate RemoveFirst { get; } + public abstract ReplaceDelegate Replace { get; } + public abstract ReplaceFirstDelegate ReplaceFirst { get; } + public abstract SliceDelegate Slice { get; } + public abstract SplitDelegate Split { get; } + public abstract MathDelegate Times { get; } + public abstract TruncateWordsDelegate TruncateWords { get; } + + public delegate string CapitalizeDelegate(string input); + public delegate object MathDelegate(object input, object operand); + public delegate string RemoveFirstDelegate(string input, string @string); + public delegate string ReplaceDelegate(string input, string @string, string replacement); + public delegate string ReplaceFirstDelegate(string input, string @string, string replacement); + public delegate object SliceDelegate(object input, int start, int? len = null); + public delegate string[] SplitDelegate(string input, string pattern); + public delegate string TruncateWordsDelegate(string input, int? words = null, string truncateString = null); + + [Test] + public void TestCapitalize() + { + Assert.That(Capitalize(input: null), Is.EqualTo(null)); + Assert.That(Capitalize(input: ""), Is.EqualTo("")); + Assert.That(Capitalize(input: " "), Is.EqualTo(" ")); + } + + + [Test] + public void TestDividedBy() + { + Assert.That(Divide(input: 12, operand: 3), Is.EqualTo(4)); + Assert.That(Divide(input: 14, operand: 3), Is.EqualTo(4)); + Assert.That(Divide(input: 15, operand: 3), Is.EqualTo(5)); + Assert.That(Divide(input: null, operand: 3), Is.Null); + Assert.That(Divide(input: 4, operand: null), Is.Null); + + // Ensure we preserve floating point behavior for division by zero, and don't start throwing exceptions. + Assert.That(Divide(input: 1.0, operand: 0.0), Is.EqualTo(double.PositiveInfinity)); + Assert.That(Divide(input: -1.0, operand: 0.0), Is.EqualTo(double.NegativeInfinity)); + Assert.That(Divide(input: 0.0, operand: 0.0), Is.EqualTo(double.NaN)); + } + + [Test] + public void TestPlus() + { + Assert.Multiple(() => + { + Assert.That(Plus(input: 1, operand: 1), Is.EqualTo(2)); + Assert.That(Plus(input: 2, operand: 3.5), Is.EqualTo(5.5)); + Assert.That(Plus(input: 3.5, operand: 2), Is.EqualTo(5.5)); + + // Test that decimals are not introducing rounding-precision issues + Assert.That(Plus(input: 148387.77, operand: 10), Is.EqualTo(148397.77)); + + // Test that mix of 32-bit and 64-bit int returns 64-bit + Assert.That(Plus(input: int.MaxValue, operand: (long)1), Is.EqualTo(2147483648)); + }); + } + + [Test] + public void TestMinus() + { + Assert.Multiple(() => + { + Assert.That(Minus(input: 5, operand: 1), Is.EqualTo(4)); + Assert.That(Minus(input: 2, operand: 3.5), Is.EqualTo(-1.5)); + Assert.That(Minus(input: 3.5, operand: 2), Is.EqualTo(1.5)); + }); + } + + [Test] + public void TestModulo() + { + Assert.Multiple(() => + { + Assert.That(Modulo(input: 3, operand: 2), Is.EqualTo(1)); + Assert.That(Modulo(input: 148387.77, operand: 10), Is.EqualTo(7.77)); + Assert.That(Modulo(input: 3455.32, operand: 10), Is.EqualTo(5.32)); + Assert.That(Modulo(input: 23423.12, operand: 10), Is.EqualTo(3.12)); + Assert.That(Modulo(input: null, operand: 3), Is.Null); + Assert.That(Modulo(input: 4, operand: null), Is.Null); + }); + } + + [Test] + public void TestRemoveFirst() + { + Assert.That(RemoveFirst(input: null, @string: "a"), Is.Null); + Assert.That(RemoveFirst(input: "", @string: "a"), Is.EqualTo("")); + Assert.That(RemoveFirst(input: "a a a a", @string: null), Is.EqualTo("a a a a")); + Assert.That(RemoveFirst(input: "a a a a", @string: ""), Is.EqualTo("a a a a")); + Assert.That(RemoveFirst(input: "a a a a", @string: "a "), Is.EqualTo("a a a")); + } + + [Test] + public void TestReplace() + { + Assert.That(actual: Replace(null, "a", "b"), Is.Null); + Assert.That(actual: Replace("", "a", "b"), Is.EqualTo(expected: "")); + Assert.That(actual: Replace("a a a a", null, "b"), Is.EqualTo(expected: "a a a a")); + Assert.That(actual: Replace("a a a a", "", "b"), Is.EqualTo(expected: "a a a a")); + Assert.That(actual: Replace("a a a a", "a", "b"), Is.EqualTo(expected: "b b b b")); + + Assert.That(actual: Replace("Tesvalue\"", "\"", "\\\""), Is.EqualTo(expected: "Tesvalue\\\"")); + Helper.AssertTemplateResult(expected: "Tesvalue\\\"", template: "{{ 'Tesvalue\"' | replace: '\"', '\\\"' }}", syntax: SyntaxCompatibilityLevel); + Helper.AssertTemplateResult( + expected: "Tesvalue\\\"", + template: "{{ context | replace: '\"', '\\\"' }}", + localVariables: Hash.FromAnonymousObject(new { context = "Tesvalue\"" }), + syntax: SyntaxCompatibilityLevel); + } + + [Test] + public void TestReplaceFirst() + { + Assert.That(ReplaceFirst(input: null, @string: "a", replacement: "b"), Is.Null); + Assert.That(ReplaceFirst(input: "", @string: "a", replacement: "b"), Is.EqualTo("")); + Assert.That(ReplaceFirst(input: "a a a a", @string: "a", replacement: "b"), Is.EqualTo("b a a a")); + Helper.AssertTemplateResult(expected: "b a a a", template: "{{ 'a a a a' | replace_first: 'a', 'b' }}", syntax: SyntaxCompatibilityLevel); + } + + [Test] + public void TestSliceString() + { + Assert.That(Slice("abcdefg", 0, 3), Is.EqualTo("abc")); + Assert.That(Slice("abcdefg", 1, 3), Is.EqualTo("bcd")); + Assert.That(Slice("abcdefg", -3, 3), Is.EqualTo("efg")); + Assert.That(Slice("abcdefg", -3, 30), Is.EqualTo("efg")); + Assert.That(Slice("abcdefg", 4, 30), Is.EqualTo("efg")); + Assert.That(Slice("abc", -4, 2), Is.EqualTo("a")); + Assert.That(Slice("abcdefg", -10, 1), Is.EqualTo("")); + + // Test replicated from the Ruby library (https://github.com/Shopify/liquid/blob/master/test/integration/standard_filter_test.rb) + Assert.That(Slice("foobar", 1, 3), Is.EqualTo("oob")); + Assert.That(Slice("foobar", 1, 1000), Is.EqualTo("oobar")); + Assert.That(Slice("foobar", 1, 0), Is.EqualTo("")); + Assert.That(Slice("foobar", 1, 1), Is.EqualTo("o")); + Assert.That(Slice("foobar", 3, 3), Is.EqualTo("bar")); + Assert.That(Slice("foobar", -2, 2), Is.EqualTo("ar")); + Assert.That(Slice("foobar", -2, 1000), Is.EqualTo("ar")); + Assert.That(Slice("foobar", -1), Is.EqualTo("r")); + Assert.That(Slice("foobar", -100, 10), Is.EqualTo("")); + Assert.That(Slice("foobar", 1, 3), Is.EqualTo("oob")); + } + + [Test] + public void TestSliceArrays() + { + // Test replicated from the Ruby library + var testArray = new[] { "f", "o", "o", "b", "a", "r" }; + Assert.That((IEnumerable)Slice(testArray, 1, 3), Is.EqualTo(ToStringArray("oob")).AsCollection); + Assert.That((IEnumerable)Slice(testArray, 1, 1000), Is.EqualTo(ToStringArray("oobar")).AsCollection); + Assert.That((IEnumerable)Slice(testArray, 1, 0), Is.EqualTo(ToStringArray("")).AsCollection); + Assert.That((IEnumerable)Slice(testArray, 1, 1), Is.EqualTo(ToStringArray("o")).AsCollection); + Assert.That((IEnumerable)Slice(testArray, 3, 3), Is.EqualTo(ToStringArray("bar")).AsCollection); + Assert.That((IEnumerable)Slice(testArray, -2, 2), Is.EqualTo(ToStringArray("ar")).AsCollection); + Assert.That((IEnumerable)Slice(testArray, -2, 1000), Is.EqualTo(ToStringArray("ar")).AsCollection); + Assert.That((IEnumerable)Slice(testArray, -1), Is.EqualTo(ToStringArray("r")).AsCollection); + Assert.That((IEnumerable)Slice(testArray, 100, 10), Is.EqualTo(ToStringArray("")).AsCollection); + Assert.That((IEnumerable)Slice(testArray, -100, 10), Is.EqualTo(ToStringArray("")).AsCollection); + + // additional tests + Assert.That((IEnumerable)Slice(testArray, -6, 2), Is.EqualTo(ToStringArray("fo")).AsCollection); + Assert.That((IEnumerable)Slice(testArray, -8, 4), Is.EqualTo(ToStringArray("fo")).AsCollection); + + // Non-string arrays tests + Assert.That((IEnumerable)Slice(new[] { 1, 2, 3, 4, 5 }, 1, 3), Is.EqualTo(new[] { 2, 3, 4 }).AsCollection); + Assert.That((IEnumerable)Slice(new[] { 'a', 'b', 'c', 'd', 'e' }, -4, 3), Is.EqualTo(new[] { 'b', 'c', 'd' }).AsCollection); + } + + [Test] + public void TestSplit() + { + Assert.That(Split("This is a sentence", " "), Is.EqualTo(new[] { "This", "is", "a", "sentence" }).AsCollection); + + // A string with no pattern should be split into a string[], as required for the Liquid Reverse filter + Assert.That(Split("YMCA", null), Is.EqualTo(new[] { "Y", "M", "C", "A" }).AsCollection); + Assert.That(Split("YMCA", ""), Is.EqualTo(new[] { "Y", "M", "C", "A" }).AsCollection); + Assert.That(Split(" ", ""), Is.EqualTo(new[] { " " }).AsCollection); + } + + [Test] + public void TestTruncateWords() + { + Assert.That(TruncateWords(null), Is.EqualTo(null)); + Assert.That(TruncateWords(""), Is.EqualTo("")); + Assert.That(TruncateWords("one two three", 4), Is.EqualTo("one two three")); + Assert.That(TruncateWords("one two three", 2), Is.EqualTo("one two...")); + Assert.That(TruncateWords("one two three"), Is.EqualTo("one two three")); + Assert.That(TruncateWords("Two small (13” x 5.5” x 10” high) baskets fit inside one large basket (13” x 16” x 10.5” high) with cover.", 15), Is.EqualTo("Two small (13” x 5.5” x 10” high) baskets fit inside one large basket (13”...")); + } + + /// + /// Convert a string into a string[] where each character is mapped into an array element. + /// + private static string[] ToStringArray(string input) + { + return input.ToCharArray().Select(character => character.ToString()).ToArray(); + } + } +} diff --git a/src/DotLiquid.Tests/Filters/StandardFiltersV20Tests.cs b/src/DotLiquid.Tests/Filters/StandardFiltersV20Tests.cs new file mode 100644 index 000000000..60cca1798 --- /dev/null +++ b/src/DotLiquid.Tests/Filters/StandardFiltersV20Tests.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections; +using System.Globalization; +using NUnit.Framework; + +namespace DotLiquid.Tests.Filters +{ + [TestFixture] + public class StandardFiltersV20Tests : StandardFiltersTestsBase + { + public override SyntaxCompatibility SyntaxCompatibilityLevel => SyntaxCompatibility.DotLiquid20; + public override CapitalizeDelegate Capitalize => i => LegacyFilters.Capitalize(_context, i); + public override MathDelegate Divide => (i, o) => StandardFilters.DividedBy(_context, i, o); + public override MathDelegate Plus => (i, o) => LegacyFilters.Plus(_context, i, o); + public override MathDelegate Minus => (i, o) => StandardFilters.Minus(_context, i, o); + public override MathDelegate Modulo => (i, o) => StandardFilters.Modulo(_context, i, o); + public override RemoveFirstDelegate RemoveFirst => (a, b) => LegacyFilters.RemoveFirst(a, b); + public override ReplaceDelegate Replace => (i, s, r) => LegacyFilters.Replace(i, s, r); + public override ReplaceFirstDelegate ReplaceFirst => (a, b, c) => LegacyFilters.ReplaceFirst(a, b, c); + public override SliceDelegate Slice => (a, b, c) => c.HasValue ? LegacyFilters.Slice(a, b, c.Value) : LegacyFilters.Slice(a, b); + public override SplitDelegate Split => (i, p) => LegacyFilters.Split(i, p); + public override MathDelegate Times => (i, o) => LegacyFilters.Times(_context, i, o); + public override TruncateWordsDelegate TruncateWords => (i, w, s) => + { + if (w.HasValue) + return s == null ? LegacyFilters.TruncateWords(i, w.Value) : LegacyFilters.TruncateWords(i, w.Value, s); + return LegacyFilters.TruncateWords(i); + }; + + private Context _context; + + [OneTimeSetUp] + public void SetUp() + { + _context = new Context(CultureInfo.InvariantCulture) + { + SyntaxCompatibilityLevel = SyntaxCompatibilityLevel + }; + } + + [Test] + public void TestCapitalizeBehavesLikeTitleize() + { + Assert.That(Capitalize(input: "That is one sentence."), Is.EqualTo("That Is One Sentence.")); + Assert.That(Capitalize(input: "title"), Is.EqualTo("Title")); + } + + [Test] + public void TestDividedByStringThrowsException() + { + Assert.Throws(() => Divide(input: "12", operand: 3)); + Assert.Throws(() => Divide(input: 12, operand: "3")); + } + + [Test] + public void TestPlusStringConcatenates() + { + Assert.That(Plus(input: "1", operand: 1), Is.EqualTo("11")); + Assert.Throws(() => Plus(input: 1, operand: "1")); + } + + [Test] + public void TestMinusStringThrowsException() + { + Assert.Throws(() => Minus(input: "2", operand: 1)); + Assert.Throws(() => Minus(input: 2, operand: "1")); + } + + [Test] + public void TestModuloStringThrowsException() + { + Assert.Throws(() => Modulo(input: "3", operand: 2)); + Assert.Throws(() => Modulo(input: 2, operand: "2")); + } + + [Test] + public void TestTimesStringReplicates() + { + Assert.That(StandardFilters.Join((IEnumerable)Times(input: "foo", operand: 4), ""), Is.EqualTo("foofoofoofoo")); + Assert.That(StandardFilters.Join((IEnumerable)Times(input: "3", operand: 4), ""), Is.EqualTo("3333")); + Assert.Throws(() => Times(input: 3, operand: "4")); + Assert.Throws(() => Times(input: "3", operand: "4")); + } + + [Test] + public void TestRemoveFirstRegexWorks() + { + Assert.That(RemoveFirst(input: "Mr. Jones", @string: "."), Is.EqualTo(expected: "r. Jones")); + Assert.That(RemoveFirst(input: "a a a a", @string: "[Aa] "), Is.EqualTo("a a a")); + } + + [Test] + public void TestReplaceRegexWorks() + { + Assert.That(actual: Replace(input: "a A A a", @string: "[Aa]", replacement: "b"), Is.EqualTo(expected: "b b b b")); + } + + [Test] + public void TestReplaceFirstRegexWorks() + { + Assert.That(ReplaceFirst(input: "a A A a", @string: "[Aa]", replacement: "b"), Is.EqualTo(expected: "b A A a")); + } + } +} diff --git a/src/DotLiquid.Tests/Filters/StandardFiltersV21Tests.cs b/src/DotLiquid.Tests/Filters/StandardFiltersV21Tests.cs new file mode 100644 index 000000000..9344f8e3a --- /dev/null +++ b/src/DotLiquid.Tests/Filters/StandardFiltersV21Tests.cs @@ -0,0 +1,108 @@ +using System; +using System.Globalization; +using NUnit.Framework; + +namespace DotLiquid.Tests.Filters +{ + [TestFixture] + public class StandardFiltersV21Tests : StandardFiltersTestsBase + { + public override SyntaxCompatibility SyntaxCompatibilityLevel => SyntaxCompatibility.DotLiquid21; + public override CapitalizeDelegate Capitalize => i => LegacyFilters.CapitalizeV21(i); + public override MathDelegate Divide => (i, o) => StandardFilters.DividedBy(_context, i, o); + public override MathDelegate Plus => (i, o) => StandardFilters.Plus(_context, i, o); + public override MathDelegate Minus => (i, o) => StandardFilters.Minus(_context, i, o); + public override MathDelegate Modulo => (i, o) => StandardFilters.Modulo(_context, i, o); + public override RemoveFirstDelegate RemoveFirst => (a, b) => LegacyFilters.RemoveFirstV21(a, b); + public override ReplaceDelegate Replace => (i, s, r) => StandardFilters.Replace(i, s, r); + public override ReplaceFirstDelegate ReplaceFirst => (a, b, c) => LegacyFilters.ReplaceFirstV21(a, b, c); + public override SliceDelegate Slice => (a, b, c) => c.HasValue ? LegacyFilters.Slice(a, b, c.Value) : LegacyFilters.Slice(a, b); + public override SplitDelegate Split => (i, p) => LegacyFilters.Split(i, p); + public override MathDelegate Times => (i, o) => StandardFilters.Times(_context, i, o); + public override TruncateWordsDelegate TruncateWords => (i, w, s) => + { + if (w.HasValue) + return s == null ? LegacyFilters.TruncateWords(i, w.Value) : LegacyFilters.TruncateWords(i, w.Value, s); + return LegacyFilters.TruncateWords(i); + }; + + private Context _context; + + [OneTimeSetUp] + public void SetUp() + { + _context = new Context(CultureInfo.InvariantCulture) + { + SyntaxCompatibilityLevel = SyntaxCompatibilityLevel + }; + } + + [Test] + public void TestCapitalizeBehavesLikeUpcaseFirst() + { + Assert.That(Capitalize(input: " my boss is Mr. Doe."), Is.EqualTo(" My boss is Mr. Doe.")); + Assert.That(Capitalize(input: "my great title"), Is.EqualTo("My great title")); + } + + [Test] + public void TestDividedByStringIsParsed() + { + Assert.That(Divide(input: "12", operand: 3), Is.EqualTo(4)); + Assert.That(Divide(input: 12, operand: "3"), Is.EqualTo(4)); + } + + [Test] + public void TestPlusStringAdds() + { + Assert.That(Plus(input: "1", operand: 1), Is.EqualTo(2)); + Assert.That(Plus(input: 1, operand: "1"), Is.EqualTo(2)); + Assert.That(Plus(input: "1", operand: "1"), Is.EqualTo(2)); + Assert.That(Plus(input: 2, operand: "3.5"), Is.EqualTo(5.5)); + Assert.That(Plus(input: "3.5", operand: 2), Is.EqualTo(5.5)); + } + + [Test] + public void TestMinusStringIsParsed() + { + Assert.That(Minus(input: "2", operand: 1), Is.EqualTo(1)); + Assert.That(Minus(input: 2, operand: 1), Is.EqualTo(1)); + Assert.That(Minus(input: 2, operand: 3.5), Is.EqualTo(-1.5)); + Assert.That(Minus(input: "2.5", operand: 4), Is.EqualTo(-1.5)); + Assert.That(Minus(input: "2.5", operand: "3.5"), Is.EqualTo(-1)); + } + + [Test] + public void TestModuloStringIsParsed() + { + Assert.That(Modulo(input: "3", operand: 2), Is.EqualTo(1)); + Assert.That(Modulo(input: 3, operand: "2"), Is.EqualTo(1)); + } + + [Test] + public void TestTimesStringIsParsed() + { + Assert.That(Times(input: "3", operand: 4), Is.EqualTo(12)); + Assert.That(Times(input: 3, operand: "4"), Is.EqualTo(12)); + Assert.That(Times(input: "3", operand: "4"), Is.EqualTo(12)); + } + + [Test] + public void TestRemoveFirstRegexFails() + { + Assert.That(RemoveFirst(input: "Mr. Jones", @string: "."), Is.EqualTo(expected: "Mr Jones")); + Assert.That(RemoveFirst(input: "a a a a", @string: "[Aa] "), Is.EqualTo("a a a a")); + } + + [Test] + public void TestReplaceRegexFails() + { + Assert.That(Replace(input: "a A A a", @string: "[Aa]", replacement: "b"), Is.EqualTo(expected: "a A A a")); + } + + [Test] + public void TestReplaceFirstRegexFails() + { + Assert.That(ReplaceFirst(input: "a A A a", @string: "[Aa]", replacement: "b"), Is.EqualTo(expected: "a A A a")); + } + } +} diff --git a/src/DotLiquid.Tests/Filters/StandardFiltersV22Tests.cs b/src/DotLiquid.Tests/Filters/StandardFiltersV22Tests.cs new file mode 100644 index 000000000..7adec21bb --- /dev/null +++ b/src/DotLiquid.Tests/Filters/StandardFiltersV22Tests.cs @@ -0,0 +1,59 @@ +using System; +using System.Globalization; +using NUnit.Framework; + +namespace DotLiquid.Tests.Filters +{ + [TestFixture] + public class StandardFiltersV22Tests : StandardFiltersTestsBase + { + public override SyntaxCompatibility SyntaxCompatibilityLevel => SyntaxCompatibility.DotLiquid22; + public override CapitalizeDelegate Capitalize => i => StandardFilters.Capitalize(i); + public override MathDelegate Divide => (i, o) => StandardFilters.DividedBy(_context, i, o); + public override MathDelegate Plus => (i, o) => StandardFilters.Plus(_context, i, o); + public override MathDelegate Minus => (i, o) => StandardFilters.Minus(_context, i, o); + public override MathDelegate Modulo => (i, o) => StandardFilters.Modulo(_context, i, o); + public override RemoveFirstDelegate RemoveFirst => (a, b) => LegacyFilters.RemoveFirstV21(a, b); + public override ReplaceDelegate Replace => (i, s, r) => StandardFilters.Replace(i, s, r); + public override ReplaceFirstDelegate ReplaceFirst => (i, s, r) => LegacyFilters.ReplaceFirstV21(i, s, r); + public override SliceDelegate Slice => (i, s, l) => l.HasValue ? LegacyFilters.Slice(i, s, l.Value) : LegacyFilters.Slice(i, s); + public override SplitDelegate Split => (i, p) => LegacyFilters.Split(i, p); + public override MathDelegate Times => (i, o) => StandardFilters.Times(_context, i, o); + public override TruncateWordsDelegate TruncateWords => (i, w, s) => + { + if (w.HasValue) + return s == null ? LegacyFilters.TruncateWords(i, w.Value) : LegacyFilters.TruncateWords(i, w.Value, s); + return LegacyFilters.TruncateWords(i); + }; + + private Context _context; + + [OneTimeSetUp] + public void SetUp() + { + _context = new Context(CultureInfo.InvariantCulture) + { + SyntaxCompatibilityLevel = SyntaxCompatibilityLevel + }; + } + + [Test] + public void TestCapitalizeDowncaseAllButFirst() + { + Assert.That(Capitalize(input: "my boss is Mr. Doe."), Is.EqualTo("My boss is mr. doe.")); + Assert.That(Capitalize(input: "my Great Title"), Is.EqualTo("My great title")); + } + + [Test] + public void TestSlice() + { + // Verify backwards compatibility for pre-22a syntax (DotLiquid returns null for null input or empty slice) + Assert.That(Slice(null, 1), Is.EqualTo(null)); // DotLiquid test case + Assert.That(Slice("", 10), Is.EqualTo(null)); // DotLiquid test case + Assert.That(Slice(123, 1), Is.EqualTo(123)); // Ignore invalid input + + Assert.That(Slice(null, 0), Is.EqualTo(null)); // Liquid test case + Assert.That(Slice("foobar", 100, 10), Is.EqualTo(null)); // Liquid test case + } + } +} diff --git a/src/DotLiquid.Tests/Filters/StandardFiltersV22aTests.cs b/src/DotLiquid.Tests/Filters/StandardFiltersV22aTests.cs new file mode 100644 index 000000000..98e4678f6 --- /dev/null +++ b/src/DotLiquid.Tests/Filters/StandardFiltersV22aTests.cs @@ -0,0 +1,73 @@ +using System; +using System.Globalization; +using NUnit.Framework; + +namespace DotLiquid.Tests.Filters +{ + [TestFixture] + public class StandardFiltersV22aTests : StandardFiltersTestsBase + { + public override SyntaxCompatibility SyntaxCompatibilityLevel => SyntaxCompatibility.DotLiquid22a; + public override CapitalizeDelegate Capitalize => i => StandardFilters.Capitalize(i); + public override MathDelegate Divide => (i, o) => StandardFilters.DividedBy(_context, i, o); + public override MathDelegate Plus => (i, o) => StandardFilters.Plus(_context, i, o); + public override MathDelegate Minus => (i, o) => StandardFilters.Minus(_context, i, o); + public override MathDelegate Modulo => (i, o) => StandardFilters.Modulo(_context, i, o); + public override RemoveFirstDelegate RemoveFirst => (a, b) => LegacyFilters.RemoveFirstV21(a, b); + public override ReplaceDelegate Replace => (i, s, r) => StandardFilters.Replace(i, s, r); + public override ReplaceFirstDelegate ReplaceFirst => (i, s, r) => LegacyFilters.ReplaceFirstV21(i, s, r); + public override SliceDelegate Slice => (a, b, c) => c.HasValue ? StandardFilters.Slice(a, b, c.Value) : StandardFilters.Slice(a, b); + public override SplitDelegate Split => (i, p) => LegacyFilters.Split(i, p); + public override MathDelegate Times => (i, o) => StandardFilters.Times(_context, i, o); + public override TruncateWordsDelegate TruncateWords => (i, w, s) => + { + if (w.HasValue) + return s == null ? LegacyFilters.TruncateWords(i, w.Value) : LegacyFilters.TruncateWords(i, w.Value, s); + return LegacyFilters.TruncateWords(i); + }; + + private Context _context; + + [OneTimeSetUp] + public void SetUp() + { + _context = new Context(CultureInfo.InvariantCulture) + { + SyntaxCompatibilityLevel = SyntaxCompatibilityLevel + }; + } + + [Test] + public void TestReplaceFirstInvalidSearchReturnsInput() + { + Assert.That(ReplaceFirst(input: "a a a a", @string: null, replacement: "b"), Is.EqualTo("a a a a")); + Assert.That(ReplaceFirst(input: "a a a a", @string: "", replacement: "b"), Is.EqualTo("a a a a")); + } + + [Test] + public void TestSlice() + { + // Verify Liquid compliance from V22a syntax: + Assert.That(Slice(null, 1), Is.EqualTo("")); // DotLiquid test case + Assert.That(Slice("", 10), Is.EqualTo("")); // DotLiquid test case + Assert.That(Slice(123, 1), Is.EqualTo(123)); // Ignore invalid input + + Assert.That(Slice(null, 0), Is.EqualTo("")); // Liquid test case + Assert.That(Slice("foobar", 100, 10), Is.EqualTo("")); // Liquid test case + } + + [Test] + public void TestSplitNullReturnsArrayWithNull() + { + Assert.That(Split(null, null), Is.EqualTo(new string[] { null }).AsCollection); + + } + + [Test] + public void TestTruncateWordsLessOneWordAllowed() + { + Assert.That(TruncateWords("Ground control to Major Tom.", 0), Is.EqualTo("...")); + Assert.That(TruncateWords("Ground control to Major Tom.", -1), Is.EqualTo("...")); + } + } +} diff --git a/src/DotLiquid.Tests/Filters/StandardFiltersV24Tests.cs b/src/DotLiquid.Tests/Filters/StandardFiltersV24Tests.cs new file mode 100644 index 000000000..76b0d4a16 --- /dev/null +++ b/src/DotLiquid.Tests/Filters/StandardFiltersV24Tests.cs @@ -0,0 +1,67 @@ +using System; +using System.Globalization; +using NUnit.Framework; + +namespace DotLiquid.Tests.Filters +{ + [TestFixture] + public class StandardFiltersV24Tests : StandardFiltersTestsBase + { + public override SyntaxCompatibility SyntaxCompatibilityLevel => SyntaxCompatibility.DotLiquid24; + public override CapitalizeDelegate Capitalize => i => StandardFilters.Capitalize(i); + public override MathDelegate Divide => (i, o) => StandardFilters.DividedBy(_context, i, o); + public override MathDelegate Plus => (i, o) => StandardFilters.Plus(_context, i, o); + public override MathDelegate Minus => (i, o) => StandardFilters.Minus(_context, i, o); + public override MathDelegate Modulo => (i, o) => StandardFilters.Modulo(_context, i, o); + public override RemoveFirstDelegate RemoveFirst => (a, b) => StandardFilters.RemoveFirst(a, b); + public override ReplaceDelegate Replace => (i, s, r) => StandardFilters.Replace(i, s, r); + public override ReplaceFirstDelegate ReplaceFirst => (a, b, c) => StandardFilters.ReplaceFirst(a, b, c); + public override SliceDelegate Slice => (a, b, c) => c.HasValue ? StandardFilters.Slice(a, b, c.Value) : StandardFilters.Slice(a, b); + public override SplitDelegate Split => (i, p) => StandardFilters.Split(i, p); + public override MathDelegate Times => (i, o) => StandardFilters.Times(_context, i, o); + public override TruncateWordsDelegate TruncateWords => (i, w, s) => + { + if (w.HasValue) + return s == null ? StandardFilters.TruncateWords(i, w.Value) : StandardFilters.TruncateWords(i, w.Value, s); + return StandardFilters.TruncateWords(i); + }; + + private Context _context; + + [OneTimeSetUp] + public void SetUp() + { + _context = new Context(CultureInfo.InvariantCulture) + { + SyntaxCompatibilityLevel = SyntaxCompatibilityLevel + }; + } + + [Test] + public void TestReplaceFirstInvalidSearchPrepends() + { + Assert.That(ReplaceFirst(input: "a a a a", @string: null, replacement: "b"), Is.EqualTo("ba a a a")); + Assert.That(ReplaceFirst(input: "a a a a", @string: "", replacement: "b"), Is.EqualTo("ba a a a")); + } + + [Test] + public void TestSplitNullReturnsEmptyArray() + { + Assert.That(Split(null, null), Has.Exactly(0).Items); + } + + [Test] + public void TestTruncateWordsLessOneWordIgnored() + { + Assert.That(TruncateWords("Ground control to Major Tom.", 0), Is.EqualTo("Ground...")); + Assert.That(TruncateWords("Ground control to Major Tom.", -1), Is.EqualTo("Ground...")); + } + + [Test] + public void TestTruncateWordsWhitespaceCollapsed() + { + Assert.That(TruncateWords(" one two three four ", 2), Is.EqualTo("one two...")); + Assert.That(TruncateWords("one two\tthree\nfour", 3), Is.EqualTo("one two three...")); + } + } +} diff --git a/src/DotLiquid.Tests/GoldenLiquidTests.cs b/src/DotLiquid.Tests/GoldenLiquidTests.cs index 033c2ca94..eaa9bb13f 100644 --- a/src/DotLiquid.Tests/GoldenLiquidTests.cs +++ b/src/DotLiquid.Tests/GoldenLiquidTests.cs @@ -51,6 +51,9 @@ public static List GetGoldenTests(bool passing) test.Error = false; } + if (testGroup.Name == "liquid.golden.tablerow_tag") + test.Want = test.Want.Replace("\n", "\r\n"); + if (Rules.FailingTests.Contains(uniqueName) != passing) tests.Add(test); } @@ -64,12 +67,7 @@ public static List GetGoldenTests(bool passing) private static T DeserializeResource(string resourceName) { // Load the JSON content -#if NETCOREAPP1_0 - var assembly = typeof(GoldenLiquidTests).GetTypeInfo().Assembly; -#else var assembly = Assembly.GetExecutingAssembly(); -#endif - var jsonContent = string.Empty; using (Stream stream = assembly.GetManifestResourceStream(resourceName)) using (StreamReader reader = new StreamReader(stream)) @@ -82,6 +80,11 @@ private static T DeserializeResource(string resourceName) } #endregion + internal static class RubyFilters + { + public static string[] Split(string input, string pattern) => ExtendedFilters.RubySplit(input, pattern); + } + [Test] [TestCaseSource(nameof(GoldenTestsPassing))] public void ExecuteGoldenLiquidTests(GoldenLiquidTest test) @@ -93,12 +96,13 @@ public void ExecuteGoldenLiquidTests(GoldenLiquidTest test) context[pair.Key] = pair.Value; } - var syntax = SyntaxCompatibility.DotLiquid22a; + var syntax = SyntaxCompatibility.DotLiquidLatest; var parameters = new RenderParameters(CultureInfo.CurrentCulture) { SyntaxCompatibilityLevel = syntax, LocalVariables = context, - ErrorsOutputMode = test.Error ? ErrorsOutputMode.Rethrow : ErrorsOutputMode.Display + ErrorsOutputMode = test.Error ? ErrorsOutputMode.Rethrow : ErrorsOutputMode.Display, + Filters = new[] { typeof(RubyFilters) } }; Helper.LockTemplateStaticVars(Template.NamingConvention, () => @@ -114,7 +118,7 @@ public void ExecuteGoldenLiquidTests(GoldenLiquidTest test) } else { - Assert.That(Template.Parse(test.Template, syntax).Render(parameters).Replace("\r\n", "\n"), Is.EqualTo(test.Want), test.UniqueName); + Assert.That(Template.Parse(test.Template, syntax).Render(parameters), Is.EqualTo(test.Want), test.UniqueName); } }); } diff --git a/src/DotLiquid.Tests/HashTests.cs b/src/DotLiquid.Tests/HashTests.cs index 6c4a56c56..95d8b1207 100644 --- a/src/DotLiquid.Tests/HashTests.cs +++ b/src/DotLiquid.Tests/HashTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections; using System.Collections.Generic; using NUnit.Framework; @@ -204,5 +205,179 @@ public void TestMergeNestedDictionaries() template: "{{ People.ID1.First }} {{ People.ID2.Last }}", localVariables: hash); } + + [Test] + public void TestFromAnonymousObjectAcceptsNull() + { + var hash = Hash.FromAnonymousObject(null); + Assert.That(hash, Is.Not.Null); + Assert.That(hash.Count, Is.EqualTo(0)); + + Assert.That(hash.Contains("unknown-key"), Is.False); + Assert.That(hash.ContainsKey("unknown-key"), Is.False); + Assert.That(hash["unknown-key"], Is.Null); + } + + [Test] + public void TestHashIDictionaryGenericsInterfaceAccess() + { + var zeroPair = new KeyValuePair("Zero", "0"); + IDictionary hash = Hash.FromDictionary(new Dictionary(StringComparer.OrdinalIgnoreCase) { { zeroPair.Key, zeroPair.Value } }); + var upperKey = zeroPair.Key; + var lowerKey = upperKey.ToLower(); + + Assert.Multiple(() => + { + Assert.That(hash.Count, Is.EqualTo(1)); + Assert.That(hash.Keys, Is.EqualTo(new[] { upperKey }).AsCollection); + Assert.That(hash.Values, Is.EqualTo(new[] { "0" }).AsCollection); + Assert.That(hash[upperKey], Is.EqualTo("0")); + Assert.That(hash[lowerKey], Is.EqualTo("0")); + Assert.That(hash.Contains(zeroPair), Is.True); + Assert.That(hash.Contains(new KeyValuePair("One", "1")), Is.False); + + var array = new KeyValuePair[1]; + hash.CopyTo(array, 0); + Assert.That(array[0].Key, Is.EqualTo(zeroPair.Key)); + Assert.That(array[0].Value, Is.EqualTo(zeroPair.Value)); + }); + } + + [Test] + public void TestHashIDictionaryGenericsInterfaceEnumerator() + { + var dictionary = new Dictionary() { { "Zero", "0" }, { "One", 1 } }; + IDictionary hash = Hash.FromDictionary(dictionary); + var enumerator = hash.GetEnumerator(); + var actualKeys = new List(); + var actualValues = new List(); + + while (enumerator.MoveNext()) + { + actualKeys.Add(enumerator.Current.Key); + actualValues.Add(enumerator.Current.Value); + } + + Assert.That(actualKeys, Is.EquivalentTo(dictionary.Keys)); + Assert.That(actualValues, Is.EquivalentTo(dictionary.Values)); + } + + [Test] + public void TestHashIDictionaryGenericsInterfaceManipulation() + { + var zeroPair = new KeyValuePair("Zero", "0"); + var onePair = new KeyValuePair("One", 1); + IDictionary hash = Hash.FromDictionary(new Dictionary(StringComparer.OrdinalIgnoreCase) { { zeroPair.Key, zeroPair.Value } }); + + Assert.Multiple(() => + { + hash.Add(onePair); + Assert.That(hash.Count, Is.EqualTo(2)); + Assert.That(hash.Contains(onePair), Is.True); + Assert.That(hash[onePair.Key], Is.EqualTo(1)); + hash.Remove(onePair); + Assert.That(hash.Count, Is.EqualTo(1)); + + hash.Clear(); + Assert.That(hash.Count, Is.EqualTo(0)); + Assert.That(hash, Is.Empty); + }); + } + + [Test] + public void TestHashIDictionaryInterfaceAccess() + { + IDictionary hash = Hash.FromDictionary(new Dictionary(StringComparer.OrdinalIgnoreCase) { { "Zero", "0" } }); + var upperKey = "Zero"; + object lowerKey = upperKey.ToLower(); + + Assert.Multiple(() => + { + Assert.That(hash.Count, Is.EqualTo(1)); + Assert.That(hash.Keys, Is.EqualTo(new[] { upperKey }).AsCollection); + Assert.That(hash.Values, Is.EqualTo(new[] { "0" }).AsCollection); + Assert.That(hash[upperKey], Is.EqualTo("0")); + Assert.That(hash[lowerKey], Is.EqualTo("0")); + Assert.That(hash.Contains(upperKey), Is.True); + Assert.That(hash.Contains(lowerKey), Is.True); + Assert.That(hash.Contains("One"), Is.False); + + var array = new KeyValuePair[1]; + hash.CopyTo(array, 0); + Assert.That(array[0].Key, Is.EqualTo(upperKey)); + Assert.That(array[0].Value, Is.EqualTo("0")); + }); + } + + [Test] + public void TestHashIDictionaryInterfaceEnumerator() + { + var dictionary = new Dictionary() { { "Zero", "0" }, { "One", 1 } }; + IDictionary hash = Hash.FromDictionary(dictionary); + var enumerator = hash.GetEnumerator(); + var actualKeys = new List(); + var actualValues = new List(); + + while (enumerator.MoveNext()) + { + actualKeys.Add(enumerator.Key); + actualValues.Add(enumerator.Value); + } + + Assert.That(actualKeys, Is.EquivalentTo(dictionary.Keys)); + Assert.That(actualValues, Is.EquivalentTo(dictionary.Values)); + } + + [Test] + public void TestHashIDictionaryInterfaceManipulation() + { + IDictionary hash = Hash.FromDictionary(new Dictionary(StringComparer.OrdinalIgnoreCase) { { "Zero", "0" } }); + var oneKey = "One"; + + Assert.Multiple(() => + { + hash.Add(oneKey, 1); + Assert.That(hash.Count, Is.EqualTo(2)); + Assert.That(hash.Contains(oneKey), Is.True); + Assert.That(hash[oneKey], Is.EqualTo(1)); + hash.Remove(oneKey); + Assert.That(hash.Count, Is.EqualTo(1)); + + hash.Clear(); + Assert.That(hash.Count, Is.EqualTo(0)); + Assert.That(hash, Is.Empty); + }); + } + + [Test] + public void TestHashIDictionaryInterfaceOther() + { + IDictionary hash = new Hash(); + + Assert.Multiple(() => + { + Assert.That(hash.IsFixedSize, Is.False); + Assert.That(hash.IsReadOnly, Is.False); + Assert.That(hash.IsSynchronized, Is.False); + Assert.That(hash.SyncRoot, Is.Not.Null); + }); + } + + [Test] + public void TestHashIIndexableInterface() + { + IIndexable hash = Hash.FromDictionary(new Dictionary(StringComparer.OrdinalIgnoreCase) { { "Zero", "0" } }); + var upperKey = "Zero"; + object lowerKey = upperKey.ToLower(); + + Assert.Multiple(() => + { + Assert.That(hash[upperKey], Is.EqualTo("0")); + Assert.That(hash[lowerKey], Is.EqualTo("0")); + Assert.That(hash.ContainsKey(upperKey), Is.True); + Assert.That(hash.ContainsKey(lowerKey), Is.True); + Assert.That(hash.ContainsKey("one"), Is.False); + }); + } } } diff --git a/src/DotLiquid.Tests/Helper.cs b/src/DotLiquid.Tests/Helper.cs index fa1379ccd..75eeefef6 100644 --- a/src/DotLiquid.Tests/Helper.cs +++ b/src/DotLiquid.Tests/Helper.cs @@ -49,12 +49,25 @@ public static void AssertTemplateResult(string expected, string template, object }); } + public static void AssertTemplateResult(string expected, string template, IIndexable localVariables, INamingConvention namingConvention, SyntaxCompatibility syntax = SyntaxCompatibility.DotLiquid20) + { + LockTemplateStaticVars(namingConvention, () => + { + var parameters = new RenderParameters(System.Globalization.CultureInfo.CurrentCulture) + { + LocalVariables = localVariables, + SyntaxCompatibilityLevel = syntax + }; + Assert.That(Template.Parse(template).Render(parameters), Is.EqualTo(expected)); + }); + } + public static void AssertTemplateResult(string expected, string template, INamingConvention namingConvention) { - AssertTemplateResult(expected: expected, template: template, anonymousObject: null, namingConvention: namingConvention); + AssertTemplateResult(expected: expected, template: template, localVariables: null, namingConvention: namingConvention); } - public static void AssertTemplateResult(string expected, string template, Hash localVariables, IEnumerable localFilters, SyntaxCompatibility syntax = SyntaxCompatibility.DotLiquid20) + public static void AssertTemplateResult(string expected, string template, IIndexable localVariables, IEnumerable localFilters, SyntaxCompatibility syntax = SyntaxCompatibility.DotLiquid20) { var parameters = new RenderParameters(System.Globalization.CultureInfo.CurrentCulture) { @@ -65,7 +78,7 @@ public static void AssertTemplateResult(string expected, string template, Hash l Assert.That(Template.Parse(template).Render(parameters), Is.EqualTo(expected)); } - public static void AssertTemplateResult(string expected, string template, Hash localVariables, SyntaxCompatibility syntax = SyntaxCompatibility.DotLiquid20) + public static void AssertTemplateResult(string expected, string template, IIndexable localVariables, SyntaxCompatibility syntax = SyntaxCompatibility.DotLiquid20) { AssertTemplateResult(expected: expected, template: template, localVariables: localVariables, localFilters: null, syntax: syntax); } diff --git a/src/DotLiquid.Tests/LiquidTypeAttributeTests.cs b/src/DotLiquid.Tests/LiquidTypeAttributeTests.cs index 960245805..3cc10aa0f 100644 --- a/src/DotLiquid.Tests/LiquidTypeAttributeTests.cs +++ b/src/DotLiquid.Tests/LiquidTypeAttributeTests.cs @@ -138,5 +138,49 @@ public void TestLiquidTypeAccessToGlobalToString() anonymousObject: new { value = new MyLiquidTypeWithGlobalMemberAllowance() }, namingConvention: new NamingConventions.RubyNamingConvention()); } + + + [Test] + public void TestLiquidTypeRootKeys() + { + Helper.AssertTemplateResult( + expected: "worked", + template: "{{ name }}", + localVariables: DropBase.FromSafeType(new MyLiquidTypeWithAllowedMember() { Name = "worked" }), + namingConvention: new NamingConventions.RubyNamingConvention()); + + Helper.AssertTemplateResult( + expected: "worked", + template: "{{ prop_allowed }}", + localVariables: DropBase.FromSafeType(new Helper.DataObjectRegistered() { PropAllowed = "worked" }), + namingConvention: new NamingConventions.RubyNamingConvention()); + } + + [Test] + public void TestNonSafeTypeException() + { + Assert.Throws(() => DropBase.FromSafeType(string.Empty)); + Assert.That(DropBase.TryFromSafeType(string.Empty, out _), Is.False); + + Template.RegisterSafeType(typeof(TemplateTests.MySimpleType), o => o.ToString()); + Assert.Throws(() => DropBase.FromSafeType(new TemplateTests.MySimpleType())); + Assert.That(DropBase.TryFromSafeType(new TemplateTests.MySimpleType(), out _), Is.False); + } + + [Test] + public void TestLiquidTypeParsesAllowedMembers() + { + Assert.That(DropProxy.TryFromLiquidType(new MyLiquidTypeWithNoAllowedMembers(), typeof(MyLiquidTypeWithNoAllowedMembers), out var noAllowedDrop), Is.True); + Assert.That(noAllowedDrop.CreateTypeResolution(typeof(MyLiquidTypeWithNoAllowedMembers)).CachedProperties, Has.Exactly(0).Items); + Assert.That(DropProxy.TryFromLiquidType(new MyLiquidTypeWithAllowedMember(), typeof(MyLiquidTypeWithAllowedMember), out var allowedDrop), Is.True); + Assert.That(allowedDrop.CreateTypeResolution(typeof(MyLiquidTypeWithAllowedMember)).CachedProperties, Has.Exactly(1).Items); + } + + [Test] + public void TestNonLiquidTypeException() + { + Assert.That(DropProxy.TryFromLiquidType(string.Empty, typeof(string), out _), Is.False); + Assert.That(DropProxy.TryFromLiquidType(new TemplateTests.MySimpleType(), typeof(TemplateTests.MySimpleType), out _), Is.False); + } } } diff --git a/src/DotLiquid.Tests/RegexpTests.cs b/src/DotLiquid.Tests/RegexpTests.cs index 9326fd264..0b8821831 100644 --- a/src/DotLiquid.Tests/RegexpTests.cs +++ b/src/DotLiquid.Tests/RegexpTests.cs @@ -12,14 +12,13 @@ namespace DotLiquid.Tests [TestFixture] public class RegexpTests { -#if !NETCOREAPP1_0 [Test] public void TestAllRegexesAreCompiled() { - var assembly = typeof (Template).GetTypeInfo().Assembly; + var assembly = typeof(Template).Assembly; foreach (Type parent in assembly.GetTypes()) { - foreach (var t in parent.GetTypeInfo().GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)) + foreach (var t in parent.GetFields(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)) { if (t.FieldType == typeof(Regex)) { @@ -28,7 +27,6 @@ public void TestAllRegexesAreCompiled() } } } -#endif private static List Run(string input, string pattern) { diff --git a/src/DotLiquid.Tests/StandardFilterTests.cs b/src/DotLiquid.Tests/StandardFilterTests.cs index 80f3c19c5..99de828b7 100644 --- a/src/DotLiquid.Tests/StandardFilterTests.cs +++ b/src/DotLiquid.Tests/StandardFilterTests.cs @@ -15,8 +15,6 @@ public class StandardFilterTests private Context _contextV20; private Context _contextV20EnUS; private Context _contextV21; - private Context _contextV22; - private Context _contextV22a; [OneTimeSetUp] public void SetUp() @@ -33,14 +31,6 @@ public void SetUp() { SyntaxCompatibilityLevel = SyntaxCompatibility.DotLiquid21 }; - _contextV22 = new Context(CultureInfo.InvariantCulture) - { - SyntaxCompatibilityLevel = SyntaxCompatibility.DotLiquid22 - }; - _contextV22a = new Context(CultureInfo.InvariantCulture) - { - SyntaxCompatibilityLevel = SyntaxCompatibility.DotLiquid22a - }; } [Test] @@ -90,7 +80,6 @@ public void TestEscape() Assert.That(StandardFilters.Escape(null), Is.EqualTo(null)); Assert.That(StandardFilters.Escape(""), Is.EqualTo("")); Assert.That(StandardFilters.Escape(""), Is.EqualTo("<strong>")); - Assert.That(StandardFilters.H(""), Is.EqualTo("<strong>")); Helper.AssertTemplateResult( expected: "Have you read 'James & the Giant Peach'?", @@ -99,6 +88,10 @@ public void TestEscape() Helper.AssertTemplateResult( expected: "Tetsuro Takara", template: "{{ 'Tetsuro Takara' | escape }}"); + + Helper.AssertTemplateResult( + expected: "Tetsuro Takara", + template: "{{ 'Tetsuro Takara' | h }}"); } [Test] @@ -120,33 +113,24 @@ public void TestEscapeOnce() } [Test] - public void TestTruncateWords() + public void TestTruncateWordsV20() { - Assert.That(StandardFilters.TruncateWords(null), Is.EqualTo(null)); - Assert.That(StandardFilters.TruncateWords(""), Is.EqualTo("")); - Assert.That(StandardFilters.TruncateWords("one two three", 4), Is.EqualTo("one two three")); - Assert.That(StandardFilters.TruncateWords("one two three", 2), Is.EqualTo("one two...")); - Assert.That(StandardFilters.TruncateWords("one two three"), Is.EqualTo("one two three")); - Assert.That(StandardFilters.TruncateWords("Two small (13” x 5.5” x 10” high) baskets fit inside one large basket (13” x 16” x 10.5” high) with cover.", 15), Is.EqualTo("Two small (13” x 5.5” x 10” high) baskets fit inside one large basket (13”...")); - - Helper.AssertTemplateResult(expected: "Ground control to...", template: "{{ \"Ground control to Major Tom.\" | truncate_words: 3}}"); - Helper.AssertTemplateResult(expected: "Ground control to--", template: "{{ \"Ground control to Major Tom.\" | truncate_words: 3, \"--\"}}"); - Helper.AssertTemplateResult(expected: "Ground control to", template: "{{ \"Ground control to Major Tom.\" | truncate_words: 3, \"\"}}"); - Helper.AssertTemplateResult(expected: "...", template: "{{ \"Ground control to Major Tom.\" | truncate_words: 0}}"); - Helper.AssertTemplateResult(expected: "...", template: "{{ \"Ground control to Major Tom.\" | truncate_words: -1}}"); - Helper.AssertTemplateResult(expected: "Liquid error: Value was either too large or too small for an Int32.", template: $"{{{{ \"Ground control to Major Tom.\" | truncate_words: {((long)int.MaxValue) + 1}}}}}"); + TestTruncateWords(SyntaxCompatibility.DotLiquid20, "truncate_words"); } [Test] - public void TestSplit() + public void TestTruncateWordsV24() { - Assert.That(StandardFilters.Split("This is a sentence", " "), Is.EqualTo(new[] { "This", "is", "a", "sentence" }).AsCollection); - Assert.That(StandardFilters.Split(null, null), Is.EqualTo(new string[] { null }).AsCollection); + TestTruncateWords(SyntaxCompatibility.DotLiquid24, "truncate_words"); + TestTruncateWords(SyntaxCompatibility.DotLiquid24, "truncatewords"); + } - // A string with no pattern should be split into a string[], as required for the Liquid Reverse filter - Assert.That(StandardFilters.Split("YMCA", null), Is.EqualTo(new[] { "Y", "M", "C", "A" }).AsCollection); - Assert.That(StandardFilters.Split("YMCA", ""), Is.EqualTo(new[] { "Y", "M", "C", "A" }).AsCollection); - Assert.That(StandardFilters.Split(" ", ""), Is.EqualTo(new[] { " " }).AsCollection); + public void TestTruncateWords(SyntaxCompatibility syntax, string filterName) + { + Helper.AssertTemplateResult(expected: "Ground control to...", template: "{{ \"Ground control to Major Tom.\" | " + filterName + ": 3}}", syntax: syntax); + Helper.AssertTemplateResult(expected: "Ground control to--", template: "{{ \"Ground control to Major Tom.\" | " + filterName + ": 3, \"--\"}}", syntax: syntax); + Helper.AssertTemplateResult(expected: "Ground control to", template: "{{ \"Ground control to Major Tom.\" | " + filterName + ": 3, \"\"}}", syntax: syntax); + Helper.AssertTemplateResult(expected: "Liquid error: Value was either too large or too small for an Int32.", template: $"{{{{ \"Ground control to Major Tom.\" | {filterName}: {((long)int.MaxValue) + 1}}}}}", syntax: syntax); } [Test] @@ -195,95 +179,6 @@ public void TestRStrip() Assert.That(StandardFilters.Rstrip(null), Is.EqualTo(null)); } - [Test] - public void TestSlice_V22() - { - Context context = _contextV22; - - // Verify backwards compatibility for pre-22a syntax (DotLiquid returns null for null input or empty slice) - Assert.That(StandardFilters.Slice(context, null, 1), Is.EqualTo(null)); // DotLiquid test case - Assert.That(StandardFilters.Slice(context, "", 10), Is.EqualTo(null)); // DotLiquid test case - - Assert.That(StandardFilters.Slice(context, null, 0), Is.EqualTo(null)); // Liquid test case - Assert.That(StandardFilters.Slice(context, "foobar", 100, 10), Is.EqualTo(null)); // Liquid test case - - // Verify DotLiquid is consistent with Liquid for everything else - TestSliceString(context); - TestSliceArrays(context); - } - - [Test] - public void TestSlice_V22a() - { - Context context = _contextV22a; - - // Verify Liquid compliance from V22a syntax: - Assert.That(StandardFilters.Slice(context, null, 1), Is.EqualTo("")); // DotLiquid test case - Assert.That(StandardFilters.Slice(context, "", 10), Is.EqualTo("")); // DotLiquid test case - - Assert.That(StandardFilters.Slice(context, null, 0), Is.EqualTo("")); // Liquid test case - Assert.That(StandardFilters.Slice(context, "foobar", 100, 10), Is.EqualTo("")); // Liquid test case - - // Verify DotLiquid is consistent with Liquid for everything else - TestSliceString(context); - TestSliceArrays(context); - } - - private void TestSliceString(Context context) - { - Assert.That(StandardFilters.Slice(context, "abcdefg", 0, 3), Is.EqualTo("abc")); - Assert.That(StandardFilters.Slice(context, "abcdefg", 1, 3), Is.EqualTo("bcd")); - Assert.That(StandardFilters.Slice(context, "abcdefg", -3, 3), Is.EqualTo("efg")); - Assert.That(StandardFilters.Slice(context, "abcdefg", -3, 30), Is.EqualTo("efg")); - Assert.That(StandardFilters.Slice(context, "abcdefg", 4, 30), Is.EqualTo("efg")); - Assert.That(StandardFilters.Slice(context, "abc", -4, 2), Is.EqualTo("a")); - Assert.That(StandardFilters.Slice(context, "abcdefg", -10, 1), Is.EqualTo("")); - - // Test replicated from the Ruby library (https://github.com/Shopify/liquid/blob/master/test/integration/standard_filter_test.rb) - Assert.That(StandardFilters.Slice(context, "foobar", 1, 3), Is.EqualTo("oob")); - Assert.That(StandardFilters.Slice(context, "foobar", 1, 1000), Is.EqualTo("oobar")); - Assert.That(StandardFilters.Slice(context, "foobar", 1, 0), Is.EqualTo("")); - Assert.That(StandardFilters.Slice(context, "foobar", 1, 1), Is.EqualTo("o")); - Assert.That(StandardFilters.Slice(context, "foobar", 3, 3), Is.EqualTo("bar")); - Assert.That(StandardFilters.Slice(context, "foobar", -2, 2), Is.EqualTo("ar")); - Assert.That(StandardFilters.Slice(context, "foobar", -2, 1000), Is.EqualTo("ar")); - Assert.That(StandardFilters.Slice(context, "foobar", -1), Is.EqualTo("r")); - Assert.That(StandardFilters.Slice(context, "foobar", -100, 10), Is.EqualTo("")); - Assert.That(StandardFilters.Slice(context, "foobar", 1, 3), Is.EqualTo("oob")); - } - - private void TestSliceArrays(Context context) - { - // Test replicated from the Ruby library - var testArray = new[] { "f", "o", "o", "b", "a", "r" }; - Assert.That((IEnumerable)StandardFilters.Slice(context, testArray, 1, 3), Is.EqualTo(ToStringArray("oob")).AsCollection); - Assert.That((IEnumerable)StandardFilters.Slice(context, testArray, 1, 1000), Is.EqualTo(ToStringArray("oobar")).AsCollection); - Assert.That((IEnumerable)StandardFilters.Slice(context, testArray, 1, 0), Is.EqualTo(ToStringArray("")).AsCollection); - Assert.That((IEnumerable)StandardFilters.Slice(context, testArray, 1, 1), Is.EqualTo(ToStringArray("o")).AsCollection); - Assert.That((IEnumerable)StandardFilters.Slice(context, testArray, 3, 3), Is.EqualTo(ToStringArray("bar")).AsCollection); - Assert.That((IEnumerable)StandardFilters.Slice(context, testArray, -2, 2), Is.EqualTo(ToStringArray("ar")).AsCollection); - Assert.That((IEnumerable)StandardFilters.Slice(context, testArray, -2, 1000), Is.EqualTo(ToStringArray("ar")).AsCollection); - Assert.That((IEnumerable)StandardFilters.Slice(context, testArray, -1), Is.EqualTo(ToStringArray("r")).AsCollection); - Assert.That((IEnumerable)StandardFilters.Slice(context, testArray, 100, 10), Is.EqualTo(ToStringArray("")).AsCollection); - Assert.That((IEnumerable)StandardFilters.Slice(context, testArray, -100, 10), Is.EqualTo(ToStringArray("")).AsCollection); - - // additional tests - Assert.That((IEnumerable)StandardFilters.Slice(context, testArray, -6, 2), Is.EqualTo(ToStringArray("fo")).AsCollection); - Assert.That((IEnumerable)StandardFilters.Slice(context, testArray, -8, 4), Is.EqualTo(ToStringArray("fo")).AsCollection); - - // Non-string arrays tests - Assert.That((IEnumerable)StandardFilters.Slice(context, new[] { 1, 2, 3, 4, 5 }, 1, 3), Is.EqualTo(new[] { 2, 3, 4 }).AsCollection); - Assert.That((IEnumerable)StandardFilters.Slice(context, new[] { 'a', 'b', 'c', 'd', 'e' }, -4, 3), Is.EqualTo(new[] { 'b', 'c', 'd' }).AsCollection); - } - - /// - /// Convert a string into a string[] where each character is mapped into an array element. - /// - private static string[] ToStringArray(string input) - { - return input.ToCharArray().Select(character => character.ToString()).ToArray(); - } - [Test] public void TestSliceShopifySamples() { @@ -318,39 +213,39 @@ public void TestJoin() public void TestSortV20() { var ints = new[] { 10, 3, 2, 1 }; - Assert.That(StandardFilters.Sort(_contextV20, null), Is.EqualTo(null)); - Assert.That(StandardFilters.Sort(_contextV20, new string[] { }), Is.EqualTo(new string[] { }).AsCollection); - Assert.That(StandardFilters.Sort(_contextV20, ints), Is.EqualTo(new[] { 1, 2, 3, 10 }).AsCollection); - Assert.That(StandardFilters.Sort(_contextV20, new[] { new { a = 10 }, new { a = 3 }, new { a = 1 }, new { a = 2 } }, "a"), Is.EqualTo(new[] { new { a = 1 }, new { a = 2 }, new { a = 3 }, new { a = 10 } }).AsCollection); + Assert.That(LegacyFilters.Sort(null), Is.EqualTo(null)); + Assert.That(LegacyFilters.Sort(new string[] { }), Is.EqualTo(new string[] { }).AsCollection); + Assert.That(LegacyFilters.Sort(ints), Is.EqualTo(new[] { 1, 2, 3, 10 }).AsCollection); + Assert.That(LegacyFilters.Sort(new[] { new { a = 10 }, new { a = 3 }, new { a = 1 }, new { a = 2 } }, "a"), Is.EqualTo(new[] { new { a = 1 }, new { a = 2 }, new { a = 3 }, new { a = 10 } }).AsCollection); // Issue #393 - Incorrect (Case-Insensitve) Alphabetic Sort var strings = new[] { "zebra", "octopus", "giraffe", "Sally Snake" }; - Assert.That(StandardFilters.Sort(_contextV20, strings), Is.EqualTo(new[] { "giraffe", "octopus", "Sally Snake", "zebra" }).AsCollection); + Assert.That(LegacyFilters.Sort(strings), Is.EqualTo(new[] { "giraffe", "octopus", "Sally Snake", "zebra" }).AsCollection); var hashes = new List(); for (var i = 0; i < strings.Length; i++) hashes.Add(CreateHash(ints[i], strings[i])); - Assert.That(StandardFilters.Sort(_contextV20, hashes, "content"), Is.EqualTo(new[] { hashes[2], hashes[1], hashes[3], hashes[0] }).AsCollection); - Assert.That(StandardFilters.Sort(_contextV20, hashes, "sortby"), Is.EqualTo(new[] { hashes[3], hashes[2], hashes[1], hashes[0] }).AsCollection); + Assert.That(LegacyFilters.Sort(hashes, "content"), Is.EqualTo(new[] { hashes[2], hashes[1], hashes[3], hashes[0] }).AsCollection); + Assert.That(LegacyFilters.Sort(hashes, "sortby"), Is.EqualTo(new[] { hashes[3], hashes[2], hashes[1], hashes[0] }).AsCollection); } [Test] public void TestSortV22() { var ints = new[] { 10, 3, 2, 1 }; - Assert.That(StandardFilters.Sort(_contextV22, null), Is.EqualTo(null)); - Assert.That(StandardFilters.Sort(_contextV22, new string[] { }), Is.EqualTo(new string[] { }).AsCollection); - Assert.That(StandardFilters.Sort(_contextV22, ints), Is.EqualTo(new[] { 1, 2, 3, 10 }).AsCollection); - Assert.That(StandardFilters.Sort(_contextV22, new[] { new { a = 10 }, new { a = 3 }, new { a = 1 }, new { a = 2 } }, "a"), Is.EqualTo(new[] { new { a = 1 }, new { a = 2 }, new { a = 3 }, new { a = 10 } }).AsCollection); + Assert.That(StandardFilters.Sort(null), Is.EqualTo(null)); + Assert.That(StandardFilters.Sort(new string[] { }), Is.EqualTo(new string[] { }).AsCollection); + Assert.That(StandardFilters.Sort(ints), Is.EqualTo(new[] { 1, 2, 3, 10 }).AsCollection); + Assert.That(StandardFilters.Sort(new[] { new { a = 10 }, new { a = 3 }, new { a = 1 }, new { a = 2 } }, "a"), Is.EqualTo(new[] { new { a = 1 }, new { a = 2 }, new { a = 3 }, new { a = 10 } }).AsCollection); var strings = new[] { "zebra", "octopus", "giraffe", "Sally Snake" }; - Assert.That(StandardFilters.Sort(_contextV22, strings), Is.EqualTo(new[] { "Sally Snake", "giraffe", "octopus", "zebra" }).AsCollection); + Assert.That(StandardFilters.Sort(strings), Is.EqualTo(new[] { "Sally Snake", "giraffe", "octopus", "zebra" }).AsCollection); var hashes = new List(); for (var i = 0; i < strings.Length; i++) hashes.Add(CreateHash(ints[i], strings[i])); - Assert.That(StandardFilters.Sort(_contextV22, hashes, "content"), Is.EqualTo(new[] { hashes[3], hashes[2], hashes[1], hashes[0] }).AsCollection); - Assert.That(StandardFilters.Sort(_contextV22, hashes, "sortby"), Is.EqualTo(new[] { hashes[3], hashes[2], hashes[1], hashes[0] }).AsCollection); + Assert.That(StandardFilters.Sort(hashes, "content"), Is.EqualTo(new[] { hashes[3], hashes[2], hashes[1], hashes[0] }).AsCollection); + Assert.That(StandardFilters.Sort(hashes, "sortby"), Is.EqualTo(new[] { hashes[3], hashes[2], hashes[1], hashes[0] }).AsCollection); } [Test] @@ -383,7 +278,7 @@ public void TestSort_OnHashList_WithProperty_DoesNotFlattenList() list.Add(hash1); list.Add(hash2); - var result = StandardFilters.Sort(_contextV20, list, "sortby").Cast().ToArray(); + var result = LegacyFilters.Sort(list, "sortby").Cast().ToArray(); Assert.That(result.Count(), Is.EqualTo(3)); Assert.That(result[0]["content"], Is.EqualTo(hash1["content"])); Assert.That(result[1]["content"], Is.EqualTo(hash2["content"])); @@ -402,7 +297,7 @@ public void TestSort_OnDictionaryWithPropertyOnlyInSomeElement_ReturnsSortedDict list.Add(hashWithNoSortByProperty); list.Add(hash1); - var result = StandardFilters.Sort(_contextV20, list, "sortby").Cast().ToArray(); + var result = LegacyFilters.Sort(list, "sortby").Cast().ToArray(); Assert.That(result.Count(), Is.EqualTo(3)); Assert.That(result[0]["content"], Is.EqualTo(hashWithNoSortByProperty["content"])); Assert.That(result[1]["content"], Is.EqualTo(hash1["content"])); @@ -422,7 +317,7 @@ public void TestSort_Indexable() Helper.LockTemplateStaticVars(new RubyNamingConvention(), () => { Assert.That( - actual: StandardFilters.Sort(_contextV20, packages, "numberOfPiecesPerPackage"), Is.EqualTo(expected: expectedPackages).AsCollection); + actual: LegacyFilters.Sort(packages, "numberOfPiecesPerPackage"), Is.EqualTo(expected: expectedPackages).AsCollection); }); } @@ -442,7 +337,7 @@ public void TestSort_ExpandoObject() var expectedPackages = new List { package2, package1, package3 }; Assert.That( - actual: StandardFilters.Sort(_contextV20, packages, property: "numberOfPiecesPerPackage"), Is.EqualTo(expected: expectedPackages)); + actual: LegacyFilters.Sort(packages, property: "numberOfPiecesPerPackage"), Is.EqualTo(expected: expectedPackages)); } private static Hash CreateHash(int sortby, string content) => @@ -1156,43 +1051,6 @@ private void TestFirstLast(NamingConventions.INamingConvention namingConvention, namingConvention: namingConvention); } - [Test] - public void TestReplace() - { - TestReplace(_contextV20); - } - - public void TestReplace(Context context) - { - Assert.That(StandardFilters.Replace(context: context, input: null, @string: "a", replacement: "b"), Is.Null); - Assert.That(actual: StandardFilters.Replace(context: context, input: "", @string: "a", replacement: "b"), Is.EqualTo(expected: "")); - Assert.That(actual: StandardFilters.Replace(context: context, input: "a a a a", @string: null, replacement: "b"), Is.EqualTo(expected: "a a a a")); - Assert.That(actual: StandardFilters.Replace(context: context, input: "a a a a", @string: "", replacement: "b"), Is.EqualTo(expected: "a a a a")); - Assert.That(actual: StandardFilters.Replace(context: context, input: "a a a a", @string: "a", replacement: "b"), Is.EqualTo(expected: "b b b b")); - - Assert.That(actual: StandardFilters.Replace(context: context, input: "Tesvalue\"", @string: "\"", replacement: "\\\""), Is.EqualTo(expected: "Tesvalue\\\"")); - Helper.AssertTemplateResult(expected: "Tesvalue\\\"", template: "{{ 'Tesvalue\"' | replace: '\"', '\\\"' }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult( - expected: "Tesvalue\\\"", - template: "{{ context | replace: '\"', '\\\"' }}", - localVariables: Hash.FromAnonymousObject(new { context = "Tesvalue\"" }), - syntax: context.SyntaxCompatibilityLevel); - } - - [Test] - public void TestReplaceRegexV20() - { - var context = _contextV20; - Assert.That(actual: StandardFilters.Replace(context: context, input: "a A A a", @string: "[Aa]", replacement: "b"), Is.EqualTo(expected: "b b b b")); - } - - [Test] - public void TestReplaceRegexV21() - { - var context = _contextV21; - Assert.That(actual: StandardFilters.Replace(context: context, input: "a A A a", @string: "[Aa]", replacement: "b"), Is.EqualTo(expected: "a A A a")); - TestReplace(context); - } [Test] public void TestReplaceChain() @@ -1206,64 +1064,40 @@ public void TestReplaceChain() Helper.AssertTemplateResult(" qwerty", assign + "{{az |replace: 'a',' q' |replace: 'z','w'}}"); } - [Test] - public void TestReplaceFirst() - { - TestReplaceFirst(_contextV20); - } - - public void TestReplaceFirst(Context context) - { - Assert.That(StandardFilters.ReplaceFirst(context: context, input: null, @string: "a", replacement: "b"), Is.Null); - Assert.That(StandardFilters.ReplaceFirst(context: context, input: "", @string: "a", replacement: "b"), Is.EqualTo("")); - Assert.That(StandardFilters.ReplaceFirst(context: context, input: "a a a a", @string: null, replacement: "b"), Is.EqualTo("a a a a")); - Assert.That(StandardFilters.ReplaceFirst(context: context, input: "a a a a", @string: "", replacement: "b"), Is.EqualTo("a a a a")); - Assert.That(StandardFilters.ReplaceFirst(context: context, input: "a a a a", @string: "a", replacement: "b"), Is.EqualTo("b a a a")); - Helper.AssertTemplateResult(expected: "b a a a", template: "{{ 'a a a a' | replace_first: 'a', 'b' }}", syntax: context.SyntaxCompatibilityLevel); - } - - [Test] - public void TestReplaceFirstRegexV20() - { - var context = _contextV20; - Assert.That(actual: StandardFilters.ReplaceFirst(context: context, input: "a A A a", @string: "[Aa]", replacement: "b"), Is.EqualTo(expected: "b A A a")); - } - - [Test] - public void TestReplaceFirstRegexV21() - { - var context = _contextV21; - Assert.That(actual: StandardFilters.ReplaceFirst(context: context, input: "a A A a", @string: "[Aa]", replacement: "b"), Is.EqualTo(expected: "a A A a")); - TestReplaceFirst(context); - } - [Test] public void TestRemove() { - TestRemove(_contextV20); - } - - public void TestRemove(Context context) - { - Assert.That(StandardFilters.Remove("a a a a", "a"), Is.EqualTo(" ")); - Assert.That(StandardFilters.RemoveFirst(context: context, input: "a a a a", @string: "a "), Is.EqualTo("a a a")); - Helper.AssertTemplateResult(expected: "a a a", template: "{{ 'a a a a' | remove_first: 'a ' }}", syntax: context.SyntaxCompatibilityLevel); } [Test] - public void TestRemoveFirstRegexV20() + public void TestReplaceLast() { - var context = _contextV20; - Assert.That(actual: StandardFilters.RemoveFirst(context: context, input: "Mr. Jones", @string: "."), Is.EqualTo(expected: "r. Jones")); + //Adapted from ReplaceFirst Tests + Assert.That(StandardFilters.ReplaceLast(input: null, @string: "a", replacement: "b"), Is.Null); + Assert.That(StandardFilters.ReplaceLast(input: "", @string: "a", replacement: "b"), Is.EqualTo("")); + Assert.That(StandardFilters.ReplaceLast(input: "a a a a", @string: null, replacement: "b"), Is.EqualTo("a a a ab")); + Assert.That(StandardFilters.ReplaceLast(input: "a a a a", @string: "", replacement: "b"), Is.EqualTo("a a a ab")); + Assert.That(StandardFilters.ReplaceLast(input: "a a a a", @string: "", replacement: null), Is.EqualTo("a a a a")); + Assert.That(StandardFilters.ReplaceLast(input: "a a b a", @string: " a", replacement: null), Is.EqualTo("a a b")); + + //Adapted from Shopify Tests + Assert.That(StandardFilters.ReplaceLast(input: "a a a a", @string: "a", replacement: "b"), Is.EqualTo("a a a b")); + Assert.That(StandardFilters.ReplaceLast(input: "1 1 1 1", @string: "1", replacement: "2"), Is.EqualTo("1 1 1 2")); + Assert.That(StandardFilters.ReplaceLast(input: "1 1 1 1", @string: "2", replacement: "3"), Is.EqualTo("1 1 1 1")); } [Test] - public void TestRemoveFirstRegexV21() + public void TestRemoveLast() { - var context = _contextV21; - Assert.That(actual: StandardFilters.RemoveFirst(context: context, input: "Mr. Jones", @string: "."), Is.EqualTo(expected: "Mr Jones")); - TestRemove(context); + Assert.That(StandardFilters.RemoveLast(input: null, @string: "a"), Is.Null); + Assert.That(StandardFilters.RemoveLast(input: "", @string: "a"), Is.EqualTo("")); + Assert.That(StandardFilters.RemoveLast(input: " ", @string: " "), Is.EqualTo(" ")); + Assert.That(StandardFilters.RemoveLast(input: "a a a a", @string: null), Is.EqualTo("a a a a")); + Assert.That(StandardFilters.RemoveLast(input: "a a a a", @string: ""), Is.EqualTo("a a a a")); + Assert.That(StandardFilters.RemoveLast(input: "a a a a", @string: "b"), Is.EqualTo("a a a a")); + Assert.That(StandardFilters.RemoveLast(input: "a b a a", @string: "a "), Is.EqualTo("a b a")); + Assert.That(StandardFilters.RemoveLast(input: "a a b a", @string: " a"), Is.EqualTo("a a b")); } [Test] @@ -1302,88 +1136,6 @@ public void TestUnixNewlinesToBr() Hash.FromAnonymousObject(new { source = "a\nb\nc" })); } - [Test] - public void TestPlus() - { - TestPlus(_contextV20); - } - - private void TestPlus(Context context) - { - using (CultureHelper.SetCulture("en-GB")) - { - Helper.AssertTemplateResult(expected: "2", template: "{{ 1 | plus:1 }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "5.5", template: "{{ 2 | plus:3.5 }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "5.5", template: "{{ 3.5 | plus:2 }}", syntax: context.SyntaxCompatibilityLevel); - - // Test that decimals are not introducing rounding-precision issues - Helper.AssertTemplateResult(expected: "148397.77", template: "{{ 148387.77 | plus:10 }}", syntax: context.SyntaxCompatibilityLevel); - - Helper.AssertTemplateResult( - expected: "2147483648", - template: "{{ i | plus: i2 }}", - localVariables: Hash.FromAnonymousObject(new { i = (int)Int32.MaxValue, i2 = (Int64)1 }), - syntax: context.SyntaxCompatibilityLevel); - } - } - - [Test] - public void TestPlusStringV20() - { - var context = _contextV20; - Helper.AssertTemplateResult(expected: "11", template: "{{ '1' | plus: 1 }}", syntax: context.SyntaxCompatibilityLevel); - var renderParams = new RenderParameters(CultureInfo.InvariantCulture) { ErrorsOutputMode = ErrorsOutputMode.Rethrow, SyntaxCompatibilityLevel = context.SyntaxCompatibilityLevel }; - Assert.Throws(() => Template.Parse("{{ 1 | plus: '1' }}").Render(renderParams)); - } - - [Test] - public void TestPlusStringV21() - { - var context = _contextV21; - Helper.AssertTemplateResult(expected: "2", template: "{{ '1' | plus: 1 }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "2", template: "{{ 1 | plus: '1' }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "2", template: "{{ '1' | plus: '1' }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "5.5", template: "{{ 2 | plus: '3.5' }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "5.5", template: "{{ '3.5' | plus: 2 }}", syntax: context.SyntaxCompatibilityLevel); - TestPlus(context); - } - - [Test] - public void TestMinus() - { - TestMinus(_contextV20); - } - - private void TestMinus(Context context) - { - using (CultureHelper.SetCulture("en-GB")) - { - Helper.AssertTemplateResult(expected: "4", template: "{{ input | minus:operand }}", localVariables: Hash.FromAnonymousObject(new { input = 5, operand = 1 }), syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "-1.5", template: "{{ 2 | minus:3.5 }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "1.5", template: "{{ 3.5 | minus:2 }}", syntax: context.SyntaxCompatibilityLevel); - } - } - - [Test] - public void TestMinusStringV20() - { - var renderParams = new RenderParameters(CultureInfo.InvariantCulture) { ErrorsOutputMode = ErrorsOutputMode.Rethrow, SyntaxCompatibilityLevel = _contextV20.SyntaxCompatibilityLevel }; - Assert.Throws(() => Template.Parse("{{ '2' | minus: 1 }}").Render(renderParams)); - Assert.Throws(() => Template.Parse("{{ 2 | minus: '1' }}").Render(renderParams)); - } - - [Test] - public void TestMinusStringV21() - { - var context = _contextV21; - Helper.AssertTemplateResult(expected: "1", template: "{{ '2' | minus: 1 }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "1", template: "{{ 2 | minus: '1' }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "-1.5", template: "{{ 2 | minus: '3.5' }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "-1.5", template: "{{ '2.5' | minus: 4 }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "-1", template: "{{ '2.5' | minus: '3.5' }}", syntax: context.SyntaxCompatibilityLevel); - TestMinus(context); - } - [Test] public void TestPlusCombinedWithMinus() { @@ -1489,27 +1241,6 @@ private void TestTimes(Context context) Assert.That(StandardFilters.Times(context: context, input: 7.5563m, operand: 1000), Is.EqualTo(7556.3)); } - [Test] - public void TestTimesStringV20() - { - var context = _contextV20; - Helper.AssertTemplateResult(expected: "foofoofoofoo", template: "{{ 'foo' | times:4 }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "3333", template: "{{ '3' | times:4 }}", syntax: context.SyntaxCompatibilityLevel); - var renderParams = new RenderParameters(CultureInfo.InvariantCulture) { ErrorsOutputMode = ErrorsOutputMode.Rethrow, SyntaxCompatibilityLevel = context.SyntaxCompatibilityLevel }; - Assert.Throws(() => Template.Parse("{{ 3 | times: '4' }}").Render(renderParams)); - Assert.Throws(() => Template.Parse("{{ '3' | times: '4' }}").Render(renderParams)); - } - - [Test] - public void TestTimesStringV21() - { - var context = _contextV21; - Helper.AssertTemplateResult(expected: "12", template: "{{ '3' | times: 4 }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "12", template: "{{ 3 | times: '4' }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "12", template: "{{ '3' | times: '4' }}", syntax: context.SyntaxCompatibilityLevel); - TestTimes(context); - } - [Test] public void TestAppend() { @@ -1534,43 +1265,6 @@ public void TestPrepend() Helper.AssertTemplateResult(expected: string.Empty, template: "{{ alsononesuch | prepend: nonesuch }}"); } - [Test] - public void TestDividedBy() - { - TestDividedBy(_contextV20); - } - - private void TestDividedBy(Context context) - { - Helper.AssertTemplateResult(expected: "4", template: "{{ 12 | divided_by:3 }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "4", template: "{{ 14 | divided_by:3 }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "5", template: "{{ 15 | divided_by:3 }}", syntax: context.SyntaxCompatibilityLevel); - Assert.That(StandardFilters.DividedBy(context: context, input: null, operand: 3), Is.Null); - Assert.That(StandardFilters.DividedBy(context: context, input: 4, operand: null), Is.Null); - - // Ensure we preserve floating point behavior for division by zero, and don't start throwing exceptions. - Helper.AssertTemplateResult(expected: double.PositiveInfinity.ToString(), template: "{{ 1.0 | divided_by:0.0 }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: double.NegativeInfinity.ToString(), template: "{{ -1.0 | divided_by:0.0 }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "NaN", template: "{{ 0.0 | divided_by:0.0 }}", syntax: context.SyntaxCompatibilityLevel); - } - - [Test] - public void TestDividedByStringV20() - { - var renderParams = new RenderParameters(CultureInfo.InvariantCulture) { ErrorsOutputMode = ErrorsOutputMode.Rethrow, SyntaxCompatibilityLevel = _contextV20.SyntaxCompatibilityLevel }; - Assert.Throws(() => Template.Parse("{{ '12' | divided_by: 3 }}").Render(renderParams)); - Assert.Throws(() => Template.Parse("{{ 12 | divided_by: '3' }}").Render(renderParams)); - } - - [Test] - public void TestDividedByStringV21() - { - var context = _contextV21; - Helper.AssertTemplateResult(expected: "4", template: "{{ '12' | divided_by: 3 }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "4", template: "{{ 12 | divided_by: '3' }}", syntax: context.SyntaxCompatibilityLevel); - TestDividedBy(context); - } - [Test] public void TestInt32DividedByInt64() { @@ -1584,38 +1278,6 @@ public void TestInt32DividedByInt64() Helper.AssertTemplateResult("4", "{{ a | divided_by:b }}", assigns); } - [Test] - public void TestModulo() - { - TestModulo(_contextV20); - } - - private void TestModulo(Context context) - { - Helper.AssertTemplateResult(expected: "1", template: "{{ 3 | modulo:2 }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "7.77", template: "{{ 148387.77 | modulo:10 }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "5.32", template: "{{ 3455.32 | modulo:10 }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "3.12", template: "{{ 23423.12 | modulo:10 }}", syntax: context.SyntaxCompatibilityLevel); - Assert.That(StandardFilters.Modulo(context: context, input: null, operand: 3), Is.Null); - Assert.That(StandardFilters.Modulo(context: context, input: 4, operand: null), Is.Null); - } - - public void TestModuloStringV20() - { - var renderParams = new RenderParameters(CultureInfo.InvariantCulture) { ErrorsOutputMode = ErrorsOutputMode.Rethrow, SyntaxCompatibilityLevel = _contextV20.SyntaxCompatibilityLevel }; - Assert.Throws(() => Template.Parse("{{ '3' | modulo: 2 }}").Render(renderParams)); - Assert.Throws(() => Template.Parse("{{ 3 | modulo: '2' }}").Render(renderParams)); - } - - [Test] - public void TestModuloStringV21() - { - var context = _contextV21; - Helper.AssertTemplateResult(expected: "1", template: "{{ '3' | modulo: 2 }}", syntax: context.SyntaxCompatibilityLevel); - Helper.AssertTemplateResult(expected: "1", template: "{{ 3 | modulo: '2' }}", syntax: context.SyntaxCompatibilityLevel); - TestModulo(context); - } - [Test] public void TestUrlencode() { @@ -1642,51 +1304,6 @@ public void TestDefault() Helper.AssertTemplateResult("foobar", "{{ unknownvariable | default: 'foobar' }}", assigns); } - [Test] - public void TestCapitalizeV20() - { - var context = _contextV20; - Assert.That(StandardFilters.Capitalize(context: context, input: null), Is.EqualTo(null)); - Assert.That(StandardFilters.Capitalize(context: context, input: ""), Is.EqualTo("")); - Assert.That(StandardFilters.Capitalize(context: context, input: " "), Is.EqualTo(" ")); - Assert.That(StandardFilters.Capitalize(context: context, input: "That is one sentence."), Is.EqualTo("That Is One Sentence.")); - - Helper.AssertTemplateResult( - expected: "Title", - template: "{{ 'title' | capitalize }}", - syntax: context.SyntaxCompatibilityLevel); - } - - [Test] - public void TestCapitalizeV21() - { - var context = _contextV21; - Assert.That(StandardFilters.Capitalize(context: context, input: null), Is.EqualTo(null)); - Assert.That(StandardFilters.Capitalize(context: context, input: ""), Is.EqualTo("")); - Assert.That(StandardFilters.Capitalize(context: context, input: " "), Is.EqualTo(" ")); - Assert.That(StandardFilters.Capitalize(context: context, input: " my boss is Mr. Doe."), Is.EqualTo(" My boss is Mr. Doe.")); - - Helper.AssertTemplateResult( - expected: "My great title", - template: "{{ 'my great title' | capitalize }}", - syntax: context.SyntaxCompatibilityLevel); - } - - [Test] - public void TestCapitalizeV22() - { - var context = _contextV22; - Assert.That(StandardFilters.Capitalize(context: context, input: null), Is.EqualTo(null)); - Assert.That(StandardFilters.Capitalize(context: context, input: ""), Is.EqualTo("")); - Assert.That(StandardFilters.Capitalize(context: context, input: " "), Is.EqualTo(" ")); - Assert.That(StandardFilters.Capitalize(context: context, input: "my boss is Mr. Doe."), Is.EqualTo("My boss is mr. doe.")); - - Helper.AssertTemplateResult( - expected: "My great title", - template: "{{ 'my Great Title' | capitalize }}", - syntax: context.SyntaxCompatibilityLevel); - } - [Test] public void TestUniq() { @@ -2050,7 +1667,7 @@ public void TestConcat() var array1 = new String[] { "one", "two" }; var array2 = new String[] { "alpha", "bravo" }; - Assert.That(StandardFilters.Concat(null, null), Is.EqualTo(null).AsCollection); + Assert.That(StandardFilters.Concat(null, null), Is.EqualTo(null)); Assert.That(StandardFilters.Concat(array1, null), Is.EqualTo(array1).AsCollection); Assert.That(StandardFilters.Concat(null, array1), Is.EqualTo(array1).AsCollection); Assert.That(StandardFilters.Concat(array1, array2), Is.EqualTo(new[] { "one", "two", "alpha", "bravo" }).AsCollection); @@ -2090,7 +1707,7 @@ public void TestReverse() var array = new String[] { "one", "two", "three" }; var arrayReversed = new String[] { "three", "two", "one" }; - Assert.That(StandardFilters.Reverse(null), Is.EqualTo(null).AsCollection); + Assert.That(StandardFilters.Reverse(null), Is.EqualTo(null)); Assert.That(StandardFilters.Reverse(array), Is.EqualTo(arrayReversed).AsCollection); Assert.That(StandardFilters.Reverse(arrayReversed), Is.EqualTo(array).AsCollection); Assert.That(StandardFilters.Reverse(new[] { 1, 2, 2, 3 }), Is.EqualTo(new[] { 3, 2, 2, 1 }).AsCollection); diff --git a/src/DotLiquid.Tests/Tags/IncludeTagTests.cs b/src/DotLiquid.Tests/Tags/IncludeTagTests.cs index 573f08949..e8c13bdcd 100644 --- a/src/DotLiquid.Tests/Tags/IncludeTagTests.cs +++ b/src/DotLiquid.Tests/Tags/IncludeTagTests.cs @@ -195,5 +195,22 @@ public void TestIncludeFromTemplateFileSystem() } Assert.That(fileSystem.CacheHitTimes, Is.EqualTo(1)); } + + [Test] + public void TestIncludeTagWithStringVariable() + { + Helper.LockTemplateStaticVars(Template.NamingConvention, () => { + Template.FileSystem = new DictionaryFileSystem(new Dictionary() { { "product", "Product: {{ product }}" } }); + Assert.Multiple(() => + { + var expected = "Product: foo"; + Helper.AssertTemplateResult(expected, "{% include 'product' with 'foo' %}"); + Helper.AssertTemplateResult(expected, "{% include 'product' for 'foo' %}"); + + Helper.AssertTemplateResult(expected, "{% include 'product' with var %}", Hash.FromAnonymousObject(new { var = "foo" })); + Helper.AssertTemplateResult(expected, "{% include 'product' for var %}", Hash.FromAnonymousObject(new { var = "foo" })); + }); + }); + } } } diff --git a/src/DotLiquid.Tests/Tags/StandardTagTests.cs b/src/DotLiquid.Tests/Tags/StandardTagTests.cs index 8afcdcd5a..ec3441885 100644 --- a/src/DotLiquid.Tests/Tags/StandardTagTests.cs +++ b/src/DotLiquid.Tests/Tags/StandardTagTests.cs @@ -164,6 +164,42 @@ public void TestForWithRange() Helper.AssertTemplateResult(" 1 2 3 ", "{%for item in (1..3) %} {{item}} {%endfor%}"); } + [Test] + public void TestForWithString_V24() + { + // Based on Liquid integration test_for_tag_string + var expected = "test string"; + Helper.AssertTemplateResult($"{expected} ", "{%for val in str%}{{val}} {%endfor%}", Hash.FromAnonymousObject(new { str = expected }), SyntaxCompatibility.DotLiquid24); + Helper.AssertTemplateResult(expected, "{%for val in str limit:1%}{{val}}{%endfor%}", Hash.FromAnonymousObject(new { str = expected }), SyntaxCompatibility.DotLiquid24); + Helper.AssertTemplateResult( + expected: "val-str-1-1-0-1-0-true-true-test string", + template: "{%for val in str%}{{forloop.name}}-{{forloop.index}}-{{forloop.length}}-{{forloop.index0}}-{{forloop.rindex}}-{{forloop.rindex0}}-{{forloop.first}}-{{forloop.last}}-{{val}}{%endfor%}", + Hash.FromAnonymousObject(new { str = expected }), SyntaxCompatibility.DotLiquid24); + + // Additional tests for null and empty strings + string nullString = null; + Helper.AssertTemplateResult(string.Empty, "{%for val in str%}{{forloop.index}}{%endfor%}", Hash.FromAnonymousObject(new { str = nullString }), SyntaxCompatibility.DotLiquid24); + Helper.AssertTemplateResult(string.Empty, "{%for val in str%}{{forloop.index}}{%endfor%}", Hash.FromAnonymousObject(new { str = string.Empty }), SyntaxCompatibility.DotLiquid24); + } + + [Test] + public void TestForWithString_V20() + { + // Based on Liquid integration test_for_tag_string + var expected = "test string"; + Helper.AssertTemplateResult("t e s t s t r i n g ", "{%for val in str%}{{val}} {%endfor%}", Hash.FromAnonymousObject(new { str = expected }), SyntaxCompatibility.DotLiquid20); + Helper.AssertTemplateResult("t", "{%for val in str limit:1%}{{val}}{%endfor%}", Hash.FromAnonymousObject(new { str = expected }), SyntaxCompatibility.DotLiquid20); + Helper.AssertTemplateResult( + expected: "val-str-1-2-0-2-1-true-false-tval-str-2-2-1-1-0-false-true-e", + template: "{%for val in str limit: 2%}{{forloop.name}}-{{forloop.index}}-{{forloop.length}}-{{forloop.index0}}-{{forloop.rindex}}-{{forloop.rindex0}}-{{forloop.first}}-{{forloop.last}}-{{val}}{%endfor%}", + Hash.FromAnonymousObject(new { str = expected }), SyntaxCompatibility.DotLiquid20); + + // Additional tests for null and empty strings + string nullString = null; + Helper.AssertTemplateResult(string.Empty, "{%for val in str%}{{forloop.index}}{%endfor%}", Hash.FromAnonymousObject(new { str = nullString }), SyntaxCompatibility.DotLiquid20); + Helper.AssertTemplateResult(string.Empty, "{%for val in str%}{{forloop.index}}{%endfor%}", Hash.FromAnonymousObject(new { str = string.Empty }), SyntaxCompatibility.DotLiquid20); + } + [Test] public void TestForWithVariable() { @@ -765,6 +801,16 @@ public void TestIncrementDetectsBadSyntax() Assert.Throws(() => Template.Parse("{% increment product.qty %}")); } + [Test] + public void TestIncrementDropRoot() + { + Helper.AssertTemplateResult(expected: "0 1", template: "{%increment port %} {{ port }}", localVariables: new ConditionTests.DummyDrop()); + Helper.AssertTemplateResult(expected: "0 1", template: "{%increment port %} {%increment port%}", localVariables: new ConditionTests.DummyDrop()); + Helper.AssertTemplateResult( + expected: "Me 0 1 Me", + template: "{{ prop }} {%increment port %} {%increment port%} {{ prop }}", localVariables: new Helper.DataObjectDrop() { Prop = "Me" }); + } + [Test] public void TestDecrement() { @@ -815,5 +861,15 @@ public void TestDecrementDetectsBadSyntax() Assert.Throws(() => Template.Parse("{% decrement var1 var2 %}")); Assert.Throws(() => Template.Parse("{% decrement product.qty %}")); } + + [Test] + public void TestDecrementDropRoot() + { + Helper.AssertTemplateResult(expected: "-1 -1", template: "{%decrement port %} {{ port }}", localVariables: new ConditionTests.DummyDrop()); + Helper.AssertTemplateResult(expected: "-1 -2", template: "{%decrement port %} {%decrement port%}", localVariables: new ConditionTests.DummyDrop()); + Helper.AssertTemplateResult( + expected: "Me -1 -2 Me", + template: "{{ prop }} {%decrement port %} {%decrement port%} {{ prop }}", localVariables: new Helper.DataObjectDrop() { Prop = "Me" }); + } } } diff --git a/src/DotLiquid.Tests/TemplateTests.cs b/src/DotLiquid.Tests/TemplateTests.cs index 8d666ef5d..40f470a32 100644 --- a/src/DotLiquid.Tests/TemplateTests.cs +++ b/src/DotLiquid.Tests/TemplateTests.cs @@ -1,3 +1,4 @@ +using System; using System.Globalization; using System.IO; using System.Net; @@ -184,6 +185,35 @@ public void TestRenderToStream() } } + [Test] + public void TestRenderNullArgumentsThrowsException() + { + var template = Template.Parse("{{test}}"); + var renderParameters = new RenderParameters(CultureInfo.InvariantCulture); + Assert.Multiple(() => + { + Assert.Throws(() => template.Render(stream: null, renderParameters)); + Assert.Throws(() => template.Render(writer: null, renderParameters)); + Assert.Throws(() => template.Render(parameters: null)); + + using (Stream stream = new MemoryStream()) + { + Assert.Throws(() => template.Render(stream, parameters: null)); + } + + using (TextWriter writer = new StringWriter(CultureInfo.InvariantCulture)) + { + Assert.Throws(() => template.Render(writer, parameters: null)); + } + }); + } + + [Test] + public void TestGetTagTypeUnknownTag() + { + Assert.That(Template.GetTagType("unknown"), Is.Null); + } + public class MySimpleType { public string Name { get; set; } diff --git a/src/DotLiquid.Tests/Util/ObjectExtensionMethodsTests.cs b/src/DotLiquid.Tests/Util/ObjectExtensionMethodsTests.cs index 38c6d467e..837828292 100644 --- a/src/DotLiquid.Tests/Util/ObjectExtensionMethodsTests.cs +++ b/src/DotLiquid.Tests/Util/ObjectExtensionMethodsTests.cs @@ -8,6 +8,14 @@ namespace DotLiquid.Tests.Util [TestFixture] public class ObjectExtensionMethodsTests { + #region Classes used in tests + private class DummyClass + { + public int IntProperty { get; set; } = 42; + public int GetValue() => 35; + } + #endregion + private static readonly object NIL = null; [Test] @@ -160,5 +168,29 @@ public void TestGetPropertyValue() Assert.That(keyValuePair.GetPropertyValue("Key"), Is.EqualTo("*key*")); Assert.That(keyValuePair.GetPropertyValue("Value"), Is.EqualTo("*value*")); } + + [Test] + public void TestRepondTo() + { + DummyClass instance = new DummyClass(); + Assert.That(ObjectExtensionMethods.RespondTo(instance, "IntProperty"), Is.True); + Assert.That(ObjectExtensionMethods.RespondTo(instance, "GetValue"), Is.True); + + Assert.That(ObjectExtensionMethods.RespondTo(instance, "NotFound"), Is.False); + + Assert.Throws(() => ObjectExtensionMethods.RespondTo(NIL, "GetValue")); + } + + [Test] + public void TestSend() + { + DummyClass instance = new DummyClass(); + Assert.That(ObjectExtensionMethods.Send(instance, "IntProperty"), Is.EqualTo(42)); + Assert.That(ObjectExtensionMethods.Send(instance, "GetValue"), Is.EqualTo(35)); + + Assert.That(ObjectExtensionMethods.Send(instance, "NotFound"), Is.Null); + + Assert.Throws(() => ObjectExtensionMethods.Send(NIL, "GetValue")); + } } } \ No newline at end of file diff --git a/src/DotLiquid.Tests/VariableTests.cs b/src/DotLiquid.Tests/VariableTests.cs index ca9b6edf6..053f1d9f0 100644 --- a/src/DotLiquid.Tests/VariableTests.cs +++ b/src/DotLiquid.Tests/VariableTests.cs @@ -1,3 +1,6 @@ +using System; +using System.Globalization; +using System.IO; using NUnit.Framework; namespace DotLiquid.Tests @@ -128,6 +131,38 @@ public void TestStringDot() Assert.That(var.Name, Is.EqualTo("test.test")); } + [Test] + public void TestVariableStringConversion() + { + using (CultureHelper.SetCulture("en-US")) + { + Assert.Multiple(() => + { + Assert.That(RenderVariable(""), Is.EqualTo(string.Empty)); + Assert.That(RenderVariable(null), Is.EqualTo(string.Empty)); + Assert.That(RenderVariable("this"), Is.EqualTo("this")); + Assert.That(RenderVariable(3), Is.EqualTo("3")); + Assert.That(RenderVariable(3m), Is.EqualTo("3")); + Assert.That(RenderVariable(3.14m), Is.EqualTo("3.14")); + Assert.That(RenderVariable(new DateTime(2006, 8, 4)), Is.EqualTo("08/04/2006 00:00:00")); + Assert.That(RenderVariable(new string[] { "foo", "bar" }), Is.EqualTo("foobar")); + Assert.That(RenderVariable(new string[] { "foo", null, "bar" }), Is.EqualTo("foobar")); + }); + } + } + + private static string RenderVariable(object data) + { + Variable variable = new Variable("{{data}}"); + Context context = new Context(CultureInfo.CurrentCulture); + context["data"] = data; + using (TextWriter writer = new StringWriter(CultureInfo.InvariantCulture)) + { + variable.Render(context, writer); + return writer.ToString(); + } + } + private static void AssertFiltersAreEqual(Variable.Filter[] expected, System.Collections.Generic.List actual) { Assert.That(actual.Count, Is.EqualTo(expected.Length)); diff --git a/src/DotLiquid.Website.Tests/DotLiquid.Website.Tests.csproj b/src/DotLiquid.Website.Tests/DotLiquid.Website.Tests.csproj index 3c930b93f..06eda0c31 100644 --- a/src/DotLiquid.Website.Tests/DotLiquid.Website.Tests.csproj +++ b/src/DotLiquid.Website.Tests/DotLiquid.Website.Tests.csproj @@ -22,9 +22,9 @@ - - - + + + diff --git a/src/DotLiquid.Website/Views/Home/Index.cshtml b/src/DotLiquid.Website/Views/Home/Index.cshtml index 13d072161..05b8b2614 100644 --- a/src/DotLiquid.Website/Views/Home/Index.cshtml +++ b/src/DotLiquid.Website/Views/Home/Index.cshtml @@ -1,4 +1,4 @@ -@{ +@{ ViewBag.IncludePrism = true; }
@@ -12,7 +12,7 @@

Requirements

-

.NET Framework 4.5 or above.

+

.NET Framework 4.5 or above, .NET Standard 2.0 or above, or .NET 6.0 or above.

License

diff --git a/src/DotLiquid/Context.cs b/src/DotLiquid/Context.cs index 922343bf3..dc414ad60 100644 --- a/src/DotLiquid/Context.cs +++ b/src/DotLiquid/Context.cs @@ -28,10 +28,20 @@ public class Context private readonly ErrorsOutputMode _errorsOutputMode; + private SyntaxCompatibility _syntaxCompatibilityLevel; + ///

/// Liquid syntax flag used for backward compatibility /// - public SyntaxCompatibility SyntaxCompatibilityLevel { get; set; } + public SyntaxCompatibility SyntaxCompatibilityLevel { + get => _syntaxCompatibilityLevel; + set + { + if (_strainer != null) + throw new ContextException(Liquid.ResourceManager.GetString("ContextPropertyReadonly"), nameof(SyntaxCompatibilityLevel)); + _syntaxCompatibilityLevel = value; + } + } /// /// Ruby Date Format flag, switches Date filter syntax between Ruby and CSharp formats. @@ -71,7 +81,7 @@ public int MaxIterations /// /// Environments /// - public List Environments { get; private set; } + public List Environments { get; private set; } /// /// Scopes @@ -100,7 +110,7 @@ public int MaxIterations /// A CultureInfo instance that will be used to parse filter input and format filter output [Obsolete("The method with timeout argument is deprecated. Please use the one with CancellationToken.")] public Context - (List environments + (IEnumerable environments , Hash outerScope , Hash registers , ErrorsOutputMode errorsOutputMode @@ -124,7 +134,7 @@ public Context /// A CultureInfo instance that will be used to parse filter input and format filter output /// public Context - (List environments + (IEnumerable environments , Hash outerScope , Hash registers , ErrorsOutputMode errorsOutputMode @@ -132,7 +142,10 @@ public Context , IFormatProvider formatProvider , CancellationToken cancellationToken) { - Environments = environments; + if (environments == null) + throw new ArgumentNullException(nameof(environments)); + + Environments = environments is List list ? list : environments.ToList(); Scopes = new List(); if (outerScope != null) @@ -156,7 +169,7 @@ public Context /// /// A CultureInfo instance that will be used to parse filter input and format filter output public Context(IFormatProvider formatProvider) - : this(new List(), new Hash(), new Hash(), ErrorsOutputMode.Display, 0, 0, formatProvider) + : this(new List(), new Hash(), new Hash(), ErrorsOutputMode.Display, 0, formatProvider, CancellationToken.None) { } @@ -202,7 +215,7 @@ public void AddFilter(string filterName, Func public void AddFilters(IEnumerable filters) { foreach (Type f in filters) - Strainer.Extend(f); + Strainer.Extend(SyntaxCompatibilityLevel, f); } /// @@ -482,10 +495,10 @@ private bool TryFindVariable(string key, out object variable) { bool foundVariable = false; object foundValue = null; - Hash scope = Scopes.FirstOrDefault(s => s.ContainsKey(key)); + IIndexable scope = Scopes.FirstOrDefault(s => s.ContainsKey(key)); if (scope == null) { - foreach (Hash environment in Environments) + foreach (var environment in Environments) { foundVariable = TryEvaluateHashOrArrayLikeObject(environment, key, out foundValue); if (foundVariable) @@ -688,7 +701,7 @@ private static object Liquidize(object obj) { return liquidizableObj.ToLiquid(); } - if (obj is string || obj is IEnumerable || obj is decimal || obj is DateTime || obj is DateTimeOffset || obj is TimeSpan || obj is Guid || obj is Enum + if (obj is IEnumerable /* string is IEnumerable */ || obj is decimal || obj is DateTime || obj is DateTimeOffset || obj is TimeSpan || obj is Guid || obj is Enum #if NET6_0_OR_GREATER || obj is DateOnly || obj is TimeOnly #endif @@ -698,11 +711,7 @@ private static object Liquidize(object obj) } var valueType = obj.GetType(); -#if NETSTANDARD1_3 - if (valueType.GetTypeInfo().IsPrimitive) -#else if (valueType.IsPrimitive) -#endif { return obj; } @@ -718,10 +727,9 @@ private static object Liquidize(object obj) return safeTypeTransformer(obj); } - var attr = (LiquidTypeAttribute)valueType.GetTypeInfo().GetCustomAttributes(typeof(LiquidTypeAttribute), false).FirstOrDefault(); - if (attr != null) + if (DropProxy.TryFromLiquidType(obj, valueType, out var drop)) { - return new DropProxy(obj, attr.AllowedMembers); + return drop; } if (IsKeyValuePair(obj)) @@ -737,11 +745,7 @@ private static bool IsKeyValuePair(object obj) if (obj != null) { Type valueType = obj.GetType(); -#if NETSTANDARD1_3 - if (valueType.GetTypeInfo().IsGenericType) -#else if (valueType.IsGenericType) -#endif { Type baseType = valueType.GetGenericTypeDefinition(); if (baseType == typeof(KeyValuePair<,>)) @@ -759,7 +763,7 @@ private void SquashInstanceAssignsWithEnvironments() Hash lastScope = Scopes.Last(); foreach (string k in lastScope.Keys) - foreach (Hash env in Environments) + foreach (IIndexable env in Environments) if (env.ContainsKey(k)) { tempAssigns[k] = env[k]; diff --git a/src/DotLiquid/DotLiquid.csproj b/src/DotLiquid/DotLiquid.csproj index 37657abe8..79f9ebce3 100644 --- a/src/DotLiquid/DotLiquid.csproj +++ b/src/DotLiquid/DotLiquid.csproj @@ -5,14 +5,17 @@ DotLiquid en-US Tim Jones;Alessandro Petrelli - netstandard1.3;netstandard2.0;net45;net6.0 + netstandard2.0;net45;net6.0 DotLiquid ../Formosatek-OpenSource.snk true true DotLiquid + 1.0.0 + 1.0.0 template;templating;language;liquid;markup images\logo_nuget.png + docs\readme_nuget.md https://raw.githubusercontent.com/dotliquid/dotliquid/master/src/DotLiquid/logo_nuget.png https://dotliquid.org Apache-2.0 OR MS-PL @@ -24,26 +27,20 @@ false false false - false - false - false portable True bin\Debug\$(TargetFramework)\DotLiquid.xml - + - + + - - $(DefineConstants);CORE - - diff --git a/src/DotLiquid/DotLiquid.nuspec b/src/DotLiquid/DotLiquid.nuspec deleted file mode 100644 index bfd57e3ce..000000000 --- a/src/DotLiquid/DotLiquid.nuspec +++ /dev/null @@ -1,46 +0,0 @@ - - - - DotLiquid - $version$ - DotLiquid - Tim Jones, Alessandro Petrelli - Tim Jones - DotLiquid is a templating system ported to the .NET framework from Ruby’s Liquid Markup. - DotLiquid is a templating system ported to the .NET framework from Ruby’s Liquid Markup. - en-US - https://www.dotliquid.org - https://raw.githubusercontent.com/dotliquid/dotliquid/master/src/DotLiquid/logo_nuget.png - images\logo_nuget.png - Apache-2.0 OR MS-PL - - false - template templating language liquid markup - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/DotLiquid/Drop.cs b/src/DotLiquid/Drop.cs index 72867b07f..f86d9d415 100644 --- a/src/DotLiquid/Drop.cs +++ b/src/DotLiquid/Drop.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Reflection; @@ -87,7 +87,7 @@ private static IEnumerable GetMembersWithoutDuplicateNames(ICollecti var declaringTypes = duplicates.Select(d => d.DeclaringType) .ToList(); - var mostDerived = declaringTypes.Single(t => !declaringTypes.Any(o => t.GetTypeInfo().IsAssignableFrom(o.GetTypeInfo()) && (o != t))); + var mostDerived = declaringTypes.Single(t => !declaringTypes.Any(o => t.IsAssignableFrom(o) && (o != t))); foreach (var duplicate in duplicates) { @@ -159,7 +159,7 @@ public virtual object this[object method] } #region IIndexable - + /// public virtual bool ContainsKey(object name) { return true; } #endregion @@ -211,13 +211,44 @@ public object InvokeDrop(object name) return pi.GetValue(GetObject(), null); return BeforeMethod(method); } + + #region Static Methods + /// + /// Initializes a new instance of the DropBase class for types not derived from DropBase + /// + /// The object to create a proxy for + public static DropBase FromSafeType(object obj) + => TryFromSafeType(obj, out var drop) ? drop : throw new DotLiquid.Exceptions.ArgumentException(Liquid.ResourceManager.GetString("MissingLiquidTypeAttributeError"), obj.ToString()); + + internal static bool TryFromSafeType(object obj, out DropBase drop) + { + var objType = obj.GetType(); + var safeTypeTransformer = Template.GetSafeTypeTransformer(objType); + if (safeTypeTransformer != null) + { + if (safeTypeTransformer(obj) is DropBase transformed) + { + drop = transformed; + return true; + } + } + else if (DropProxy.TryFromLiquidType(obj, objType, out var dropProxy)) + { + drop = dropProxy; + return true; + } + + drop = null; + return false; + } + #endregion } public abstract class Drop : DropBase { internal override object GetObject() { return this; } - internal override TypeResolution CreateTypeResolution(Type type) { return new TypeResolution(type, mi => mi.DeclaringType.GetTypeInfo().BaseType != null && typeof(Drop).GetTypeInfo().IsAssignableFrom(mi.DeclaringType.GetTypeInfo().BaseType.GetTypeInfo())); } + internal override TypeResolution CreateTypeResolution(Type type) { return new TypeResolution(type, mi => mi.DeclaringType.BaseType != null && typeof(Drop).IsAssignableFrom(mi.DeclaringType.BaseType)); } } /// @@ -255,6 +286,19 @@ public DropProxy(object obj, string[] allowedMembers, Func value _value = value; } + internal static bool TryFromLiquidType(object obj, Type objType, out DropProxy drop) + { + var liquidTypeAttribute = TypeUtility.GetLiquidTypeAttribute(objType); + if (liquidTypeAttribute != null) + { + drop = new DropProxy(obj, liquidTypeAttribute.AllowedMembers); + return true; + } + + drop = null; + return false; + } + #region IValueTypeConvertible public virtual object ConvertToValueType() diff --git a/src/DotLiquid/Exceptions/ArgumentException.cs b/src/DotLiquid/Exceptions/ArgumentException.cs index 95fdbce30..26d53d5fc 100644 --- a/src/DotLiquid/Exceptions/ArgumentException.cs +++ b/src/DotLiquid/Exceptions/ArgumentException.cs @@ -2,9 +2,7 @@ namespace DotLiquid.Exceptions { -#if !CORE [Serializable] -#endif public class ArgumentException : LiquidException { public ArgumentException(string message, params string[] args) diff --git a/src/DotLiquid/Exceptions/ContextException.cs b/src/DotLiquid/Exceptions/ContextException.cs index 6c6d9702d..9734fb7be 100644 --- a/src/DotLiquid/Exceptions/ContextException.cs +++ b/src/DotLiquid/Exceptions/ContextException.cs @@ -2,9 +2,7 @@ namespace DotLiquid.Exceptions { -#if !CORE [Serializable] -#endif public class ContextException : LiquidException { public ContextException(string message, params string[] args) diff --git a/src/DotLiquid/Exceptions/FileSystemException.cs b/src/DotLiquid/Exceptions/FileSystemException.cs index bb190e4ef..d71f4023d 100644 --- a/src/DotLiquid/Exceptions/FileSystemException.cs +++ b/src/DotLiquid/Exceptions/FileSystemException.cs @@ -2,9 +2,7 @@ namespace DotLiquid.Exceptions { -#if !CORE [Serializable] -#endif public class FileSystemException : LiquidException { public FileSystemException(string message, params string[] args) diff --git a/src/DotLiquid/Exceptions/FilterNotFoundException.cs b/src/DotLiquid/Exceptions/FilterNotFoundException.cs index 3bc7f98d7..5de34f14d 100644 --- a/src/DotLiquid/Exceptions/FilterNotFoundException.cs +++ b/src/DotLiquid/Exceptions/FilterNotFoundException.cs @@ -2,9 +2,7 @@ namespace DotLiquid.Exceptions { -#if !CORE [Serializable] -#endif public class FilterNotFoundException : LiquidException { public FilterNotFoundException(string message, FilterNotFoundException innerException) diff --git a/src/DotLiquid/Exceptions/LiquidException.cs b/src/DotLiquid/Exceptions/LiquidException.cs index c4edf6776..175e6c040 100644 --- a/src/DotLiquid/Exceptions/LiquidException.cs +++ b/src/DotLiquid/Exceptions/LiquidException.cs @@ -2,15 +2,8 @@ namespace DotLiquid.Exceptions { -#if !CORE [Serializable] -#endif - public abstract class LiquidException : -#if CORE - Exception -#else - ApplicationException -#endif + public abstract class LiquidException : ApplicationException { protected LiquidException(string message, Exception innerException) : base(message, innerException) diff --git a/src/DotLiquid/Exceptions/RenderException.cs b/src/DotLiquid/Exceptions/RenderException.cs index 490893956..a4826b9ca 100644 --- a/src/DotLiquid/Exceptions/RenderException.cs +++ b/src/DotLiquid/Exceptions/RenderException.cs @@ -2,15 +2,8 @@ namespace DotLiquid.Exceptions { -#if !CORE [Serializable] -#endif - public abstract class RenderException : -#if CORE - Exception -#else - ApplicationException -#endif + public abstract class RenderException : LiquidException { protected RenderException(string message, Exception innerException) : base(message, innerException) diff --git a/src/DotLiquid/Exceptions/StackLevelException.cs b/src/DotLiquid/Exceptions/StackLevelException.cs index 418f37cb7..8c7040c3e 100644 --- a/src/DotLiquid/Exceptions/StackLevelException.cs +++ b/src/DotLiquid/Exceptions/StackLevelException.cs @@ -2,9 +2,7 @@ namespace DotLiquid.Exceptions { -#if !CORE [Serializable] -#endif public class StackLevelException : LiquidException { public StackLevelException(string message) diff --git a/src/DotLiquid/Exceptions/SyntaxException.cs b/src/DotLiquid/Exceptions/SyntaxException.cs index 7c16ed93e..93c75c841 100644 --- a/src/DotLiquid/Exceptions/SyntaxException.cs +++ b/src/DotLiquid/Exceptions/SyntaxException.cs @@ -5,9 +5,7 @@ namespace DotLiquid.Exceptions /// /// An exception that is thrown when an invalid or unknown syntax is encountered in a template. /// -#if !CORE [Serializable] -#endif public class SyntaxException : LiquidException { /// diff --git a/src/DotLiquid/Exceptions/VariableNotFoundException.cs b/src/DotLiquid/Exceptions/VariableNotFoundException.cs index f2182f758..1d1379526 100644 --- a/src/DotLiquid/Exceptions/VariableNotFoundException.cs +++ b/src/DotLiquid/Exceptions/VariableNotFoundException.cs @@ -2,9 +2,7 @@ namespace DotLiquid.Exceptions { -#if !CORE [Serializable] -#endif public class VariableNotFoundException : LiquidException { public VariableNotFoundException(string message, params string[] args) diff --git a/src/DotLiquid/ExtendedFilters.cs b/src/DotLiquid/ExtendedFilters.cs index 3d4098fbd..34a480492 100644 --- a/src/DotLiquid/ExtendedFilters.cs +++ b/src/DotLiquid/ExtendedFilters.cs @@ -1,3 +1,5 @@ +using System; +using System.Linq; using System.Text.RegularExpressions; namespace DotLiquid @@ -16,21 +18,15 @@ public static class ExtendedFilters public static string Titleize(Context context, string input) { return input.IsNullOrWhiteSpace() - ? input -#if CORE - : Regex.Replace(input, @"\b(\w)", m => m.Value.ToUpper(), RegexOptions.None, Template.RegexTimeOut); -#else - : context.CurrentCulture.TextInfo.ToTitleCase(input); -#endif + ? input : context.CurrentCulture.TextInfo.ToTitleCase(input); } /// /// Converts just the first character to uppercase /// - /// /// /// - public static string UpcaseFirst(Context context, string input) + public static string UpcaseFirst(string input) { if (input.IsNullOrWhiteSpace()) return input; @@ -49,5 +45,42 @@ public static string RegexReplace(string input, string pattern, string replaceme { return Regex.Replace(input: input, pattern: pattern, replacement: replacement, options: RegexOptions.None, matchTimeout: Template.RegexTimeOut); } + + /// + /// Split input string into an array of substrings separated by given pattern. + /// + /// + /// If is null or empty, an empty array is returned. + /// If is null or empty, the input string is converted to an array of single-character strings. + /// If is a space, the input string is split using any whitespace character, and all empty entries are removed. + /// If is any other value, the input string is split using the pattern, and empty entries at the end are removed. + /// + /// Input to be transformed by this filter + /// separator string + public static string[] RubySplit(string input, string pattern) + { + if (string.IsNullOrEmpty(input)) + return new string[] { }; + + // If the pattern is empty convert to an array as specified in the Liquid Reverse filter example. + // See: https://shopify.github.io/liquid/filters/reverse/ + if (string.IsNullOrEmpty(pattern)) + return input.ToCharArray().Select(character => character.ToString()).ToArray(); + + // Ruby docs: If pattern is a single space, str is split on whitespace, with leading and trailing whitespace and runs of contiguous whitespace characters ignored. + if (pattern == " ") + return input.Split(Liquid.AsciiWhitespaceChars, StringSplitOptions.RemoveEmptyEntries); + + // Ruby docs: When field_sep is a string different from ' ' and limit is 0, the split occurs at each occurrence of field_sep; trailing empty substrings are not returned. + var parts = input.Split(new[] { pattern }, StringSplitOptions.None); + int indexTillTrailingEmpty = parts.Length; + + while (indexTillTrailingEmpty > 0 && string.IsNullOrEmpty(parts[indexTillTrailingEmpty - 1])) + { + indexTillTrailingEmpty--; + } + + return indexTillTrailingEmpty < parts.Length ? parts.Take(indexTillTrailingEmpty).ToArray() : parts; + } } } diff --git a/src/DotLiquid/Hash.cs b/src/DotLiquid/Hash.cs index 2f8b14e13..2390b85a0 100644 --- a/src/DotLiquid/Hash.cs +++ b/src/DotLiquid/Hash.cs @@ -10,7 +10,7 @@ namespace DotLiquid /// /// Represents a collection of keys and values and is a DotLiquid safe type /// - public class Hash : IDictionary, IDictionary + public class Hash : IDictionary, IDictionary, IIndexable { #region Static fields @@ -63,16 +63,14 @@ private static void AddBaseClassProperties(Type type, List propert } propertyList - .AddRange(type.GetTypeInfo().DeclaredProperties + .AddRange(type.GetProperties(BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public) .Where( p => p.CanRead && - p.GetMethod.IsPublic && - !p.GetMethod.IsStatic && propertyList.All(p1 => p1.Name != p.Name)) .ToList()); - AddBaseClassProperties(type.GetTypeInfo().BaseType, propertyList); + AddBaseClassProperties(type.BaseType, propertyList); } private static Action GenerateMapper(Type type, bool includeBaseClassProperties) @@ -88,8 +86,8 @@ private static Action GenerateMapper(Type type, bool includeBaseCl ); //Add properties - var propertyList = type.GetTypeInfo().DeclaredProperties - .Where(p => p.CanRead && p.GetMethod.IsPublic && !p.GetMethod.IsStatic).ToList(); + var propertyList = type.GetProperties(BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public) + .Where(p => p.CanRead).ToList(); //Add properties from base class if (includeBaseClassProperties) AddBaseClassProperties(type, propertyList); @@ -100,7 +98,7 @@ private static Action GenerateMapper(Type type, bool includeBaseCl Expression.Assign( Expression.MakeIndex( hashParam, - typeof(Hash).GetTypeInfo().GetDeclaredProperty("Item"), + typeof(Hash).GetProperty("Item"), new[] { Expression.Constant(property.Name, typeof(string)) } ), Expression.Convert( @@ -186,10 +184,7 @@ protected virtual object GetValue(string key) if (_lambda != null) return _lambda(this, key); - if (_defaultValue != null) - return _defaultValue; - - return null; + return _defaultValue; } /// @@ -380,7 +375,14 @@ public ICollection Values { get { return _nestedDictionary.Values; } } + #endregion + #region IIndexable + /// + object IIndexable.this[object key] => ((IDictionary)this)[key]; + + /// + public bool ContainsKey(object key) => Contains(key); #endregion } } diff --git a/src/DotLiquid/IIndexable.cs b/src/DotLiquid/IIndexable.cs index 77dcf6f7b..999f718e4 100644 --- a/src/DotLiquid/IIndexable.cs +++ b/src/DotLiquid/IIndexable.cs @@ -1,8 +1,22 @@ namespace DotLiquid { + /// + /// Defines an interface for indexable objects, allowing access to values by key. + /// public interface IIndexable { + /// + /// Gets the value associated with the specified key. + /// + /// The key of the value to retrieve. + /// The value associated with the specified key, or null if the key is not found. object this[object key] { get; } + + /// + /// Determines whether the indexable object contains a value with the specified key. + /// + /// The key to search for. + /// true if the indexable object contains a value with the specified key; otherwise, false. bool ContainsKey(object key); } } diff --git a/src/DotLiquid/LegacyFilters.cs b/src/DotLiquid/LegacyFilters.cs new file mode 100644 index 000000000..c7f7224ad --- /dev/null +++ b/src/DotLiquid/LegacyFilters.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; + +namespace DotLiquid +{ + /// + /// A collection of filters implementing the behavior of a previous version of DotLiquid + /// + public static class LegacyFilters + { + /// + /// capitalize words in the input sentence + /// + /// The DotLiquid context + /// Input to be transformed by this filter + [LiquidFilter(MaxVersion = SyntaxCompatibility.DotLiquid20)] + public static string Capitalize(Context context, string input) => ExtendedFilters.Titleize(context, input); + + /// + /// capitalize words in the input sentence + /// + /// Input to be transformed by this filter + [LiquidFilter(Name = nameof(Capitalize), MinVersion = SyntaxCompatibility.DotLiquid21, MaxVersion = SyntaxCompatibility.DotLiquid21)] + public static string CapitalizeV21(string input) => ExtendedFilters.UpcaseFirst(input); + + /// + /// Sort elements of the array + /// + /// The object to sort + /// Optional property with which to sort an array of hashes or drops + [LiquidFilter(MaxVersion = SyntaxCompatibility.DotLiquid21)] + public static IEnumerable Sort(object input, string property = null) => StandardFilters.SortInternal(StringComparer.OrdinalIgnoreCase, input, property); + + /// + /// Remove the first occurrence of a substring + /// + /// Input to be transformed by this filter + /// String to be removed from input + [LiquidFilter(MaxVersion = SyntaxCompatibility.DotLiquid20)] + public static string RemoveFirst(string input, string @string) + { + return input.IsNullOrWhiteSpace() + ? input + : ReplaceFirst(input: input, @string: @string, replacement: string.Empty); + } + + /// + /// Remove the first occurrence of a substring + /// + /// Input to be transformed by this filter + /// String to be removed from input + [LiquidFilter(Name = nameof(RemoveFirst), MinVersion = SyntaxCompatibility.DotLiquid21, MaxVersion = SyntaxCompatibility.DotLiquid22a)] + public static string RemoveFirstV21(string input, string @string) + { + return input.IsNullOrWhiteSpace() + ? input + : ReplaceFirstV21(input: input, @string: @string, replacement: string.Empty); + } + + /// + /// Replaces every occurrence of the first argument in a string with the second argument + /// + /// Input to be transformed by this filter + /// Substring to be replaced + /// Replacement string to be inserted + [LiquidFilter(MaxVersion = SyntaxCompatibility.DotLiquid20)] + public static string Replace(string input, string @string, string replacement = "") + { + if (string.IsNullOrEmpty(input) || string.IsNullOrEmpty(@string)) + return input; + + return ExtendedFilters.RegexReplace(input: input, pattern: @string, replacement: replacement); + } + + /// + /// Replace the first occurrence of a string with another + /// + /// Input to be transformed by this filter + /// Substring to be replaced + /// Replacement string to be inserted + [LiquidFilter(MaxVersion = SyntaxCompatibility.DotLiquid20)] + public static string ReplaceFirst(string input, string @string, string replacement = "") + { + if (string.IsNullOrEmpty(input) || string.IsNullOrEmpty(@string)) + return input; + + bool doneReplacement = false; + return Regex.Replace(input, @string, m => + { + if (doneReplacement) + return m.Value; + + doneReplacement = true; + return replacement; + }, RegexOptions.None, Template.RegexTimeOut); + } + + /// + /// Replace the first occurrence of a string with another + /// + /// Input to be transformed by this filter + /// Substring to be replaced + /// Replacement string to be inserted + [LiquidFilter(Name = nameof(ReplaceFirst), MinVersion = SyntaxCompatibility.DotLiquid21, MaxVersion = SyntaxCompatibility.DotLiquid22a)] + public static string ReplaceFirstV21(string input, string @string, string replacement = "") + { + if (string.IsNullOrEmpty(input) || string.IsNullOrEmpty(@string)) + return input; + int position = input.IndexOf(@string); + return position < 0 ? input : input.Remove(position, @string.Length).Insert(position, replacement); + } + + /// + /// Addition + /// + /// The DotLiquid context + /// Input to be transformed by this filter + /// Number to be added to input + [LiquidFilter(MaxVersion = SyntaxCompatibility.DotLiquid20)] + public static object Plus(Context context, object input, object operand) + { + return input is string + ? string.Concat(input, operand) + : StandardFilters.DoMathsOperation(context, input, operand, Expression.AddChecked); + } + + /// + /// Return a Part of a String + /// + /// Input to be transformed by this filter + /// start position of string + /// optional length of slice to be returned + [LiquidFilter(MaxVersion = SyntaxCompatibility.DotLiquid22)] + public static object Slice(object input, int start, int len = 1) + { + if (input == null) + return null; + + if (input is string inputString) + { + if (start > inputString.Length) + return null; + + if (start < 0) + { + start += inputString.Length; + if (start < 0) + { + len = Math.Max(0, len + start); + start = 0; + } + } + if (start + len > inputString.Length) + { + len = inputString.Length - start; + } + return inputString.Substring(Convert.ToInt32(start), Convert.ToInt32(len)); + } + else if (input is IEnumerable enumerableInput) + { + return StandardFilters.Slice(input, start, len); + } + + return input; + } + + /// + /// Split input string into an array of substrings separated by given pattern, eliminating empty entries at the end. + /// + /// + /// If is null or empty, an array containing the original input is returned. + /// If is null or empty, the input string is converted to an array of single-character strings. + /// + /// Input to be transformed by this filter + /// separator string + [LiquidFilter(MaxVersion = SyntaxCompatibility.DotLiquid22a)] + public static string[] Split(string input, string pattern) + { + if (input.IsNullOrWhiteSpace()) + return new[] { input }; + + return StandardFilters.Split(input, pattern); + } + + /// + /// Multiplication + /// + /// The DotLiquid context + /// Input to be transformed by this filter + /// Number to multiple input by + [LiquidFilter(MaxVersion = SyntaxCompatibility.DotLiquid20)] + public static object Times(Context context, object input, object operand) + { + return input is string @string && (operand is int || operand is long) + ? Enumerable.Repeat(@string, Convert.ToInt32(operand)) + : StandardFilters.DoMathsOperation(context, input, operand, Expression.MultiplyChecked); + } + + /// + /// Truncate a string down to x words + /// + /// Input to be transformed by this filter + /// optional maximum number of words in returned string, defaults to 15 + /// Optional suffix to append when string is truncated, defaults to ellipsis(...) + [LiquidFilter(MaxVersion = SyntaxCompatibility.DotLiquid22a)] + public static string TruncateWords(string input, int words = 15, string truncateString = "...") + { + if (string.IsNullOrEmpty(input)) + { + return input; + } + + if (words <= 0) + { + return truncateString; + } + + var wordArray = input.Split(' '); + return wordArray.Length > words + ? string.Join(separator: " ", values: wordArray.Take(words)) + truncateString + : input; + } + } +} diff --git a/src/DotLiquid/Liquid.cs b/src/DotLiquid/Liquid.cs index fa800bae4..e85fd5294 100644 --- a/src/DotLiquid/Liquid.cs +++ b/src/DotLiquid/Liquid.cs @@ -34,6 +34,7 @@ public static class Liquid internal static readonly string DirectorySeparators = @"[\\/]"; internal static readonly string LimitRelativePath = @"^(?![\\\/\.])(?:[^<>:;,?""*|\x00-\x1F\/\\]+|[\/\\](?!\.))+(? LazyDirectorySeparatorsRegex = new Lazy(() => R.C(DirectorySeparators), LazyThreadSafetyMode.ExecutionAndPublication); private static readonly Lazy LazyLimitRelativePathRegex = new Lazy(() => R.C(LimitRelativePath), LazyThreadSafetyMode.ExecutionAndPublication); private static readonly Lazy LazyVariableSegmentRegex = new Lazy(() => R.B(R.Q(@"\A\s*(?{0}+)\s*\Z"), Liquid.VariableSegment), LazyThreadSafetyMode.ExecutionAndPublication); @@ -69,8 +70,6 @@ static Liquid() Template.RegisterTag("tablerow"); - Template.RegisterFilter(typeof(StandardFilters)); - // Safe list optional filters so that they can be enabled by Designers. Template.SafelistFilter(typeof(ExtendedFilters)); Template.SafelistFilter(typeof(ShopifyFilters)); diff --git a/src/DotLiquid/LiquidFilterAttribute.cs b/src/DotLiquid/LiquidFilterAttribute.cs new file mode 100644 index 000000000..af3ee62bc --- /dev/null +++ b/src/DotLiquid/LiquidFilterAttribute.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace DotLiquid +{ + /// + /// Represents an attribute that can be used to specify the name and syntax compatibility of a Liquid filter. + /// + [AttributeUsage(AttributeTargets.Method)] + internal class LiquidFilterAttribute : Attribute + { + /// + /// Gets or sets the name of the Liquid filter. Defaults to the method name. + /// + public string Name { get; set; } + + /// + /// Gets or sets the alternative name of the Liquid filter. + /// + public string Alias { get; set; } + + /// + /// Gets or sets the minimum syntax compatibility version for the Liquid filter. + /// + public SyntaxCompatibility MinVersion { get; set; } = SyntaxCompatibility.DotLiquid20; + + /// + /// Gets or sets the maximum syntax compatibility version for the Liquid filter. + /// + public SyntaxCompatibility MaxVersion { get; set; } = SyntaxCompatibility.DotLiquidLatest; + } +} diff --git a/src/DotLiquid/Properties/AssemblyInfo.cs b/src/DotLiquid/Properties/AssemblyInfo.cs index 801609d2d..fbe84784d 100644 --- a/src/DotLiquid/Properties/AssemblyInfo.cs +++ b/src/DotLiquid/Properties/AssemblyInfo.cs @@ -25,8 +25,4 @@ [assembly: Guid("75d1dc49-0097-4f60-b5fd-0e43d213b38b")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] -[assembly: AssemblyInformationalVersion("1.0.0.0")] - [assembly: InternalsVisibleTo("DotLiquid.Tests, PublicKey=002400000480000094000000060200000024000052534131000400000100010093ae26e2c87851b659e9847a0a9c6088a4ad1988df9b176d56c3996e33458273df5c2138b5bf13b2352a99152f10ef1bc2564069179d5344ba723a875ea048b80fcb34c1c5ff7e3d131cb208140265e5144183570d1e0433c1a37959720e0d8d83a7ee870d5e0dd904afc62663103eb2e2105e1eddeadfe876c9ccc90a31cfbf")] \ No newline at end of file diff --git a/src/DotLiquid/Properties/Resources.it.resx b/src/DotLiquid/Properties/Resources.it.resx index 063d0c1be..6b7eeaa7a 100755 --- a/src/DotLiquid/Properties/Resources.it.resx +++ b/src/DotLiquid/Properties/Resources.it.resx @@ -1,4 +1,4 @@ - + |", RegexOptions.Singleline | RegexOptions.IgnoreCase), LazyThreadSafetyMode.ExecutionAndPublication); private static readonly Lazy StripHtmlTags = new Lazy(() => R.C(@"<.*?>", RegexOptions.Singleline), LazyThreadSafetyMode.ExecutionAndPublication); - -#if NETSTANDARD1_3 - private class StringAwareObjectComparer : IComparer - { - private readonly StringComparer _stringComparer; - - public StringAwareObjectComparer(StringComparer stringComparer) - { - _stringComparer = stringComparer; - } - - public int Compare(Object x, Object y) - { - if (x == y) - return 0; - if (x == null) - return -1; - if (y == null) - return 1; - - if (x is string textX && y is string textY) - return _stringComparer.Compare(textX, textY); - - return Comparer.Default.Compare(x, y); - } - } -#endif - + private static string Space = " "; /// /// Return the size of an array or of an string /// @@ -70,17 +43,13 @@ public static int Size(object input) /// /// Returns a substring of one character or series of array items beginning at the index specified by the first argument. /// - /// The DotLiquid context /// The input to be sliced /// zero-based start position of string or array, negative values count back from the end of the string/array. /// An optional argument specifies the length of the substring or number of array items to be returned - public static object Slice(Context context, object input, int offset, int length = 1) + [LiquidFilter(MinVersion = SyntaxCompatibility.DotLiquid22a)] + public static object Slice(object input, int offset, int length = 1) { - if (context.SyntaxCompatibilityLevel < SyntaxCompatibility.DotLiquid22a && input is string inputString) - { - return SliceString(input: inputString, start: offset, len: length); - } - else if (input is IEnumerable enumerableInput) + if (input is IEnumerable enumerableInput) { var inputSize = Size(input); var skip = offset; @@ -104,34 +73,7 @@ public static object Slice(Context context, object input, int offset, int length return enumerableInput.Cast().Skip(skip).Take(take); } - return (context.SyntaxCompatibilityLevel >= SyntaxCompatibility.DotLiquid22a && input == null) ? string.Empty : input; - } - - /// - /// Return a Part of a String - /// - /// Input to be transformed by this filter - /// start position of string - /// optional length of slice to be returned - private static string SliceString(string input, long start, long len) - { - if (input == null || start > input.Length) - return null; - - if (start < 0) - { - start += input.Length; - if (start < 0) - { - len = Math.Max(0, len + start); - start = 0; - } - } - if (start + len > input.Length) - { - len = input.Length - start; - } - return input.Substring(Convert.ToInt32(start), Convert.ToInt32(len)); + return input == null ? string.Empty : input; } /// @@ -179,17 +121,10 @@ public static string UrlDecode(string input) /// /// capitalize words in the input sentence /// - /// The DotLiquid context /// Input to be transformed by this filter - public static string Capitalize(Context context, string input) + [LiquidFilter(MinVersion = SyntaxCompatibility.DotLiquid22)] + public static string Capitalize(string input) { - if (context.SyntaxCompatibilityLevel < SyntaxCompatibility.DotLiquid22) - { - if (context.SyntaxCompatibilityLevel == SyntaxCompatibility.DotLiquid21) - return ExtendedFilters.UpcaseFirst(context, input); - return ExtendedFilters.Titleize(context, input); - } - if (input.IsNullOrWhiteSpace()) return input; @@ -203,6 +138,7 @@ public static string Capitalize(Context context, string input) /// String to escape /// Escaped string /// Alias of H + [LiquidFilter(Name = nameof(Escape), Alias = "H")] public static string Escape(string input) { if (string.IsNullOrEmpty(input)) @@ -230,17 +166,6 @@ public static string EscapeOnce(string input) return string.IsNullOrEmpty(input) ? input : WebUtility.HtmlEncode(WebUtility.HtmlDecode(input)); } - /// - /// Escape html chars - /// - /// String to escape - /// Escaped string - /// Alias of Escape - public static string H(string input) - { - return Escape(input); - } - /// /// Truncates a string down to x characters /// @@ -259,7 +184,7 @@ public static string Truncate(string input, int length = 50, string truncateStri return truncateString; } - var lengthExcludingTruncateString = length - truncateString.Length; + var lengthExcludingTruncateString = truncateString == null ? length : length - truncateString.Length; return input.Length > length ? input.Substring(startIndex: 0, length: lengthExcludingTruncateString < 0 ? 0 : lengthExcludingTruncateString) + truncateString : input; @@ -271,6 +196,7 @@ public static string Truncate(string input, int length = 50, string truncateStri /// Input to be transformed by this filter /// optional maximum number of words in returned string, defaults to 15 /// Optional suffix to append when string is truncated, defaults to ellipsis(...) + [LiquidFilter(MinVersion = SyntaxCompatibility.DotLiquid24, Alias = "Truncatewords")] public static string TruncateWords(string input, int words = 15, string truncateString = "...") { if (string.IsNullOrEmpty(input)) @@ -279,29 +205,29 @@ public static string TruncateWords(string input, int words = 15, string truncate } if (words <= 0) - { - return truncateString; - } + words = 1; - var wordArray = input.Split(' '); + // Split to an array using any ascii whitespace as noted in the StandardFilters.Split method. + var wordArray = input.Split(Liquid.AsciiWhitespaceChars, words + 1, StringSplitOptions.RemoveEmptyEntries); return wordArray.Length > words - ? string.Join(separator: " ", values: wordArray.Take(words)) + truncateString + ? string.Join(separator: Space, values: wordArray.Take(words)) + truncateString : input; } /// - /// Split input string into an array of substrings separated by given pattern. + /// Split input string into an array of substrings separated by given pattern, eliminating empty entries at the end. /// /// - /// If the pattern is empty the input string is converted to an array of 1-char - /// strings (as specified in the Liquid Reverse filter example). + /// If is null or empty, an empty array is returned. + /// If is null or empty, the input string is converted to an array of single-character strings. /// /// Input to be transformed by this filter /// separator string + [LiquidFilter(MinVersion = SyntaxCompatibility.DotLiquid24)] public static string[] Split(string input, string pattern) { - if (input.IsNullOrWhiteSpace()) - return new[] { input }; + if (string.IsNullOrEmpty(input)) + return new string[] { }; // If the pattern is empty convert to an array as specified in the Liquid Reverse filter example. // See: https://shopify.github.io/liquid/filters/reverse/ @@ -404,19 +330,10 @@ public static string Join(IEnumerable input, string glue = " ") /// /// Sort elements of the array /// - /// The DotLiquid context /// The object to sort /// Optional property with which to sort an array of hashes or drops - public static IEnumerable Sort(Context context, object input, string property = null) - { - if (input == null) - return null; - - if (context.SyntaxCompatibilityLevel >= SyntaxCompatibility.DotLiquid22) - return SortInternal(StringComparer.Ordinal, input, property); - else - return SortInternal(StringComparer.OrdinalIgnoreCase, input, property); - } + [LiquidFilter(MinVersion = SyntaxCompatibility.DotLiquid22)] + public static IEnumerable Sort(object input, string property = null) => SortInternal(StringComparer.Ordinal, input, property); /// /// Sort elements of the array in case-insensitive order @@ -425,14 +342,14 @@ public static IEnumerable Sort(Context context, object input, string property = /// Optional property with which to sort an array of hashes or drops public static IEnumerable SortNatural(object input, string property = null) { - if (input == null) - return null; - return SortInternal(StringComparer.OrdinalIgnoreCase, input, property); } - private static IEnumerable SortInternal(StringComparer stringComparer, object input, string property = null) + internal static IEnumerable SortInternal(StringComparer comparer, object input, string property = null) { + if (input == null) + return null; + List ary; if (input is IEnumerable enumerableHash && !string.IsNullOrEmpty(property)) ary = enumerableHash.Cast().ToList(); @@ -446,12 +363,6 @@ private static IEnumerable SortInternal(StringComparer stringComparer, object in if (!ary.Any()) return ary; -#if NETSTANDARD1_3 - var comparer = new StringAwareObjectComparer(stringComparer); -#else - var comparer = stringComparer; -#endif - if (string.IsNullOrEmpty(property)) { ary.Sort((a, b) => comparer.Compare(a, b)); @@ -498,48 +409,53 @@ public static IEnumerable Map(IEnumerable enumerableInput, string property) /// /// Replaces every occurrence of the first argument in a string with the second argument /// - /// The DotLiquid context /// Input to be transformed by this filter /// Substring to be replaced /// Replacement string to be inserted - public static string Replace(Context context, string input, string @string, string replacement = "") + [LiquidFilter(Name = nameof(Replace), MinVersion = SyntaxCompatibility.DotLiquid21)] + public static string Replace(string input, string @string, string replacement = "") { if (string.IsNullOrEmpty(input) || string.IsNullOrEmpty(@string)) return input; - if (context.SyntaxCompatibilityLevel >= SyntaxCompatibility.DotLiquid21) - return input.Replace(@string, replacement); - - return ExtendedFilters.RegexReplace(input: input, pattern: @string, replacement: replacement); + return input.Replace(@string, replacement); } /// /// Replace the first occurrence of a string with another /// - /// The DotLiquid context /// Input to be transformed by this filter /// Substring to be replaced /// Replacement string to be inserted - public static string ReplaceFirst(Context context, string input, string @string, string replacement = "") + [LiquidFilter(MinVersion = SyntaxCompatibility.DotLiquid24)] + public static string ReplaceFirst(string input, string @string, string replacement = "") { - if (string.IsNullOrEmpty(input) || string.IsNullOrEmpty(@string)) + if (string.IsNullOrEmpty(input)) return input; - if (context.SyntaxCompatibilityLevel >= SyntaxCompatibility.DotLiquid21) - { - int position = input.IndexOf(@string); - return position < 0 ? input : input.Remove(position, @string.Length).Insert(position, replacement); - } + if (string.IsNullOrEmpty(@string)) + return input.Insert(0, replacement ?? string.Empty); - bool doneReplacement = false; - return Regex.Replace(input, @string, m => - { - if (doneReplacement) - return m.Value; + int position = input.IndexOf(@string); + return position < 0 ? input : input.Remove(position, @string.Length).Insert(position, replacement ?? string.Empty); + } - doneReplacement = true; - return replacement; - }, RegexOptions.None, Template.RegexTimeOut); + /// + /// Replace the last occurrence of a string with another + /// + /// Input to be transformed by this filter + /// Substring to be replaced + /// Replacement string to be inserted + public static string ReplaceLast(string input, string @string, string replacement) + { + if (string.IsNullOrEmpty(input)) + return input; + + if (string.IsNullOrEmpty(@string)) + return input.Insert(input.Length, replacement ?? string.Empty); + + int position = input.LastIndexOf(@string); + return position < 0 ? input : input.Remove(position, @string.Length).Insert(position, replacement ?? string.Empty); } /// @@ -557,15 +473,17 @@ public static string Remove(string input, string @string) /// /// Remove the first occurrence of a substring /// - /// The DotLiquid context /// Input to be transformed by this filter /// String to be removed from input - public static string RemoveFirst(Context context, string input, string @string) - { - return input.IsNullOrWhiteSpace() - ? input - : ReplaceFirst(context: context, input: input, @string: @string, replacement: string.Empty); - } + [LiquidFilter(MinVersion = SyntaxCompatibility.DotLiquid24)] + public static string RemoveFirst(string input, string @string) => ReplaceFirst(input: input, @string: @string, replacement: string.Empty); + + /// + /// Remove the last occurrence of a substring + /// + /// Input to be transformed by this filter + /// String to be removed from input + public static string RemoveLast(string input, string @string) => ReplaceLast(input: input, @string: @string, replacement: string.Empty); /// /// Add one string to another @@ -643,7 +561,7 @@ public static string Date(Context context, object input, string format) } else if ((input is decimal) || (input is double) || (input is float) || (input is int) || (input is uint) || (input is long) || (input is ulong) || (input is short) || (input is ushort)) { -#if CORE +#if NET6_0_OR_GREATER || NETSTANDARD2_0_OR_GREATER dateTimeOffset = DateTimeOffset.FromUnixTimeSeconds(Convert.ToInt64(input)).ToLocalTime(); #else dateTimeOffset = new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero).AddSeconds(Convert.ToDouble(input)).ToLocalTime(); @@ -726,14 +644,10 @@ public static object Last(IEnumerable array) /// The DotLiquid context /// Input to be transformed by this filter /// Number to be added to input + [LiquidFilter(MinVersion = SyntaxCompatibility.DotLiquid21)] public static object Plus(Context context, object input, object operand) { - if (context.SyntaxCompatibilityLevel >= SyntaxCompatibility.DotLiquid21) - return DoMathsOperation(context, input, operand, Expression.AddChecked); - - return input is string - ? string.Concat(input, operand) - : DoMathsOperation(context, input, operand, Expression.AddChecked); + return DoMathsOperation(context, input, operand, Expression.AddChecked); } /// @@ -753,15 +667,8 @@ public static object Minus(Context context, object input, object operand) /// The DotLiquid context /// Input to be transformed by this filter /// Number to multiple input by - public static object Times(Context context, object input, object operand) - { - if (context.SyntaxCompatibilityLevel >= SyntaxCompatibility.DotLiquid21) - return DoMathsOperation(context, input, operand, Expression.MultiplyChecked); - - return input is string && (operand is int || operand is long) - ? Enumerable.Repeat((string)input, Convert.ToInt32(operand)) - : DoMathsOperation(context, input, operand, Expression.MultiplyChecked); - } + [LiquidFilter(MinVersion = SyntaxCompatibility.DotLiquid21)] + public static object Times(Context context, object input, object operand) => DoMathsOperation(context, input, operand, Expression.MultiplyChecked); /// /// Rounds a decimal value to the specified places @@ -845,7 +752,7 @@ public static string Default(string input, string @defaultValue) private static bool IsReal(object o) => o is double || o is float || o is decimal; - private static object DoMathsOperation(Context context, object input, object operand, Func operation) + internal static object DoMathsOperation(Context context, object input, object operand, Func operation) { if (input == null || operand == null) return null; @@ -1048,13 +955,9 @@ private static object ResolveObjectPropertyValue(this object obj, string propert indexable = safeTypeTransformer(obj) as DropBase; else { - var liquidTypeAttribute = type - .GetTypeInfo() - .GetCustomAttributes(attributeType: typeof(LiquidTypeAttribute), inherit: false) - .FirstOrDefault() as LiquidTypeAttribute; - if (liquidTypeAttribute != null) + if (DropProxy.TryFromLiquidType(obj, type, out var drop)) { - indexable = new DropProxy(obj, liquidTypeAttribute.AllowedMembers); + indexable = drop; } else if (TypeUtility.IsAnonymousType(type) && obj.GetType().GetRuntimeProperty(propertyName) != null) { diff --git a/src/DotLiquid/Strainer.cs b/src/DotLiquid/Strainer.cs index ee15f59fb..9eb2e2fb3 100644 --- a/src/DotLiquid/Strainer.cs +++ b/src/DotLiquid/Strainer.cs @@ -1,8 +1,10 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Runtime.ExceptionServices; +using System.Xml.Linq; using DotLiquid.Exceptions; using DotLiquid.Util; @@ -18,7 +20,8 @@ public class Strainer { private static readonly Dictionary Filters = new Dictionary(); private static readonly Dictionary> FilterFuncs = new Dictionary>(); - + private static readonly LiquidFilterAttribute DefaultLiquidFilterAttribute = new LiquidFilterAttribute(); + public static void GlobalFilter(Type filter) { Filters[filter.AssemblyQualifiedName] = filter; @@ -31,16 +34,27 @@ public static void GlobalFilter(string rawName, object target, MethodInfo method FilterFuncs[name] = Tuple.Create(target, methodInfo); } + /// + /// Creates a new instance of the class. + /// + /// The DotLiquid context to be used for creating the . + /// A new instance of the class. public static Strainer Create(Context context) { - Strainer strainer = new Strainer(context); + var strainer = new Strainer(context); + + // Note for maintainers, SyntaxCompatibility may need to be adjusted in the future if LiquidFilterAttribute are changed but not if changes are limited to parsing + if (context.SyntaxCompatibilityLevel < SyntaxCompatibility.DotLiquid24) + strainer.Extend(context.SyntaxCompatibilityLevel, typeof(LegacyFilters)); + + strainer.Extend(context.SyntaxCompatibilityLevel, typeof(StandardFilters)); foreach (var keyValue in Filters) - strainer.Extend(keyValue.Value); + strainer.Extend(context.SyntaxCompatibilityLevel, keyValue.Value); foreach (var keyValue in FilterFuncs) strainer.AddMethodInfo(keyValue.Key, keyValue.Value.Item1, keyValue.Value.Item2); - + return strainer; } @@ -52,6 +66,10 @@ public IEnumerable Methods get { return _methods.Values.SelectMany(m => m.Select(x => x.Item2)); } } + /// + /// Creates a new instance of the class. + /// + /// The DotLiquid context to be used for creating the . public Strainer(Context context) { _context = context; @@ -61,24 +79,38 @@ public Strainer(Context context) /// In this C# implementation, we can't use mixins. So we grab all the static /// methods from the specified type and use them instead. /// - /// - public void Extend(Type type) + /// The Liquid syntax flag used for backward compatibility + /// The type from which static methods will be extracted and mixed to the current . + public void Extend(SyntaxCompatibility syntaxCompatibilityLevel, Type type) { // Calls to Extend replace existing filters with the same number of params. var methods = type.GetRuntimeMethods().Where(m => m.IsPublic && m.IsStatic); foreach (var method in methods) { - string methodName = Template.NamingConvention.GetMemberName(method.Name); - if (_methods.Any(m => method.MatchesMethod(m))) + var filterInfo = TypeUtility.GetLiquidFilterAttribute(method) ?? DefaultLiquidFilterAttribute; + if (filterInfo.MinVersion > syntaxCompatibilityLevel || filterInfo.MaxVersion < syntaxCompatibilityLevel) + continue; + + string methodName = Template.NamingConvention.GetMemberName(filterInfo.Name ?? method.Name); + ReplaceMethodInfo(methodName, method); + + if (filterInfo.Alias != null) { - _methods.Remove(methodName); + string aliasName = Template.NamingConvention.GetMemberName(filterInfo.Alias); + if (!aliasName.Equals(methodName)) + ReplaceMethodInfo(aliasName, method); } } + } - foreach (MethodInfo methodInfo in methods) + private void ReplaceMethodInfo(string name, MethodInfo method) + { + if (_methods.Any(m => method.MatchesMethod(name, m))) { - AddMethodInfo(methodInfo.Name, null, methodInfo); - } // foreach + _methods.Remove(name); + } + + _methods.TryAdd(name, () => new List>()).Add(Tuple.Create(default(object), method)); } public void AddFunction(string rawName, Func func) @@ -111,7 +143,7 @@ public bool RespondTo(string method) public object Invoke(string method, List args) { // First, try to find a method with the same number of arguments minus context which we set automatically further down. - var methodInfo = _methods[method].FirstOrDefault(m => + var methodInfo = _methods[method].FirstOrDefault(m => m.Item2.GetNonContextParameterCount() == args.Count); // If we failed to do so, try one with max numbers of arguments, hoping @@ -143,18 +175,7 @@ public object Invoke(string method, List args) { var parameterType = parameterInfos[argumentIndex].ParameterType; if (convertibleArg.GetType() != parameterType - && !parameterType -#if NETSTANDARD1_3 - .GetTypeInfo() -#endif - .IsAssignableFrom( - convertibleArg - .GetType() -#if NETSTANDARD1_3 - .GetTypeInfo() -#endif - ) - ) + && !parameterType.IsInstanceOfType(convertibleArg)) { args[argumentIndex] = Convert.ChangeType(convertibleArg, parameterType); } diff --git a/src/DotLiquid/SyntaxCompatibilityEnum.cs b/src/DotLiquid/SyntaxCompatibilityEnum.cs index 8f122f1b6..c1ccc3747 100644 --- a/src/DotLiquid/SyntaxCompatibilityEnum.cs +++ b/src/DotLiquid/SyntaxCompatibilityEnum.cs @@ -24,5 +24,15 @@ public enum SyntaxCompatibility /// Behavior as of DotLiquid 2.2a /// DotLiquid22a = 221, + + /// + /// Behavior as of DotLiquid 2.4 + /// + DotLiquid24 = 240, + + /// + /// Equivalent to the latest version of DotLiquid + /// + DotLiquidLatest = DotLiquid24, } } diff --git a/src/DotLiquid/Tags/Decrement.cs b/src/DotLiquid/Tags/Decrement.cs index f67625eda..c591a2be6 100644 --- a/src/DotLiquid/Tags/Decrement.cs +++ b/src/DotLiquid/Tags/Decrement.cs @@ -43,31 +43,40 @@ public override void Initialize(string tagName, string markup, List toke /// The output buffer containing the currently rendered template public override void Render(Context context, TextWriter result) { - Decrement32(context, result, context.Environments[0].TryGetValue(_variable, out var counterObj) ? counterObj : 0); + var environment = context.Environments[0]; + var currentValue = environment.ContainsKey(_variable) ? environment[_variable] : 0; + if (environment is IDictionary dict) + Decrement32(dict, result, currentValue); + else + { + var stackedEnvironment = new Hash(); + context.Environments.Insert(0, stackedEnvironment); + Decrement32(stackedEnvironment, result, currentValue); + } base.Render(context, result); } - private void Decrement32(Context context, TextWriter result, object current) + private void Decrement32(IDictionary environment, TextWriter result, object current) { try { checked { //needed to force OverflowException at runtime var counter = Convert.ToInt32(current) - 1; - context.Environments[0][_variable] = counter; + environment[_variable] = counter; result.Write(counter); } } catch (OverflowException) { - Decrement64(context, result, current); + Decrement64(environment, result, current); } } - private void Decrement64(Context context, TextWriter result, object current) + private void Decrement64(IDictionary environment, TextWriter result, object current) { var counter = Convert.ToInt64(current) - 1; - context.Environments[0][_variable] = counter; + environment[_variable] = counter; result.Write(counter); } } diff --git a/src/DotLiquid/Tags/For.cs b/src/DotLiquid/Tags/For.cs index bec6d6daf..40251995e 100644 --- a/src/DotLiquid/Tags/For.cs +++ b/src/DotLiquid/Tags/For.cs @@ -199,6 +199,9 @@ public override void Render(Context context, TextWriter result) private static List SliceCollectionUsingEach(Context context, IEnumerable collection, int from, int? to) { + if (context.SyntaxCompatibilityLevel >= SyntaxCompatibility.DotLiquid24 && collection is string @string && @string.Length > 0) + return new List { @string }; + List segments = new List(); int index = 0; foreach (object item in collection) diff --git a/src/DotLiquid/Tags/Include.cs b/src/DotLiquid/Tags/Include.cs index 22ef3d324..ed81f16d9 100644 --- a/src/DotLiquid/Tags/Include.cs +++ b/src/DotLiquid/Tags/Include.cs @@ -60,9 +60,9 @@ public override void Render(Context context, TextWriter result) foreach (var keyValue in _attributes) context[keyValue.Key] = context[keyValue.Value]; - if (variable is IEnumerable) + if (variable is IEnumerable enumerable && !(variable is string)) { - ((IEnumerable) variable).Cast().ToList().ForEach(v => + enumerable.Cast().ToList().ForEach(v => { context[shortenedTemplateName] = v; partial.Render(result, RenderParameters.FromContext(context, result.FormatProvider)); diff --git a/src/DotLiquid/Tags/Increment.cs b/src/DotLiquid/Tags/Increment.cs index 00bf41e59..5d169ed72 100644 --- a/src/DotLiquid/Tags/Increment.cs +++ b/src/DotLiquid/Tags/Increment.cs @@ -43,31 +43,40 @@ public override void Initialize(string tagName, string markup, List toke /// The output buffer containing the currently rendered template public override void Render(Context context, TextWriter result) { - Increment32(context, result, context.Environments[0].TryGetValue(_variable, out var counterObj) ? counterObj : 0); + var environment = context.Environments[0]; + var currentValue = environment.ContainsKey(_variable) ? environment[_variable] : 0; + if (environment is IDictionary dict) + Increment32(dict, result, currentValue); + else + { + var stackedEnvironment = new Hash(); + context.Environments.Insert(0, stackedEnvironment); + Increment32(stackedEnvironment, result, currentValue); + } base.Render(context, result); } - private void Increment32(Context context, TextWriter result, object current) + private void Increment32(IDictionary environment, TextWriter result, object current) { try { checked { //needed to force OverflowException at runtime var counter = Convert.ToInt32(current); - context.Environments[0][_variable] = counter + 1; + environment[_variable] = counter + 1; result.Write(counter); } } catch (OverflowException) { - Increment64(context, result, current); + Increment64(environment, result, current); } } - private void Increment64(Context context, TextWriter result, object current) + private void Increment64(IDictionary environment, TextWriter result, object current) { var counter = Convert.ToInt64(current); - context.Environments[0][_variable] = counter + 1; + environment[_variable] = counter + 1; result.Write(counter); } } diff --git a/src/DotLiquid/Tags/Param.cs b/src/DotLiquid/Tags/Param.cs index c17e9bc7d..1aca96a82 100644 --- a/src/DotLiquid/Tags/Param.cs +++ b/src/DotLiquid/Tags/Param.cs @@ -82,11 +82,7 @@ private static void SetCulture(Context context, string value) value = String.Empty; // String.Empty will ensure the InvariantCulture is returned try { -#if CORE - context.CurrentCulture = new CultureInfo(value); -#else context.CurrentCulture = CultureInfo.GetCultureInfo(value); -#endif } catch (CultureNotFoundException exception) { diff --git a/src/DotLiquid/Template.cs b/src/DotLiquid/Template.cs index 373ed3d5f..c057599fc 100755 --- a/src/DotLiquid/Template.cs +++ b/src/DotLiquid/Template.cs @@ -49,7 +49,7 @@ public class Template /// public static bool DefaultIsThreadSafe { get; set; } - private static Dictionary> Tags { get; set; } + private static Dictionary> Tags { get; } /// /// TimeOut used for all Regex in DotLiquid @@ -100,8 +100,9 @@ public static void RegisterTagFactory(ITagFactory tagFactory) /// public static Type GetTagType(string name) { - Tags.TryGetValue(name, out Tuple result); - return result.Item2; + if (Tags.TryGetValue(name, out Tuple result)) + return result.Item2; + return null; } /// @@ -111,24 +112,15 @@ public static Type GetTagType(string name) /// internal static bool IsRawTag(string name) { - Tags.TryGetValue(name, out Tuple result); - return typeof(RawBlock) -#if NETSTANDARD1_3 - .GetTypeInfo() -#endif - .IsAssignableFrom(result?.Item2 -#if NETSTANDARD1_3 - ?.GetTypeInfo() -#endif - ); + if (Tags.TryGetValue(name, out Tuple result)) + return typeof(RawBlock).IsAssignableFrom(result.Item2); + return false; } internal static Tag CreateTag(string name) { Tag tagInstance = null; - Tags.TryGetValue(name, out Tuple result); - - if (result != null) + if (Tags.TryGetValue(name, out Tuple result)) { tagInstance = result.Item1.Create(); } @@ -202,11 +194,11 @@ public static Func GetValueTypeTransformer(Type type) // Check for interfaces return ValueTypeTransformerCache.GetOrAdd(type, (key) => { - foreach (var interfaceType in type.GetTypeInfo().ImplementedInterfaces) + foreach (var interfaceType in type.GetInterfaces()) { if (ValueTypeTransformers.TryGetValue(interfaceType, out transformer)) return transformer; - if (interfaceType.GetTypeInfo().IsGenericType && ValueTypeTransformers.TryGetValue(interfaceType.GetGenericTypeDefinition(), out transformer)) + if (interfaceType.IsGenericType && ValueTypeTransformers.TryGetValue(interfaceType.GetGenericTypeDefinition(), out transformer)) return transformer; } @@ -226,12 +218,11 @@ public static Func GetSafeTypeTransformer(Type type) return transformer; // Check for interfaces - var interfaces = type.GetTypeInfo().ImplementedInterfaces; - foreach (var interfaceType in interfaces) + foreach (var interfaceType in type.GetInterfaces()) { if (SafeTypeTransformers.TryGetValue(interfaceType, out transformer)) return transformer; - if (interfaceType.GetTypeInfo().IsGenericType && SafeTypeTransformers.TryGetValue( + if (interfaceType.IsGenericType && SafeTypeTransformers.TryGetValue( interfaceType.GetGenericTypeDefinition(), out transformer)) return transformer; } @@ -379,7 +370,7 @@ public string Render(IFormatProvider formatProvider = null) /// Local variables. /// String formatting provider. /// The rendering result as string. - public string Render(Hash localVariables, IFormatProvider formatProvider = null) + public string Render(IIndexable localVariables, IFormatProvider formatProvider = null) { using (var writer = new StringWriter(formatProvider ?? CultureInfo.CurrentCulture)) { @@ -400,6 +391,9 @@ public string Render(Hash localVariables, IFormatProvider formatProvider = null) /// The rendering result as string. public string Render(RenderParameters parameters) { + if (parameters == null) + throw new ArgumentNullException(paramName: nameof(parameters)); + using (var writer = new StringWriter(parameters.FormatProvider)) { return this.Render(writer, parameters); @@ -438,6 +432,11 @@ private class StreamWriterWithFormatProvider : StreamWriter /// The render parameters. public void Render(Stream stream, RenderParameters parameters) { + if (stream == null) + throw new ArgumentNullException(paramName: nameof(stream)); + if (parameters == null) + throw new ArgumentNullException(paramName: nameof(parameters)); + // Can't dispose this new StreamWriter, because it would close the // passed-in stream, which isn't up to us. StreamWriter streamWriter = new StreamWriterWithFormatProvider(stream, parameters.FormatProvider); diff --git a/src/DotLiquid/Tokenizer.cs b/src/DotLiquid/Tokenizer.cs index 9891924f9..0bd8f3c2e 100644 --- a/src/DotLiquid/Tokenizer.cs +++ b/src/DotLiquid/Tokenizer.cs @@ -17,7 +17,6 @@ internal static class Tokenizer private static readonly HashSet SearchQuoteOrVariableEnd = new HashSet { '}', '\'', '"' }; private static readonly HashSet SearchQuoteOrTagEnd = new HashSet { '%', '\'', '"' }; private static readonly char[] WhitespaceCharsV20 = new char[] { '\t', ' ' }; - private static readonly char[] WhitespaceCharsV22 = new char[] { '\t', '\n', '\v', '\f', '\r', ' ' }; private static readonly Regex LiquidAnyStartingTagRegex = R.B(R.Q(@"({0})([-])?"), Liquid.AnyStartingTag); private static readonly Regex TagNameRegex = R.B(R.Q(@"{0}\s*(\w+)"), Liquid.AnyStartingTag); private static readonly Regex VariableSegmentRegex = R.C(Liquid.VariableSegment); @@ -35,7 +34,7 @@ internal static List Tokenize(string source, SyntaxCompatibility syntaxC return new List(); // Trim leading whitespace - backward compatible list of chars - var whitespaceChars = syntaxCompatibilityLevel < SyntaxCompatibility.DotLiquid22 ? WhitespaceCharsV20 : WhitespaceCharsV22; + var whitespaceChars = syntaxCompatibilityLevel < SyntaxCompatibility.DotLiquid22 ? WhitespaceCharsV20 : Liquid.AsciiWhitespaceChars; // Trim trailing whitespace - new lines or spaces/tabs but not both if (syntaxCompatibilityLevel < SyntaxCompatibility.DotLiquid22) diff --git a/src/DotLiquid/Util/MethodInfoExtensionMethods.cs b/src/DotLiquid/Util/MethodInfoExtensionMethods.cs index 26f3a7ebd..609610177 100644 --- a/src/DotLiquid/Util/MethodInfoExtensionMethods.cs +++ b/src/DotLiquid/Util/MethodInfoExtensionMethods.cs @@ -23,11 +23,12 @@ public static int GetNonContextParameterCount(this MethodInfo method) /// Check if current method matches compareMethod in name and in parameters /// /// + /// /// /// - public static bool MatchesMethod(this MethodInfo method, KeyValuePair>> compareMethod) + public static bool MatchesMethod(this MethodInfo method, string methodName, KeyValuePair>> compareMethod) { - if (compareMethod.Key != Template.NamingConvention.GetMemberName(method.Name)) + if (compareMethod.Key != methodName) { return false; } diff --git a/src/DotLiquid/Util/ObjectExtensionMethods.cs b/src/DotLiquid/Util/ObjectExtensionMethods.cs index 24485c1cf..e9057195a 100644 --- a/src/DotLiquid/Util/ObjectExtensionMethods.cs +++ b/src/DotLiquid/Util/ObjectExtensionMethods.cs @@ -23,7 +23,7 @@ private class SafeTypeInsensitiveEqualityComparer : IEqualityComparer public static bool RespondTo(this object value, string member, bool ensureNoParameters = true) { if (value == null) - throw new ArgumentNullException("value"); + throw new ArgumentNullException(nameof(value)); Type type = value.GetType(); @@ -48,7 +48,7 @@ public static bool RespondTo(this object value, string member, bool ensureNoPara public static object Send(this object value, string member, object[] parameters = null) { if (value == null) - throw new ArgumentNullException("value"); + throw new ArgumentNullException(nameof(value)); Type type = value.GetType(); diff --git a/src/DotLiquid/Util/R.cs b/src/DotLiquid/Util/R.cs index 944bc1825..e6aeb7e52 100644 --- a/src/DotLiquid/Util/R.cs +++ b/src/DotLiquid/Util/R.cs @@ -32,13 +32,10 @@ public static Regex B(string format, params string[] args) /// the regex public static Regex C(string pattern, RegexOptions options = RegexOptions.None) { -#if !CORE - options = options | RegexOptions.Compiled; -#endif - var regex = new Regex(pattern, options, Template.RegexTimeOut); + var regex = new Regex(pattern, options | RegexOptions.Compiled, Template.RegexTimeOut); // execute once to trigger the lazy compilation (not strictly necessary, but avoids the first real execution taking a longer time than subsequent ones) - regex.IsMatch(string.Empty); + _ = regex.IsMatch(string.Empty); return regex; } diff --git a/src/DotLiquid/Util/ReflectionCacheValue.cs b/src/DotLiquid/Util/ReflectionCacheValue.cs index b7c2958f7..ded7ecdf4 100644 --- a/src/DotLiquid/Util/ReflectionCacheValue.cs +++ b/src/DotLiquid/Util/ReflectionCacheValue.cs @@ -24,20 +24,11 @@ public ReflectionCacheValue(Type type) private bool IsAnonymousInternal() { -#if NETSTANDARD1_3 - var typeInfo = _type.GetTypeInfo(); -#endif return (_type.Name.StartsWith("<>") || _type.Name.StartsWith("VB$")) && (_type.Name.Contains("AnonymousType") || _type.Name.Contains("AnonType")) -#if NETSTANDARD1_3 - && typeInfo.GetCustomAttribute() != null - && typeInfo.IsGenericType - && (typeInfo.Attributes & AnonymousTypeAttributes) == AnonymousTypeAttributes; -#else && _type.GetCustomAttribute() != null && _type.IsGenericType && (_type.Attributes & AnonymousTypeAttributes) == AnonymousTypeAttributes; -#endif } } } diff --git a/src/DotLiquid/Util/TypeUtility.cs b/src/DotLiquid/Util/TypeUtility.cs index 905813510..4035934ba 100644 --- a/src/DotLiquid/Util/TypeUtility.cs +++ b/src/DotLiquid/Util/TypeUtility.cs @@ -1,15 +1,33 @@ using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; using System.Runtime.CompilerServices; namespace DotLiquid.Util { internal static class TypeUtility { - private static ConditionalWeakTable _cache = new ConditionalWeakTable(); + private static readonly ConditionalWeakTable _cache = new ConditionalWeakTable(); + + private static readonly ConditionalWeakTable _filterAttributeCache = new ConditionalWeakTable(); + + private static readonly ConditionalWeakTable _typeAttributeCache = new ConditionalWeakTable(); + public static bool IsAnonymousType(Type t) { return _cache.GetValue(t, (key) => new ReflectionCacheValue(t)).IsAnonymous; } + + public static LiquidFilterAttribute GetLiquidFilterAttribute(MethodInfo method) + { + return _filterAttributeCache.GetValue(method, (key) => method.GetCustomAttribute()); + } + + public static LiquidTypeAttribute GetLiquidTypeAttribute(Type type) + { + return _typeAttributeCache.GetValue(type, (key) => type.GetCustomAttribute()); + } } } diff --git a/src/DotLiquid/readme_nuget.md b/src/DotLiquid/readme_nuget.md new file mode 100644 index 000000000..3393de8db --- /dev/null +++ b/src/DotLiquid/readme_nuget.md @@ -0,0 +1 @@ +DotLiquid is a templating system ported to the .NET framework from Ruby’s Liquid Markup.