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 06238b0..010ab27 100644
--- a/src/Bonsai.Scripting.Expressions.Design/Bonsai.Scripting.Expressions.Design.csproj
+++ b/src/Bonsai.Scripting.Expressions.Design/Bonsai.Scripting.Expressions.Design.csproj
@@ -5,6 +5,9 @@
true
net472;net8.0-windows
+
+
+
diff --git a/src/Bonsai.Scripting.Expressions.Design/CaretExpressionAnalyzer.cs b/src/Bonsai.Scripting.Expressions.Design/CaretExpressionAnalyzer.cs
new file mode 100644
index 0000000..20ae504
--- /dev/null
+++ b/src/Bonsai.Scripting.Expressions.Design/CaretExpressionAnalyzer.cs
@@ -0,0 +1,790 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Linq.Dynamic.Core;
+using System.Linq.Dynamic.Core.Config;
+using System.Linq.Dynamic.Core.Exceptions;
+using System.Linq.Dynamic.Core.Tokenizer;
+using System.Linq.Expressions;
+using System.Text.RegularExpressions;
+
+namespace Bonsai.Scripting.Expressions.Design
+{
+ ///
+ /// Analyzes the expression at selected caret position to determine the nearest declaring type.
+ /// Some parts of the code is based on https://github.com/zzzprojects/System.Linq.Dynamic.Core
+ /// and licensed under the Apache 2.0 license.
+ ///
+ internal class CaretExpressionAnalyzer
+ {
+ private static readonly string[] OutKeywords = ["out", "$out"];
+ private const string DiscardVariable = "_";
+ private const char DotCharacter = '.';
+ internal static readonly Dictionary PredefinedTypeKeywords = new()
+ {
+ { "object", typeof(object) },
+ { "bool", typeof(bool) },
+ { "byte", typeof(byte) },
+ { "char", typeof(char) },
+ { "decimal", typeof(decimal) },
+ { "double", typeof(double) },
+ { "float", typeof(float) },
+ { "int", typeof(int) },
+ { "long", typeof(long) },
+ { "sbyte", typeof(sbyte) },
+ { "short", typeof(short) },
+ { "string", typeof(string) },
+ { "uint", typeof(uint) },
+ { "ulong", typeof(ulong) },
+ { "ushort", typeof(ushort) }
+ };
+
+ internal static readonly Type[] PredefinedTypes = new[]
+ {
+ typeof(object),
+ typeof(bool),
+ typeof(char),
+ typeof(string),
+ typeof(sbyte),
+ typeof(byte),
+ typeof(short),
+ typeof(ushort),
+ typeof(int),
+ typeof(uint),
+ typeof(long),
+ typeof(ulong),
+ typeof(float),
+ typeof(double),
+ typeof(decimal),
+ typeof(DateTime),
+ typeof(DateTimeOffset),
+ typeof(TimeSpan),
+ typeof(Guid),
+ typeof(Math),
+ typeof(Convert),
+ typeof(Uri),
+ typeof(Enum)
+ };
+
+ readonly ParsingConfig _parsingConfig;
+ readonly TextParser _textParser;
+ readonly Stack _primaryStack;
+ readonly string _text;
+
+ public CaretExpressionAnalyzer(ParsingConfig config, string text, int position)
+ {
+ if (!string.IsNullOrEmpty(text) && position > 0 && text[position - 1] == DotCharacter)
+ position = position - 1;
+
+ _parsingConfig = config;
+ _text = position > 0 ? text.Substring(0, position) : string.Empty;
+ _textParser = new TextParser(_parsingConfig, _text);
+ _primaryStack = new Stack();
+ }
+
+ public Type ParseExpressionType(Type itType, out bool isClassIdentifier)
+ {
+ _primaryStack.Clear();
+ try { ParseConditionalOperator(); }
+ catch (ParseException) { }
+
+ isClassIdentifier = false;
+ var primaryText = _text.Substring(_primaryStack.FirstOrDefault());
+ try
+ {
+ return !string.IsNullOrEmpty(primaryText)
+ ? DynamicExpressionParser.ParseLambda(_parsingConfig, itType, null, primaryText).ReturnType
+ : null;
+ }
+ catch (ParseException)
+ {
+ isClassIdentifier = true;
+ if (PredefinedTypeKeywords.TryGetValue(primaryText, out Type keywordType))
+ return keywordType;
+ else if (PredefinedTypes.Append(itType).FirstOrDefault(type => primaryText.Equals(type.Name)) is Type type)
+ return type;
+ else
+ throw;
+ }
+ }
+
+ // out keyword
+ private void ParseOutKeyword()
+ {
+ if (_textParser.CurrentToken.Id == TokenId.Identifier && OutKeywords.Contains(_textParser.CurrentToken.Text))
+ {
+ // Go to next token (which should be a '_')
+ _textParser.NextToken();
+
+ var variableName = _textParser.CurrentToken.Text;
+ if (variableName != DiscardVariable)
+ {
+ throw ParseError("OutKeywordRequiresDiscard");
+ }
+
+ // Advance to next token
+ _textParser.NextToken();
+ }
+
+ ParseConditionalOperator();
+ }
+
+ // ?: operator
+ private void ParseConditionalOperator()
+ {
+ int errorPos = _textParser.CurrentToken.Pos;
+ ParseNullCoalescingOperator();
+ if (_textParser.CurrentToken.Id == TokenId.Question)
+ {
+ _textParser.NextToken();
+ ParseConditionalOperator();
+ _textParser.ValidateToken(TokenId.Colon, "ColonExpected");
+ _textParser.NextToken();
+ ParseConditionalOperator();
+ }
+ }
+
+ // ?? (null-coalescing) operator
+ private void ParseNullCoalescingOperator()
+ {
+ ParseLambdaOperator();
+ if (_textParser.CurrentToken.Id == TokenId.NullCoalescing)
+ {
+ _textParser.NextToken();
+ ParseConditionalOperator();
+ }
+ }
+
+ // => operator - Added Support for projection operator
+ private void ParseLambdaOperator()
+ {
+ ParseOrOperator();
+
+ if (_textParser.CurrentToken.Id == TokenId.Lambda)
+ {
+ _textParser.NextToken();
+ if (_textParser.CurrentToken.Id is TokenId.Identifier or TokenId.OpenParen)
+ {
+ ParseConditionalOperator();
+ }
+ _textParser.ValidateToken(TokenId.OpenParen, "OpenParenExpected");
+ }
+ }
+
+ // Or operator
+ // - ||
+ // - Or
+ // - OrElse
+ private void ParseOrOperator()
+ {
+ ParseAndOperator();
+ while (_textParser.CurrentToken.Id == TokenId.DoubleBar)
+ {
+ Token op = _textParser.CurrentToken;
+ _textParser.NextToken();
+ ParseAndOperator();
+ }
+ }
+
+ // And operator
+ // - &&
+ // - And
+ // - AndAlso
+ private void ParseAndOperator()
+ {
+ ParseIn();
+ while (_textParser.CurrentToken.Id == TokenId.DoubleAmpersand)
+ {
+ Token op = _textParser.CurrentToken;
+ _textParser.NextToken();
+ ParseIn();
+ }
+ }
+
+ // "in" / "not in" / "not_in" operator for literals - example: "x in (1,2,3,4)"
+ // "in" / "not in" / "not_in" operator to mimic contains - example: "x in @0", compare to @0.Contains(x)
+ private void ParseIn()
+ {
+ ParseLogicalAndOrOperator();
+ while (_textParser.TryGetToken(["in", "not_in", "not"], [TokenId.Exclamation], out var token))
+ {
+ if (token.Text == "not" || token.Id == TokenId.Exclamation)
+ {
+ _textParser.NextToken();
+
+ if (!TokenIsIdentifier("in"))
+ {
+ throw ParseError(token.Pos, "TokenExpected", "in");
+ }
+ }
+
+ _textParser.NextToken();
+
+ if (_textParser.CurrentToken.Id == TokenId.OpenParen) // literals (or other inline list)
+ {
+ while (_textParser.CurrentToken.Id != TokenId.CloseParen)
+ {
+ _textParser.NextToken();
+
+ // we need to parse unary expressions because otherwise 'in' clause will fail in use cases like 'in (-1, -1)' or 'in (!true)'
+ ParseUnary();
+
+ if (_textParser.CurrentToken.Id == TokenId.End)
+ {
+ throw ParseError(token.Pos, "CloseParenOrCommaExpected");
+ }
+ }
+
+ // Since this started with an open paren, make sure to move off the close
+ _textParser.NextToken();
+ }
+ else if (_textParser.CurrentToken.Id == TokenId.Identifier) // a single argument
+ {
+ ParsePrimary();
+ }
+ else
+ {
+ throw ParseError(token.Pos, "OpenParenOrIdentifierExpected");
+ }
+ }
+ }
+
+ // &, | bitwise operators
+ private void ParseLogicalAndOrOperator()
+ {
+ ParseComparisonOperator();
+
+ while (_textParser.CurrentToken.Id is TokenId.Ampersand or TokenId.Bar)
+ {
+ Token op = _textParser.CurrentToken;
+ _textParser.NextToken();
+ ParseComparisonOperator();
+ }
+ }
+
+ // =, ==, !=, <>, >, >=, <, <= operators
+ private void ParseComparisonOperator()
+ {
+ ParseShiftOperator();
+ while (_textParser.CurrentToken.Id.IsComparisonOperator())
+ {
+ Token op = _textParser.CurrentToken;
+ _textParser.NextToken();
+ ParseShiftOperator();
+ }
+ }
+
+ // <<, >> operators
+ private void ParseShiftOperator()
+ {
+ ParseAdditive();
+ while (_textParser.CurrentToken.Id == TokenId.DoubleLessThan || _textParser.CurrentToken.Id == TokenId.DoubleGreaterThan)
+ {
+ Token op = _textParser.CurrentToken;
+ _textParser.NextToken();
+ ParseAdditive();
+ }
+ }
+
+ // +, - operators
+ private void ParseAdditive()
+ {
+ ParseArithmetic();
+ while (_textParser.CurrentToken.Id is TokenId.Plus or TokenId.Minus)
+ {
+ Token op = _textParser.CurrentToken;
+ _textParser.NextToken();
+ ParseArithmetic();
+ }
+ }
+
+ // *, /, %, mod operators
+ private void ParseArithmetic()
+ {
+ ParseUnary();
+ while (_textParser.CurrentToken.Id is TokenId.Asterisk or TokenId.Slash or TokenId.Percent || TokenIsIdentifier("mod"))
+ {
+ Token op = _textParser.CurrentToken;
+ _textParser.NextToken();
+ ParseUnary();
+ }
+ }
+
+ // -, !, not unary operators
+ private void ParseUnary()
+ {
+ if (_textParser.CurrentToken.Id == TokenId.Minus || _textParser.CurrentToken.Id == TokenId.Exclamation || TokenIsIdentifier("not"))
+ {
+ Token op = _textParser.CurrentToken;
+ _textParser.NextToken();
+ if (op.Id == TokenId.Minus && _textParser.CurrentToken.Id is TokenId.IntegerLiteral or TokenId.RealLiteral)
+ {
+ _textParser.CurrentToken.Text = "-" + _textParser.CurrentToken.Text;
+ _textParser.CurrentToken.Pos = op.Pos;
+ ParsePrimary();
+ }
+
+ ParseUnary();
+ }
+
+ ParsePrimary();
+ }
+
+ // primary elements
+ private void ParsePrimary()
+ {
+ _primaryStack.Push(_textParser.CurrentToken.Pos);
+ ParsePrimaryStart();
+
+ while (true)
+ {
+ if (_textParser.CurrentToken.Id == TokenId.Dot)
+ {
+ _textParser.NextToken();
+ ParseMemberAccess();
+ }
+ else if (_textParser.CurrentToken.Id == TokenId.NullPropagation)
+ {
+ throw new NotSupportedException("An expression tree lambda may not contain a null propagating operator. Use the 'np()' or 'np(...)' (null-propagation) function instead.");
+ }
+ else if (_textParser.CurrentToken.Id == TokenId.OpenBracket)
+ {
+ ParseElementAccess();
+ }
+ else
+ {
+ break;
+ }
+ }
+
+ if (_textParser.CurrentToken.Id != TokenId.End)
+ _primaryStack.Pop();
+ }
+
+ private void ParsePrimaryStart()
+ {
+ switch (_textParser.CurrentToken.Id)
+ {
+ case TokenId.Identifier:
+ ParseIdentifier();
+ break;
+
+ case TokenId.StringLiteral:
+ ParseStringLiteralAsStringExpressionOrTypeExpression();
+ break;
+
+ case TokenId.IntegerLiteral:
+ ParseIntegerLiteral();
+ break;
+
+ case TokenId.RealLiteral:
+ ParseRealLiteral();
+ break;
+
+ case TokenId.OpenParen:
+ ParseParenExpression();
+ break;
+
+ default:
+ throw ParseError("ExpressionExpected");
+ }
+ }
+
+ private void ParseStringLiteralAsStringExpressionOrTypeExpression()
+ {
+ var clonedTextParser = _textParser.Clone();
+ clonedTextParser.NextToken();
+
+ // Check if next token is a "(" or a "?(".
+ // Used for casting like $"\"System.DateTime\"(Abc)" or $"\"System.DateTime\"?(Abc)".
+ // In that case, the string value is NOT forced to stay a string.
+ bool forceParseAsString = true;
+ if (clonedTextParser.CurrentToken.Id == TokenId.OpenParen)
+ {
+ forceParseAsString = false;
+ }
+ else if (clonedTextParser.CurrentToken.Id == TokenId.Question)
+ {
+ clonedTextParser.NextToken();
+ if (clonedTextParser.CurrentToken.Id == TokenId.OpenParen)
+ {
+ forceParseAsString = false;
+ }
+ }
+
+ ParseStringLiteral(forceParseAsString);
+ }
+
+ private void ParseStringLiteral(bool forceParseAsString)
+ {
+ _textParser.ValidateToken(TokenId.StringLiteral);
+
+ var text = _textParser.CurrentToken.Text;
+ var parsedStringValue = ParseStringAndEscape(text);
+
+ if (_textParser.CurrentToken.Text[0] == '\'')
+ {
+ if (parsedStringValue.Length > 1)
+ {
+ throw ParseError("InvalidCharacterLiteral");
+ }
+
+ _textParser.NextToken();
+ return;
+ }
+
+ _textParser.NextToken();
+
+ // While the next token is also a string, keep concatenating these strings and get next token
+ while (_textParser.CurrentToken.Id == TokenId.StringLiteral)
+ {
+ text += _textParser.CurrentToken.Text;
+ _textParser.NextToken();
+ }
+
+ parsedStringValue = ParseStringAndEscape(text);
+ }
+
+ private string ParseStringAndEscape(string text)
+ {
+ return _parsingConfig.StringLiteralParsing == StringLiteralParsingType.EscapeDoubleQuoteByTwoDoubleQuotes ?
+ StringParser.ParseStringAndUnescapeTwoDoubleQuotesByASingleDoubleQuote(text, _textParser.CurrentToken.Pos) :
+ StringParser.ParseStringAndUnescape(text, _textParser.CurrentToken.Pos);
+ }
+
+ private void ParseIntegerLiteral()
+ {
+ _textParser.ValidateToken(TokenId.IntegerLiteral);
+ string text = _textParser.CurrentToken.Text;
+ var tokenPosition = _textParser.CurrentToken.Pos;
+ _textParser.NextToken();
+ }
+
+ private void ParseRealLiteral()
+ {
+ _textParser.ValidateToken(TokenId.RealLiteral);
+ string text = _textParser.CurrentToken.Text;
+ _textParser.NextToken();
+ }
+
+ private void ParseParenExpression()
+ {
+ _textParser.ValidateToken(TokenId.OpenParen, "OpenParenExpected");
+ _textParser.NextToken();
+ ParseConditionalOperator();
+ _textParser.ValidateToken(TokenId.CloseParen, "CloseParenOrOperatorExpected");
+ _textParser.NextToken();
+ }
+
+ private void ParseIdentifier()
+ {
+ _textParser.ValidateToken(TokenId.Identifier);
+ if (TokenIsIdentifier("new"))
+ ParseNew();
+ else
+ ParseMemberAccess();
+ }
+
+ // new (...) function
+ private void ParseNew()
+ {
+ _textParser.NextToken();
+ if (_textParser.CurrentToken.Id != TokenId.OpenParen &&
+ _textParser.CurrentToken.Id != TokenId.OpenCurlyParen &&
+ _textParser.CurrentToken.Id != TokenId.OpenBracket &&
+ _textParser.CurrentToken.Id != TokenId.Identifier)
+ {
+ throw ParseError("OpenParenOrIdentifierExpected");
+ }
+
+ if (_textParser.CurrentToken.Id == TokenId.Identifier)
+ {
+ var newTypeName = _textParser.CurrentToken.Text;
+
+ _textParser.NextToken();
+
+ while (_textParser.CurrentToken.Id is TokenId.Dot or TokenId.Plus)
+ {
+ var sep = _textParser.CurrentToken.Text;
+ _textParser.NextToken();
+ if (_textParser.CurrentToken.Id != TokenId.Identifier)
+ {
+ throw ParseError("IdentifierExpected");
+ }
+ newTypeName += sep + _textParser.CurrentToken.Text;
+ _textParser.NextToken();
+ }
+
+ if (_textParser.CurrentToken.Id != TokenId.OpenParen &&
+ _textParser.CurrentToken.Id != TokenId.OpenBracket &&
+ _textParser.CurrentToken.Id != TokenId.OpenCurlyParen)
+ {
+ throw ParseError("OpenParenExpected");
+ }
+ }
+
+ bool arrayInitializer = false;
+ if (_textParser.CurrentToken.Id == TokenId.OpenBracket)
+ {
+ _textParser.NextToken();
+ _textParser.ValidateToken(TokenId.CloseBracket, "CloseBracketExpected");
+ _textParser.NextToken();
+ _textParser.ValidateToken(TokenId.OpenCurlyParen, "OpenCurlyParenExpected");
+ arrayInitializer = true;
+ }
+
+ _textParser.NextToken();
+
+ var properties = new List();
+ var expressions = new List();
+
+ while (_textParser.CurrentToken.Id != TokenId.CloseParen && _textParser.CurrentToken.Id != TokenId.CloseCurlyParen)
+ {
+ int exprPos = _textParser.CurrentToken.Pos;
+ ParseConditionalOperator();
+ if (!arrayInitializer)
+ {
+ string? propName;
+ if (TokenIsIdentifier("as"))
+ {
+ _textParser.NextToken();
+ propName = GetIdentifierAs();
+ }
+ }
+
+ if (_textParser.CurrentToken.Id != TokenId.Comma)
+ {
+ break;
+ }
+
+ _textParser.NextToken();
+ }
+
+ if (_textParser.CurrentToken.Id != TokenId.CloseParen && _textParser.CurrentToken.Id != TokenId.CloseCurlyParen)
+ {
+ throw ParseError("CloseParenOrCommaExpected");
+ }
+ _textParser.NextToken();
+ }
+
+ private void ParseLambdaInvocation()
+ {
+ int errorPos = _textParser.CurrentToken.Pos;
+ _textParser.NextToken();
+ ParseArgumentList();
+ }
+
+ private void ParseMemberAccess()
+ {
+ var errorPos = _textParser.CurrentToken.Pos;
+ var id = GetIdentifier();
+ _textParser.NextToken();
+
+ // Parse as Lambda
+ if (_textParser.CurrentToken.Id == TokenId.Lambda)
+ {
+ ParseAsLambda();
+ return;
+ }
+
+ // This could be enum like "A.B.C.MyEnum.Value1" or "A.B.C+MyEnum.Value1".
+ //
+ // Or it's a nested (static) class with a
+ // - static property like "NestedClass.MyProperty"
+ // - static method like "NestedClass.MyMethod"
+ if (_textParser.CurrentToken.Id is TokenId.Dot or TokenId.Plus)
+ {
+ ParseAsEnumOrNestedClass();
+ }
+
+ if (_textParser.CurrentToken.Id == TokenId.OpenParen)
+ {
+ ParseArgumentList();
+ }
+ }
+
+ private void ParseAsLambda()
+ {
+ // next
+ _textParser.NextToken();
+ ParseConditionalOperator();
+ }
+
+ private void ParseAsEnumOrNestedClass()
+ {
+ while (_textParser.CurrentToken.Id is TokenId.Dot or TokenId.Plus)
+ {
+ if (_textParser.CurrentToken.Id is TokenId.Dot or TokenId.Plus)
+ {
+ _textParser.NextToken();
+ }
+
+ if (_textParser.CurrentToken.Id == TokenId.Identifier)
+ {
+ _textParser.NextToken();
+ }
+ }
+
+ if (_textParser.CurrentToken.Id == TokenId.Identifier)
+ ParseMemberAccess();
+ }
+
+ private void ParseArgumentList()
+ {
+ _textParser.ValidateToken(TokenId.OpenParen, "OpenParenExpected");
+ _textParser.NextToken();
+
+ if (_textParser.CurrentToken.Id != TokenId.CloseParen)
+ ParseArguments();
+
+ _textParser.ValidateToken(TokenId.CloseParen, "CloseParenOrCommaExpected");
+ _textParser.NextToken();
+ }
+
+ private void ParseArguments()
+ {
+ while (true)
+ {
+ ParseOutKeyword();
+ if (_textParser.CurrentToken.Id != TokenId.Comma)
+ {
+ break;
+ }
+
+ _textParser.NextToken();
+ }
+ }
+
+ private void ParseElementAccess()
+ {
+ int errorPos = _textParser.CurrentToken.Pos;
+ _textParser.ValidateToken(TokenId.OpenBracket, "OpenParenExpected");
+ _textParser.NextToken();
+
+ ParseArguments();
+ _textParser.ValidateToken(TokenId.CloseBracket, "CloseBracketOrCommaExpected");
+ _textParser.NextToken();
+ }
+
+ private bool TokenIsIdentifier(string id)
+ {
+ return _textParser.TokenIsIdentifier(id);
+ }
+
+ private string GetIdentifier()
+ {
+ _textParser.ValidateToken(TokenId.Identifier, "IdentifierExpected");
+ return SanitizeId(_textParser.CurrentToken.Text);
+ }
+
+ private string GetIdentifierAs()
+ {
+ _textParser.ValidateToken(TokenId.Identifier, "IdentifierExpected");
+
+ if (!_parsingConfig.SupportDotInPropertyNames)
+ {
+ var id = SanitizeId(_textParser.CurrentToken.Text);
+ _textParser.NextToken();
+ return id;
+ }
+
+ var parts = new List();
+ while (_textParser.CurrentToken.Id is TokenId.Dot or TokenId.Identifier)
+ {
+ parts.Add(_textParser.CurrentToken.Text);
+ _textParser.NextToken();
+ }
+
+ return SanitizeId(string.Concat(parts));
+ }
+
+ private static string SanitizeId(string id)
+ {
+ if (id.Length > 1 && id[0] == '@')
+ {
+ id = id.Substring(1);
+ }
+
+ return id;
+ }
+
+ private Exception ParseError(string format, params object[] args)
+ {
+ return ParseError(_textParser.CurrentToken.Pos, format, args);
+ }
+
+ private static Exception ParseError(int pos, string format, params object[] args)
+ {
+ return new ParseException(string.Format(CultureInfo.CurrentCulture, format, args), pos);
+ }
+ }
+
+ internal static class TokenIdExtensions
+ {
+ internal static bool IsEqualityOperator(this TokenId tokenId)
+ {
+ return tokenId is TokenId.Equal or TokenId.DoubleEqual or TokenId.ExclamationEqual or TokenId.LessGreater;
+ }
+
+ internal static bool IsComparisonOperator(this TokenId tokenId)
+ {
+ return tokenId is TokenId.Equal or TokenId.DoubleEqual or TokenId.ExclamationEqual or TokenId.LessGreater or TokenId.GreaterThan or TokenId.GreaterThanEqual or TokenId.LessThan or TokenId.LessThanEqual;
+ }
+ }
+
+ ///
+ /// Parse a Double and Single Quoted string.
+ /// Some parts of the code is based on https://github.com/zzzprojects/Eval-Expression.NET
+ ///
+ internal static class StringParser
+ {
+ private const string TwoDoubleQuotes = "\"\"";
+ private const string SingleDoubleQuote = "\"";
+
+ internal static string ParseStringAndUnescape(string s, int pos = default)
+ {
+ if (s == null || s.Length < 2)
+ {
+ throw new ParseException(string.Format(CultureInfo.CurrentCulture, "InvalidStringLength", s, 2), pos);
+ }
+
+ if (s[0] != '"' && s[0] != '\'')
+ {
+ throw new ParseException(string.Format(CultureInfo.CurrentCulture, "InvalidStringQuoteCharacter"), pos);
+ }
+
+ char quote = s[0]; // This can be single or a double quote
+ if (s.Last() != quote)
+ {
+ throw new ParseException(string.Format(CultureInfo.CurrentCulture, "UnexpectedUnclosedString", s.Length, s), pos);
+ }
+
+ try
+ {
+ return Regex.Unescape(s.Substring(1, s.Length - 2));
+ }
+ catch (Exception ex)
+ {
+ throw new ParseException(ex.Message, pos, ex);
+ }
+ }
+
+ internal static string ParseStringAndUnescapeTwoDoubleQuotesByASingleDoubleQuote(string input, int position = default)
+ {
+ return ReplaceTwoDoubleQuotesByASingleDoubleQuote(ParseStringAndUnescape(input, position), position);
+ }
+
+ private static string ReplaceTwoDoubleQuotesByASingleDoubleQuote(string input, int position)
+ {
+ try
+ {
+ return Regex.Replace(input, TwoDoubleQuotes, SingleDoubleQuote);
+ }
+ catch (Exception ex)
+ {
+ throw new ParseException(ex.Message, position, ex);
+ }
+ }
+ }
+}
diff --git a/src/Bonsai.Scripting.Expressions.Design/ExpressionScriptEditor.cs b/src/Bonsai.Scripting.Expressions.Design/ExpressionScriptEditor.cs
index 55e80cf..7d151f3 100644
--- a/src/Bonsai.Scripting.Expressions.Design/ExpressionScriptEditor.cs
+++ b/src/Bonsai.Scripting.Expressions.Design/ExpressionScriptEditor.cs
@@ -15,29 +15,53 @@ public class ExpressionScriptEditor : RichTextEditor
{
static readonly bool IsRunningOnMono = Type.GetType("Mono.Runtime") != null;
+ ///
+ public override object EditValue(ITypeDescriptorContext context, IServiceProvider provider, object value)
+ {
+ if (provider != null && !IsRunningOnMono)
+ {
+ var scintillaEditor = new ScintillaExpressionScriptEditor(this);
+ return scintillaEditor.EditValue(context, provider, value);
+ }
+
+ return base.EditValue(context, provider, value);
+ }
+ }
+
+ internal class ScintillaExpressionScriptEditor : DataSourceTypeEditor
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ScintillaExpressionScriptEditor(UITypeEditor baseEditor)
+ : base(DataSource.Input)
+ {
+ BaseEditor = baseEditor;
+ }
+
+ private UITypeEditor BaseEditor { get; }
+
///
public override UITypeEditorEditStyle GetEditStyle(ITypeDescriptorContext context)
{
return UITypeEditorEditStyle.Modal;
}
- ///
public override object EditValue(ITypeDescriptorContext context, IServiceProvider provider, object value)
{
- if (provider != null && !IsRunningOnMono)
+ var workflowBuilder = (WorkflowBuilder)provider.GetService(typeof(WorkflowBuilder));
+ var editorService = (IWindowsFormsEditorService)provider.GetService(typeof(IWindowsFormsEditorService));
+ if (editorService != null)
{
- var editorService = (IWindowsFormsEditorService)provider.GetService(typeof(IWindowsFormsEditorService));
- if (editorService != null)
- {
- using var editorDialog = new ExpressionScriptEditorDialog();
- editorDialog.Script = (string)value;
- return editorService.ShowDialog(editorDialog) == DialogResult.OK
- ? editorDialog.Script
- : value;
- }
+ var itType = GetDataSource(context, provider)?.ObservableType;
+ using var editorDialog = new ExpressionScriptEditorDialog(itType);
+ editorDialog.Script = (string)value;
+ return editorService.ShowDialog(editorDialog) == DialogResult.OK
+ ? editorDialog.Script
+ : value;
}
- return base.EditValue(context, provider, value);
+ return BaseEditor.EditValue(context, provider, value);
}
}
}
diff --git a/src/Bonsai.Scripting.Expressions.Design/ExpressionScriptEditorDialog.Designer.cs b/src/Bonsai.Scripting.Expressions.Design/ExpressionScriptEditorDialog.Designer.cs
index 948b613..723b2b3 100644
--- a/src/Bonsai.Scripting.Expressions.Design/ExpressionScriptEditorDialog.Designer.cs
+++ b/src/Bonsai.Scripting.Expressions.Design/ExpressionScriptEditorDialog.Designer.cs
@@ -45,7 +45,7 @@ private void InitializeComponent()
this.scintilla.TabWidth = 2;
this.scintilla.UseTabs = false;
this.scintilla.WrapMode = ScintillaNET.WrapMode.Word;
- this.scintilla.KeyDown += new System.Windows.Forms.KeyEventHandler(this.scintilla_KeyDown);
+ this.scintilla.CharAdded += new System.EventHandler(this.scintilla_CharAdded);
this.scintilla.TextChanged += new System.EventHandler(this.scintilla_TextChanged);
//
// okButton
diff --git a/src/Bonsai.Scripting.Expressions.Design/ExpressionScriptEditorDialog.cs b/src/Bonsai.Scripting.Expressions.Design/ExpressionScriptEditorDialog.cs
index 22f1022..e917df3 100644
--- a/src/Bonsai.Scripting.Expressions.Design/ExpressionScriptEditorDialog.cs
+++ b/src/Bonsai.Scripting.Expressions.Design/ExpressionScriptEditorDialog.cs
@@ -1,14 +1,24 @@
-using ScintillaNET;
+using Bonsai.Scripting.Expressions.Design.Properties;
+using ScintillaNET;
using System;
+using System.Collections.Generic;
using System.Drawing;
+using System.Linq;
+using System.Linq.Dynamic.Core;
+using System.Linq.Dynamic.Core.Exceptions;
+using System.Reflection;
+using System.Text;
using System.Windows.Forms;
namespace Bonsai.Scripting.Expressions.Design
{
internal partial class ExpressionScriptEditorDialog : Form
{
- public ExpressionScriptEditorDialog()
+ readonly StringBuilder autoCompleteList = new();
+
+ public ExpressionScriptEditorDialog(Type? itType = null)
{
+ ItType = itType;
InitializeComponent();
scintilla.StyleResetDefault();
scintilla.Styles[Style.Default].Font = "Consolas";
@@ -25,11 +35,24 @@ public ExpressionScriptEditorDialog()
scintilla.Styles[Style.Cpp.Word2].ForeColor = ColorTranslator.FromHtml("#2b91af");
scintilla.Lexer = Lexer.Cpp;
- var types = "Object Boolean Char String SByte Byte Int16 UInt16 Int32 UInt32 Int64 UInt64 Single Double Decimal DateTime DateTimeOffset TimeSpan Guid Math Convert";
- scintilla.SetKeywords(0, "it iif new outerIt as true false null");
- scintilla.SetKeywords(1, string.Join(" ", types, types.ToLowerInvariant()));
+ var typeKeywords = string.Join(" ", CaretExpressionAnalyzer.PredefinedTypeKeywords.Keys);
+ var predefinedTypes = CaretExpressionAnalyzer.PredefinedTypes;
+ scintilla.SetKeywords(0, "it iif new outerIt as true false null " + typeKeywords);
+ scintilla.SetKeywords(1, string.Join(" ", Enumerable.Concat(
+ (itType is not null ? predefinedTypes.Append(itType) : predefinedTypes).Select(type => type.Name),
+ predefinedTypes.Select(type => type.Name.ToLowerInvariant()))));
+
+ scintilla.AutoCSeparator = ';';
+ scintilla.AutoCTypeSeparator = '?';
+ scintilla.AutoCDropRestOfWord = true;
+ scintilla.RegisterRgbaImage(0, Resources.FieldIcon);
+ scintilla.RegisterRgbaImage(1, Resources.PropertyIcon);
+ scintilla.RegisterRgbaImage(2, Resources.MethodIcon);
+ scintilla.RegisterRgbaImage(3, Resources.TypeIcon);
}
+ public Type? ItType { get; }
+
public string Script { get; set; }
protected override void OnLoad(EventArgs e)
@@ -47,20 +70,33 @@ protected override void OnLoad(EventArgs e)
protected override void OnKeyDown(KeyEventArgs e)
{
- if (e.KeyCode == Keys.Escape && !e.Handled)
+ if (e.KeyData == Keys.Escape && !e.Handled)
{
Close();
e.Handled = true;
}
+ if (e.Modifiers == Keys.Control && e.KeyCode == Keys.Enter)
+ {
+ okButton.PerformClick();
+ }
+
+ if (e.Modifiers == Keys.Control && e.KeyCode == Keys.Space &&
+ TryGetAutoCompleteList(ItType, out string list, out int lenEntered))
+ {
+ scintilla.AutoCShow(lenEntered, list);
+ e.SuppressKeyPress = true;
+ }
+
base.OnKeyDown(e);
}
- private void scintilla_KeyDown(object sender, KeyEventArgs e)
+ private void scintilla_CharAdded(object sender, CharAddedEventArgs e)
{
- if (e.KeyCode == Keys.Enter && e.Modifiers == Keys.Control)
+ autoCompleteList.Clear();
+ if (e.Char == '.' && TryGetAutoCompleteList(ItType, out string list, out int lenEntered))
{
- okButton.PerformClick();
+ scintilla.AutoCShow(lenEntered, list);
}
}
@@ -68,5 +104,110 @@ private void scintilla_TextChanged(object sender, EventArgs e)
{
Script = scintilla.Text;
}
+
+ private bool TryGetAutoCompleteList(Type itType, out string list, out int lenEntered)
+ {
+ if (itType is not null)
+ {
+ try
+ {
+ var currentPos = scintilla.CurrentPosition;
+ var config = ParsingConfigHelper.CreateParsingConfig(itType);
+ var wordStartPos = scintilla.WordStartPosition(currentPos, true);
+ scintilla.CurrentPosition = wordStartPos;
+ lenEntered = currentPos - wordStartPos;
+ var analyzer = new CaretExpressionAnalyzer(config, scintilla.Text, wordStartPos);
+ var primaryType = analyzer.ParseExpressionType(itType, out bool isClassIdentifier);
+
+ autoCompleteList.Clear();
+ var appendTypes = false;
+ var bindingFlags = BindingFlags.Public;
+ bindingFlags |= isClassIdentifier
+ ? BindingFlags.Static | BindingFlags.FlattenHierarchy
+ : BindingFlags.Instance;
+
+ if (primaryType is null && !isClassIdentifier)
+ {
+ AppendMember("it", -1, autoCompleteList);
+ primaryType = itType;
+ appendTypes = true;
+ }
+
+ if (!primaryType.IsEnum)
+ AppendFields(primaryType, bindingFlags, autoCompleteList);
+ else if (isClassIdentifier)
+ AppendEnumNames(primaryType, autoCompleteList);
+
+ AppendProperties(primaryType, bindingFlags, autoCompleteList);
+ AppendMethods(primaryType, bindingFlags, autoCompleteList);
+ if (appendTypes)
+ AppendTypes(itType, autoCompleteList);
+ list = autoCompleteList.ToString();
+ return true;
+ }
+ catch (ParseException) { }
+ }
+
+ lenEntered = default;
+ list = default;
+ return false;
+ }
+
+ private void AppendFields(Type type, BindingFlags bindingFlags, StringBuilder sb)
+ {
+ foreach (var field in type.GetFields(bindingFlags)
+ .OrderBy(f => f.Name))
+ {
+ AppendMember(field.Name, 0, sb);
+ }
+ }
+
+ private void AppendEnumNames(Type type, StringBuilder sb)
+ {
+ foreach (var name in Enum.GetNames(type).OrderBy(f => f))
+ {
+ AppendMember(name, 0, sb);
+ }
+ }
+
+ private void AppendProperties(Type type, BindingFlags bindingFlags, StringBuilder sb)
+ {
+ foreach (var property in type.GetProperties(bindingFlags)
+ .Except(type.GetDefaultMembers())
+ .OrderBy(p => p.Name))
+ {
+ AppendMember(property.Name, 1, sb);
+ }
+ }
+
+ private void AppendMethods(Type type, BindingFlags bindingFlags, StringBuilder sb)
+ {
+ var nameSet = new HashSet();
+ foreach (var method in type.GetMethods(bindingFlags)
+ .OrderBy(m => m.Name))
+ {
+ if (!method.IsSpecialName && nameSet.Add(method.Name))
+ AppendMember(method.Name, 2, sb);
+ }
+ }
+
+ private void AppendTypes(Type itType, StringBuilder sb)
+ {
+ foreach (var type in CaretExpressionAnalyzer.PredefinedTypes.Append(itType)
+ .OrderBy(t => t.Name))
+ {
+ AppendMember(type.Name, 3, sb);
+ }
+ }
+
+ private void AppendMember(string name, int type, StringBuilder sb)
+ {
+ if (sb.Length > 0)
+ sb.Append(scintilla.AutoCSeparator);
+
+ sb.Append(name);
+ sb.Append(scintilla.AutoCTypeSeparator);
+ sb.Append(type);
+ }
}
}
diff --git a/src/Bonsai.Scripting.Expressions.Design/Properties/Resources.Designer.cs b/src/Bonsai.Scripting.Expressions.Design/Properties/Resources.Designer.cs
new file mode 100644
index 0000000..aa65bea
--- /dev/null
+++ b/src/Bonsai.Scripting.Expressions.Design/Properties/Resources.Designer.cs
@@ -0,0 +1,103 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+// Runtime Version:4.0.30319.42000
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+
+namespace Bonsai.Scripting.Expressions.Design.Properties {
+ using System;
+
+
+ ///
+ /// A strongly-typed resource class, for looking up localized strings, etc.
+ ///
+ // This class was auto-generated by the StronglyTypedResourceBuilder
+ // class via a tool like ResGen or Visual Studio.
+ // To add or remove a member, edit your .ResX file then rerun ResGen
+ // with the /str option, or rebuild your VS project.
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ internal class Resources {
+
+ private static global::System.Resources.ResourceManager resourceMan;
+
+ private static global::System.Globalization.CultureInfo resourceCulture;
+
+ [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+ internal Resources() {
+ }
+
+ ///
+ /// Returns the cached ResourceManager instance used by this class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Resources.ResourceManager ResourceManager {
+ get {
+ if (object.ReferenceEquals(resourceMan, null)) {
+ global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Bonsai.Scripting.Expressions.Design.Properties.Resources", typeof(Resources).Assembly);
+ resourceMan = temp;
+ }
+ return resourceMan;
+ }
+ }
+
+ ///
+ /// Overrides the current thread's CurrentUICulture property for all
+ /// resource lookups using this strongly typed resource class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Globalization.CultureInfo Culture {
+ get {
+ return resourceCulture;
+ }
+ set {
+ resourceCulture = value;
+ }
+ }
+
+ ///
+ /// Looks up a localized resource of type System.Drawing.Bitmap.
+ ///
+ internal static System.Drawing.Bitmap FieldIcon {
+ get {
+ object obj = ResourceManager.GetObject("FieldIcon", resourceCulture);
+ return ((System.Drawing.Bitmap)(obj));
+ }
+ }
+
+ ///
+ /// Looks up a localized resource of type System.Drawing.Bitmap.
+ ///
+ internal static System.Drawing.Bitmap MethodIcon {
+ get {
+ object obj = ResourceManager.GetObject("MethodIcon", resourceCulture);
+ return ((System.Drawing.Bitmap)(obj));
+ }
+ }
+
+ ///
+ /// Looks up a localized resource of type System.Drawing.Bitmap.
+ ///
+ internal static System.Drawing.Bitmap PropertyIcon {
+ get {
+ object obj = ResourceManager.GetObject("PropertyIcon", resourceCulture);
+ return ((System.Drawing.Bitmap)(obj));
+ }
+ }
+
+ ///
+ /// Looks up a localized resource of type System.Drawing.Bitmap.
+ ///
+ internal static System.Drawing.Bitmap TypeIcon {
+ get {
+ object obj = ResourceManager.GetObject("TypeIcon", resourceCulture);
+ return ((System.Drawing.Bitmap)(obj));
+ }
+ }
+ }
+}
diff --git a/src/Bonsai.Scripting.Expressions.Design/Properties/Resources.resx b/src/Bonsai.Scripting.Expressions.Design/Properties/Resources.resx
new file mode 100644
index 0000000..b675004
--- /dev/null
+++ b/src/Bonsai.Scripting.Expressions.Design/Properties/Resources.resx
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+
+
+ iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAAO
+ wQAADsEBuJFr7QAAAJtJREFUOE/FksENhCAURCnFWuiCm71oH1sE1egetoR1Q+AfMUNCwk78onvZSYZ8
+ 1Pc8gDEnEZExpZRRzPxeTQW999k5V4q5K2Jwmua8Ls9SzKpIA7f356ssCiG8DJYeyAJrbSl+arDUB5qA
+ QVXAIg3sCq72f4L2OIugngJ/yG3BGONDRAbcgQGbM9EhyDkSXQI5regWyAH0E3g3O1k27MT5pgeyAAAA
+ AElFTkSuQmCC
+
+
+
+
+ iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAAO
+ wQAADsEBuJFr7QAAAM1JREFUOE9jYBhU4Pv37wpfv37d/+3bt/8gGsRHV4MTfP36teHtyw//l7Tu/Z+q
+ OwFMg/ggcXS1KOD79+8O3759O39y+7X/5W5z/88s2/L//vWnYBrEB4mD5EHq0PWCAci5k3I2/C9znfP/
+ 7P6b/z+8/wjHID5IHCQPUoeuFwxAEiAnd8StwGoASBwkT9CA549f/1/ddxDFCyA+SJwoA2C2Xjt9H8wH
+ 0TAxkgyAaUDn4zUAPRBhBhAViNiiEWQA0dEIA2QnJGRAUVIeMAAAz+NnEPQ3nqAAAAAASUVORK5CYII=
+
+
+
+
+ iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAAO
+ wQAADsEBuJFr7QAAAOZJREFUOE+1kiEOhDAQRTnCHmWPsMFzBwQHWI1ahceTrKxE4FmN5wA4BKKGmcqS
+ 36QEhpLNbsJPRrSZ9xnmN4qukDHmQUQfZra+cJ7n+S57DzLGpNM02aIobBzHrpIksbiDEcwls0prfRvH
+ UWdZtoP7vl8nYWaNPsk6EdFTKXUKN01ju67DFKlknZi5zvM8aAAYd1VVYR8vyTpJA28CyJ+/GpRluTOQ
+ 1bZt+BeI6O3HPCssN7jEEIzRt2fEioQOb+EMFtGh6gMM4YFsv+ZhGMveoIZh+B+G0IzHgXh+hr0QC7IN
+ xnO1FiyaXLCSPwr5AAAAAElFTkSuQmCC
+
+
+
+
+ iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8
+ YQUAAAAJcEhZcwAADsIAAA7CARUoSoAAAACWSURBVDhPlZHRDYAwCEQ7h1M4kgs4ggs5hkO4hj9N+1mD
+ CQZOwPYSYk29hxwpgXLOcynloqIz3odi87mvjWoIIs3HNj3VDbHMHoSeChiZEWDm02FW9cknAkTFEHME
+ 2ZneubMJwBzk73kADFZB5IUEwPyt1rq8ZgmRVASYIUZigFUqA0/YeRhACrb0PwLL2xJ+F8raEukGVF3/
+ 7l5SVIkAAAAASUVORK5CYII=
+
+
+
\ No newline at end of file