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