diff --git a/Bonsai.Scripting.Expressions.sln b/Bonsai.Scripting.Expressions.sln
index 56bb096..e157fec 100644
--- a/Bonsai.Scripting.Expressions.sln
+++ b/Bonsai.Scripting.Expressions.sln
@@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bonsai.Scripting.Expression
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bonsai.Scripting.Expressions.Design", "src\Bonsai.Scripting.Expressions.Design\Bonsai.Scripting.Expressions.Design.csproj", "{A6ADFFAA-CA25-545C-AC3F-4BC3D819B8CB}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bonsai.Scripting.Expressions.Tests", "src\Bonsai.Scripting.Expressions.Tests\Bonsai.Scripting.Expressions.Tests.csproj", "{CE4BD71F-2CCD-4ED5-8F4E-EAC123EEF518}"
+EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{DEE5DD87-39C1-BF34-B639-A387DCCF972B}"
ProjectSection(SolutionItems) = preProject
build\Common.csproj.props = build\Common.csproj.props
@@ -31,6 +33,10 @@ Global
{A6ADFFAA-CA25-545C-AC3F-4BC3D819B8CB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A6ADFFAA-CA25-545C-AC3F-4BC3D819B8CB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A6ADFFAA-CA25-545C-AC3F-4BC3D819B8CB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CE4BD71F-2CCD-4ED5-8F4E-EAC123EEF518}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CE4BD71F-2CCD-4ED5-8F4E-EAC123EEF518}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CE4BD71F-2CCD-4ED5-8F4E-EAC123EEF518}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CE4BD71F-2CCD-4ED5-8F4E-EAC123EEF518}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/src/Bonsai.Scripting.Expressions.Design/Bonsai.Scripting.Expressions.Design.csproj b/src/Bonsai.Scripting.Expressions.Design/Bonsai.Scripting.Expressions.Design.csproj
index 9984b85..06238b0 100644
--- a/src/Bonsai.Scripting.Expressions.Design/Bonsai.Scripting.Expressions.Design.csproj
+++ b/src/Bonsai.Scripting.Expressions.Design/Bonsai.Scripting.Expressions.Design.csproj
@@ -3,7 +3,7 @@
This package provides editors for expression scripting in the Bonsai programming language.
$(PackageTags) Design
true
- net472
+ net472;net8.0-windows
diff --git a/src/Bonsai.Scripting.Expressions.Tests/AssemblyInfo.cs b/src/Bonsai.Scripting.Expressions.Tests/AssemblyInfo.cs
new file mode 100644
index 0000000..aaf278c
--- /dev/null
+++ b/src/Bonsai.Scripting.Expressions.Tests/AssemblyInfo.cs
@@ -0,0 +1 @@
+[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
diff --git a/src/Bonsai.Scripting.Expressions.Tests/Bonsai.Scripting.Expressions.Tests.csproj b/src/Bonsai.Scripting.Expressions.Tests/Bonsai.Scripting.Expressions.Tests.csproj
new file mode 100644
index 0000000..0e1a08c
--- /dev/null
+++ b/src/Bonsai.Scripting.Expressions.Tests/Bonsai.Scripting.Expressions.Tests.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net472;net8.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Bonsai.Scripting.Expressions.Tests/ExpressionScriptingTests.cs b/src/Bonsai.Scripting.Expressions.Tests/ExpressionScriptingTests.cs
new file mode 100644
index 0000000..f5c9c98
--- /dev/null
+++ b/src/Bonsai.Scripting.Expressions.Tests/ExpressionScriptingTests.cs
@@ -0,0 +1,87 @@
+using Bonsai.Expressions;
+using System;
+using System.Collections;
+using System.Reactive.Linq;
+using System.Threading.Tasks;
+
+namespace Bonsai.Scripting.Expressions.Tests
+{
+ [TestClass]
+ public sealed class ExpressionScriptingTests
+ {
+ async Task AssertExpressionTransform(string expression, TSource value, TResult expected)
+ {
+ var workflowBuilder = new WorkflowBuilder();
+ var source = workflowBuilder.Workflow.Add(new CombinatorBuilder { Combinator = new Return(value) });
+ var transform = workflowBuilder.Workflow.Add(new ExpressionTransform { Expression = expression });
+ var output = workflowBuilder.Workflow.Add(new WorkflowOutputBuilder());
+ workflowBuilder.Workflow.AddEdge(source, transform, new());
+ workflowBuilder.Workflow.AddEdge(transform, output, new());
+ var result = await workflowBuilder.Workflow.BuildObservable();
+ if (expected is ICollection expectedCollection && result is ICollection resultCollection)
+ CollectionAssert.AreEqual(expectedCollection, resultCollection);
+ else
+ Assert.AreEqual(expected, result);
+ }
+
+ [DataTestMethod]
+ [DataRow("it", 42, 42)]
+ [DataRow("it * 2", 21, 42)]
+ [DataRow("Single(it)", 42, 42f)]
+ [DataRow("Math.PI", 42, Math.PI)]
+ [DataRow("Convert.ToInt16(it)", 42, (short)42)]
+ [DataRow("new(it as Data).Data", 42, 42)]
+ // modern Dynamic LINQ parser
+ [DataRow("float(it)", 42, 42f)]
+ [DataRow("long?(it).HasValue", 42, true)]
+ [DataRow("bool.TrueString", 42, "True")]
+ [DataRow("new[] { it }", 42, new[] { 42 })]
+ [DataRow("new[] { it }.Select(x => x * 2).ToArray()", 21, new[] { 42 })]
+ [DataRow("np(string(null).Length) ?? it", 42, 42)]
+ public Task TestExpressionTransform(string expression, TSource value, TResult expected)
+ {
+ return AssertExpressionTransform(expression, value, expected);
+ }
+
+ [TestMethod]
+ public Task TestNullExpression() => AssertExpressionTransform("null", 0, (object)null);
+
+ [TestMethod]
+ public Task TestNullString() => AssertExpressionTransform("string(null)", 0, (string)null);
+
+ [TestMethod]
+ public Task TestObjectExpression() => AssertExpressionTransform("object(it)", 42, (object)42);
+
+ [DataTestMethod]
+ [DataRow("single(it)", 42, 42f)]
+ [DataRow("int64?(it).hasvalue", 42, true)]
+ [DataRow("math.pi", 42, Math.PI)]
+ [DataRow("boolean.truestring", 42, "True")]
+ [DataRow("convert.toint16(it)", 42, (short)42)]
+ [DataRow("datetime.minvalue.second", 42, 0)]
+ [DataRow("datetimeoffset.minvalue.second", 42, 0)]
+ [DataRow("guid.empty.tobytearray()[0]", 42, 0)]
+ [DataRow("timespan.tickspermillisecond", 0, TimeSpan.TicksPerMillisecond)]
+ [DataRow("it > 0 ? convert.toint16(it) : int16.minvalue", 42, (short)42)]
+ public Task TestCasingCompatibility(string expression, TSource value, TResult expected)
+ {
+ return AssertExpressionTransform(expression, value, expected);
+ }
+
+ [DataTestMethod]
+ [DataRow("")]
+ [DataRow("string(it)")]
+ public Task TestInvalidExpression(string expression)
+ {
+ return Assert.ThrowsExactlyAsync(() =>
+ AssertExpressionTransform(expression, 42, (object)null));
+ }
+
+ class Return(TValue value) : Source
+ {
+ public TValue Value { get; } = value;
+
+ public override IObservable Generate() => Observable.Return(Value);
+ }
+ }
+}
diff --git a/src/Bonsai.Scripting.Expressions/Bonsai.Scripting.Expressions.csproj b/src/Bonsai.Scripting.Expressions/Bonsai.Scripting.Expressions.csproj
index bae39d8..8f09185 100644
--- a/src/Bonsai.Scripting.Expressions/Bonsai.Scripting.Expressions.csproj
+++ b/src/Bonsai.Scripting.Expressions/Bonsai.Scripting.Expressions.csproj
@@ -1,10 +1,10 @@
This package provides operators implementing expression scripting infrastructure.
- net472
+ net472;net8.0
-
+
\ No newline at end of file
diff --git a/src/Bonsai.Scripting.Expressions/CompatibilityAnalyzer.cs b/src/Bonsai.Scripting.Expressions/CompatibilityAnalyzer.cs
new file mode 100644
index 0000000..c7b08e6
--- /dev/null
+++ b/src/Bonsai.Scripting.Expressions/CompatibilityAnalyzer.cs
@@ -0,0 +1,69 @@
+using System.Collections.Generic;
+using System.Linq.Dynamic.Core;
+using System.Linq.Dynamic.Core.Tokenizer;
+using System.Text;
+
+namespace Bonsai.Scripting.Expressions
+{
+ internal static class CompatibilityAnalyzer
+ {
+ internal static readonly Dictionary LegacyKeywords = new()
+ {
+ { "boolean", "bool" },
+ { "datetime", "DateTime" },
+ { "datetimeoffset", "DateTimeOffset" },
+ { "guid", "Guid" },
+ { "int16", "short" },
+ { "int32", "int" },
+ { "int64", "long" },
+ { "single", "float" },
+ { "timespan", "TimeSpan" },
+ { "uint32", "uint" },
+ { "uint64", "ulong" },
+ { "uint16", "ushort" },
+ { "math", "Math" },
+ { "convert", "Convert" }
+ };
+
+ public static bool ReplaceLegacyKeywords(ParsingConfig? parsingConfig, string text, out string result)
+ {
+ result = text;
+ if (string.IsNullOrEmpty(text))
+ return false;
+
+
+ List<(Token, string)> replacements = null;
+ var previousTokenId = TokenId.Unknown;
+ var textParser = new TextParser(parsingConfig, text);
+ while (textParser.CurrentToken.Id != TokenId.End)
+ {
+ if (textParser.CurrentToken.Id == TokenId.Identifier &&
+ previousTokenId != TokenId.Dot &&
+ LegacyKeywords.TryGetValue(textParser.CurrentToken.Text, out var keyword))
+ {
+ replacements ??= new();
+ replacements.Add((textParser.CurrentToken, keyword));
+ }
+
+ previousTokenId = textParser.CurrentToken.Id;
+ textParser.NextToken();
+ }
+
+ if (replacements?.Count > 0)
+ {
+ var sb = new StringBuilder(text);
+ for (int i = 0; i < replacements.Count; i++)
+ {
+ var (token, keyword) = replacements[i];
+ sb.Remove(token.Pos, token.Text.Length);
+ sb.Insert(token.Pos, keyword);
+ }
+
+ result = sb.ToString();
+ return true;
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/src/Bonsai.Scripting.Expressions/DynamicExpressionHelper.cs b/src/Bonsai.Scripting.Expressions/DynamicExpressionHelper.cs
new file mode 100644
index 0000000..a5cdaa4
--- /dev/null
+++ b/src/Bonsai.Scripting.Expressions/DynamicExpressionHelper.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Linq.Dynamic.Core;
+using System.Linq.Dynamic.Core.Exceptions;
+using System.Linq.Expressions;
+
+namespace Bonsai.Scripting.Expressions
+{
+ internal class DynamicExpressionHelper
+ {
+ public static LambdaExpression ParseLambda(Type delegateType, ParsingConfig? parsingConfig, ParameterExpression[] parameters, Type? resultType, string expression, params object?[] values)
+ {
+ return ParseLambda(delegateType, parsingConfig, true, parameters, resultType, expression, values);
+ }
+
+ public static LambdaExpression ParseLambda(ParsingConfig? parsingConfig, Type itType, Type? resultType, string expression, params object?[] values)
+ {
+ return ParseLambda(null, parsingConfig, true, new[] { Expression.Parameter(itType, "it") }, resultType, expression, values);
+ }
+
+ public static LambdaExpression ParseLambda(Type? delegateType, ParsingConfig? parsingConfig, bool createParameterCtor, ParameterExpression[] parameters, Type? resultType, string expression, params object?[] values)
+ {
+ try
+ {
+ return DynamicExpressionParser.ParseLambda(delegateType, parsingConfig, createParameterCtor, parameters, resultType, expression, values);
+ }
+ catch (ParseException)
+ {
+ if (!CompatibilityAnalyzer.ReplaceLegacyKeywords(parsingConfig, expression, out expression))
+ throw;
+
+ return DynamicExpressionParser.ParseLambda(delegateType, parsingConfig, createParameterCtor, parameters, resultType, expression, values);
+ }
+ }
+ }
+}
diff --git a/src/Bonsai.Scripting.Expressions/ExpressionCondition.cs b/src/Bonsai.Scripting.Expressions/ExpressionCondition.cs
index b876282..9195b40 100644
--- a/src/Bonsai.Scripting.Expressions/ExpressionCondition.cs
+++ b/src/Bonsai.Scripting.Expressions/ExpressionCondition.cs
@@ -3,6 +3,7 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
+using System.Linq.Dynamic.Core;
using System.Linq.Expressions;
using System.Reactive.Linq;
using System.Reflection;
@@ -52,7 +53,8 @@ public override Expression Build(IEnumerable arguments)
{
var source = arguments.First();
var sourceType = source.Type.GetGenericArguments()[0];
- var predicate = System.Linq.Dynamic.DynamicExpression.ParseLambda(sourceType, typeof(bool), Expression);
+ var config = ParsingConfigHelper.CreateParsingConfig(sourceType);
+ var predicate = DynamicExpressionHelper.ParseLambda(config, sourceType, typeof(bool), Expression);
return System.Linq.Expressions.Expression.Call(whereMethod.MakeGenericMethod(sourceType), source, predicate);
}
diff --git a/src/Bonsai.Scripting.Expressions/ExpressionSink.cs b/src/Bonsai.Scripting.Expressions/ExpressionSink.cs
index 133f97f..559765d 100644
--- a/src/Bonsai.Scripting.Expressions/ExpressionSink.cs
+++ b/src/Bonsai.Scripting.Expressions/ExpressionSink.cs
@@ -3,6 +3,7 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
+using System.Linq.Dynamic.Core;
using System.Linq.Expressions;
using System.Reactive.Linq;
using System.Reflection;
@@ -59,7 +60,8 @@ public override Expression Build(IEnumerable arguments)
var sourceType = source.Type.GetGenericArguments()[0];
var actionType = System.Linq.Expressions.Expression.GetActionType(sourceType);
var itParameter = new[] { System.Linq.Expressions.Expression.Parameter(sourceType, string.Empty) };
- var onNext = System.Linq.Dynamic.DynamicExpression.ParseLambda(actionType, itParameter, null, Expression);
+ var config = ParsingConfigHelper.CreateParsingConfig(sourceType);
+ var onNext = DynamicExpressionHelper.ParseLambda(actionType, config, itParameter, null, Expression);
return System.Linq.Expressions.Expression.Call(doMethod.MakeGenericMethod(sourceType), source, onNext);
}
else return source;
diff --git a/src/Bonsai.Scripting.Expressions/ExpressionTransform.cs b/src/Bonsai.Scripting.Expressions/ExpressionTransform.cs
index b839455..00ab754 100644
--- a/src/Bonsai.Scripting.Expressions/ExpressionTransform.cs
+++ b/src/Bonsai.Scripting.Expressions/ExpressionTransform.cs
@@ -3,6 +3,7 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
+using System.Linq.Dynamic.Core;
using System.Linq.Expressions;
using System.Reactive.Linq;
using System.Reflection;
@@ -53,7 +54,8 @@ public override Expression Build(IEnumerable arguments)
{
var source = arguments.First();
var sourceType = source.Type.GetGenericArguments()[0];
- var selector = System.Linq.Dynamic.DynamicExpression.ParseLambda(sourceType, null, Expression);
+ var config = ParsingConfigHelper.CreateParsingConfig(sourceType);
+ var selector = DynamicExpressionHelper.ParseLambda(config, sourceType, null, Expression);
return System.Linq.Expressions.Expression.Call(selectMethod.MakeGenericMethod(sourceType, selector.ReturnType), source, selector);
}
diff --git a/src/Bonsai.Scripting.Expressions/ParsingConfigHelper.cs b/src/Bonsai.Scripting.Expressions/ParsingConfigHelper.cs
new file mode 100644
index 0000000..0929d2e
--- /dev/null
+++ b/src/Bonsai.Scripting.Expressions/ParsingConfigHelper.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Dynamic.Core;
+using System.Linq.Dynamic.Core.CustomTypeProviders;
+
+namespace Bonsai.Scripting.Expressions
+{
+ internal static class ParsingConfigHelper
+ {
+ public static ParsingConfig CreateParsingConfig(params Type[] additionalTypes)
+ {
+ var config = new ParsingConfig();
+ config.CustomTypeProvider = CreateCustomTypeProvider(config, additionalTypes);
+ return config;
+ }
+
+ static IDynamicLinqCustomTypeProvider CreateCustomTypeProvider(ParsingConfig config, params Type[] additionalTypes)
+ {
+ return new SimpleDynamicLinqCustomTypeProvider(
+ config,
+ additionalTypes.SelectMany(EnumerateTypeHierarchy).ToList());
+ }
+
+ static IEnumerable EnumerateTypeHierarchy(Type type)
+ {
+ var interfaces = type.GetInterfaces();
+ for (int i = 0; i < interfaces.Length; i++)
+ {
+ yield return interfaces[i];
+ }
+
+ while (type is not null)
+ {
+ yield return type;
+ type = type.BaseType;
+ }
+ }
+
+ class SimpleDynamicLinqCustomTypeProvider : DefaultDynamicLinqCustomTypeProvider
+ {
+ readonly HashSet customTypes;
+
+ public SimpleDynamicLinqCustomTypeProvider(ParsingConfig config, IList additionalTypes)
+ : base(config, additionalTypes, cacheCustomTypes: false)
+ {
+ customTypes = new(AdditionalTypes);
+ }
+
+ public override HashSet GetCustomTypes()
+ {
+ return customTypes;
+ }
+ }
+ }
+}