diff --git a/README.md b/README.md index dddcd227..d9bbd253 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,44 @@ var result = template(data); */ ``` +### Multi-dimensional Arrays + +Handlebars.Net supports indexing and iterating true multi-dimensional (rank > 1) .NET arrays, such as `int[,]` or `int[,,]`, in addition to jagged arrays (`int[][]`) and other list/enumerable types. + +A multi-dimensional array is indexed and iterated one dimension at a time, so a 2D array is treated as an array of rows and a 3D array as an array of 2D "slabs", and so on: + +```c# +string source = "{{#each grid}}[{{#each this}}{{this}}{{/each}}]{{/each}}"; + +var template = Handlebars.Compile(source); + +var data = new { + grid = new int[,] { { 1, 2, 3 }, { 4, 5, 6 } } +}; + +var result = template(data); + +/* Would render: +[123][456] +*/ +``` + +Individual elements can also be reached directly by chaining index segments, one per dimension: + +```c# +string source = "{{ grid.[1].[2] }}"; // grid[1, 2] + +var template = Handlebars.Compile(source); + +var data = new { + grid = new int[,] { { 1, 2, 3 }, { 4, 5, 6 } } +}; + +var result = template(data); // "6" +``` + +This is a C#-specific capability, since JavaScript/Handlebars.js has no equivalent to true multi-dimensional arrays. + ### Registering Partials ```c# diff --git a/source/Handlebars.Test/BasicIntegrationTests.cs b/source/Handlebars.Test/BasicIntegrationTests.cs index 3f655750..0f0ae3f5 100644 --- a/source/Handlebars.Test/BasicIntegrationTests.cs +++ b/source/Handlebars.Test/BasicIntegrationTests.cs @@ -349,6 +349,49 @@ public void BasicPathArrayNoSquareBracketsChildPath(IHandlebars handlebars) Assert.Equal("Hello, Handlebars.Net!", result); } + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void BasicPathJaggedArray(IHandlebars handlebars) + { + var source = "{{ grid.[1].[2] }}"; + var template = handlebars.Compile(source); + var data = new + { + grid = new[] { new[] { 1, 2, 3 }, new[] { 4, 5, 6 } } + }; + var result = template(data); + Assert.Equal("6", result); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void BasicPathTwoDimensionalArray(IHandlebars handlebars) + { + var source = "{{ grid.[1].[2] }}"; + var template = handlebars.Compile(source); + var data = new + { + grid = new int[,] { { 1, 2, 3 }, { 4, 5, 6 } } + }; + var result = template(data); + Assert.Equal("6", result); + } + + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] + public void BasicPathThreeDimensionalArray(IHandlebars handlebars) + { + var source = "{{ cube.[1].[0].[1] }}"; + var template = handlebars.Compile(source); + var data = new + { + cube = new int[,,] + { + { { 1, 2 }, { 3, 4 } }, + { { 5, 6 }, { 7, 8 } } + } + }; + var result = template(data); + Assert.Equal("6", result); + } + [Theory, ClassData(typeof(HandlebarsEnvGenerator))] public void BasicPathEnumerableNoSquareBracketsChildPath(IHandlebars handlebars) { diff --git a/source/Handlebars.Test/IteratorTests.cs b/source/Handlebars.Test/IteratorTests.cs index 10b6e2d8..f60c9f22 100644 --- a/source/Handlebars.Test/IteratorTests.cs +++ b/source/Handlebars.Test/IteratorTests.cs @@ -415,6 +415,62 @@ public void ImmutableArrayTest() var result = template(data); Assert.Equal("0123", result); } + + [Fact] + public void JaggedArrayIterator() + { + var source = "{{#each data}}[{{#each this}}{{this}}{{/each}}]{{/each}}"; + var template = Handlebars.Compile(source); + var data = new + { + data = new[] { new[] { 1, 2 }, new[] { 3, 4, 5 } } + }; + var result = template(data); + Assert.Equal("[12][345]", result); + } + + [Fact] + public void TwoDimensionalArrayIteratorIteratesByRow() + { + var source = "{{#each data}}[{{#each this}}{{this}}{{/each}}]{{/each}}"; + var template = Handlebars.Compile(source); + var data = new + { + data = new int[,] { { 1, 2, 3 }, { 4, 5, 6 } } + }; + var result = template(data); + Assert.Equal("[123][456]", result); + } + + [Fact] + public void TwoDimensionalArrayIteratorWithIndex() + { + var source = "{{#each data}}{{@index}}:{{#each this}}{{@index}}={{this}} {{/each}}\n{{/each}}"; + var template = Handlebars.Compile(source); + var data = new + { + data = new int[,] { { 1, 2 }, { 3, 4 } } + }; + var result = template(data); + Assert.Equal("0:0=1 1=2 \n1:0=3 1=4 \n", result); + } + + [Fact] + public void ThreeDimensionalArrayIterator() + { + var source = "{{#each data}}{{#each this}}[{{#each this}}{{this}}{{/each}}]{{/each}}\n{{/each}}"; + var template = Handlebars.Compile(source); + var data = new + { + data = new int[,,] + { + { { 1, 2 }, { 3, 4 } }, + { { 5, 6 }, { 7, 8 } } + } + }; + var result = template(data); + Assert.Equal("[12][34]\n[56][78]\n", result); + } } } diff --git a/source/Handlebars/Iterators/MultidimensionalArrayIterator.cs b/source/Handlebars/Iterators/MultidimensionalArrayIterator.cs new file mode 100644 index 00000000..a600feb9 --- /dev/null +++ b/source/Handlebars/Iterators/MultidimensionalArrayIterator.cs @@ -0,0 +1,67 @@ +using System; +using HandlebarsDotNet.Compiler; +using HandlebarsDotNet.PathStructure; +using HandlebarsDotNet.Runtime; +using HandlebarsDotNet.ValueProviders; + +namespace HandlebarsDotNet.Iterators +{ + /// + /// Iterates a true multi-dimensional (rank > 1) one row at a time, + /// i.e. by its outer-most dimension. Each yielded value is a + /// covering the remaining dimensions, so nested dimensions can be walked with further + /// {{#each}} blocks or indexers. + /// + public sealed class MultidimensionalArrayIterator : IIterator + { + public void Iterate( + in EncodedTextWriter writer, + BindingContext context, + ChainSegment[] blockParamsVariables, + object input, + TemplateDelegate template, + TemplateDelegate ifEmpty + ) + { + using var innerContext = context.CreateFrame(); + var iterator = new IteratorValues(innerContext); + var blockParamsValues = new BlockParamsValues(innerContext, blockParamsVariables); + + blockParamsValues.CreateProperty(0, out var _0); + blockParamsValues.CreateProperty(1, out var _1); + + var array = (Array) input; + var count = array.GetLength(0); + + iterator.First = BoxedValues.True; + iterator.Last = BoxedValues.False; + + var index = 0; + var lastIndex = count - 1; + for (; index < count; index++) + { + var value = (object?) new MultidimensionalArraySlice(array, new[] { index }); + var objectIndex = BoxedValues.Int(index); + + if (index == 1) iterator.First = BoxedValues.False; + if (index == lastIndex) iterator.Last = BoxedValues.True; + + iterator.Key = iterator.Index = objectIndex; + + blockParamsValues[_0] = value; + blockParamsValues[_1] = objectIndex; + + iterator.Value = value; + innerContext.Value = value; + + template(writer, innerContext); + } + + if (index == 0) + { + innerContext.Value = context.Value; + ifEmpty(writer, innerContext); + } + } + } +} diff --git a/source/Handlebars/MemberAccessors/EnumerableAccessors/EnumerableMemberAccessor.cs b/source/Handlebars/MemberAccessors/EnumerableAccessors/EnumerableMemberAccessor.cs index 32c10c72..759b1f06 100644 --- a/source/Handlebars/MemberAccessors/EnumerableAccessors/EnumerableMemberAccessor.cs +++ b/source/Handlebars/MemberAccessors/EnumerableAccessors/EnumerableMemberAccessor.cs @@ -9,6 +9,11 @@ public class EnumerableMemberAccessor : IMemberAccessor { public static EnumerableMemberAccessor Create(Type type) { + if (type.IsArray && type.GetArrayRank() > 1) + { + return new MultiDimensionalArrayMemberAccessor(); + } + if (type.IsAssignableToGenericType(typeof(IList<>), out var genericType)) { var typeArgument = genericType.GenericTypeArguments[0]; diff --git a/source/Handlebars/MemberAccessors/EnumerableAccessors/MultiDimensionalArrayMemberAccessor.cs b/source/Handlebars/MemberAccessors/EnumerableAccessors/MultiDimensionalArrayMemberAccessor.cs new file mode 100644 index 00000000..3a32d14e --- /dev/null +++ b/source/Handlebars/MemberAccessors/EnumerableAccessors/MultiDimensionalArrayMemberAccessor.cs @@ -0,0 +1,21 @@ +using System; +using HandlebarsDotNet.Runtime; + +namespace HandlebarsDotNet.MemberAccessors.EnumerableAccessors +{ + public sealed class MultiDimensionalArrayMemberAccessor : EnumerableMemberAccessor + { + protected override bool TryGetValueInternal(object instance, int index, out object? value) + { + var array = (Array) instance; + if (index >= array.GetLength(0)) + { + value = null; + return false; + } + + value = new MultidimensionalArraySlice(array, new[] { index }); + return true; + } + } +} diff --git a/source/Handlebars/ObjectDescriptors/EnumerableObjectDescriptor.cs b/source/Handlebars/ObjectDescriptors/EnumerableObjectDescriptor.cs index 6face1c9..f8829025 100644 --- a/source/Handlebars/ObjectDescriptors/EnumerableObjectDescriptor.cs +++ b/source/Handlebars/ObjectDescriptors/EnumerableObjectDescriptor.cs @@ -88,17 +88,25 @@ public bool TryGetDescriptor(Type type, [MaybeNullWhen(false)] out ObjectDescrip private static bool TryCreateArrayDescriptor(Type type, object[] parameters, [MaybeNullWhen(false)] out ObjectDescriptor value) { - if (type.IsArray) + if (!type.IsArray) { - value = (ObjectDescriptor) ArrayObjectDescriptorFactoryMethodInfo - .MakeGenericMethod(type.GetElementType()!) - .Invoke(null, parameters)!; + value = ObjectDescriptor.Empty; + return false; + } + if (type.GetArrayRank() > 1) + { + var accessor = (IMemberAccessor) parameters[0]; + var descriptor = (ObjectDescriptor) parameters[1]; + value = new ObjectDescriptor(type, accessor, descriptor.GetProperties, self => new MultidimensionalArrayIterator(), descriptor.Dependencies); return true; } - value = ObjectDescriptor.Empty; - return false; + value = (ObjectDescriptor) ArrayObjectDescriptorFactoryMethodInfo + .MakeGenericMethod(type.GetElementType()!) + .Invoke(null, parameters)!; + + return true; } private static bool TryCreateDescriptorFromOpenGeneric(Type type, Type openGenericType, object[] parameters, MethodInfo method, [MaybeNullWhen(false)] out ObjectDescriptor descriptor) diff --git a/source/Handlebars/Runtime/MultidimensionalArraySlice.cs b/source/Handlebars/Runtime/MultidimensionalArraySlice.cs new file mode 100644 index 00000000..222e9960 --- /dev/null +++ b/source/Handlebars/Runtime/MultidimensionalArraySlice.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace HandlebarsDotNet.Runtime +{ + /// + /// Represents a lazy view over one dimension of a true multi-dimensional + /// (rank > 1, e.g. int[,]), fixing the leading indices supplied so far. + /// Indexing or iterating a slice either yields the element (once every dimension has been + /// fixed) or a narrower for the remaining dimensions. + /// This lets a rank-N array be walked one dimension at a time, e.g. {{grid.[0].[1]}} + /// or nested {{#each}} blocks, without ever needing to cast it to T[]. + /// + public sealed class MultidimensionalArraySlice : IReadOnlyList + { + private readonly Array _array; + private readonly int[] _indices; + + internal MultidimensionalArraySlice(Array array, int[] indices) + { + _array = array; + _indices = indices; + } + + public int Count => _array.GetLength(_indices.Length); + + public object? this[int index] + { + get + { + var indices = new int[_indices.Length + 1]; + Array.Copy(_indices, indices, _indices.Length); + indices[_indices.Length] = index; + + return indices.Length == _array.Rank + ? _array.GetValue(indices) + : new MultidimensionalArraySlice(_array, indices); + } + } + + public IEnumerator GetEnumerator() + { + var count = Count; + for (var index = 0; index < count; index++) + { + yield return this[index]; + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +}