From e9113b624ff30d1e28a9948dbc4f86cabb92e2ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20L=2E=20Charlier?= Date: Wed, 26 Aug 2026 10:15:15 +0200 Subject: [PATCH] feat(array): add variadic array construction --- Expressif.Cli.Tests/packages.lock.json | 18 +- Expressif.Cli/packages.lock.json | 66 ++--- .../Bindings/BindingTestAdapter.cs | 26 -- .../Bindings/ExpressionBinderTest.cs | 139 ++++++---- .../Bindings/FunctionBinderTest.cs | 247 ++++++------------ .../Bindings/IntervalBinderTest.cs | 147 ++--------- .../Bindings/ParameterBinderTest.cs | 122 ++++----- .../Bindings/PredicationBinderTest.cs | 83 +++--- Expressif.Testing/Bindings/SyntaxFactory.cs | 115 ++++++++ Expressif.Testing/ExpressionTest.cs | 2 +- .../Functions/Array/ArrayTest.cs | 103 ++++++++ .../Functions/FunctionFactoryTest.cs | 35 +-- .../Numeric/PercentChangeFunctionTest.cs | 2 +- .../Functions/Special/CoerceFunctionsTest.cs | 2 +- .../Functions/Special/SpecialFunctionsTest.cs | 12 +- .../Serializer/ExpressionSerializerTest.cs | 4 +- .../Serializer/FunctionSerializerTest.cs | 6 +- .../Serializer/ParameterSerializerTest.cs | 6 +- Expressif.Testing/packages.lock.json | 18 +- Expressif/Bindings/BindingModels.cs | 11 +- Expressif/Bindings/ExpressifBinder.cs | 63 ++++- Expressif/Expressif.csproj | 2 +- Expressif/ExpressifException.cs | 2 + Expressif/Functions/Array/Array.cs | 56 ++++ Expressif/Functions/BaseExpressionFactory.cs | 14 +- .../Catalog/FunctionDocumentation.cs | 3 +- Expressif/Functions/FunctionFactory.cs | 101 ++++++- Expressif/Serializers/FunctionSerializer.cs | 2 + Expressif/Serializers/ParameterSerializer.cs | 5 +- Expressif/Values/ParameterValueConverter.cs | 16 +- Expressif/packages.lock.json | 18 +- conformance/functions/array/array.yaml | 38 +++ docs/_data/function.json | 20 ++ 33 files changed, 907 insertions(+), 597 deletions(-) delete mode 100644 Expressif.Testing/Bindings/BindingTestAdapter.cs create mode 100644 Expressif.Testing/Bindings/SyntaxFactory.cs create mode 100644 Expressif.Testing/Functions/Array/ArrayTest.cs create mode 100644 Expressif/Functions/Array/Array.cs create mode 100644 conformance/functions/array/array.yaml diff --git a/Expressif.Cli.Tests/packages.lock.json b/Expressif.Cli.Tests/packages.lock.json index 4eca0bf1..6cdbb2a0 100644 --- a/Expressif.Cli.Tests/packages.lock.json +++ b/Expressif.Cli.Tests/packages.lock.json @@ -79,8 +79,8 @@ }, "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -190,7 +190,7 @@ "expressif": { "type": "Project", "dependencies": { - "Expressif.Syntax": "[0.21.0, )" + "Expressif.Syntax": "[0.28.0, )" } }, "Expressif-cli": { @@ -280,8 +280,8 @@ }, "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -391,7 +391,7 @@ "expressif": { "type": "Project", "dependencies": { - "Expressif.Syntax": "[0.21.0, )" + "Expressif.Syntax": "[0.28.0, )" } }, "Expressif-cli": { @@ -481,8 +481,8 @@ }, "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -592,7 +592,7 @@ "expressif": { "type": "Project", "dependencies": { - "Expressif.Syntax": "[0.21.0, )" + "Expressif.Syntax": "[0.28.0, )" } }, "Expressif-cli": { diff --git a/Expressif.Cli/packages.lock.json b/Expressif.Cli/packages.lock.json index d1f1afeb..f92814e9 100644 --- a/Expressif.Cli/packages.lock.json +++ b/Expressif.Cli/packages.lock.json @@ -31,8 +31,8 @@ }, "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -50,15 +50,15 @@ "expressif": { "type": "Project", "dependencies": { - "Expressif.Syntax": "[0.21.0, )" + "Expressif.Syntax": "[0.28.0, )" } } }, "net10.0/linux-musl-x64": { "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -72,8 +72,8 @@ "net10.0/linux-x64": { "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -87,8 +87,8 @@ "net10.0/win-arm64": { "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -102,8 +102,8 @@ "net10.0/win-x64": { "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -144,8 +144,8 @@ }, "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -163,15 +163,15 @@ "expressif": { "type": "Project", "dependencies": { - "Expressif.Syntax": "[0.21.0, )" + "Expressif.Syntax": "[0.28.0, )" } } }, "net8.0/linux-musl-x64": { "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -185,8 +185,8 @@ "net8.0/linux-x64": { "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -200,8 +200,8 @@ "net8.0/win-arm64": { "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -215,8 +215,8 @@ "net8.0/win-x64": { "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -257,8 +257,8 @@ }, "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -276,15 +276,15 @@ "expressif": { "type": "Project", "dependencies": { - "Expressif.Syntax": "[0.21.0, )" + "Expressif.Syntax": "[0.28.0, )" } } }, "net9.0/linux-musl-x64": { "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -298,8 +298,8 @@ "net9.0/linux-x64": { "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -313,8 +313,8 @@ "net9.0/win-arm64": { "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -328,8 +328,8 @@ "net9.0/win-x64": { "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } diff --git a/Expressif.Testing/Bindings/BindingTestAdapter.cs b/Expressif.Testing/Bindings/BindingTestAdapter.cs deleted file mode 100644 index 7117e880..00000000 --- a/Expressif.Testing/Bindings/BindingTestAdapter.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Expressif.Bindings; -using Expressif.Functions; -namespace Expressif.Testing.Bindings; - -internal static class BindingTestAdapter -{ - private static ExpressifBinder Binder { get; } = new(); - - public static IRootExpression Root(string source) => Binder.Bind(ExpressionParser.Parse(source)); - public static Function Function(string source) => Binder.BindFunction(ExpressionParser.Parse(source)); - public static IParameter Parameter(string source) => Binder.BindParameter(ExpressionParser.Parse(source)); - public static IParameter[] Parameters(string source) => Binder.BindFunction(ExpressionParser.Parse($"test{source}")).Parameters; - public static IPredication Predication(string source) => Binder.BindPredication(ExpressionParser.Parse(source)); - public static OpenExpression Open(string source) => ((OpenRootExpression)Root(source)).Expression; - public static Expressif.Bindings.ClosedExpression Closed(string source) - => ((ClosedRootExpression)Root(source)).Expression; - - public static IntervalBinding Interval(string source) - => ((IntervalParameter)Parameter(source)).Value; - - public static IFunction Executable(string source, IContext? context = null) - => new FunctionFactory().Instantiate(Root(source), context ?? new Context()); - - public static IFunction ExecutableClosed(string source, IContext? context = null) - => new FunctionFactory().InstantiateClosed(Root(source), context ?? new Context()); -} diff --git a/Expressif.Testing/Bindings/ExpressionBinderTest.cs b/Expressif.Testing/Bindings/ExpressionBinderTest.cs index 89a52d46..cf9176cb 100644 --- a/Expressif.Testing/Bindings/ExpressionBinderTest.cs +++ b/Expressif.Testing/Bindings/ExpressionBinderTest.cs @@ -1,5 +1,4 @@ using Expressif.Bindings; -using System.Diagnostics; namespace Expressif.Testing.Bindings; @@ -10,43 +9,65 @@ public void Setup() { } [Test] - public void Bind_ParsedSyntax_ReturnsExecutableExpression() + public void Bind_OpenFunction_ReturnsBoundFunction() { - var syntax = ExpressionParser.Parse("upper"); - var expression = ExpressionBinder.Bind(syntax); + var syntax = SyntaxFactory.Open(null, SyntaxFactory.Function("upper")); + var expression = ((OpenRootExpression)new ExpressifBinder().Bind(syntax)).Expression; - Assert.That(expression.Evaluate("foo"), Is.EqualTo("FOO")); + Assert.That(expression.Members.Single().Name, Is.EqualTo("upper")); } [Test] - [TestCase("text-to-func(\"foo\", @bar)", 1)] - [TestCase("text-to-func", 1)] - [TestCase("text-to-func(\"foo\") | numeric-to-func(\"foo\", @bar)", 2)] - [TestCase("text-to-func(\"foo\") | numeric-to-func(\"foo\", @bar) | boolean-to-func", 3)] - public void Parse_Expression_Valid(string value, int count) - => Assert.That(BindingTestAdapter.Open(value).Members.Count, Is.EqualTo(count)); + public void Bind_OpenExpression_PreservesMembers() + { + var syntax = SyntaxFactory.Open( + SyntaxFactory.Function("text-to-func", SyntaxFactory.Argument(SyntaxFactory.Text("foo"))), + SyntaxFactory.Function("numeric-to-func", SyntaxFactory.Argument(SyntaxFactory.Variable("bar"))), + SyntaxFactory.Function("boolean-to-func")); + + var expression = ((OpenRootExpression)new ExpressifBinder().Bind(syntax)).Expression; + + Assert.That(expression.Members.Select(member => member.Name), + Is.EqualTo(new[] { "text-to-func", "numeric-to-func", "boolean-to-func" })); + } [Test] - [TestCase("@foo | text-to-func(\"foo\", @bar)", 1)] - [TestCase("@foo | text-to-func(\"foo\") | numeric-to-func(\"foo\", @bar)", 2)] - [TestCase("\"foo\"", 0)] - public void Parse_ParametrizedExpression_Valid(string value, int count) - => Assert.That(BindingTestAdapter.Closed(value).Members.Count, Is.EqualTo(count)); + public void Bind_ClosedExpression_PreservesSourceAndPipeline() + { + var syntax = SyntaxFactory.Closed( + SyntaxFactory.Variable("foo"), + SyntaxFactory.Function("text-to-func", SyntaxFactory.Argument(SyntaxFactory.Text("foo"))), + SyntaxFactory.Function("numeric-to-func", SyntaxFactory.Argument(SyntaxFactory.Variable("bar")))); + + var expression = ((ClosedRootExpression)new ExpressifBinder().Bind(syntax)).Expression; + + Assert.Multiple(() => + { + Assert.That(expression.Parameter, Is.EqualTo(new VariableParameter("foo"))); + Assert.That(expression.Members.Select(member => member.Name), + Is.EqualTo(new[] { "text-to-func", "numeric-to-func" })); + }); + } [Test] - [TestCase("{1,2,3} | sum")] - [TestCase("@foo | count")] - [TestCase("^.foo | min")] - [TestCase("^.1 | last")] - [TestCase("{#true,#true} | every")] - [TestCase("{#false,#true} | any")] - public void Parse_InputExpression_ImplicitFoldAggregation_Valid(string value) - => Assert.That(BindingTestAdapter.Closed(value).IsImplicitFoldAggregation, Is.True); + public void Bind_InputExpression_ImplicitFoldAggregation_Valid() + { + var syntax = SyntaxFactory.Closed( + SyntaxFactory.Array(SyntaxFactory.Number(1), SyntaxFactory.Number(2), SyntaxFactory.Number(3)), + SyntaxFactory.Function("sum")); + + var expression = ((ClosedRootExpression)new ExpressifBinder().Bind(syntax)).Expression; + + Assert.That(expression.IsImplicitFoldAggregation, Is.True); + } [Test] public void Parse_ClosedExpression_SumDetectedAsImplicitFoldAccumulator() { - var expression = BindingTestAdapter.Closed("{1,2,3} | sum"); + var syntax = SyntaxFactory.Closed( + SyntaxFactory.Array(SyntaxFactory.Number(1), SyntaxFactory.Number(2), SyntaxFactory.Number(3)), + SyntaxFactory.Function("sum")); + var expression = ((ClosedRootExpression)new ExpressifBinder().Bind(syntax)).Expression; Assert.That(expression.IsImplicitFoldAggregation, Is.True); Assert.That(expression.GetImplicitFoldAccumulator(), Is.Not.Null); @@ -54,28 +75,26 @@ public void Parse_ClosedExpression_SumDetectedAsImplicitFoldAccumulator() } [Test] - [TestCase("@foo | lower", typeof(ClosedRootExpression))] - [TestCase("{1,2,3} | sum", typeof(ClosedRootExpression))] - [TestCase("@arr | count", typeof(ClosedRootExpression))] - [TestCase("{10,20,30} | lag", typeof(ClosedRootExpression))] - [TestCase("{10,20,30} | lead", typeof(ClosedRootExpression))] - [TestCase("{1,2,3} | scan(sum)", typeof(ClosedRootExpression))] - [TestCase("sum | add(3)", typeof(OpenRootExpression))] - [TestCase("lower(foo) | trim", typeof(OpenRootExpression))] - [TestCase("{1,2,3} | broadcast(sum)", typeof(ClosedRootExpression))] - [TestCase("{1,2,3} | map(multiply(2))", typeof(ClosedRootExpression))] - [TestCase("{`alice`,`bob`} | map(upper | first-chars(2))", typeof(ClosedRootExpression))] - [TestCase("{1,2,3,4} | filter(greater-than(2))", typeof(ClosedRootExpression))] - public void Parse_RootExpression_ClosedFirst(string value, Type expectedType) - => Assert.That(BindingTestAdapter.Root(value), Is.TypeOf(expectedType)); + public void Bind_RootExpression_DistinguishesOpenAndClosedSyntax() + { + var binder = new ExpressifBinder(); + var closed = SyntaxFactory.Closed(SyntaxFactory.Variable("foo"), SyntaxFactory.Function("lower")); + var open = SyntaxFactory.Open(SyntaxFactory.Function("sum"), SyntaxFactory.Function("add")); + + Assert.Multiple(() => + { + Assert.That(binder.Bind(closed), Is.TypeOf()); + Assert.That(binder.Bind(open), Is.TypeOf()); + }); + } [Test] public void Parse_ContextualRecordAccessAsPipelineStage_ThrowsActionableBindingException() { - const string source = "record(name:=.lastName | append(\", \") | ^.firstName)"; + var syntax = SyntaxFactory.Open(null, SyntaxFactory.RecordAccess("firstName", true)); Assert.That( - () => BindingTestAdapter.Root(source), + () => new ExpressifBinder().Bind(syntax), Throws.TypeOf() .With.Message.EqualTo( "Contextual record access '^.firstName' cannot be used directly as a pipeline stage. " + @@ -85,7 +104,13 @@ public void Parse_ContextualRecordAccessAsPipelineStage_ThrowsActionableBindingE [Test] public void Parse_MapShorthand_LowersToMapFunction() { - var expression = BindingTestAdapter.Closed("{1,2,3} |> (absolute | add(5)) | reverse"); + var syntax = SyntaxFactory.Closed( + SyntaxFactory.Array(SyntaxFactory.Number(1), SyntaxFactory.Number(2), SyntaxFactory.Number(3)), + SyntaxFactory.Map( + SyntaxFactory.Function("absolute"), + SyntaxFactory.Function("add", SyntaxFactory.Argument(SyntaxFactory.Number(5)))), + SyntaxFactory.Function("reverse")); + var expression = ((ClosedRootExpression)new ExpressifBinder().Bind(syntax)).Expression; Assert.Multiple(() => { @@ -96,13 +121,14 @@ public void Parse_MapShorthand_LowersToMapFunction() } [Test] - [TestCase("|> add(1) | sum", new[] { "add" })] - [TestCase("|> (add(1)) | sum", new[] { "add" })] - [TestCase("|> (absolute | add(1)) | sum", new[] { "absolute", "add" })] - public void Parse_LeadingMapPipeline_ResumesParentPipelineAfterMappedExpression( - string value, string[] expectedMappedFunctions) + public void Parse_LeadingMapPipeline_ResumesParentPipelineAfterMappedExpression() { - var root = BindingTestAdapter.Root(value); + var syntax = SyntaxFactory.Open( + SyntaxFactory.Map( + SyntaxFactory.Function("absolute"), + SyntaxFactory.Function("add", SyntaxFactory.Argument(SyntaxFactory.Number(1)))), + SyntaxFactory.Function("sum")); + var root = new ExpressifBinder().Bind(syntax); Assert.That(root, Is.TypeOf()); var expression = ((OpenRootExpression)root).Expression; @@ -114,14 +140,19 @@ public void Parse_LeadingMapPipeline_ResumesParentPipelineAfterMappedExpression( Assert.That(expression.Members.Select(x => x.Name), Is.EqualTo(new[] { "map", "sum" })); Assert.That(map.Name, Is.EqualTo("map")); Assert.That(map.Syntax, Is.EqualTo(FunctionSyntax.MapShorthand)); - Assert.That(mappedExpression.Members.Select(x => x.Name), Is.EqualTo(expectedMappedFunctions)); + Assert.That(mappedExpression.Members.Select(x => x.Name), Is.EqualTo(new[] { "absolute", "add" })); }); } [Test] public void Parse_UnparenthesizedMapShorthand_ConsumesSingleFunction() { - var expression = BindingTestAdapter.Closed("{1,2,3} |> absolute | add(1) | sum"); + var syntax = SyntaxFactory.Closed( + SyntaxFactory.Array(SyntaxFactory.Number(1), SyntaxFactory.Number(2), SyntaxFactory.Number(3)), + SyntaxFactory.Map(SyntaxFactory.Function("absolute")), + SyntaxFactory.Function("add", SyntaxFactory.Argument(SyntaxFactory.Number(1))), + SyntaxFactory.Function("sum")); + var expression = ((ClosedRootExpression)new ExpressifBinder().Bind(syntax)).Expression; var map = expression.Members.First(); var mappedExpression = ((OpenExpressionParameter)map.Parameters.Single()).Expression; @@ -136,7 +167,13 @@ public void Parse_UnparenthesizedMapShorthand_ConsumesSingleFunction() [Test] public void Parse_AdjacentOpenComposition_PreservesTupleProjections() { - var adjacent = BindingTestAdapter.Function("adjacent($1 | subtract($0) | multiply($1))"); + var composition = SyntaxFactory.Open( + SyntaxFactory.TupleProjection(1), + SyntaxFactory.Function("subtract", SyntaxFactory.Argument(SyntaxFactory.TupleProjection(0))), + SyntaxFactory.Function("multiply", SyntaxFactory.Argument(SyntaxFactory.TupleProjection(1)))); + var syntax = SyntaxFactory.Open( + SyntaxFactory.Function("adjacent", SyntaxFactory.Argument(SyntaxFactory.Parenthesized(composition)))); + var adjacent = new ExpressifBinder().BindFunction(syntax); var expression = ((OpenExpressionParameter)adjacent.Parameters.Single()).Expression; Assert.Multiple(() => diff --git a/Expressif.Testing/Bindings/FunctionBinderTest.cs b/Expressif.Testing/Bindings/FunctionBinderTest.cs index 3db68a02..9f5589e7 100644 --- a/Expressif.Testing/Bindings/FunctionBinderTest.cs +++ b/Expressif.Testing/Bindings/FunctionBinderTest.cs @@ -1,232 +1,133 @@ using Expressif.Bindings; -using System.Diagnostics; -using System.Linq; namespace Expressif.Testing.Bindings; public class FunctionBinderTest { - [TestCase(".name", "name")] - [TestCase(".birth-date", "birth-date")] - [TestCase(".amount_tax", "amount_tax")] - public void Parse_FieldShorthand_LowersToFieldFunction(string value, string expectedName) - { - var function = BindingTestAdapter.Function(value); - - Assert.Multiple(() => - { - Assert.That(function.Name, Is.EqualTo("field")); - Assert.That(function.Syntax, Is.EqualTo(FunctionSyntax.FieldShorthand)); - Assert.That(((LiteralParameter)function.Parameters.Single()).Value, Is.EqualTo(expectedName)); - }); - } - - [SetUp] - public void Setup() - { } + private static ExpressifBinder Binder { get; } = new(); [Test] - [TestCase("text-to-func(\"foo\")", 1)] - [TestCase("text-to-func()", 0)] - [TestCase("text-to-func", 0)] - [TestCase("text-to-func(\"foo\", @bar)", 2)] - public void Parse_Function_Valid(string value, int count) + public void Bind_Function_PreservesArguments() { - var function = BindingTestAdapter.Function(value); + var syntax = SyntaxFactory.Open(SyntaxFactory.Function( + "text-to-func", + SyntaxFactory.Argument(SyntaxFactory.Text("foo")), + SyntaxFactory.Argument(SyntaxFactory.Variable("bar")))); + + var function = Binder.BindFunction(syntax); + Assert.Multiple(() => { Assert.That(function.Name, Is.EqualTo("text-to-func")); - Assert.That(function.Parameters.Count, Is.EqualTo(count)); + Assert.That(function.Parameters, Has.Length.EqualTo(2)); + Assert.That(function.Parameters[0], Is.TypeOf()); + Assert.That(function.Parameters[1], Is.TypeOf()); }); } [Test] - public void Parse_Function_NamedArguments_PreservesNamesAndOrder() - { - var function = BindingTestAdapter.Function("replace-slice(append := \"abc\", start := 2, length := 4)"); - - Assert.That(function.Arguments.Select(x => x.Name), - Is.EqualTo(new[] { "append", "start", "length" })); - } - - [Test] - public void Parse_Function_PositionalAfterNamed_ThrowsSpecificException() - => Assert.That( - () => BindingTestAdapter.Function("replace-slice(start := 2, 4, append := \"abc\")"), - Throws.TypeOf()); - - [Test] - public void Parse_Function_DuplicateNamedArgument_ThrowsSpecificException() - => Assert.That( - () => BindingTestAdapter.Function("replace-slice(start := 2, start := 4, append := \"abc\")"), - Throws.TypeOf()); - - [Test] - public void Parse_Function_MapWithOpenExpressionParameter_Valid() + public void Bind_NamedArguments_PreservesNamesAndOrder() { - var function = BindingTestAdapter.Function("map(upper | first-chars(2))"); + var syntax = SyntaxFactory.Open(SyntaxFactory.Function( + "replace-slice", + SyntaxFactory.Named("append", SyntaxFactory.Text("abc")), + SyntaxFactory.Named("start", SyntaxFactory.Number(2)), + SyntaxFactory.Named("length", SyntaxFactory.Number(4)))); - Assert.That(function.Name, Is.EqualTo("map")); - Assert.That(function.Parameters, Has.Length.EqualTo(1)); - Assert.That(function.Parameters[0], Is.TypeOf()); + var function = Binder.BindFunction(syntax); - var parameter = (OpenExpressionParameter)function.Parameters[0]; - Assert.That(parameter.Expression.Members.Select(x => x.Name), Is.EqualTo(new[] { "upper", "first-chars" })); + Assert.That(function.Arguments.Select(argument => argument.Name), + Is.EqualTo(new[] { "append", "start", "length" })); } [Test] - public void Parse_Function_MapWithPredicateParameter_Valid() + public void Bind_PositionalAfterNamed_ThrowsSpecificException() { - var function = BindingTestAdapter.Function("map(even)"); + var syntax = SyntaxFactory.Open(SyntaxFactory.Function( + "replace-slice", + SyntaxFactory.Named("start", SyntaxFactory.Number(2)), + SyntaxFactory.Argument(SyntaxFactory.Number(4)))); - Assert.That(function.Parameters, Has.Length.EqualTo(1)); - Assert.That(function.Parameters[0], Is.TypeOf()); - Assert.That(((OpenExpressionParameter)function.Parameters[0]).Expression.Members.Single().Name, Is.EqualTo("even")); + Assert.That(() => Binder.BindFunction(syntax), + Throws.TypeOf()); } [Test] - public void Parse_Function_FilterWithExpressionParameter_Valid() + public void Bind_DuplicateNamedArgument_ThrowsSpecificException() { - var function = BindingTestAdapter.Function("filter(greater-than(2))"); - - Assert.That(function.Name, Is.EqualTo("filter")); - Assert.That(function.Parameters, Has.Length.EqualTo(1)); - Assert.That(function.Parameters[0], Is.TypeOf()); - - var parameter = (OpenExpressionParameter)function.Parameters[0]; - Assert.That(parameter.Expression.Members.Single().Name, Is.EqualTo("greater-than")); - } + var syntax = SyntaxFactory.Open(SyntaxFactory.Function( + "replace-slice", + SyntaxFactory.Named("start", SyntaxFactory.Number(2)), + SyntaxFactory.Named("start", SyntaxFactory.Number(4)))); - [Test] - public void Parse_Function_FilterWithAndPredicate_Valid() - { - var function = BindingTestAdapter.Function("filter(greater-than(2) |AND less-than(5))"); - - Assert.That(function.Name, Is.EqualTo("filter")); - Assert.That(function.Parameters, Has.Length.EqualTo(1)); - Assert.That(function.Parameters[0], Is.TypeOf()); - - var parameter = (OpenExpressionParameter)function.Parameters[0]; - var combinator = parameter.Expression.Members.Single(); - Assert.That(combinator.Name, Is.EqualTo("and")); - Assert.That(combinator.Parameters, Has.Length.EqualTo(2)); - - var leftFunction = ((OpenExpressionParameter)combinator.Parameters[0]).Expression.Members.Single(); - Assert.That(leftFunction.Name, Is.EqualTo("greater-than")); - Assert.That(leftFunction.Parameters, Has.Length.EqualTo(1)); - Assert.That(((LiteralParameter)leftFunction.Parameters[0]).Value, Is.EqualTo(2m)); - - var rightFunction = ((OpenExpressionParameter)combinator.Parameters[1]).Expression.Members.Single(); - Assert.That(rightFunction.Name, Is.EqualTo("less-than")); - Assert.That(rightFunction.Parameters, Has.Length.EqualTo(1)); - Assert.That(((LiteralParameter)rightFunction.Parameters[0]).Value, Is.EqualTo(5m)); + Assert.That(() => Binder.BindFunction(syntax), Throws.TypeOf()); } [Test] - public void Parse_Function_MapWithTwoParametersFunction_Valid() + public void Bind_ArraySpread_PreservesSpreadAndOrder() { - var function = BindingTestAdapter.Function("map(add(10,2))"); + var syntax = SyntaxFactory.Open(SyntaxFactory.Function( + "array", + SyntaxFactory.Argument(SyntaxFactory.Number(1)), + SyntaxFactory.Spread(SyntaxFactory.Variable("values")), + SyntaxFactory.Argument(SyntaxFactory.Number(4)))); - Assert.That(function.Name, Is.EqualTo("map")); - Assert.That(function.Parameters, Has.Length.EqualTo(1)); - Assert.That(function.Parameters[0], Is.TypeOf()); + var function = Binder.BindFunction(syntax); - var parameter = (OpenExpressionParameter)function.Parameters[0]; - Assert.That(parameter.Expression.Members.Count(), Is.EqualTo(1)); - Assert.That(parameter.Expression.Members.Single().Name, Is.EqualTo("add")); - Assert.That(parameter.Expression.Members.Single().Parameters, Has.Length.EqualTo(2)); + Assert.Multiple(() => + { + Assert.That(function.Arguments.Select(argument => argument.IsSpread), + Is.EqualTo(new[] { false, true, false })); + Assert.That(function.Arguments[1].Value, Is.TypeOf()); + }); } [Test] - public void Parse_Function_MapWithThreeFunctionsMixedArity_Valid() + public void Bind_ArrayImplicitSpread_UsesIncomingValue() { - var function = BindingTestAdapter.Function("map(increment | add(10) | add(10,2))"); - - Assert.That(function.Name, Is.EqualTo("map")); - Assert.That(function.Parameters, Has.Length.EqualTo(1)); - Assert.That(function.Parameters[0], Is.TypeOf()); - - var parameter = (OpenExpressionParameter)function.Parameters[0]; - var members = parameter.Expression.Members.ToArray(); - - Assert.That(members, Has.Length.EqualTo(3)); - Assert.That(members[0].Name, Is.EqualTo("increment")); - Assert.That(members[0].Parameters, Has.Length.EqualTo(0)); - Assert.That(members[1].Name, Is.EqualTo("add")); - Assert.That(members[1].Parameters, Has.Length.EqualTo(1)); - Assert.That(members[2].Name, Is.EqualTo("add")); - Assert.That(members[2].Parameters.Length, Is.GreaterThanOrEqualTo(2)); - } + var syntax = SyntaxFactory.Open(SyntaxFactory.Function("array", SyntaxFactory.Spread())); - [TestCase("some(less-than(5))")] - [TestCase("some(.active)")] - [TestCase("some(.age | less-than(18))")] - [TestCase("map(.name | upper)")] - public void Parse_Function_WithExpressionParameter_Valid(string value) - { - var function = BindingTestAdapter.Function(value); + var function = Binder.BindFunction(syntax); - Assert.That(function.Parameters, Has.Length.EqualTo(1)); - Assert.That(function.Parameters[0], Is.TypeOf()); + Assert.That(function.Arguments.Single().Value, Is.TypeOf()); } [Test] - public void Parse_Function_WithMultipleExpressionParameters_Valid() + public void Bind_ArrayNamedArgument_ThrowsBindingError() { - var function = BindingTestAdapter.Function( - "coalesce(.nickname, field(name), .display-name | upper)"); + var syntax = SyntaxFactory.Open( + SyntaxFactory.Function("array", SyntaxFactory.Named("value", SyntaxFactory.Number(1)))); - Assert.That(function.Parameters, Has.Length.EqualTo(3)); - Assert.That(function.Parameters, Has.All.TypeOf()); + Assert.That( + () => Binder.BindFunction(syntax), + Throws.TypeOf() + .With.Message.EqualTo("Function 'array' does not support named arguments.")); } [Test] - public void Parse_Function_WithNestedLongFormExpressionParameters_Valid() + public void Bind_RegularFunctionSpread_ThrowsBindingError() { - var function = BindingTestAdapter.Function( - "coalesce(field(nickname), field(name))"); + var syntax = SyntaxFactory.Open( + SyntaxFactory.Function("add", SyntaxFactory.Spread(SyntaxFactory.Array(SyntaxFactory.Number(1))))); - var expressions = function.Parameters.Cast().ToArray(); - Assert.That(expressions, Has.Length.EqualTo(2)); - Assert.That(expressions.Select(x => x.Expression.Members.Single().Name), - Is.EqualTo(new[] { "field", "field" })); - } - - [TestCase("\"value\"", typeof(QuotedLiteralParameter))] - [TestCase("\"name\"", typeof(QuotedLiteralParameter))] - [TestCase("@foo", typeof(VariableParameter))] - [TestCase("^.name", typeof(ObjectPropertyParameter))] - public void Parse_Function_WithScalarParameter_PreservesParameterType(string value, Type expectedType) - { - var function = BindingTestAdapter.Function($"example({value})"); - - Assert.That(function.Parameters.Single(), Is.TypeOf(expectedType)); + Assert.That( + () => Binder.BindFunction(syntax), + Throws.TypeOf() + .With.Message.EqualTo("Function 'add' does not support spread arguments.")); } [Test] - public void Parse_Function_RecordWithEntries_Valid() + public void Bind_FunctionWithExpressionParameter_CreatesOpenExpressionParameter() { - var function = BindingTestAdapter.Function("record(..., name := field(name) | upper, original := ..., active := #true)"); - - Assert.That(function.Name, Is.EqualTo("record")); - Assert.That(function.Parameters, Has.Length.EqualTo(1)); - Assert.That(function.Parameters[0], Is.TypeOf()); - - var parameter = (RecordDefinitionParameter)function.Parameters[0]; - Assert.That(parameter.Entries, Has.Length.EqualTo(4)); - Assert.That(parameter.Entries[0], Is.TypeOf()); - Assert.That(parameter.Entries[1], Is.TypeOf()); - Assert.That(parameter.Entries[2], Is.TypeOf()); - Assert.That(parameter.Entries[3], Is.TypeOf()); - } + var expression = SyntaxFactory.Parenthesized(SyntaxFactory.Open( + SyntaxFactory.Function("upper"), + SyntaxFactory.Function("first-chars", SyntaxFactory.Argument(SyntaxFactory.Number(2))))); + var syntax = SyntaxFactory.Open(SyntaxFactory.Function("map", SyntaxFactory.Argument(expression))); - [Test] - public void Parse_Function_RecordWithTrailingComma_Valid() - { - var function = BindingTestAdapter.Function("record(name := \"Alice\",)"); + var function = Binder.BindFunction(syntax); - Assert.That(function.Name, Is.EqualTo("record")); - Assert.That(function.Parameters, Has.Length.EqualTo(1)); - Assert.That(function.Parameters[0], Is.TypeOf()); + Assert.That(function.Parameters.Single(), Is.TypeOf()); + Assert.That(((OpenExpressionParameter)function.Parameters.Single()).Expression.Members.Select(member => member.Name), + Is.EqualTo(new[] { "upper", "first-chars" })); } } diff --git a/Expressif.Testing/Bindings/IntervalBinderTest.cs b/Expressif.Testing/Bindings/IntervalBinderTest.cs index 1c5bde9d..1efde48f 100644 --- a/Expressif.Testing/Bindings/IntervalBinderTest.cs +++ b/Expressif.Testing/Bindings/IntervalBinderTest.cs @@ -1,148 +1,49 @@ using Expressif.Bindings; -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Expressif.Testing.Bindings; public class IntervalBinderTest { - [Test] - [TestCase("I[25, 40]", '[', "25", "40", ']')] - [TestCase("I(25, 40]", ']', "25", "40", ']')] - [TestCase("I(25, 40)", ']', "25", "40", '[')] - [TestCase("I[25, 40)", '[', "25", "40", '[')] - [TestCase("I]25, 40]", ']', "25", "40", ']')] - [TestCase("I]25, 40[", ']', "25", "40", '[')] - [TestCase("I[25, 40[", '[', "25", "40", '[')] - [TestCase("I[-25.1221, 40.125]", '[', "-25.1221", "40.125", ']')] - public void Parse_IntervalDecimal_Valid(string value, char lowerBoundIntervalType, string lowerBound, string upperBound, char upperBoundIntervalType) - { - var interval = BindingTestAdapter.Interval(value); - Assert.That(interval, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(interval.LowerBoundType, Is.EqualTo(lowerBoundIntervalType)); - Assert.That(interval.UpperBoundType, Is.EqualTo(upperBoundIntervalType)); - AssertBound(interval.LowerBound, lowerBound); - AssertBound(interval.UpperBound, upperBound); - }); - } + private static ExpressifBinder Binder { get; } = new(); [Test] - [TestCase("I[25, +INF]", '[', "25", "+INF", ']')] - [TestCase("I]25, +INF]", ']', "25", "+INF", ']')] - [TestCase("I[-INF, 40[", '[', "-INF", "40", '[')] - public void Parse_IntervalInfinite_Valid(string value, char lowerBoundIntervalType, string lowerBound, string upperBound, char upperBoundIntervalType) + public void Bind_FiniteInterval_PreservesBoundsAndInclusivity() { - var interval = BindingTestAdapter.Interval(value); - Assert.That(interval, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(interval.LowerBoundType, Is.EqualTo(lowerBoundIntervalType)); - Assert.That(interval.UpperBoundType, Is.EqualTo(upperBoundIntervalType)); - AssertBound(interval.LowerBound, lowerBound); - AssertBound(interval.UpperBound, upperBound); - }); - } + var syntax = SyntaxFactory.Interval( + new IntervalBound(IntervalBoundKind.Finite, SyntaxFactory.Number(25)), + new IntervalBound(IntervalBoundKind.Finite, SyntaxFactory.Number(40)), + true, + false); - [Test] - [TestCase("I(+)", ']', "0", "+INF", ']')] - [TestCase("I(-)", '[', "-INF", "0", '[')] - [TestCase("I(0+)", '[', "0", "+INF", ']')] - [TestCase("I(0-)", '[', "-INF", "0", ']')] - public void Parse_IntervalZeroBasedShorthand_Valid(string value, char lowerBoundIntervalType, string lowerBound, string upperBound, char upperBoundIntervalType) - { - var interval = BindingTestAdapter.Interval(value); - Assert.That(interval, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(interval.LowerBoundType, Is.EqualTo(lowerBoundIntervalType)); - Assert.That(interval.UpperBoundType, Is.EqualTo(upperBoundIntervalType)); - AssertBound(interval.LowerBound, lowerBound); - AssertBound(interval.UpperBound, upperBound); - }); - } + var interval = BindInterval(syntax); - [Test] - [TestCase("I(absolutely-positive)", ']', "0", "+INF", ']')] - [TestCase("I(absolutely-negative)", '[', "-INF", "0", '[')] - [TestCase("I(positive)", '[', "0", "+INF", ']')] - [TestCase("I(negative)", '[', "-INF", "0", ']')] - public void Parse_IntervalZeroBasedLonghand_Valid(string value, char lowerBoundIntervalType, string lowerBound, string upperBound, char upperBoundIntervalType) - { - var interval = BindingTestAdapter.Interval(value); - Assert.That(interval, Is.Not.Null); Assert.Multiple(() => { - Assert.That(interval.LowerBoundType, Is.EqualTo(lowerBoundIntervalType)); - Assert.That(interval.UpperBoundType, Is.EqualTo(upperBoundIntervalType)); - AssertBound(interval.LowerBound, lowerBound); - AssertBound(interval.UpperBound, upperBound); + Assert.That(interval.LowerBoundType, Is.EqualTo('[')); + Assert.That(interval.UpperBoundType, Is.EqualTo('[')); + Assert.That(interval.LowerBound.Value, Is.EqualTo(25m)); + Assert.That(interval.UpperBound.Value, Is.EqualTo(40m)); }); } [Test] - [TestCase("I(>40)", ']', "40", "+INF", ']')] - [TestCase("I(<40)", '[', "-INF", "40", '[')] - [TestCase("I(>=40)", '[', "40", "+INF", ']')] - [TestCase("I(<=40)", '[', "-INF", "40", ']')] - public void Parse_IntervalNonZeroBasedShorthand_Valid(string value, char lowerBoundIntervalType, string lowerBound, string upperBound, char upperBoundIntervalType) + public void Bind_InfiniteInterval_PreservesBoundKinds() { - var interval = BindingTestAdapter.Interval(value); - Assert.That(interval, Is.Not.Null); - Assert.Multiple(() => - { - Assert.That(interval.LowerBoundType, Is.EqualTo(lowerBoundIntervalType)); - Assert.That(interval.UpperBoundType, Is.EqualTo(upperBoundIntervalType)); - AssertBound(interval.LowerBound, lowerBound); - AssertBound(interval.UpperBound, upperBound); - }); - } + var syntax = SyntaxFactory.Interval( + new IntervalBound(IntervalBoundKind.NegativeInfinity, null), + new IntervalBound(IntervalBoundKind.PositiveInfinity, null), + true, + true); + + var interval = BindInterval(syntax); - [Test] - [TestCase("I[#\"2022-10-01\", #\"2022-12-01\"]", '[', "2022-10-01", "2022-12-01", ']')] - [TestCase("I]#\"2022-10-01\", #\"2022-12-01\"]", ']', "2022-10-01", "2022-12-01", ']')] - [TestCase("I]#\"2022-10-01\", #\"2022-12-01\"[", ']', "2022-10-01", "2022-12-01", '[')] - [TestCase("I[#\"2022-10-01\", #\"2022-12-01\"[", '[', "2022-10-01", "2022-12-01", '[')] - [TestCase("I[#\"2022-10-01T16:45:12\", #\"2022-12-17T12:24:20\"]", '[', "2022-10-01 16:45:12", "2022-12-17 12:24:20", ']')] - public void Parse_IntervalDateTime_Valid(string value, char lowerBoundIntervalType, string lowerBound, string upperBound, char upperBoundIntervalType) - { - var interval = BindingTestAdapter.Interval(value); - Assert.That(interval, Is.Not.Null); Assert.Multiple(() => { - Assert.That(interval.LowerBoundType, Is.EqualTo(lowerBoundIntervalType)); - Assert.That(interval.UpperBoundType, Is.EqualTo(upperBoundIntervalType)); - AssertBound(interval.LowerBound, lowerBound); - AssertBound(interval.UpperBound, upperBound); + Assert.That(interval.LowerBound.Kind, Is.EqualTo(IntervalBoundBindingKind.NegativeInfinity)); + Assert.That(interval.UpperBound.Kind, Is.EqualTo(IntervalBoundBindingKind.PositiveInfinity)); }); } - private static void AssertBound(IntervalBoundBinding actual, string expected) - { - var expectedKind = expected switch - { - "-INF" => IntervalBoundBindingKind.NegativeInfinity, - "+INF" => IntervalBoundBindingKind.PositiveInfinity, - _ => IntervalBoundBindingKind.Finite, - }; - Assert.That(actual.Kind, Is.EqualTo(expectedKind)); - - if (expectedKind is not IntervalBoundBindingKind.Finite) - { - Assert.That(actual.Value, Is.Null); - return; - } - - object expectedValue = expected.Contains(':', StringComparison.Ordinal) - ? (object)DateTime.Parse(expected, CultureInfo.InvariantCulture) - : DateOnly.TryParse(expected, CultureInfo.InvariantCulture, out var date) - ? (object)date - : (object)decimal.Parse(expected, CultureInfo.InvariantCulture); - Assert.That(actual.Value, Is.EqualTo(expectedValue)); - } + private static IntervalBinding BindInterval(IntervalLiteralSyntax interval) + => ((IntervalParameter)Binder.BindParameter(SyntaxFactory.Closed(interval))).Value; } diff --git a/Expressif.Testing/Bindings/ParameterBinderTest.cs b/Expressif.Testing/Bindings/ParameterBinderTest.cs index 2cf9ff2d..dba6126e 100644 --- a/Expressif.Testing/Bindings/ParameterBinderTest.cs +++ b/Expressif.Testing/Bindings/ParameterBinderTest.cs @@ -1,107 +1,99 @@ using Expressif.Bindings; -using Expressif.Values; namespace Expressif.Testing.Bindings; public class ParameterBinderTest { - [SetUp] - public void Setup() - { } + private static ExpressifBinder Binder { get; } = new(); [Test] public void Bind_LiteralSyntax_PreservesTypedValues() { Assert.Multiple(() => { - Assert.That(((LiteralParameter)BindingTestAdapter.Parameter("42")).Value, Is.EqualTo(42m).And.TypeOf()); - Assert.That(((LiteralParameter)BindingTestAdapter.Parameter("#true")).Value, Is.EqualTo(true).And.TypeOf()); - Assert.That(((LiteralParameter)BindingTestAdapter.Parameter("#null")).Value, Is.Null); - Assert.That(((QuotedLiteralParameter)BindingTestAdapter.Parameter("\"Alice\"")).Value, Is.EqualTo("Alice").And.TypeOf()); - Assert.That(((LiteralParameter)BindingTestAdapter.Parameter("#\"2026-08-17\"")).Value, Is.EqualTo(new DateOnly(2026, 8, 17)).And.TypeOf()); - Assert.That(((LiteralParameter)BindingTestAdapter.Parameter("#\"2026-08-17T14:30:00\"")).Value, Is.EqualTo(new DateTime(2026, 8, 17, 14, 30, 0)).And.TypeOf()); - Assert.That(((LiteralParameter)BindingTestAdapter.Parameter("#\"14:30:00\"")).Value, Is.EqualTo(new TimeOnly(14, 30, 0)).And.TypeOf()); + Assert.That(BindParameter(SyntaxFactory.Number(42)), Is.EqualTo(new LiteralParameter(42m))); + Assert.That(BindParameter(SyntaxFactory.Boolean(true)), Is.EqualTo(new LiteralParameter(true))); + Assert.That(BindParameter(SyntaxFactory.Null()), Is.EqualTo(new LiteralParameter(null))); + Assert.That(BindParameter(SyntaxFactory.Text("Alice")), + Is.EqualTo(new QuotedLiteralParameter("Alice"))); }); } [Test] - [TestCase("`foo`", typeof(QuotedLiteralParameter))] - [TestCase("\"foo\"", typeof(QuotedLiteralParameter))] - [TestCase("@foo", typeof(VariableParameter))] - [TestCase("^.foo", typeof(ObjectPropertyParameter))] - [TestCase("^.52", typeof(ObjectIndexParameter))] - [TestCase("{}", typeof(ArrayParameter))] - [TestCase("{:}", typeof(RecordLiteralParameter))] - [TestCase("{1,2,3}", typeof(ArrayParameter))] - [TestCase("{name := \"Alice\"}", typeof(RecordLiteralParameter))] - [TestCase("{`first name` := \"Alice Smith\", active := #true}", typeof(RecordLiteralParameter))] - [TestCase("{@foo}", typeof(ArrayParameter))] - [TestCase("{ @foo, ^.1, ^.bar }", typeof(ArrayParameter))] - [TestCase("( @foo | text-to-func(\"bar\") )", typeof(InputExpressionParameter))] - [TestCase("T(10, 20)", typeof(TupleParameter))] - [TestCase("T(1, T(2, 3))", typeof(TupleParameter))] - public void Parse_Parameter_Valid(string value, Type type) - => Assert.That(BindingTestAdapter.Parameter(value), Is.TypeOf(type)); + public void Bind_References_PreserveParameterTypes() + { + Assert.Multiple(() => + { + Assert.That(BindParameter(SyntaxFactory.Variable("foo")), Is.TypeOf()); + Assert.That(BindParameter(SyntaxFactory.RecordAccess("foo")), Is.TypeOf()); + Assert.That(BindParameter(SyntaxFactory.Incoming()), Is.TypeOf()); + }); + } [Test] - public void Parse_ParenthesizedClosedExpression_PreservesSourceAndPipeline() + public void Bind_ArrayLiteral_PreservesSpreadElements() { - var parameter = (InputExpressionParameter)BindingTestAdapter.Parameter("( @foo | text-to-func(\"bar\") )"); + var syntax = SyntaxFactory.Array( + SyntaxFactory.ArrayElement(SyntaxFactory.Number(1)), + SyntaxFactory.ArrayElement(SyntaxFactory.Variable("items"), true), + SyntaxFactory.ArrayElement(null, true)); + + var parameter = (ArrayParameter)BindParameter(syntax); Assert.Multiple(() => { - Assert.That(parameter.Expression.Parameter, Is.EqualTo(new VariableParameter("foo"))); - Assert.That(parameter.Expression.Members.Select(x => x.Name), Is.EqualTo(new[] { "text-to-func" })); + Assert.That(parameter.Elements.Select(element => element.IsSpread), + Is.EqualTo(new[] { false, true, true })); + Assert.That(parameter.Elements.Last().Value, Is.TypeOf()); }); } [Test] - [TestCase("(\"foo\", \"bar\")")] - [TestCase("( \"foo\", \"bar\" ) ")] - [TestCase("(@foo , \"bar\")")] - [TestCase("(^.foo , ^.1)")] - [TestCase("(I[10, 45] , ^.1)")] - [TestCase("(I[10, 45[ , ^.foo)")] - [TestCase("(@foo , { @foo | text-to-func(\"bar\", @foo) })")] - [TestCase("(@foo , { @foo | text-to-func(\"bar\", { @fool | numeric-to-func(^.3, ^.bez) }) })")] - public void Parse_Parameters_Valid(string value) - => Assert.That(BindingTestAdapter.Parameters(value).Count, Is.EqualTo(2)); + public void Bind_RecordLiteral_PreservesNamedFields() + { + var syntax = SyntaxFactory.Record( + SyntaxFactory.Field("name", SyntaxFactory.Text("Alice")), + SyntaxFactory.Field("active", SyntaxFactory.Boolean(true))); + + var parameter = (RecordLiteralParameter)BindParameter(syntax); + + Assert.That(parameter.Fields.Select(field => field.Name), Is.EqualTo(new[] { "name", "active" })); + } [Test] - [TestCase("{{1, 2, 3}, {4, 5}}")] - [TestCase("{{\"a\", \"b\"}, {\"c\"}}")] - [TestCase("{{#true, #false}, {#null, 3}}")] - public void Parse_Parameter_NestedArrays_Valid(string value) + public void Bind_RecordLiteralUnnamedSpread_ThrowsBindingError() { - var parsed = BindingTestAdapter.Parameter(value); + var syntax = SyntaxFactory.Record(new RecordSpreadSyntax(new SourceSpan(0, 3), "...")); - Assert.Multiple(() => - { - Assert.That(parsed, Is.TypeOf()); - var outer = (ArrayParameter)parsed; - Assert.That(outer.Values, Has.Length.EqualTo(2)); - Assert.That(outer.Values[0], Is.TypeOf()); - Assert.That(outer.Values[1], Is.TypeOf()); - }); + Assert.That( + () => BindParameter(syntax), + Throws.TypeOf() + .With.Message.EqualTo("Record literal spread entries must specify a field name.")); } [Test] - public void Parse_Parameter_EmptyRecordLiteral_ParsesNoFields() + public void Bind_RecordLiteralSpreadField_ThrowsBindingError() { - var parsed = BindingTestAdapter.Parameter("{:}"); + var syntax = SyntaxFactory.Record(SyntaxFactory.Field("field", SyntaxFactory.Variable("value"), true)); - Assert.Multiple(() => - { - Assert.That(parsed, Is.TypeOf()); - Assert.That(((RecordLiteralParameter)parsed).Fields, Is.Empty); - }); + Assert.That( + () => BindParameter(syntax), + Throws.TypeOf() + .With.Message.EqualTo("Record literal field 'field' does not support spread values.")); } [Test] - public void Parse_TupleLiteral_RoundTripsThroughClosedExpression() + public void Bind_TupleLiteral_PreservesNestedTuple() { - var value = Expression.CreateClosed("T(1, T(2, 3))").Evaluate(null); + var syntax = SyntaxFactory.Tuple( + SyntaxFactory.Number(1), + SyntaxFactory.Tuple(SyntaxFactory.Number(2), SyntaxFactory.Number(3))); - Assert.That(ValueFormatter.Format(value), Is.EqualTo("T(1, T(2, 3))")); + var parameter = (TupleParameter)BindParameter(syntax); + + Assert.That(parameter.Values.Last(), Is.TypeOf()); } + + private static IParameter BindParameter(ValueSyntax value) + => Binder.BindParameter(SyntaxFactory.Closed(value)); } diff --git a/Expressif.Testing/Bindings/PredicationBinderTest.cs b/Expressif.Testing/Bindings/PredicationBinderTest.cs index ace9ab94..e5ef0a76 100644 --- a/Expressif.Testing/Bindings/PredicationBinderTest.cs +++ b/Expressif.Testing/Bindings/PredicationBinderTest.cs @@ -1,77 +1,60 @@ using Expressif.Bindings; -using System.Diagnostics; namespace Expressif.Testing.Bindings; public class PredicationBinderTest { - [SetUp] - public void Setup() - { } + private static ExpressifBinder Binder { get; } = new(); [Test] - [TestCase("is-func(foo, @bar)", 1)] - [TestCase("is-func", 1)] - [TestCase("!is-func(foo)", 2)] - [TestCase(" ! is-func(foo)", 2)] - [TestCase("(is-func(foo))", 2)] - [TestCase("is-func(foo) |AND is-foo", 2)] - [TestCase("(is-func(foo) |AND is-foo)", 2)] - [TestCase("(is-func(foo) |AND is-foo) |OR bar(123)", 2)] - [TestCase("(is-func(foo) |AND is-foo) |OR !bar(123)", 2)] - public void Parse_Predication_Valid(string value, int count) - => Assert.That(BindingTestAdapter.Predication(value), Is.Not.Null); - - [Test] - [TestCase("123 | !equal-to(125)")] - [TestCase("123 | ! equal-to(125) ")] - [TestCase("123 | !equal-to(125) |OR even ")] - [TestCase("123 | ( ! equal-to(125) ) ")] - [TestCase("123 | ( ! equal-to(125) |OR even ) |AND !null ")] - public void Parse_ParametrizedPredication_Valid(string value) - => Assert.That(BindingTestAdapter.Predication(value), Is.Not.Null); + public void Bind_SinglePredication_PreservesFunction() + { + var syntax = SyntaxFactory.Open(SyntaxFactory.Function("even")); - [Test] - [TestCase("is-func")] - [TestCase("is-func(foo, @bar)")] - public void Parse_SinglePredication_Valid(string value) - => Assert.That(BindingTestAdapter.Predication(value), Is.Not.Null); + var predication = (SinglePredication)Binder.BindPredication(syntax); - [Test] - [TestCase("!is-func")] - [TestCase("!is-func(foo, @bar)")] - public void Parse_UnaryPredication_Valid(string value) - => Assert.That(BindingTestAdapter.Predication(value), Is.Not.Null); + Assert.That(predication.Members.Single().Name, Is.EqualTo("even")); + } [Test] - [TestCase("is-func(foo) |AND is-foo")] - public void Parse_BinaryPredication_Valid(string value) - => Assert.That(BindingTestAdapter.Predication(value), Is.Not.Null); - - public void Parse_NotShorthand_LowersToCombinator() + public void Bind_UnaryPredication_LowersToNot() { - var predication = (SinglePredication)BindingTestAdapter.Predication("!even"); + var syntax = SyntaxFactory.Open(SyntaxFactory.Unary("!", SyntaxFactory.Function("even"))); - Assert.That(predication.Members.Last().Name, Is.EqualTo("not")); + var predication = (SinglePredication)Binder.BindPredication(syntax); + + Assert.That(predication.Members.Select(member => member.Name), Is.EqualTo(new[] { "even", "not" })); } - [TestCase("even |AND odd", "and")] - [TestCase("even |OR odd", "or")] - [TestCase("even |XOR odd", "xor")] - public void Parse_BinaryShorthand_PreservesBothOperands(string value, string expected) + [Test] + public void Bind_BinaryPredication_LowersToCombinator() { - var predication = (BinaryPredication)BindingTestAdapter.Predication(value); + var syntax = SyntaxFactory.Open(SyntaxFactory.Binary( + SyntaxFactory.Function("even"), + "|AND", + SyntaxFactory.Function("odd"))); + + var predication = (BinaryPredication)Binder.BindPredication(syntax); Assert.Multiple(() => { - Assert.That(predication.Operator.Name, Is.EqualTo(expected)); + Assert.That(predication.Operator.Name, Is.EqualTo("and")); Assert.That(predication.LeftMember, Is.TypeOf()); Assert.That(predication.RightMember, Is.TypeOf()); }); } [Test] - [TestCase("(is-func)")] - public void Parse_SubPredication_Valid(string value) - => Assert.That(BindingTestAdapter.Predication(value), Is.Not.Null); + public void Bind_ClosedPredication_PreservesSourceMember() + { + var syntax = SyntaxFactory.Closed( + SyntaxFactory.Number(123), + SyntaxFactory.Unary("!", SyntaxFactory.Function( + "equal-to", + SyntaxFactory.Argument(SyntaxFactory.Number(125))))); + + var predication = (SinglePredication)Binder.BindPredication(syntax); + + Assert.That(predication.Members.Select(member => member.Name), Is.EqualTo(new[] { "equal-to", "not" })); + } } diff --git a/Expressif.Testing/Bindings/SyntaxFactory.cs b/Expressif.Testing/Bindings/SyntaxFactory.cs new file mode 100644 index 00000000..d6630d72 --- /dev/null +++ b/Expressif.Testing/Bindings/SyntaxFactory.cs @@ -0,0 +1,115 @@ +namespace Expressif.Testing.Bindings; + +internal static class SyntaxFactory +{ + private static SourceSpan Span(string text) => new(0, text.Length); + + public static FunctionCallSyntax Function(string name, params ArgumentSyntax[] arguments) + => new(Span(name), name, name, arguments.Length > 0, arguments); + + public static PositionalArgumentSyntax Argument(ExpressionSyntax value) + => new(value.Span, value.Text, value); + + public static NamedArgumentSyntax Named(string name, ExpressionSyntax value) + => new(Span(name), name, new ArgumentNameSyntax(Span(name), name, name, false, null), value); + + public static SpreadArgumentSyntax Spread(ExpressionSyntax? value = null) + => new(Span("..."), "...", value); + + public static OpenExpressionSyntax Open(ExpressionSyntax? source = null, params ExpressionSyntax[] pipeline) + => new(new SourceSpan(0, 0), string.Empty, source, pipeline); + + public static ClosedExpressionSyntax Closed(ValueSyntax value, params ExpressionSyntax[] pipeline) + => new(new SourceSpan(0, 0), string.Empty, value, pipeline); + + public static ParenthesizedExpressionSyntax Parenthesized(RootExpressionSyntax expression) + => new(new SourceSpan(0, 0), string.Empty, expression); + + public static MapShorthandSyntax Map(params ExpressionSyntax[] pipeline) + => new(new SourceSpan(0, 0), string.Empty, Open(null, pipeline)); + + public static NumericLiteralSyntax Number(decimal value) + { + var text = value.ToString(System.Globalization.CultureInfo.InvariantCulture); + return new NumericLiteralSyntax(Span(text), text); + } + + public static QuotedLiteralSyntax Text(string value) + { + var text = $"\"{value}\""; + return new QuotedLiteralSyntax(Span(text), text, QuotingStyle.DoubleQuote); + } + + public static BooleanLiteralSyntax Boolean(bool value) + { + var text = value ? "#true" : "#false"; + return new BooleanLiteralSyntax(Span(text), text); + } + + public static VariableSyntax Variable(string name) + { + var text = $"@{name}"; + return new VariableSyntax(Span(text), text); + } + + public static IncomingValueSyntax Incoming() => new(Span("@_"), "@_"); + + public static NullLiteralSyntax Null() => new(Span("#null"), "#null"); + + public static ArrayLiteralSyntax Array(params ExpressionSyntax[] values) + => new( + new SourceSpan(0, 0), + string.Empty, + values.Select(value => new ArrayElementSyntax(value.Span, value.Text, value, false))); + + public static ArrayLiteralSyntax Array(params ArrayElementSyntax[] elements) + => new(new SourceSpan(0, 0), string.Empty, elements); + + public static ArrayElementSyntax ArrayElement(ExpressionSyntax? value, bool spread = false) + => new(new SourceSpan(0, 0), string.Empty, value, spread); + + public static TupleLiteralSyntax Tuple(params ValueSyntax[] values) + => new(new SourceSpan(0, 0), string.Empty, values); + + public static RecordLiteralSyntax Record(params RecordEntrySyntax[] entries) + => new(new SourceSpan(0, 0), string.Empty, entries); + + public static RecordFieldSyntax Field(string name, ExpressionSyntax? value, bool spread = false) + => new( + new SourceSpan(0, 0), + string.Empty, + new RecordFieldNameSyntax(Span(name), name, name, false, null), + value, + spread); + + public static RecordAccessSyntax RecordAccess(string name, bool original = true) + { + var text = $"{(original ? "^" : string.Empty)}.{name}"; + return new(Span(text), text, original, [new RecordFieldSelector(name, null)]); + } + + public static TupleProjectionSyntax TupleProjection(int index) + => new(new SourceSpan(0, 0), string.Empty, TupleProjectionDirection.FromStart, index); + + public static BinaryExpressionSyntax Binary(ExpressionSyntax left, string @operator, ExpressionSyntax right) + => new( + new SourceSpan(0, 0), + string.Empty, + left, + new BinaryOperatorSyntax(Span(@operator), @operator), + right); + + public static UnaryExpressionSyntax Unary(string @operator, ExpressionSyntax operand) + => new( + new SourceSpan(0, 0), + string.Empty, + new UnaryOperatorSyntax(Span(@operator), @operator), + operand); + + public static IntervalLiteralSyntax Interval( + IntervalBound lower, + IntervalBound upper, + bool lowerInclusive, + bool upperInclusive) + => new(new SourceSpan(0, 0), string.Empty, lower, upper, lowerInclusive, upperInclusive); +} diff --git a/Expressif.Testing/ExpressionTest.cs b/Expressif.Testing/ExpressionTest.cs index 91c15c3e..2bef3d39 100644 --- a/Expressif.Testing/ExpressionTest.cs +++ b/Expressif.Testing/ExpressionTest.cs @@ -580,7 +580,7 @@ public void Evaluate_Record_DuplicateExplicitField_ThrowsParseException() [Test] public void Evaluate_Record_EmbeddingIncomingValue_DoesNotExpand() { - var expression = Expression.Create("record(original := ..., normalized := upper)"); + var expression = Expression.Create("record(original := @_, normalized := upper)"); var result = (RecordValue)expression.Evaluate("Alice")!; Assert.Multiple(() => diff --git a/Expressif.Testing/Functions/Array/ArrayTest.cs b/Expressif.Testing/Functions/Array/ArrayTest.cs new file mode 100644 index 00000000..db14fcf3 --- /dev/null +++ b/Expressif.Testing/Functions/Array/ArrayTest.cs @@ -0,0 +1,103 @@ +using Expressif.Testing.Conformance; +using Expressif.Functions.Array; +using System.Text.Json; +using ArrayFunction = Expressif.Functions.Array.Array; + +namespace Expressif.Testing.Functions.Array; + +[TestFixture] +public class ArrayTest +{ + [Conformance] + public void Array_Valid_VariadicValues(object? input, string expression, string expected) + => Assert.That(JsonSerializer.Serialize(Expression.Create(expression).Evaluate(input)), Is.EqualTo(expected)); + + [Test] + public void Evaluate_ArgumentsAndSpreads_EvaluatesOnceFromLeftToRight() + { + var order = new List(); + var function = new ArrayFunction(() => + [ + new(input => { order.Add("a"); return 1; }), + new(input => { order.Add("b"); return new[] { 2, 3 }; }, true), + new(input => { order.Add("c"); return 4; }), + ]); + + Assert.Multiple(() => + { + Assert.That(function.Evaluate(null), Is.EqualTo(new[] { 1, 2, 3, 4 })); + Assert.That(order, Is.EqualTo(new[] { "a", "b", "c" })); + }); + } + + [Test] + public void Expression_VariableSpread_ExpandsInPlace() + { + var context = new Context(); + context.Variables.Add("values", new[] { 2, 3 }); + + Assert.That( + Expression.Create("array(1, ...@values, 4)", context).Evaluate(null), + Is.EqualTo(new[] { 1, 2, 3, 4 })); + } + + [Test] + public void Expression_VariablePipeline_ImplicitSpread_ExpandsCurrentInput() + { + var context = new Context(); + context.Variables.Add("items", new[] { 1, 2, 3 }); + + Assert.That( + Expression.Create("@items | array(0, ..., 4)", context).Evaluate(null), + Is.EqualTo(new[] { 0, 1, 2, 3, 4 })); + } + + [Test] + public void Expression_VariablePipeline_ExplicitCurrentInputSpread_ExpandsCurrentInput() + { + var context = new Context(); + context.Variables.Add("items", new[] { 1, 2, 3 }); + + Assert.That( + Expression.Create("@items | array(0, ...@_, 4)", context).Evaluate(null), + Is.EqualTo(new[] { 0, 1, 2, 3, 4 })); + } + + [Test] + public void Expression_ArrayLiteralPipeline_ImplicitSpread_ExpandsCurrentInput() + => Assert.That( + Expression.Create("{1, 2, 3} | array(0, ..., 4)").Evaluate(null), + Is.EqualTo(new[] { 0, 1, 2, 3, 4 })); + + [Test] + public void Expression_VariablePipeline_ComputedSpread_EvaluatesAgainstCurrentInput() + { + var context = new Context(); + context.Variables.Add("items", new[] { 1, 2, 3 }); + + Assert.That( + Expression.Create("@items | array(0, ...(filter(greater-than(1))), 4)", context).Evaluate(null), + Is.EqualTo(new[] { 0, 2, 3, 4 })); + } + + [TestCase("array(...#null)", "Spread argument cannot be null.")] + [TestCase("array(...42)", "Spread argument must evaluate to an array.")] + [TestCase("array(...\"abc\")", "Spread argument must evaluate to an array.")] + public void Expression_InvalidSpread_ThrowsSpecificError(string source, string message) + => Assert.That( + () => Expression.Create(source).Evaluate(null), + Throws.TypeOf().With.Message.EqualTo(message)); + + [Test] + public void ArrayLiteral_Spread_ExpandsInPlace() + => Assert.That( + Expression.CreateClosed("{1, ...{2, 3}, 4}").Evaluate(null), + Is.EqualTo(new[] { 1, 2, 3, 4 })); + + [TestCase("{before := 1, ..., after := 2}", "Record literal spread entries must specify a field name.")] + [TestCase("{field := ...@value}", "Record literal field 'field' does not support spread values.")] + public void RecordLiteral_Spread_ThrowsBindingError(string source, string message) + => Assert.That( + () => Expression.CreateClosed(source), + Throws.TypeOf().With.Message.EqualTo(message)); +} diff --git a/Expressif.Testing/Functions/FunctionFactoryTest.cs b/Expressif.Testing/Functions/FunctionFactoryTest.cs index e17cda98..8976b51d 100644 --- a/Expressif.Testing/Functions/FunctionFactoryTest.cs +++ b/Expressif.Testing/Functions/FunctionFactoryTest.cs @@ -15,13 +15,13 @@ public class FunctionFactoryTest [TestCase("even", 5, false)] [TestCase("is-even", 4, true)] public void Instantiate_PredicateOnlyExpression_EvaluatesBoolean(string source, object value, bool expected) - => Assert.That(BindingTestAdapter.Executable(source).Evaluate(value), Is.EqualTo(expected)); + => Assert.That(Instantiate(source, new Context()).Evaluate(value), Is.EqualTo(expected)); [TestCase(4, false)] [TestCase(6, true)] [TestCase(7, false)] public void Instantiate_ComposedPredicateOnlyExpression_EvaluatesAgainstOriginalInput(object value, bool expected) - => Assert.That(BindingTestAdapter.Executable("even |AND greater-than(5)").Evaluate(value), Is.EqualTo(expected)); + => Assert.That(Instantiate("even |AND greater-than(5)", new Context()).Evaluate(value), Is.EqualTo(expected)); [TestCase("replace-slice(2, 4, \"abc\")")] [TestCase("replace-slice(start := 2, length := 4, append := \"abc\")")] @@ -29,7 +29,7 @@ public void Instantiate_ComposedPredicateOnlyExpression_EvaluatesAgainstOriginal [TestCase("replace-slice(append := \"abc\", start := 2, length := 4)")] public void Instantiate_ReplaceSliceArgumentForms_Equivalent(string source) { - var function = BindingTestAdapter.Function(source); + var function = new ExpressifBinder().BindFunction(ExpressifSyntax.Parse(source)); var root = new OpenRootExpression(new OpenExpression([function])); var runtime = new FunctionFactory().Instantiate(root, new Context()); @@ -42,7 +42,7 @@ public void Instantiate_ReplaceSliceArgumentForms_Equivalent(string source) [TestCase("replace-slice(1, 2, \"x\", 4)", typeof(TooManyPositionalArgumentsException))] public void Instantiate_InvalidNamedArguments_ThrowsSpecificException(string source, Type exceptionType) { - var function = BindingTestAdapter.Function(source); + var function = new ExpressifBinder().BindFunction(ExpressifSyntax.Parse(source)); var root = new OpenRootExpression(new OpenExpression([function])); Assert.That(() => new FunctionFactory().Instantiate(root, new Context()), Throws.TypeOf(exceptionType)); @@ -145,7 +145,7 @@ public void Instantiate_RoundMultipleExpressionParameter_Valid() [Test] public void Instantiate_FoldWithAccumulatorName_Valid() { - var function = BindingTestAdapter.Executable("fold(sum)", new Context()); + var function = Instantiate("fold(sum)", new Context()); var fold = GetSingleFunction(function); var accumulator = fold.Accumulator.Invoke(); @@ -156,7 +156,7 @@ public void Instantiate_FoldWithAccumulatorName_Valid() [Test] public void Instantiate_BroadcastWithAccumulatorName_Valid() { - var function = BindingTestAdapter.Executable("broadcast(sum)", new Context()); + var function = Instantiate("broadcast(sum)", new Context()); var broadcast = GetSingleFunction(function); var accumulator = broadcast.Accumulator.Invoke(); @@ -167,7 +167,7 @@ public void Instantiate_BroadcastWithAccumulatorName_Valid() [Test] public void Instantiate_ScanWithAccumulatorName_Valid() { - var function = BindingTestAdapter.Executable("scan(sum)", new Context()); + var function = Instantiate("scan(sum)", new Context()); var scan = GetSingleFunction(function); var accumulator = scan.Accumulator.Invoke(); @@ -178,7 +178,7 @@ public void Instantiate_ScanWithAccumulatorName_Valid() [Test] public void Instantiate_Lag_Valid() { - var function = BindingTestAdapter.Executable("lag", new Context()); + var function = Instantiate("lag", new Context()); var lag = GetSingleFunction(function); Assert.That(lag, Is.Not.Null); @@ -187,7 +187,7 @@ public void Instantiate_Lag_Valid() [Test] public void Instantiate_Lead_Valid() { - var function = BindingTestAdapter.Executable("lead", new Context()); + var function = Instantiate("lead", new Context()); var lead = GetSingleFunction(function); Assert.That(lead, Is.Not.Null); @@ -201,7 +201,7 @@ public void Instantiate_Lead_Valid() [TestCase("skip-last(2)", typeof(SkipLastElements))] public void Instantiate_ArraySelectionAliases_Valid(string expression, Type expectedType) { - var function = BindingTestAdapter.Executable(expression, new Context()); + var function = Instantiate(expression, new Context()); var selectionFunction = GetSingleFunction(function, expectedType); Assert.That(selectionFunction, Is.Not.Null); @@ -216,7 +216,7 @@ public void Instantiate_ArraySelectionAliases_Valid(string expression, Type expe [Test] public void Instantiate_SliceElements_Valid() { - var function = BindingTestAdapter.Executable("slice-elements(1,4)", new Context()); + var function = Instantiate("slice-elements(1,4)", new Context()); var sliceElements = GetSingleFunction(function); Assert.That(sliceElements, Is.Not.Null); @@ -227,7 +227,7 @@ public void Instantiate_SliceElements_Valid() [Test] public void Instantiate_MapWithOpenExpressionParameter_Valid() { - var function = BindingTestAdapter.Executable("map(lower | trim)", new Context()); + var function = Instantiate("map(lower | trim)", new Context()); var map = GetSingleFunction(function); var transformation = map.Transformation.Invoke(); var transformationFunctions = GetFunctions(transformation).ToArray(); @@ -242,7 +242,7 @@ public void Instantiate_MapWithOpenExpressionParameter_Valid() [Test] public void Instantiate_FilterWithPredicateExpression_Valid() { - var function = BindingTestAdapter.Executable("filter(greater-than(2))", new Context()); + var function = Instantiate("filter(greater-than(2))", new Context()); var filter = GetSingleFunction(function); var predicate = filter.Predicate.Invoke(); @@ -271,7 +271,7 @@ public void Instantiate_FilterWithClosedPredicateParameter_ResolvesNestedFunctio [Test] public void Instantiate_SliceAlias_Valid() { - var function = BindingTestAdapter.Executable("slice(1,4)", new Context()); + var function = Instantiate("slice(1,4)", new Context()); var sliceElements = GetSingleFunction(function); Assert.That(sliceElements, Is.Not.Null); @@ -282,7 +282,7 @@ public void Instantiate_SliceAlias_Valid() [Test] public void Instantiate_FunctionWithFuncStringConstructor_NotTreatedAsAggregation() { - var function = BindingTestAdapter.Executable("prefix(`abc`)", new Context()); + var function = Instantiate("prefix(`abc`)", new Context()); var prefix = GetSingleFunction(function); Assert.That(prefix, Is.Not.Null); @@ -301,6 +301,11 @@ private static T GetSingleFunction(IFunction function) ?? throw new InvalidOperationException($"Could not find function of type '{typeof(T).Name}'."); } + private static IFunction Instantiate(string source, IContext context) + => new FunctionFactory().Instantiate( + new ExpressifBinder().Bind(ExpressifSyntax.Parse(source)), + context); + private static IFunction GetSingleFunction(IFunction function, Type expectedType) { return GetFunctions(function).SingleOrDefault(expectedType.IsInstanceOfType) diff --git a/Expressif.Testing/Functions/Numeric/PercentChangeFunctionTest.cs b/Expressif.Testing/Functions/Numeric/PercentChangeFunctionTest.cs index 15f48fe7..955146f7 100644 --- a/Expressif.Testing/Functions/Numeric/PercentChangeFunctionTest.cs +++ b/Expressif.Testing/Functions/Numeric/PercentChangeFunctionTest.cs @@ -21,6 +21,6 @@ public void PercentChange_Valid_Previous(object value, object previous, decimal? [TestCase("100 | percent-change(\"abc\")", null)] public void Instantiate_Expression_Valid(string expression, decimal? expected) => Assert.That( - BindingTestAdapter.ExecutableClosed(expression, new Context()).Evaluate(null), + Expression.CreateClosed(expression, new Context()).Evaluate(null), Is.EqualTo(expected)); } diff --git a/Expressif.Testing/Functions/Special/CoerceFunctionsTest.cs b/Expressif.Testing/Functions/Special/CoerceFunctionsTest.cs index 42881c14..b489be54 100644 --- a/Expressif.Testing/Functions/Special/CoerceFunctionsTest.cs +++ b/Expressif.Testing/Functions/Special/CoerceFunctionsTest.cs @@ -60,7 +60,7 @@ public void CoerceFunctions_ImplementTypedAndUntypedContracts() [TestCase("coerce-datetime", "2026-08-19 14:30:00", typeof(DateTime))] public void CoerceFunctions_AreAvailableToExpressionFactory(string name, object value, Type expectedType) { - var function = BindingTestAdapter.Executable(name, new Context()); + var function = Expression.Create(name, new Context()); Assert.That(function.Evaluate(value), Is.TypeOf(expectedType)); } diff --git a/Expressif.Testing/Functions/Special/SpecialFunctionsTest.cs b/Expressif.Testing/Functions/Special/SpecialFunctionsTest.cs index fdb0d096..d88e576f 100644 --- a/Expressif.Testing/Functions/Special/SpecialFunctionsTest.cs +++ b/Expressif.Testing/Functions/Special/SpecialFunctionsTest.cs @@ -14,7 +14,7 @@ public void Coalesce_Valid_Expressions(object? value, string[] expressions, obje { var context = new Context(); context.CurrentObject.Set(value); - var function = BindingTestAdapter.Executable($"coalesce({string.Join(", ", expressions)})", context); + var function = Expression.Create($"coalesce({string.Join(", ", expressions)})", context); Assert.That(function.Evaluate(value), Is.EqualTo(expected)); } @@ -61,7 +61,7 @@ public void Coalesce_OneExpression_ThrowsBindingError() [Test] public void Coalesce_OneParsedExpression_ThrowsBindingError() => Assert.That( - () => BindingTestAdapter.Executable("coalesce(^.name)", new Context()), + () => Expression.Create("coalesce(^.name)", new Context()), Throws.TypeOf()); [Test] @@ -74,7 +74,7 @@ public void Coalesce_SelectedValue_ContinuesThroughPipeline() }; var context = new Context(); context.CurrentObject.Set(value); - var function = BindingTestAdapter.Executable( + var function = Expression.Create( "coalesce(^.nickname, ^.name, \"Anonymous\") | upper", context); @@ -91,7 +91,7 @@ public void Coalesce_NestedExpressions_EvaluatesEachArgument() }; var context = new Context(); context.CurrentObject.Set(value); - var function = BindingTestAdapter.Executable( + var function = Expression.Create( "coalesce(field(nickname), .name)", context); @@ -110,7 +110,7 @@ public void Coalesce_MissingField_ContinuesWithNextCandidate(string expression, var value = new Dictionary { ["name"] = "Alice" }; var context = new Context(); context.CurrentObject.Set(value); - var function = BindingTestAdapter.Executable(expression, context); + var function = Expression.Create(expression, context); Assert.That(function.Evaluate(value), Is.EqualTo(expected)); } @@ -125,7 +125,7 @@ public void Coalesce_ExplicitNullField_ContinuesWithNextCandidate() }; var context = new Context(); context.CurrentObject.Set(value); - var function = BindingTestAdapter.Executable("coalesce(.nickname, .name)", context); + var function = Expression.Create("coalesce(.nickname, .name)", context); Assert.That(function.Evaluate(value), Is.EqualTo("Alice")); } diff --git a/Expressif.Testing/Serializer/ExpressionSerializerTest.cs b/Expressif.Testing/Serializer/ExpressionSerializerTest.cs index fa7aeaa4..86c1f814 100644 --- a/Expressif.Testing/Serializer/ExpressionSerializerTest.cs +++ b/Expressif.Testing/Serializer/ExpressionSerializerTest.cs @@ -65,7 +65,9 @@ public void Serialize_ClosedExpression_WithRootAndMembers() [Test] public void Serialize_MapShorthand_PreservesShorthand() { - var expression = BindingTestAdapter.Closed("{1,2,3} |> (absolute | add(5)) | reverse"); + var root = new ExpressifBinder().Bind( + ExpressifSyntax.Parse("{1,2,3} |> (absolute | add(5)) | reverse")); + var expression = ((ClosedRootExpression)root).Expression; Assert.That(new ExpressionSerializer().Serialize(expression), Is.EqualTo("{1, 2, 3} |> (absolute | add(5)) | reverse")); diff --git a/Expressif.Testing/Serializer/FunctionSerializerTest.cs b/Expressif.Testing/Serializer/FunctionSerializerTest.cs index dad861ee..98d5e6d7 100644 --- a/Expressif.Testing/Serializer/FunctionSerializerTest.cs +++ b/Expressif.Testing/Serializer/FunctionSerializerTest.cs @@ -13,7 +13,7 @@ public class FunctionSerializerTest [Test] public void Serialize_FieldShorthand_PreservesShorthand() { - var function = BindingTestAdapter.Function(".name"); + var function = new ExpressifBinder().BindFunction(ExpressifSyntax.Parse(".name")); Assert.That(new FunctionSerializer().Serialize(function), Is.EqualTo(".name")); } @@ -21,7 +21,7 @@ public void Serialize_FieldShorthand_PreservesShorthand() [Test] public void Serialize_DynamicFieldName_PreservesLongForm() { - var function = BindingTestAdapter.Function("field(\"requested-field\")"); + var function = new ExpressifBinder().BindFunction(ExpressifSyntax.Parse("field(\"requested-field\")")); Assert.That(new FunctionSerializer().Serialize(function), Is.EqualTo("field(\"requested-field\")")); } @@ -88,7 +88,7 @@ public void Serialize_MultipleParameter_MultipleParameterSerializerCall() [Test] public void Serialize_NamedArguments_PreservesNames() { - var function = BindingTestAdapter.Function("replace-slice(2, append := \"abc\", length := 4)"); + var function = new ExpressifBinder().BindFunction(ExpressifSyntax.Parse("replace-slice(2, append := \"abc\", length := 4)")); Assert.That(new FunctionSerializer().Serialize(function), Is.EqualTo("replace-slice(2, append := \"abc\", length := 4)")); diff --git a/Expressif.Testing/Serializer/ParameterSerializerTest.cs b/Expressif.Testing/Serializer/ParameterSerializerTest.cs index 6cc22482..aba44465 100644 --- a/Expressif.Testing/Serializer/ParameterSerializerTest.cs +++ b/Expressif.Testing/Serializer/ParameterSerializerTest.cs @@ -35,7 +35,7 @@ public void Serialize_TypedLiteralParameter_PreservesSyntaxKind() [TestCase("I[#\"2022-12-10\", #\"2022-12-31\"[", "I[#\"2022-12-10\", #\"2022-12-31\")")] public void Serialize_IntervalParameter_NormalizesAliases(string source, string expected) { - var parameter = BindingTestAdapter.Parameter(source); + var parameter = new ExpressifBinder().BindParameter(ExpressifSyntax.Parse(source)); Assert.That(new ParameterSerializer().Serialize(parameter), Is.EqualTo(expected)); } @@ -63,7 +63,7 @@ public void Serialize_LiteralParameter_WithEmbeddedDoubleQuotes_RoundTrip() var parameter = new LiteralParameter("Alice said \"hello\""); var serialized = serializer.Serialize(parameter); - var parsed = BindingTestAdapter.Parameter(serialized); + var parsed = new ExpressifBinder().BindParameter(ExpressifSyntax.Parse(serialized)); Assert.Multiple(() => { @@ -105,7 +105,7 @@ public void Serialize_RecordLiteralParameter_EmptyRecord_RoundTrip() { var serializer = new ParameterSerializer(); var serialized = serializer.Serialize(new RecordLiteralParameter(Array.Empty())); - var parsed = BindingTestAdapter.Parameter(serialized); + var parsed = new ExpressifBinder().BindParameter(ExpressifSyntax.Parse(serialized)); Assert.Multiple(() => { diff --git a/Expressif.Testing/packages.lock.json b/Expressif.Testing/packages.lock.json index e7a2711c..3da45bc6 100644 --- a/Expressif.Testing/packages.lock.json +++ b/Expressif.Testing/packages.lock.json @@ -99,8 +99,8 @@ }, "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -192,7 +192,7 @@ "expressif": { "type": "Project", "dependencies": { - "Expressif.Syntax": "[0.21.0, )" + "Expressif.Syntax": "[0.28.0, )" } } }, @@ -294,8 +294,8 @@ }, "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -387,7 +387,7 @@ "expressif": { "type": "Project", "dependencies": { - "Expressif.Syntax": "[0.21.0, )" + "Expressif.Syntax": "[0.28.0, )" } } }, @@ -489,8 +489,8 @@ }, "Expressif.Syntax": { "type": "Transitive", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -582,7 +582,7 @@ "expressif": { "type": "Project", "dependencies": { - "Expressif.Syntax": "[0.21.0, )" + "Expressif.Syntax": "[0.28.0, )" } } } diff --git a/Expressif/Bindings/BindingModels.cs b/Expressif/Bindings/BindingModels.cs index 1caa1f9b..cee18d8e 100644 --- a/Expressif/Bindings/BindingModels.cs +++ b/Expressif/Bindings/BindingModels.cs @@ -13,7 +13,7 @@ public enum FunctionSyntax TupleProjectionShorthand, } -public sealed record FunctionArgument(string? Name, IParameter Value); +public sealed record FunctionArgument(string? Name, IParameter Value, bool IsSpread = false); public sealed class Function : IBoundExpression { @@ -57,7 +57,14 @@ public sealed record ObjectPropertyParameter(string Name) : IParameter; public sealed record ObjectIndexParameter(int Index) : IParameter; public sealed record TupleProjectionParameter(int Index, bool FromEnd = false) : IParameter; public sealed record ContextParameter(Func Function) : IParameter; -public sealed record ArrayParameter(IParameter[] Values) : IParameter; +public sealed record ArrayElementParameter(IParameter Value, bool IsSpread = false); +public sealed record ArrayParameter(ArrayElementParameter[] Elements) : IParameter +{ + public ArrayParameter(IParameter[] values) + : this(values.Select(value => new ArrayElementParameter(value)).ToArray()) { } + + public IParameter[] Values => Elements.Select(element => element.Value).ToArray(); +} public sealed record TupleParameter(IParameter[] Values) : IParameter; public sealed record QuotedLiteralParameter(string Value) : IParameter; public sealed record IncomingValueParameter() : IParameter; diff --git a/Expressif/Bindings/ExpressifBinder.cs b/Expressif/Bindings/ExpressifBinder.cs index 16226ebd..4b3df9f8 100644 --- a/Expressif/Bindings/ExpressifBinder.cs +++ b/Expressif/Bindings/ExpressifBinder.cs @@ -150,9 +150,29 @@ private Function BindFunction(FunctionCallSyntax syntax) { "field" => BindFieldFunction(syntax), "record" => BindRecordFunction(syntax), + "array" => Function.FromArguments(syntax.Name, BindArrayFunctionArguments(syntax)), _ => Function.FromArguments(syntax.Name, BindFunctionArguments(syntax)), }; + private FunctionArgument[] BindArrayFunctionArguments(FunctionCallSyntax syntax) + { + var arguments = new List(); + foreach (var argument in syntax.Arguments) + { + if (argument is NamedArgumentSyntax) + throw new BindingException("Function 'array' does not support named arguments."); + + arguments.Add(new FunctionArgument( + null, + argument is SpreadArgumentSyntax { IsImplicitSpread: true } + ? new IncomingValueParameter() + : BindArgument(argument.Value + ?? throw new BindingException("An explicit spread argument must include an expression.")), + argument is SpreadArgumentSyntax)); + } + return arguments.ToArray(); + } + private FunctionArgument[] BindFunctionArguments(FunctionCallSyntax syntax) { var arguments = new List(); @@ -160,6 +180,9 @@ private FunctionArgument[] BindFunctionArguments(FunctionCallSyntax syntax) var hasNamedArgument = false; foreach (var argument in syntax.Arguments) { + if (argument is SpreadArgumentSyntax) + throw new BindingException($"Function '{syntax.Name}' does not support spread arguments."); + if (argument is NamedArgumentSyntax named) { hasNamedArgument = true; @@ -171,7 +194,7 @@ private FunctionArgument[] BindFunctionArguments(FunctionCallSyntax syntax) { if (hasNamedArgument) throw new PositionalArgumentAfterNamedArgumentException(syntax.Name); - arguments.Add(new FunctionArgument(null, BindArgument(argument.Value))); + arguments.Add(new FunctionArgument(null, BindArgument(RequireArgumentValue(argument)))); } } return arguments.ToArray(); @@ -183,9 +206,12 @@ private Function BindFieldFunction(FunctionCallSyntax syntax) && TryGetBareFunctionName(positional.Value, out var fieldName)) return new Function(syntax.Name, [new LiteralParameter(fieldName)]); - return new Function(syntax.Name, syntax.Arguments.Select(argument => BindArgument(argument.Value)).ToArray()); + return new Function(syntax.Name, syntax.Arguments.Select(argument => BindArgument(RequireArgumentValue(argument))).ToArray()); } + private static ExpressionSyntax RequireArgumentValue(ArgumentSyntax argument) + => argument.Value ?? throw new BindingException("A non-spread argument must include an expression."); + private static bool TryGetBareFunctionName(ExpressionSyntax syntax, out string name) { var function = syntax switch @@ -206,6 +232,7 @@ private Function BindRecordFunction(FunctionCallSyntax syntax) private IRecordDefinitionEntry BindRecordEntry(ArgumentSyntax syntax) => syntax switch { NamedArgumentSyntax named => new RecordNamedEntry(named.Name.Value, BindArgument(named.Value)), + SpreadArgumentSyntax => new RecordSpreadEntry(), PositionalArgumentSyntax { Value: IncomingValueSyntax } => new RecordSpreadEntry(), _ => throw Unsupported(syntax), }; @@ -248,12 +275,40 @@ private OpenExpression BindRelativeRecordAccess(ClosedExpressionSyntax syntax, R DateTimeLiteralSyntax dateTime => new LiteralParameter(dateTime.Value), TimeLiteralSyntax time => new LiteralParameter(time.Value), IntervalLiteralSyntax interval => new IntervalParameter(BindInterval(interval)), - ArrayLiteralSyntax array => new ArrayParameter(array.Values.Select(BindArgument).ToArray()), + ArrayLiteralSyntax array => new ArrayParameter(array.Elements.Select(BindArrayElement).ToArray()), TupleLiteralSyntax tuple => new TupleParameter(tuple.Values.Select(BindValue).ToArray()), - RecordLiteralSyntax record => new RecordLiteralParameter(record.Fields.Select(field => new RecordLiteralField(field.Name.Value, BindValue(field.Value))).ToArray()), + RecordLiteralSyntax record => BindRecordLiteral(record), _ => throw Unsupported(syntax), }; + private ArrayElementParameter BindArrayElement(ArrayElementSyntax element) + => new( + element.IsImplicitSpread + ? new IncomingValueParameter() + : BindArgument(element.Expression + ?? throw new BindingException("An explicit array spread must include an expression.")), + element.IsSpread); + + private RecordLiteralParameter BindRecordLiteral(RecordLiteralSyntax record) + { + if (record.Entries.Count != record.Fields.Count) + throw new BindingException("Record literal spread entries must specify a field name."); + + var fields = new List(); + foreach (var field in record.Fields) + { + if (field.IsSpread) + throw new BindingException($"Record literal field '{field.Name.Value}' does not support spread values."); + + fields.Add(new RecordLiteralField( + field.Name.Value, + field.Value is ValueSyntax value + ? BindValue(value) + : throw new BindingException($"Record literal field '{field.Name.Value}' must contain a value."))); + } + return new RecordLiteralParameter(fields.ToArray()); + } + private static IntervalBinding BindInterval(IntervalLiteralSyntax syntax) => new( BindIntervalBound(syntax.LowerBound), diff --git a/Expressif/Expressif.csproj b/Expressif/Expressif.csproj index 87d331ed..e685d7f6 100644 --- a/Expressif/Expressif.csproj +++ b/Expressif/Expressif.csproj @@ -9,7 +9,7 @@ True - + diff --git a/Expressif/ExpressifException.cs b/Expressif/ExpressifException.cs index 36e468fb..eeca3014 100644 --- a/Expressif/ExpressifException.cs +++ b/Expressif/ExpressifException.cs @@ -67,3 +67,5 @@ public ExpressionRequiresInputException(string? reference) : $"The expression cannot be evaluated without an input because it references '{reference}'.") { } } + +public sealed class SpreadArgumentException(string message) : ExpressifException(message); diff --git a/Expressif/Functions/Array/Array.cs b/Expressif/Functions/Array/Array.cs new file mode 100644 index 00000000..92778eea --- /dev/null +++ b/Expressif/Functions/Array/Array.cs @@ -0,0 +1,56 @@ +using System.Collections; + +namespace Expressif.Functions.Array; + +/// +/// Constructs a new array by evaluating zero or more positional expressions from left to right against the same input. +/// Spread arguments expand array values in place. This is the runtime-expression counterpart of array literal syntax. +/// +[Function(prefix: "", aliases: ["array"])] +public class Array : IFunction +{ + private Func Values { get; } + + /// Creates an empty array constructor. + public Array() + : this(() => []) { } + + /// Specifies zero or more positional expressions whose evaluated values become array elements. + public Array(Func values) + => Values = values; + + public object?[] Evaluate(object? value) + { + var result = new List(); + foreach (var argument in Values.Invoke()) + { + var evaluated = argument.Evaluator.Invoke(value); + if (argument.IsSpread) + SpreadValues.Append(evaluated, result); + else + result.Add(evaluated); + } + return result.ToArray(); + } + + object? IFunction.Evaluate(object? value) => Evaluate(value); +} + +public sealed record ArrayArgumentEvaluator( + Func Evaluator, + bool IsSpread = false); + +internal static class SpreadValues +{ + public static void Append(object? value, ICollection target) + { + if (value is null) + throw new SpreadArgumentException("Spread argument cannot be null."); + + if (value is string || value is not IEnumerable enumerable) + throw new SpreadArgumentException("Spread argument must evaluate to an array."); + + foreach (var item in enumerable) + target.Add(item); + } +} diff --git a/Expressif/Functions/BaseExpressionFactory.cs b/Expressif/Functions/BaseExpressionFactory.cs index ec927730..8df97761 100644 --- a/Expressif/Functions/BaseExpressionFactory.cs +++ b/Expressif/Functions/BaseExpressionFactory.cs @@ -100,14 +100,18 @@ protected virtual Delegate CreateParameter(IParameter parameter, Type scalarType object?[] BuildArray(ArrayParameter array, IContext currentContext) { - var values = new object?[array.Values.Length]; - for (var i = 0; i < array.Values.Length; i++) + var values = new List(); + foreach (var element in array.Elements) { - var elementFactory = CreateParameter(array.Values[i], typeof(object), currentContext); - values[i] = elementFactory.DynamicInvoke(); + var elementFactory = CreateParameter(element.Value, typeof(object), currentContext); + var evaluated = elementFactory.DynamicInvoke(); + if (element.IsSpread) + Functions.Array.SpreadValues.Append(evaluated, values); + else + values.Add(evaluated); } - return values; + return values.ToArray(); } Expressif.Values.Tuple BuildTuple(TupleParameter tuple, IContext currentContext) diff --git a/Expressif/Functions/Catalog/FunctionDocumentation.cs b/Expressif/Functions/Catalog/FunctionDocumentation.cs index 553eee67..a33a990c 100644 --- a/Expressif/Functions/Catalog/FunctionDocumentation.cs +++ b/Expressif/Functions/Catalog/FunctionDocumentation.cs @@ -15,4 +15,5 @@ public sealed record FunctionParameterDocumentation( string Name, string Type, bool Optional, - string Summary); + string Summary, + bool Variadic = false); diff --git a/Expressif/Functions/FunctionFactory.cs b/Expressif/Functions/FunctionFactory.cs index 927fc03c..47ae9cd1 100644 --- a/Expressif/Functions/FunctionFactory.cs +++ b/Expressif/Functions/FunctionFactory.cs @@ -6,6 +6,7 @@ using Expressif.Values; using RecordEntryEvaluator = Expressif.Functions.Record.RecordEntryEvaluator; using RecordFunction = Expressif.Functions.Record.Record; +using ArrayFunction = Expressif.Functions.Array.Array; using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; @@ -92,18 +93,30 @@ private static IPredication BuildPredication(OpenExpression expression) private IFunction BuildClosedExpression(Bindings.ClosedExpression expression, IContext context) { - var sourceParameter = CreateParameter(expression.Parameter, typeof(object), context); + var sourceEvaluator = BuildSourceEvaluator(expression.Parameter, context); var functions = new List(); foreach (var member in expression.Members) functions.Add(InstantiateOrWrapAggregation(member, context)); - return new DelegatedFunction(_ => + return new DelegatedFunction(input => { - var source = sourceParameter.DynamicInvoke(); + var source = sourceEvaluator.Invoke(input); return functions.Aggregate(source, (current, function) => function.Evaluate(current)); }); } + private Func BuildSourceEvaluator(IParameter parameter, IContext context) + { + if (parameter is IncomingValueParameter + or ArrayParameter + or RecordLiteralParameter + or InputExpressionParameter) + return BuildValueEvaluator(parameter, context); + + var provider = CreateParameter(parameter, typeof(object), context); + return _ => provider.DynamicInvoke(); + } + private IFunction InstantiateOrWrapAggregation(Bindings.Function function, IContext context) { var name = function.Name.ToKebabCase(); @@ -117,6 +130,8 @@ private IFunction InstantiateOrWrapAggregation(Bindings.Function function, ICont if (name.Equals("record", StringComparison.OrdinalIgnoreCase)) return BuildRecordFunction(function, context); + if (name.Equals("array", StringComparison.OrdinalIgnoreCase)) + return BuildArrayFunction(function, context); if (name.Equals("coalesce", StringComparison.OrdinalIgnoreCase)) return BuildCoalesceFunction(function, context); @@ -146,6 +161,86 @@ private IFunction InstantiateOrWrapAggregation(Bindings.Function function, ICont return Instantiate(type, function.Arguments, context); } + private IFunction BuildArrayFunction(Bindings.Function function, IContext context) + { + var values = function.Arguments + .Select(argument => new ArrayArgumentEvaluator( + BuildValueEvaluator(argument.Value, context), + argument.IsSpread)) + .ToArray(); + return new ArrayFunction(() => values); + } + + private Func BuildValueEvaluator(IParameter parameter, IContext context) + { + if (parameter is IncomingValueParameter) + return input => input; + + if (parameter is OpenExpressionParameter open) + { + var evaluator = BuildOpenExpressionRecordEvaluator(open, context); + return input => WithCurrentObject(context, input, () => evaluator.Invoke(input)); + } + + if (parameter is InputExpressionParameter inputExpression) + { + var source = BuildValueEvaluator(inputExpression.Expression.Parameter, context); + var chain = new ChainFunction(inputExpression.Expression.Members + .Select(member => InstantiateOrWrapAggregation(member, context)) + .ToArray()); + return input => WithCurrentObject( + context, + input, + () => chain.Evaluate(source.Invoke(input))); + } + + if (parameter is ArrayParameter array) + { + var elements = array.Elements + .Select(element => new ArrayArgumentEvaluator( + BuildValueEvaluator(element.Value, context), + element.IsSpread)) + .ToArray(); + var function = new ArrayFunction(() => elements); + return function.Evaluate; + } + + if (parameter is RecordLiteralParameter record) + { + var fields = record.Fields + .Select(field => new + { + field.Name, + Evaluator = BuildValueEvaluator(field.Value, context), + }) + .ToArray(); + return input => + { + var value = new RecordValue(); + foreach (var field in fields) + value.Set(field.Name, field.Evaluator.Invoke(input)); + return value; + }; + } + + var provider = CreateParameter(parameter, typeof(object), context); + return input => WithCurrentObject(context, input, () => provider.DynamicInvoke()); + } + + private static object? WithCurrentObject(IContext context, object? input, Func evaluator) + { + var previous = context.CurrentObject.Value; + context.CurrentObject.Set(input); + try + { + return evaluator.Invoke(); + } + finally + { + context.CurrentObject.Set(previous); + } + } + private IFunction BuildCoalesceFunction(Bindings.Function function, IContext context) { if (function.Parameters.Length < 2) diff --git a/Expressif/Serializers/FunctionSerializer.cs b/Expressif/Serializers/FunctionSerializer.cs index d2336524..5e524d07 100644 --- a/Expressif/Serializers/FunctionSerializer.cs +++ b/Expressif/Serializers/FunctionSerializer.cs @@ -41,6 +41,8 @@ public virtual void Serialize(Function function, ref StringBuilder stringBuilder { if (argument.Name is not null) stringBuilder.Append(argument.Name).Append(" := "); + if (argument.IsSpread) + stringBuilder.Append("..."); stringBuilder.Append(ParameterSerializer.Serialize(argument.Value switch { IParameter p => p, diff --git a/Expressif/Serializers/ParameterSerializer.cs b/Expressif/Serializers/ParameterSerializer.cs index 550b341a..f92aeb80 100644 --- a/Expressif/Serializers/ParameterSerializer.cs +++ b/Expressif/Serializers/ParameterSerializer.cs @@ -19,7 +19,7 @@ public virtual string Serialize(IParameter parameter) { return parameter switch { - ArrayParameter a => $"{{{string.Join(", ", a.Values.Select(Serialize))}}}", + ArrayParameter a => $"{{{string.Join(", ", a.Elements.Select(SerializeArrayElement))}}}", TupleParameter t => $"T({string.Join(", ", t.Values.Select(Serialize))})", RecordLiteralParameter r when r.Fields.Length == 0 => "{:}", RecordLiteralParameter r => $"{{{string.Join(", ", r.Fields.Select(x => $"{SerializeFieldName(x.Name)} := {Serialize(x.Value)}"))}}}", @@ -37,6 +37,9 @@ public virtual string Serialize(IParameter parameter) }; } + private string SerializeArrayElement(ArrayElementParameter element) + => $"{(element.IsSpread ? "..." : string.Empty)}{Serialize(element.Value)}"; + private string SerializeRecordEntry(IRecordDefinitionEntry entry) => entry switch { diff --git a/Expressif/Values/ParameterValueConverter.cs b/Expressif/Values/ParameterValueConverter.cs index a2ea8e26..4bb97fb4 100644 --- a/Expressif/Values/ParameterValueConverter.cs +++ b/Expressif/Values/ParameterValueConverter.cs @@ -19,12 +19,26 @@ public sealed class ParameterValueConverter { LiteralParameter literal => literal.Value, QuotedLiteralParameter quoted => quoted.Value, - ArrayParameter array => array.Values.Select(Convert).ToArray(), + ArrayParameter array => ConvertArray(array), TupleParameter tuple => new Tuple(tuple.Values.Select(Convert).ToArray()), RecordLiteralParameter record => ConvertRecord(record), _ => serializer.Serialize(parameter), }; + private object?[] ConvertArray(ArrayParameter array) + { + var values = new List(); + foreach (var element in array.Elements) + { + var value = Convert(element.Value); + if (element.IsSpread) + Functions.Array.SpreadValues.Append(value, values); + else + values.Add(value); + } + return values.ToArray(); + } + private RecordValue ConvertRecord(RecordLiteralParameter record) { var value = new RecordValue(); diff --git a/Expressif/packages.lock.json b/Expressif/packages.lock.json index c32cff04..3515d845 100644 --- a/Expressif/packages.lock.json +++ b/Expressif/packages.lock.json @@ -10,9 +10,9 @@ }, "Expressif.Syntax": { "type": "Direct", - "requested": "[0.21.0, )", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "requested": "[0.28.0, )", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -46,9 +46,9 @@ }, "Expressif.Syntax": { "type": "Direct", - "requested": "[0.21.0, )", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "requested": "[0.28.0, )", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } @@ -82,9 +82,9 @@ }, "Expressif.Syntax": { "type": "Direct", - "requested": "[0.21.0, )", - "resolved": "0.21.0", - "contentHash": "k2V64huHLI+6167sxMlzTOmMIVCTzEGu2j/UhP/sRFgx7qxtoLGQ8/wbxotgli/dY35mlt4K5Ulqv7WBw9gbIw==", + "requested": "[0.28.0, )", + "resolved": "0.28.0", + "contentHash": "WHKuIeBbGvFp1cnW4+z1SFFDFfnqDLRF2XBemidJ7or/R+SWvykobfFLfrEwcJSsiFu6iync2ty0Wc/yH9A+6A==", "dependencies": { "TreeSitter.DotNet": "1.3.0" } diff --git a/conformance/functions/array/array.yaml b/conformance/functions/array/array.yaml new file mode 100644 index 00000000..0a2d4043 --- /dev/null +++ b/conformance/functions/array/array.yaml @@ -0,0 +1,38 @@ +suite: array +kind: function +operator: array +tests: + - id: array.valid.variadic-values + cases: + - id: array.valid.variadic-values.special.null + value: (null) + parameters: [array()] + expected: '[]' + - id: array.valid.variadic-values.special.empty + value: (empty) + parameters: [array(@_)] + expected: '["(empty)"]' + - id: array.valid.variadic-values.array.single + value: [] + parameters: [array(1)] + expected: '[1]' + - id: array.valid.variadic-values.array.multiple + value: [] + parameters: ["array(1, 2, 3)"] + expected: '[1,2,3]' + - id: array.valid.variadic-values.array.heterogeneous + value: [] + parameters: ['array(1, "a", #true)'] + expected: '[1,"a",true]' + - id: array.valid.variadic-values.array.expression + value: 10 + parameters: ["array(add(1, 2))"] + expected: '[12]' + - id: array.valid.variadic-values.array.spread + value: [] + parameters: ['array(1, ...{2, 3}, 4)'] + expected: '[1,2,3,4]' + - id: array.valid.variadic-values.array.record + value: [] + parameters: ["array(record(year:=2024, total:=100))"] + expected: '[{"year":2024,"total":100}]' diff --git a/docs/_data/function.json b/docs/_data/function.json index 81887079..b34538da 100644 --- a/docs/_data/function.json +++ b/docs/_data/function.json @@ -18,6 +18,26 @@ } ] }, + { + "Name": "array", + "IsPublic": true, + "Aliases": [ + "array" + ], + "Scope": "Array", + "Input": "any", + "Output": "array", + "Summary": "Constructs a new array by evaluating zero or more positional expressions from left to right against the same input. Spread arguments expand array values in place. This is the runtime-expression counterpart of array literal syntax.", + "Parameters": [ + { + "Name": "values", + "Type": "array", + "Optional": true, + "Variadic": true, + "Summary": "Specifies zero or more positional expressions whose evaluated values become array elements." + } + ] + }, { "Name": "broadcast", "IsPublic": true,