From aab1683bce56c4e0dc43850496afc43c3a5d6feb Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Fri, 24 Apr 2026 19:02:01 -0500 Subject: [PATCH 01/10] feat: add TextMate grammar for FileMaker calculations Introduce source.fmcalc grammar covering comments, strings with escapes, numeric literals, $/$$ variables, Table::Field references, control forms (Let/Case/If/While/Choose), operators, and builtin functions grouped by category. Custom function calls scope as entity.name.function. Rename FmScriptRegistryOptions to FmLanguageRegistryOptions and serve both source.fmscript and source.fmcalc with per-scope Lazy caches. Point the field Calculation Editor at the new scope so it no longer mis-tokenizes the first identifier as a script step. Refactor fmscript bracket-params to include source.fmcalc rather than duplicating a hardcoded subset. Add tokenization tests asserting representative fixtures for both grammars, including cross-grammar resolution from script step brackets. Closes #193 --- src/SharpFM/Editors/ScriptTextEditor.cs | 4 +- .../Editor/CalculationEditorWindow.axaml.cs | 4 +- .../Editor/FmLanguageRegistryOptions.cs | 66 ++++++ .../Editor/FmScriptRegistryOptions.cs | 58 ----- .../Scripting/Editor/fmcalc.tmLanguage.json | 211 ++++++++++++++++++ .../Scripting/Editor/fmscript.tmLanguage.json | 46 +--- src/SharpFM/SharpFM.csproj | 1 + .../Scripting/GrammarTokenizationTests.cs | 172 ++++++++++++++ 8 files changed, 455 insertions(+), 107 deletions(-) create mode 100644 src/SharpFM/Scripting/Editor/FmLanguageRegistryOptions.cs delete mode 100644 src/SharpFM/Scripting/Editor/FmScriptRegistryOptions.cs create mode 100644 src/SharpFM/Scripting/Editor/fmcalc.tmLanguage.json create mode 100644 tests/SharpFM.Tests/Scripting/GrammarTokenizationTests.cs diff --git a/src/SharpFM/Editors/ScriptTextEditor.cs b/src/SharpFM/Editors/ScriptTextEditor.cs index 4ee6c71..02e8905 100644 --- a/src/SharpFM/Editors/ScriptTextEditor.cs +++ b/src/SharpFM/Editors/ScriptTextEditor.cs @@ -32,7 +32,7 @@ public class ScriptTextEditor : TextEditor, IDisposable private static readonly RegistryOptions SharedRegistry = new((ThemeName)(int)ThemeName.DarkPlus); - private static readonly FmScriptRegistryOptions SharedFmRegistry = + private static readonly FmLanguageRegistryOptions SharedFmRegistry = new(SharedRegistry); private readonly TextMate.Installation _textMate; @@ -41,7 +41,7 @@ public class ScriptTextEditor : TextEditor, IDisposable public ScriptTextEditor() { _textMate = this.InstallTextMate(SharedFmRegistry); - _textMate.SetGrammar(FmScriptRegistryOptions.ScopeName); + _textMate.SetGrammar(FmLanguageRegistryOptions.ScriptScopeName); _controller = new ScriptEditorController(this); _controller.StatusMessageRaised += OnStatusMessageRaised; diff --git a/src/SharpFM/Schema/Editor/CalculationEditorWindow.axaml.cs b/src/SharpFM/Schema/Editor/CalculationEditorWindow.axaml.cs index 1affbd8..f4ff00a 100644 --- a/src/SharpFM/Schema/Editor/CalculationEditorWindow.axaml.cs +++ b/src/SharpFM/Schema/Editor/CalculationEditorWindow.axaml.cs @@ -28,10 +28,10 @@ public CalculationEditorWindow(FmField field) // Set up FM script syntax highlighting for calculations var registryOptions = new RegistryOptions((ThemeName)(int)ThemeName.DarkPlus); - var fmRegistry = new FmScriptRegistryOptions(registryOptions); + var fmRegistry = new FmLanguageRegistryOptions(registryOptions); var editor = this.FindControl("calcEditor")!; _textMateInstallation = editor.InstallTextMate(fmRegistry); - _textMateInstallation.SetGrammar(FmScriptRegistryOptions.ScopeName); + _textMateInstallation.SetGrammar(FmLanguageRegistryOptions.CalcScopeName); // Populate fields editor.Text = field.Calculation ?? ""; diff --git a/src/SharpFM/Scripting/Editor/FmLanguageRegistryOptions.cs b/src/SharpFM/Scripting/Editor/FmLanguageRegistryOptions.cs new file mode 100644 index 0000000..a5180f8 --- /dev/null +++ b/src/SharpFM/Scripting/Editor/FmLanguageRegistryOptions.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using TextMateSharp.Grammars; +using TextMateSharp.Internal.Grammars.Reader; +using TextMateSharp.Internal.Types; +using TextMateSharp.Registry; +using TextMateSharp.Themes; + +namespace SharpFM.Scripting.Editor; + +/// +/// Serves the embedded FileMaker TextMate grammars (source.fmscript +/// and source.fmcalc) and delegates everything else to an inner +/// . Cross-grammar includes — e.g. the +/// script grammar embedding the calc grammar inside [ ... ] — resolve +/// through this method. +/// +[ExcludeFromCodeCoverage] +public class FmLanguageRegistryOptions : IRegistryOptions +{ + public const string ScriptScopeName = "source.fmscript"; + public const string CalcScopeName = "source.fmcalc"; + + private readonly RegistryOptions _inner; + + private static readonly Lazy ScriptGrammar = + new(() => LoadGrammar("fmscript.tmLanguage.json"), LazyThreadSafetyMode.ExecutionAndPublication); + + private static readonly Lazy CalcGrammar = + new(() => LoadGrammar("fmcalc.tmLanguage.json"), LazyThreadSafetyMode.ExecutionAndPublication); + + public FmLanguageRegistryOptions(RegistryOptions inner) + { + _inner = inner; + } + + public IRawTheme GetDefaultTheme() => _inner.GetDefaultTheme(); + + public IRawTheme GetTheme(string scopeName) => _inner.GetTheme(scopeName); + + public ICollection GetInjections(string scopeName) => _inner.GetInjections(scopeName); + + public IRawGrammar GetGrammar(string scopeName) => scopeName switch + { + ScriptScopeName => ScriptGrammar.Value, + CalcScopeName => CalcGrammar.Value, + _ => _inner.GetGrammar(scopeName), + }; + + private static IRawGrammar LoadGrammar(string fileName) + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceName = assembly.GetManifestResourceNames() + .FirstOrDefault(n => n.EndsWith(fileName, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"Embedded grammar resource not found: {fileName}"); + + using var stream = assembly.GetManifestResourceStream(resourceName)!; + using var reader = new StreamReader(stream); + return GrammarReader.ReadGrammarSync(reader); + } +} diff --git a/src/SharpFM/Scripting/Editor/FmScriptRegistryOptions.cs b/src/SharpFM/Scripting/Editor/FmScriptRegistryOptions.cs deleted file mode 100644 index 9e057ec..0000000 --- a/src/SharpFM/Scripting/Editor/FmScriptRegistryOptions.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Reflection; -using System.Threading; -using TextMateSharp.Grammars; -using TextMateSharp.Internal.Grammars.Reader; -using TextMateSharp.Internal.Types; -using TextMateSharp.Registry; -using TextMateSharp.Themes; - -namespace SharpFM.Scripting.Editor; - -[ExcludeFromCodeCoverage] -public class FmScriptRegistryOptions : IRegistryOptions -{ - public const string ScopeName = "source.fmscript"; - - private readonly RegistryOptions _inner; - - // The embedded .tmLanguage.json is immutable; parsing it is not free. Cache - // the parsed grammar once per process so repeated TextMate installs (e.g. - // fresh script-editor instances) don't reparse ~KB of JSON each time. - private static readonly Lazy CachedGrammar = - new(LoadFmScriptGrammar, LazyThreadSafetyMode.ExecutionAndPublication); - - public FmScriptRegistryOptions(RegistryOptions inner) - { - _inner = inner; - } - - public IRawTheme GetDefaultTheme() => _inner.GetDefaultTheme(); - - public IRawTheme GetTheme(string scopeName) => _inner.GetTheme(scopeName); - - public ICollection GetInjections(string scopeName) => _inner.GetInjections(scopeName); - - public IRawGrammar GetGrammar(string scopeName) - { - if (scopeName == ScopeName) - return CachedGrammar.Value; - - return _inner.GetGrammar(scopeName); - } - - private static IRawGrammar LoadFmScriptGrammar() - { - var assembly = Assembly.GetExecutingAssembly(); - var resourceName = assembly.GetManifestResourceNames() - [System.Array.FindIndex(assembly.GetManifestResourceNames(), - n => n.EndsWith("fmscript.tmLanguage.json"))]; - - using var stream = assembly.GetManifestResourceStream(resourceName)!; - using var reader = new StreamReader(stream); - return GrammarReader.ReadGrammarSync(reader); - } -} diff --git a/src/SharpFM/Scripting/Editor/fmcalc.tmLanguage.json b/src/SharpFM/Scripting/Editor/fmcalc.tmLanguage.json new file mode 100644 index 0000000..a828241 --- /dev/null +++ b/src/SharpFM/Scripting/Editor/fmcalc.tmLanguage.json @@ -0,0 +1,211 @@ +{ + "name": "FileMaker Calculation", + "scopeName": "source.fmcalc", + "fileTypes": ["fmcalc"], + "patterns": [ + { "include": "#expression" } + ], + "repository": { + "expression": { + "patterns": [ + { "include": "#comment-block" }, + { "include": "#comment-line" }, + { "include": "#string" }, + { "include": "#number" }, + { "include": "#constant" }, + { "include": "#control-form" }, + { "include": "#operator-word" }, + { "include": "#variable" }, + { "include": "#field-reference" }, + { "include": "#builtin-function" }, + { "include": "#custom-function" }, + { "include": "#operator-symbol" }, + { "include": "#punctuation" } + ] + }, + "comment-block": { + "name": "comment.block.fmcalc", + "begin": "/\\*", + "beginCaptures": { + "0": { "name": "punctuation.definition.comment.fmcalc" } + }, + "end": "\\*/", + "endCaptures": { + "0": { "name": "punctuation.definition.comment.fmcalc" } + } + }, + "comment-line": { + "name": "comment.line.double-slash.fmcalc", + "begin": "//", + "beginCaptures": { + "0": { "name": "punctuation.definition.comment.fmcalc" } + }, + "end": "$" + }, + "string": { + "name": "string.quoted.double.fmcalc", + "begin": "\"", + "beginCaptures": { + "0": { "name": "punctuation.definition.string.begin.fmcalc" } + }, + "end": "\"", + "endCaptures": { + "0": { "name": "punctuation.definition.string.end.fmcalc" } + }, + "patterns": [ + { + "name": "constant.character.escape.fmcalc", + "match": "\\\\(\"|\\\\|n|r|t)" + } + ] + }, + "number": { + "name": "constant.numeric.fmcalc", + "match": "\\b\\d+(\\.\\d+)?([eE][+-]?\\d+)?\\b" + }, + "constant": { + "name": "constant.language.fmcalc", + "match": "\\b(True|False|Pi)\\b" + }, + "control-form": { + "name": "keyword.control.fmcalc", + "match": "\\b(Let|Case|If|While|Choose)(?=\\s*\\()" + }, + "operator-word": { + "name": "keyword.operator.word.fmcalc", + "match": "\\b(and|or|not|xor)\\b" + }, + "variable": { + "name": "variable.other.fmcalc", + "match": "\\${1,2}[A-Za-z_][A-Za-z0-9_.]*" + }, + "field-reference": { + "match": "\\b([A-Za-z_][A-Za-z0-9_ ]*?)(::)([A-Za-z_][A-Za-z0-9_]*)", + "captures": { + "1": { "name": "entity.name.type.fmcalc" }, + "2": { "name": "punctuation.separator.field.fmcalc" }, + "3": { "name": "variable.other.member.fmcalc" } + } + }, + "builtin-function": { + "patterns": [ + { + "match": "\\b(Char|Code|Exact|Filter|FilterValues|GetAsCSS|GetAsDate|GetAsNumber|GetAsSVG|GetAsText|GetAsTime|GetAsTimestamp|GetAsURLEncoded|Hiragana|KanaHankaku|KanaZenkaku|KanjiNumeral|KatakanaToRoman|Left|LeftValues|LeftWords|Length|Lower|Middle|MiddleValues|MiddleWords|NumToJText|PatternCount|Position|Proper|Quote|Replace|Right|RightValues|RightWords|RomanHankaku|RomanZenkaku|SerialIncrement|SortValues|Substitute|Trim|TrimAll|UniqueValues|Upper|ValueCount|VerifyID|WordCount)\\s*(?=\\()", + "captures": { + "1": { "name": "support.function.text.fmcalc" } + } + }, + { + "match": "\\b(RGB|TextColor|TextColorRemove|TextFont|TextFontRemove|TextFormatRemove|TextSize|TextSizeRemove|TextStyleAdd|TextStyleRemove)\\s*(?=\\()", + "captures": { + "1": { "name": "support.function.text-formatting.fmcalc" } + } + }, + { + "match": "\\b(Abs|Ceiling|Combination|Div|Exp|Factorial|Floor|Int|Lg|Ln|Log|Mod|Random|Round|SetPrecision|Sign|Sqrt|Truncate)\\s*(?=\\()", + "captures": { + "1": { "name": "support.function.number.fmcalc" } + } + }, + { + "match": "\\b(Date|Day|DayName|DayNameJ|DayOfWeek|DayOfYear|Month|MonthName|MonthNameJ|WeekOfYear|WeekOfYearFiscal|Year|YearName)\\s*(?=\\()", + "captures": { + "1": { "name": "support.function.date.fmcalc" } + } + }, + { + "match": "\\b(Hour|Minute|Seconds|Time|Timestamp)\\s*(?=\\()", + "captures": { + "1": { "name": "support.function.time.fmcalc" } + } + }, + { + "match": "\\b(Average|Count|List|Max|Min|StDev|StDevP|Sum|Variance|VarianceP)\\s*(?=\\()", + "captures": { + "1": { "name": "support.function.aggregate.fmcalc" } + } + }, + { + "match": "\\b(GetSummary|GetNthRecord|Last|GetRepetition|Extend)\\s*(?=\\()", + "captures": { + "1": { "name": "support.function.summary.fmcalc" } + } + }, + { + "match": "\\b(FV|NPV|PMT|PV)\\s*(?=\\()", + "captures": { + "1": { "name": "support.function.financial.fmcalc" } + } + }, + { + "match": "\\b(Acos|Asin|Atan|Cos|Degrees|Radians|Sin|Tan)\\s*(?=\\()", + "captures": { + "1": { "name": "support.function.trigonometric.fmcalc" } + } + }, + { + "match": "\\b(Evaluate|EvaluationError|GetAsBoolean|GetField|GetFieldName|IsEmpty|IsValid|IsValidExpression|Lookup|LookupNext|Self|SetField)\\s*(?=\\()", + "captures": { + "1": { "name": "support.function.logical.fmcalc" } + } + }, + { + "match": "\\b(Get)\\s*(?=\\()", + "captures": { + "1": { "name": "support.function.get.fmcalc" } + } + }, + { + "match": "\\b(Base64Decode|Base64Encode|Base64EncodeRFC|CryptAuthCode|CryptDecrypt|CryptDecryptBase64|CryptDigest|CryptEncrypt|CryptEncryptBase64|CryptGenerateSignature|CryptVerifySignature|GetContainerAttribute|GetHeight|GetThumbnail|GetWidth|HexDecode|HexEncode|VerifyContainer)\\s*(?=\\()", + "captures": { + "1": { "name": "support.function.container.fmcalc" } + } + }, + { + "match": "\\b(JSONDeleteElement|JSONFormatElements|JSONGetElement|JSONListKeys|JSONListValues|JSONSetElement)\\s*(?=\\()", + "captures": { + "1": { "name": "support.function.json.fmcalc" } + } + }, + { + "match": "\\b(ExecuteSQL)\\s*(?=\\()", + "captures": { + "1": { "name": "support.function.sql.fmcalc" } + } + }, + { + "match": "\\b(GetSensor|GetLiveRemoteCallResult|GetLiveRemoteCallStatus)\\s*(?=\\()", + "captures": { + "1": { "name": "support.function.external.fmcalc" } + } + }, + { + "match": "\\b(DatabaseNames|FieldBounds|FieldComment|FieldIDs|FieldNames|FieldRepetitions|FieldStyle|FieldType|GetNextSerialValue|LayoutIDs|LayoutNames|LayoutObjectNames|RelationInfo|ScriptIDs|ScriptNames|TableIDs|TableNames|ValueListIDs|ValueListItems|ValueListNames|WindowNames)\\s*(?=\\()", + "captures": { + "1": { "name": "support.function.design.fmcalc" } + } + } + ] + }, + "custom-function": { + "match": "\\b([A-Za-z_][A-Za-z0-9_]*)\\s*(?=\\()", + "captures": { + "1": { "name": "entity.name.function.fmcalc" } + } + }, + "operator-symbol": { + "name": "keyword.operator.fmcalc", + "match": "(\\^|\\*|/|\\+|-|&|=|≠|<>|≤|<=|≥|>=|<|>)" + }, + "punctuation": { + "patterns": [ + { "match": "\\(", "name": "punctuation.section.parens.begin.fmcalc" }, + { "match": "\\)", "name": "punctuation.section.parens.end.fmcalc" }, + { "match": ";", "name": "punctuation.separator.fmcalc" }, + { "match": ",", "name": "punctuation.separator.fmcalc" }, + { "match": "\\[", "name": "punctuation.section.brackets.begin.fmcalc" }, + { "match": "\\]", "name": "punctuation.section.brackets.end.fmcalc" } + ] + } + } +} diff --git a/src/SharpFM/Scripting/Editor/fmscript.tmLanguage.json b/src/SharpFM/Scripting/Editor/fmscript.tmLanguage.json index 1b33196..f86a5c3 100644 --- a/src/SharpFM/Scripting/Editor/fmscript.tmLanguage.json +++ b/src/SharpFM/Scripting/Editor/fmscript.tmLanguage.json @@ -49,59 +49,15 @@ "0": { "name": "punctuation.bracket.close.fmscript" } }, "patterns": [ - { "include": "#string" }, - { "include": "#variable" }, { "include": "#param-label" }, - { "include": "#semicolon" }, - { "include": "#number" }, - { "include": "#boolean-value" }, - { "include": "#operator" }, - { "include": "#function-call" }, - { "include": "#field-reference" } + { "include": "source.fmcalc" } ] }, - "string": { - "match": "\"[^\"]*\"", - "name": "string.quoted.double.fmscript" - }, - "variable": { - "match": "\\${1,2}[A-Za-z_][A-Za-z0-9_.]*", - "name": "variable.other.fmscript" - }, "param-label": { "match": "\\b(Value|With dialog|Select|Exit after last|Perform without dialog|Target|Specify|Output File|Animation)\\s*:", "captures": { "1": { "name": "support.type.property-name.fmscript" } } - }, - "semicolon": { - "match": ";", - "name": "punctuation.separator.fmscript" - }, - "number": { - "match": "\\b\\d+(\\.\\d+)?\\b", - "name": "constant.numeric.fmscript" - }, - "boolean-value": { - "match": "\\b(On|Off|True|False)\\b", - "name": "constant.language.fmscript" - }, - "operator": { - "match": "(>=|<=|<>|>|<|=|&|\\band\\b|\\bor\\b|\\bnot\\b|\\bxor\\b)", - "name": "keyword.operator.fmscript" - }, - "function-call": { - "match": "\\b(Get|GetValue|Let|If|Case|Evaluate|ExecuteSQL|GetAsText|GetAsNumber|GetAsDate|GetAsTime|GetAsTimestamp|List|FilterValues|ValueCount|PatternCount|Position|Length|Left|Right|Middle|Substitute|Trim|Upper|Lower|Proper|Char|Code|Date|Time|Timestamp|Year|Month|Day|Hour|Minute|Seconds|Round|Abs|Int|Mod|Ceiling|Floor|Sqrt|Exp|Log|Ln|Pi|Random|Count|Sum|Average|Min|Max|GetNthRecord|Last|GetSummary|Self|GetField|SetField|JSONSetElement|JSONGetElement|JSONListKeys|JSONListValues|JSONDeleteElement|JSONFormatElements|Base64Encode|Base64Decode|CryptEncrypt|CryptDecrypt|CryptDigest|CryptGenerateSignature|CryptVerifySignature|HexEncode|HexDecode|GetContainerAttribute|GetHeight|GetWidth|GetThumbnail|VerifyContainer|UniqueValues|SortValues|While)\\s*(?=\\()", - "captures": { - "1": { "name": "support.function.fmscript" } - } - }, - "field-reference": { - "match": "\\b([A-Za-z_][A-Za-z0-9_ ]*)::([A-Za-z_][A-Za-z0-9_ ]*)", - "captures": { - "1": { "name": "entity.name.type.fmscript" }, - "2": { "name": "variable.other.member.fmscript" } - } } } } diff --git a/src/SharpFM/SharpFM.csproj b/src/SharpFM/SharpFM.csproj index 489473a..39768c5 100644 --- a/src/SharpFM/SharpFM.csproj +++ b/src/SharpFM/SharpFM.csproj @@ -52,6 +52,7 @@ + diff --git a/tests/SharpFM.Tests/Scripting/GrammarTokenizationTests.cs b/tests/SharpFM.Tests/Scripting/GrammarTokenizationTests.cs new file mode 100644 index 0000000..3d689e7 --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/GrammarTokenizationTests.cs @@ -0,0 +1,172 @@ +using System.Linq; +using SharpFM.Scripting.Editor; +using TextMateSharp.Grammars; +using TextMateSharp.Registry; +using Xunit; + +namespace SharpFM.Tests.ScriptConverter; + +/// +/// Loads the embedded FileMaker TextMate grammars through a real TextMateSharp +/// Registry and asserts representative fixtures tokenize as expected. Guards +/// against silent regressions from grammar edits, function-list churn, or +/// cross-grammar include resolution failing. +/// +public class GrammarTokenizationTests +{ + private static IGrammar LoadGrammar(string scopeName) + { + var options = new FmLanguageRegistryOptions(new RegistryOptions((ThemeName)(int)ThemeName.DarkPlus)); + var registry = new Registry(options); + return registry.LoadGrammar(scopeName); + } + + private static string[] ScopesAt(IGrammar grammar, string line, int column) + { + var result = grammar.TokenizeLine(line); + var token = result.Tokens.First(t => column >= t.StartIndex && column < t.EndIndex); + return token.Scopes.ToArray(); + } + + private static bool LineHasScope(IGrammar grammar, string line, string scope) + { + var result = grammar.TokenizeLine(line); + return result.Tokens.Any(t => t.Scopes.Any(s => s == scope || s.StartsWith(scope + "."))); + } + + [Fact] + public void FmCalc_LineComment_IsScopedAsComment() + { + var g = LoadGrammar(FmLanguageRegistryOptions.CalcScopeName); + Assert.Contains(ScopesAt(g, "// hello", 3), s => s.StartsWith("comment.line")); + } + + [Fact] + public void FmCalc_BlockComment_IsScopedAsBlockComment() + { + var g = LoadGrammar(FmLanguageRegistryOptions.CalcScopeName); + Assert.Contains(ScopesAt(g, "/* note */ 1", 4), s => s.StartsWith("comment.block")); + } + + [Fact] + public void FmCalc_StringWithEscape_HasEscapeScope() + { + var g = LoadGrammar(FmLanguageRegistryOptions.CalcScopeName); + var line = "\"a\\\"b\""; + // Position of the backslash escape (index 2) + Assert.Contains(ScopesAt(g, line, 2), s => s.Contains("constant.character.escape")); + } + + [Fact] + public void FmCalc_NumericLiteral_IsConstantNumeric() + { + var g = LoadGrammar(FmLanguageRegistryOptions.CalcScopeName); + Assert.Contains(ScopesAt(g, "1.5e3", 0), s => s.StartsWith("constant.numeric")); + } + + [Fact] + public void FmCalc_LetControlForm_IsKeywordControl() + { + var g = LoadGrammar(FmLanguageRegistryOptions.CalcScopeName); + Assert.Contains(ScopesAt(g, "Let ( x = 1 ; x )", 0), s => s.StartsWith("keyword.control")); + } + + [Fact] + public void FmCalc_NestedLet_BothLetTokensAreKeywords() + { + var g = LoadGrammar(FmLanguageRegistryOptions.CalcScopeName); + var line = "Let([a=1;b=Let([c=2];c)];a+b)"; + Assert.Contains(ScopesAt(g, line, 0), s => s.StartsWith("keyword.control")); + Assert.Contains(ScopesAt(g, line, 11), s => s.StartsWith("keyword.control")); + } + + [Fact] + public void FmCalc_FieldReference_TablePartIsEntityName() + { + var g = LoadGrammar(FmLanguageRegistryOptions.CalcScopeName); + Assert.Contains(ScopesAt(g, "Customer::Name", 0), s => s.StartsWith("entity.name.type")); + Assert.Contains(ScopesAt(g, "Customer::Name", 10), s => s.StartsWith("variable.other.member")); + } + + [Fact] + public void FmCalc_DollarVariable_IsVariableScope() + { + var g = LoadGrammar(FmLanguageRegistryOptions.CalcScopeName); + Assert.Contains(ScopesAt(g, "$myVar + $$global", 0), s => s.StartsWith("variable.other")); + Assert.Contains(ScopesAt(g, "$myVar + $$global", 9), s => s.StartsWith("variable.other")); + } + + [Fact] + public void FmCalc_BuiltinFunction_HasCategoryScope() + { + var g = LoadGrammar(FmLanguageRegistryOptions.CalcScopeName); + Assert.Contains(ScopesAt(g, "Length ( name )", 0), s => s.Contains("support.function.text")); + Assert.Contains(ScopesAt(g, "JSONGetElement ( j ; \"k\" )", 0), s => s.Contains("support.function.json")); + } + + [Fact] + public void FmCalc_CustomFunction_IsEntityNameFunction() + { + var g = LoadGrammar(FmLanguageRegistryOptions.CalcScopeName); + Assert.Contains(ScopesAt(g, "MyCustomFn ( 1 )", 0), s => s.StartsWith("entity.name.function")); + } + + [Fact] + public void FmCalc_BlockComment_SpansMultipleLines() + { + var g = LoadGrammar(FmLanguageRegistryOptions.CalcScopeName); + var first = g.TokenizeLine("/* opens"); + Assert.Contains(first.Tokens, t => t.Scopes.Any(s => s.StartsWith("comment.block"))); + var second = g.TokenizeLine("still inside */", first.RuleStack, System.TimeSpan.MaxValue); + Assert.True(LineHasScope(g, "still inside", "comment.block") || + second.Tokens.Any(t => t.Scopes.Any(s => s.StartsWith("comment.block")))); + } + + [Fact] + public void FmCalc_BooleanConstants_AreConstantLanguage() + { + var g = LoadGrammar(FmLanguageRegistryOptions.CalcScopeName); + Assert.Contains(ScopesAt(g, "True", 0), s => s.StartsWith("constant.language")); + Assert.Contains(ScopesAt(g, "False", 0), s => s.StartsWith("constant.language")); + Assert.Contains(ScopesAt(g, "Pi", 0), s => s.StartsWith("constant.language")); + } + + [Fact] + public void FmScript_StepName_IsEntityNameFunction() + { + var g = LoadGrammar(FmLanguageRegistryOptions.ScriptScopeName); + Assert.Contains(ScopesAt(g, "Set Variable [ $x ; Value: 1 ]", 0), s => s.StartsWith("entity.name.function")); + } + + [Fact] + public void FmScript_BracketCalc_UsesCalcGrammar_FieldRef() + { + var g = LoadGrammar(FmLanguageRegistryOptions.ScriptScopeName); + var line = "Set Field [ Customer::Name ; \"Bob\" ]"; + Assert.True(LineHasScope(g, line, "entity.name.type.fmcalc")); + Assert.True(LineHasScope(g, line, "string.quoted.double.fmcalc")); + } + + [Fact] + public void FmScript_BracketCalc_UsesCalcGrammar_BuiltinFunction() + { + var g = LoadGrammar(FmLanguageRegistryOptions.ScriptScopeName); + var line = "Set Variable [ $x ; Value: Length ( $name ) ]"; + Assert.True(LineHasScope(g, line, "support.function.text.fmcalc")); + Assert.True(LineHasScope(g, line, "variable.other.fmcalc")); + } + + [Fact] + public void FmScript_ParamLabel_IsScriptScope() + { + var g = LoadGrammar(FmLanguageRegistryOptions.ScriptScopeName); + Assert.True(LineHasScope(g, "Set Variable [ $x ; Value: 1 ]", "support.type.property-name.fmscript")); + } + + [Fact] + public void FmScript_HashCommentLine_IsScriptComment() + { + var g = LoadGrammar(FmLanguageRegistryOptions.ScriptScopeName); + Assert.True(LineHasScope(g, "# this is a script comment", "comment.line.number-sign.fmscript")); + } +} From 0a4f7bfe7914d6356b0778be68a68abcb6557c78 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Fri, 24 Apr 2026 19:15:39 -0500 Subject: [PATCH 02/10] refactor: build fmcalc grammar from a runtime catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-authored fmcalc.tmLanguage.json with FmCalcCatalog — a typed list of functions (name, category, signature, description), control forms (with snippet templates), constants, and word operators in SharpFM.Model. FmCalcGrammarBuilder turns the catalog into the TextMate grammar JSON; FmLanguageRegistryOptions feeds that JSON into TextMateSharp at first use and caches the result. The catalog is the single source of truth — the upcoming completion provider will read from the same lists, so grammar and intellisense can never drift. No build-time codegen, no committed grammar artifact: function-list edits are a one-line catalog change. Tests: catalog has no duplicates, every function has signature and description, builder emits valid JSON containing every function name, control-form snippets carry tab-stops. --- .../Scripting/Calc/FmCalcCatalog.cs | 283 ++++++++++++++++++ .../Scripting/Calc/FmCalcControlForm.cs | 13 + .../Scripting/Calc/FmCalcFunction.cs | 12 + .../Scripting/Calc/FmCalcGrammarBuilder.cs | 207 +++++++++++++ .../Scripting/Calc/FunctionCategory.cs | 26 ++ .../Editor/FmLanguageRegistryOptions.cs | 32 +- .../Scripting/Editor/fmcalc.tmLanguage.json | 211 ------------- src/SharpFM/SharpFM.csproj | 1 - .../Scripting/FmCalcCatalogTests.cs | 59 ++++ 9 files changed, 622 insertions(+), 222 deletions(-) create mode 100644 src/SharpFM.Model/Scripting/Calc/FmCalcCatalog.cs create mode 100644 src/SharpFM.Model/Scripting/Calc/FmCalcControlForm.cs create mode 100644 src/SharpFM.Model/Scripting/Calc/FmCalcFunction.cs create mode 100644 src/SharpFM.Model/Scripting/Calc/FmCalcGrammarBuilder.cs create mode 100644 src/SharpFM.Model/Scripting/Calc/FunctionCategory.cs delete mode 100644 src/SharpFM/Scripting/Editor/fmcalc.tmLanguage.json create mode 100644 tests/SharpFM.Tests/Scripting/FmCalcCatalogTests.cs diff --git a/src/SharpFM.Model/Scripting/Calc/FmCalcCatalog.cs b/src/SharpFM.Model/Scripting/Calc/FmCalcCatalog.cs new file mode 100644 index 0000000..0f1b979 --- /dev/null +++ b/src/SharpFM.Model/Scripting/Calc/FmCalcCatalog.cs @@ -0,0 +1,283 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace SharpFM.Model.Scripting.Calc; + +/// +/// Single source of truth for FileMaker calculation built-ins. The TextMate +/// grammar (fmcalc.tmLanguage.json) is regenerated from this catalog +/// by SharpFM.Tools.GenerateGrammar; the completion provider reads +/// from the same lists at runtime. Edit here, regenerate grammar, both stay +/// in sync. +/// +/// Categorisation follows FileMaker's calculation dialog. Function lists +/// are based on the public function reference at help.claris.com and may +/// trail very recent FM releases; bumps are a one-line append. +/// +public static class FmCalcCatalog +{ + public static IReadOnlyList Functions { get; } = BuildFunctions(); + public static IReadOnlyList ControlForms { get; } = BuildControlForms(); + public static IReadOnlyList Constants { get; } = new ReadOnlyCollection( + new[] { "True", "False", "Pi" }); + + /// Word operators that must scope as keyword.operator.word, not function names. + public static IReadOnlyList WordOperators { get; } = new ReadOnlyCollection( + new[] { "and", "or", "not", "xor" }); + + private static IReadOnlyList BuildFunctions() + { + var list = new List(); + + void Add(string n, FunctionCategory c, string sig, string desc) => + list.Add(new FmCalcFunction(n, c, sig, desc)); + + // Text + Add("Char", FunctionCategory.Text, "Char(number)", "Returns the character for a Unicode code point."); + Add("Code", FunctionCategory.Text, "Code(text)", "Returns the Unicode code points for the characters in text."); + Add("Exact", FunctionCategory.Text, "Exact(originalText; comparisonText)", "Returns true if the two values match exactly."); + Add("Filter", FunctionCategory.Text, "Filter(textToFilter; filterText)", "Returns characters from textToFilter that also appear in filterText."); + Add("FilterValues", FunctionCategory.Text, "FilterValues(textToFilter; filterValues)", "Returns values from textToFilter that match a value list."); + Add("GetAsCSS", FunctionCategory.Text, "GetAsCSS(text)", "Returns text marked up with CSS style attributes."); + Add("GetAsDate", FunctionCategory.Text, "GetAsDate(text)", "Converts text to a date."); + Add("GetAsNumber", FunctionCategory.Text, "GetAsNumber(text)", "Returns only the numeric characters from text."); + Add("GetAsSVG", FunctionCategory.Text, "GetAsSVG(text)", "Returns text marked up as SVG."); + Add("GetAsText", FunctionCategory.Text, "GetAsText(data)", "Converts a value to text."); + Add("GetAsTime", FunctionCategory.Text, "GetAsTime(text)", "Converts text to a time."); + Add("GetAsTimestamp", FunctionCategory.Text, "GetAsTimestamp(text)", "Converts text to a timestamp."); + Add("GetAsURLEncoded", FunctionCategory.Text, "GetAsURLEncoded(text)", "Returns the URL-encoded form of text."); + Add("Hiragana", FunctionCategory.Text, "Hiragana(text)", "Converts katakana to hiragana."); + Add("KanaHankaku", FunctionCategory.Text, "KanaHankaku(text)", "Converts full-width katakana to half-width."); + Add("KanaZenkaku", FunctionCategory.Text, "KanaZenkaku(text)", "Converts half-width katakana to full-width."); + Add("KanjiNumeral", FunctionCategory.Text, "KanjiNumeral(text)", "Converts Arabic numerals to kanji."); + Add("KatakanaToRoman", FunctionCategory.Text, "KatakanaToRoman(text)", "Converts katakana to roman characters."); + Add("Left", FunctionCategory.Text, "Left(text; numberOfCharacters)", "Returns the leftmost characters of text."); + Add("LeftValues", FunctionCategory.Text, "LeftValues(text; numberOfValues)", "Returns the leftmost values from a return-delimited list."); + Add("LeftWords", FunctionCategory.Text, "LeftWords(text; numberOfWords)", "Returns the leftmost words from text."); + Add("Length", FunctionCategory.Text, "Length(text)", "Returns the number of characters in text."); + Add("Lower", FunctionCategory.Text, "Lower(text)", "Returns text in lowercase."); + Add("Middle", FunctionCategory.Text, "Middle(text; start; numberOfCharacters)", "Returns characters from text starting at start."); + Add("MiddleValues", FunctionCategory.Text, "MiddleValues(text; startingValue; numberOfValues)", "Returns values from a list starting at startingValue."); + Add("MiddleWords", FunctionCategory.Text, "MiddleWords(text; startingWord; numberOfWords)", "Returns words from text starting at startingWord."); + Add("NumToJText", FunctionCategory.Text, "NumToJText(number; separator; characterType)", "Converts Arabic numerals to Japanese text."); + Add("PatternCount", FunctionCategory.Text, "PatternCount(text; searchString)", "Returns how many times searchString appears in text."); + Add("Position", FunctionCategory.Text, "Position(text; searchString; start; occurrence)", "Returns the starting position of searchString in text."); + Add("Proper", FunctionCategory.Text, "Proper(text)", "Returns text with the first letter of each word capitalized."); + Add("Quote", FunctionCategory.Text, "Quote(text)", "Returns text wrapped in quotes with internal quotes escaped."); + Add("Replace", FunctionCategory.Text, "Replace(text; start; numberOfCharacters; replacementText)", "Replaces a range of characters in text."); + Add("Right", FunctionCategory.Text, "Right(text; numberOfCharacters)", "Returns the rightmost characters of text."); + Add("RightValues", FunctionCategory.Text, "RightValues(text; numberOfValues)", "Returns the rightmost values from a return-delimited list."); + Add("RightWords", FunctionCategory.Text, "RightWords(text; numberOfWords)", "Returns the rightmost words from text."); + Add("RomanHankaku", FunctionCategory.Text, "RomanHankaku(text)", "Converts full-width roman to half-width."); + Add("RomanZenkaku", FunctionCategory.Text, "RomanZenkaku(text)", "Converts half-width roman to full-width."); + Add("SerialIncrement", FunctionCategory.Text, "SerialIncrement(text; incrementBy)", "Returns text with its trailing digits incremented."); + Add("SortValues", FunctionCategory.Text, "SortValues(values; dataType; locale)", "Returns a sorted list of values."); + Add("Substitute", FunctionCategory.Text, "Substitute(text; searchString; replaceString)", "Replaces every occurrence of searchString in text."); + Add("Trim", FunctionCategory.Text, "Trim(text)", "Removes leading and trailing spaces from text."); + Add("TrimAll", FunctionCategory.Text, "TrimAll(text; trimSpaces; trimType)", "Removes spaces with finer control than Trim."); + Add("UniqueValues", FunctionCategory.Text, "UniqueValues(values; fieldType; locale)", "Returns the unique values from a list."); + Add("Upper", FunctionCategory.Text, "Upper(text)", "Returns text in uppercase."); + Add("ValueCount", FunctionCategory.Text, "ValueCount(text)", "Returns the number of values in a return-delimited list."); + Add("VerifyID", FunctionCategory.Text, "VerifyID(id)", "Returns whether an ID has a valid checksum."); + Add("WordCount", FunctionCategory.Text, "WordCount(text)", "Returns the number of words in text."); + + // Text formatting + Add("RGB", FunctionCategory.TextFormatting, "RGB(red; green; blue)", "Returns a numeric color value."); + Add("TextColor", FunctionCategory.TextFormatting, "TextColor(text; rgb)", "Returns text with the given color applied."); + Add("TextColorRemove", FunctionCategory.TextFormatting, "TextColorRemove(text; rgb)", "Removes color from text."); + Add("TextFont", FunctionCategory.TextFormatting, "TextFont(text; fontName; fontScript)", "Returns text with the given font applied."); + Add("TextFontRemove", FunctionCategory.TextFormatting, "TextFontRemove(text; fontName; fontScript)", "Removes font from text."); + Add("TextFormatRemove", FunctionCategory.TextFormatting, "TextFormatRemove(text)", "Removes all formatting from text."); + Add("TextSize", FunctionCategory.TextFormatting, "TextSize(text; size)", "Returns text at the given size."); + Add("TextSizeRemove", FunctionCategory.TextFormatting, "TextSizeRemove(text; size)", "Removes size from text."); + Add("TextStyleAdd", FunctionCategory.TextFormatting, "TextStyleAdd(text; style)", "Returns text with the given style applied."); + Add("TextStyleRemove", FunctionCategory.TextFormatting, "TextStyleRemove(text; style)", "Removes style from text."); + + // Number + Add("Abs", FunctionCategory.Number, "Abs(number)", "Returns the absolute value of number."); + Add("Ceiling", FunctionCategory.Number, "Ceiling(number)", "Rounds number up to the next integer."); + Add("Combination", FunctionCategory.Number, "Combination(setSize; numberOfChoices)", "Returns the number of combinations."); + Add("Div", FunctionCategory.Number, "Div(number; divisor)", "Returns the integer part of number divided by divisor."); + Add("Exp", FunctionCategory.Number, "Exp(number)", "Returns e raised to the power of number."); + Add("Factorial", FunctionCategory.Number, "Factorial(number; numberOfFactors)", "Returns the factorial of number."); + Add("Floor", FunctionCategory.Number, "Floor(number)", "Rounds number down to the previous integer."); + Add("Int", FunctionCategory.Number, "Int(number)", "Returns the integer part of number."); + Add("Lg", FunctionCategory.Number, "Lg(number)", "Returns the base-2 logarithm of number."); + Add("Ln", FunctionCategory.Number, "Ln(number)", "Returns the natural logarithm of number."); + Add("Log", FunctionCategory.Number, "Log(number)", "Returns the base-10 logarithm of number."); + Add("Mod", FunctionCategory.Number, "Mod(number; divisor)", "Returns the remainder of number divided by divisor."); + Add("Random", FunctionCategory.Number, "Random", "Returns a random number between 0 and 1."); + Add("Round", FunctionCategory.Number, "Round(number; precision)", "Rounds number to precision decimal places."); + Add("SetPrecision", FunctionCategory.Number, "SetPrecision(expression; precision)", "Returns expression evaluated with extended precision."); + Add("Sign", FunctionCategory.Number, "Sign(number)", "Returns -1, 0, or 1 depending on the sign of number."); + Add("Sqrt", FunctionCategory.Number, "Sqrt(number)", "Returns the square root of number."); + Add("Truncate", FunctionCategory.Number, "Truncate(number; precision)", "Truncates number to precision decimal places."); + + // Date + Add("Date", FunctionCategory.Date, "Date(month; day; year)", "Returns a date value."); + Add("Day", FunctionCategory.Date, "Day(date)", "Returns the day of the month from date."); + Add("DayName", FunctionCategory.Date, "DayName(date)", "Returns the weekday name for date."); + Add("DayNameJ", FunctionCategory.Date, "DayNameJ(date)", "Returns the Japanese weekday name for date."); + Add("DayOfWeek", FunctionCategory.Date, "DayOfWeek(date)", "Returns the day of the week (1-7) for date."); + Add("DayOfYear", FunctionCategory.Date, "DayOfYear(date)", "Returns the day of the year (1-366) for date."); + Add("Month", FunctionCategory.Date, "Month(date)", "Returns the month number for date."); + Add("MonthName", FunctionCategory.Date, "MonthName(date)", "Returns the month name for date."); + Add("MonthNameJ", FunctionCategory.Date, "MonthNameJ(date)", "Returns the Japanese month name for date."); + Add("WeekOfYear", FunctionCategory.Date, "WeekOfYear(date)", "Returns the week number of the year for date."); + Add("WeekOfYearFiscal", FunctionCategory.Date, "WeekOfYearFiscal(date; startingDay)", "Returns the fiscal week of year for date."); + Add("Year", FunctionCategory.Date, "Year(date)", "Returns the year for date."); + Add("YearName", FunctionCategory.Date, "YearName(date; format)", "Returns the Japanese era year name for date."); + + // Time + Add("Hour", FunctionCategory.Time, "Hour(time)", "Returns the hour for time."); + Add("Minute", FunctionCategory.Time, "Minute(time)", "Returns the minute for time."); + Add("Seconds", FunctionCategory.Time, "Seconds(time)", "Returns the seconds for time."); + Add("Time", FunctionCategory.Time, "Time(hours; minutes; seconds)", "Returns a time value."); + Add("Timestamp", FunctionCategory.Time, "Timestamp(date; time)", "Returns a timestamp value."); + + // Aggregate + Add("Average", FunctionCategory.Aggregate, "Average(field {; field...})", "Returns the average of non-blank values."); + Add("Count", FunctionCategory.Aggregate, "Count(field {; field...})", "Returns the count of non-blank values."); + Add("List", FunctionCategory.Aggregate, "List(field {; field...})", "Returns a return-delimited list of non-blank values."); + Add("Max", FunctionCategory.Aggregate, "Max(field {; field...})", "Returns the maximum of non-blank values."); + Add("Min", FunctionCategory.Aggregate, "Min(field {; field...})", "Returns the minimum of non-blank values."); + Add("StDev", FunctionCategory.Aggregate, "StDev(field {; field...})", "Returns the sample standard deviation."); + Add("StDevP", FunctionCategory.Aggregate, "StDevP(field {; field...})", "Returns the population standard deviation."); + Add("Sum", FunctionCategory.Aggregate, "Sum(field {; field...})", "Returns the sum of non-blank values."); + Add("Variance", FunctionCategory.Aggregate, "Variance(field {; field...})", "Returns the sample variance."); + Add("VarianceP", FunctionCategory.Aggregate, "VarianceP(field {; field...})", "Returns the population variance."); + + // Summary / repeating + Add("GetSummary", FunctionCategory.Summary, "GetSummary(summaryField; breakField)", "Returns a summary value broken on breakField."); + Add("GetNthRecord", FunctionCategory.Summary, "GetNthRecord(field; recordNumber)", "Returns field's value from the Nth record."); + Add("Last", FunctionCategory.Summary, "Last(field)", "Returns the last non-blank value in a related set."); + Add("GetRepetition", FunctionCategory.Summary, "GetRepetition(repeatingField; number)", "Returns the Nth repetition of a repeating field."); + Add("Extend", FunctionCategory.Summary, "Extend(nonRepeatingField)", "Allows a non-repeating field to apply to all repetitions."); + + // Financial + Add("FV", FunctionCategory.Financial, "FV(payment; interestRate; periods)", "Returns future value."); + Add("NPV", FunctionCategory.Financial, "NPV(payment; interestRate)", "Returns net present value."); + Add("PMT", FunctionCategory.Financial, "PMT(principal; interestRate; term)", "Returns payment amount."); + Add("PV", FunctionCategory.Financial, "PV(payment; interestRate; periods)", "Returns present value."); + + // Trigonometric + Add("Acos", FunctionCategory.Trigonometric, "Acos(number)", "Returns the arc cosine of number."); + Add("Asin", FunctionCategory.Trigonometric, "Asin(number)", "Returns the arc sine of number."); + Add("Atan", FunctionCategory.Trigonometric, "Atan(number)", "Returns the arc tangent of number."); + Add("Cos", FunctionCategory.Trigonometric, "Cos(angleInRadians)", "Returns the cosine of angle."); + Add("Degrees", FunctionCategory.Trigonometric, "Degrees(angleInRadians)", "Converts radians to degrees."); + Add("Radians", FunctionCategory.Trigonometric, "Radians(angleInDegrees)", "Converts degrees to radians."); + Add("Sin", FunctionCategory.Trigonometric, "Sin(angleInRadians)", "Returns the sine of angle."); + Add("Tan", FunctionCategory.Trigonometric, "Tan(angleInRadians)", "Returns the tangent of angle."); + + // Logical + Add("Evaluate", FunctionCategory.Logical, "Evaluate(expression {; [fields]})", "Evaluates an expression provided as text."); + Add("EvaluationError", FunctionCategory.Logical, "EvaluationError(expression)", "Returns the error number from an evaluation."); + Add("GetAsBoolean", FunctionCategory.Logical, "GetAsBoolean(data)", "Returns 0 if data is empty/zero, otherwise 1."); + Add("GetField", FunctionCategory.Logical, "GetField(fieldName)", "Returns the value of the field whose name is fieldName."); + Add("GetFieldName", FunctionCategory.Logical, "GetFieldName(field)", "Returns the fully qualified name of field."); + Add("IsEmpty", FunctionCategory.Logical, "IsEmpty(expression)", "Returns true if expression is empty."); + Add("IsValid", FunctionCategory.Logical, "IsValid(field)", "Returns true if field is valid and references a real field."); + Add("IsValidExpression", FunctionCategory.Logical, "IsValidExpression(expression)", "Returns true if expression is syntactically valid."); + Add("Lookup", FunctionCategory.Logical, "Lookup(sourceField {; failExpression})", "Returns a looked-up value through a relationship."); + Add("LookupNext", FunctionCategory.Logical, "LookupNext(sourceField; lower-/higher-flag)", "Returns the next looked-up value."); + Add("Self", FunctionCategory.Logical, "Self", "Refers to the object whose property is being evaluated."); + Add("SetField", FunctionCategory.Logical, "SetField(fieldName; value)", "Sets the field whose name is fieldName."); + + // Get + Add("Get", FunctionCategory.Get, "Get(parameter)", "Returns information about the FileMaker environment."); + + // Container + Add("Base64Decode", FunctionCategory.Container, "Base64Decode(text {; fileNameWithExtension})", "Decodes base64 to container or text."); + Add("Base64Encode", FunctionCategory.Container, "Base64Encode(data)", "Encodes data as base64."); + Add("Base64EncodeRFC", FunctionCategory.Container, "Base64EncodeRFC(rfcNumber; data)", "Encodes data as base64 per the given RFC."); + Add("CryptAuthCode", FunctionCategory.Container, "CryptAuthCode(data; algorithm; key)", "Returns an HMAC authentication code."); + Add("CryptDecrypt", FunctionCategory.Container, "CryptDecrypt(data; key)", "Decrypts data with key."); + Add("CryptDecryptBase64", FunctionCategory.Container, "CryptDecryptBase64(text; key)", "Decrypts base64-encoded data with key."); + Add("CryptDigest", FunctionCategory.Container, "CryptDigest(data; algorithm)", "Returns a cryptographic digest of data."); + Add("CryptEncrypt", FunctionCategory.Container, "CryptEncrypt(data; key)", "Encrypts data with key."); + Add("CryptEncryptBase64", FunctionCategory.Container, "CryptEncryptBase64(data; key)", "Encrypts data with key and returns base64."); + Add("CryptGenerateSignature", FunctionCategory.Container, "CryptGenerateSignature(data; algorithm; privateRSAKey; password)", "Generates an RSA signature."); + Add("CryptVerifySignature", FunctionCategory.Container, "CryptVerifySignature(data; algorithm; publicRSAKey; signature)", "Verifies an RSA signature."); + Add("GetContainerAttribute", FunctionCategory.Container, "GetContainerAttribute(field; attribute)", "Returns metadata about a container value."); + Add("GetHeight", FunctionCategory.Container, "GetHeight(field)", "Returns the height of an image container."); + Add("GetThumbnail", FunctionCategory.Container, "GetThumbnail(field; fitToWidth; fitToHeight)", "Returns a thumbnail of a container."); + Add("GetWidth", FunctionCategory.Container, "GetWidth(field)", "Returns the width of an image container."); + Add("HexDecode", FunctionCategory.Container, "HexDecode(text {; fileNameWithExtension})", "Decodes hex to container or text."); + Add("HexEncode", FunctionCategory.Container, "HexEncode(data)", "Encodes data as hex."); + Add("VerifyContainer", FunctionCategory.Container, "VerifyContainer(field)", "Returns whether a container's checksum verifies."); + + // JSON + Add("JSONDeleteElement", FunctionCategory.Json, "JSONDeleteElement(json; keyOrIndexOrPath)", "Deletes an element at the given path."); + Add("JSONFormatElements", FunctionCategory.Json, "JSONFormatElements(json)", "Returns json formatted with indentation."); + Add("JSONGetElement", FunctionCategory.Json, "JSONGetElement(json; keyOrIndexOrPath)", "Returns an element at the given path."); + Add("JSONListKeys", FunctionCategory.Json, "JSONListKeys(json; keyOrIndexOrPath)", "Returns the keys of an object element."); + Add("JSONListValues", FunctionCategory.Json, "JSONListValues(json; keyOrIndexOrPath)", "Returns the values of an object/array element."); + Add("JSONSetElement", FunctionCategory.Json, "JSONSetElement(json; keyOrIndexOrPath; value; type)", "Sets an element at the given path."); + + // SQL + Add("ExecuteSQL", FunctionCategory.Sql, "ExecuteSQL(sql; fieldSeparator; rowSeparator {; arguments...})", "Executes an SQL query against the open database."); + + // External + Add("GetSensor", FunctionCategory.External, "GetSensor(sensorType {; options})", "Returns a value from a device sensor (FileMaker Go)."); + Add("GetLiveRemoteCallResult", FunctionCategory.External, "GetLiveRemoteCallResult(callID)", "Returns the result of a live remote call."); + Add("GetLiveRemoteCallStatus", FunctionCategory.External, "GetLiveRemoteCallStatus(callID)", "Returns the status of a live remote call."); + + // Design + Add("DatabaseNames", FunctionCategory.Design, "DatabaseNames", "Returns names of open databases."); + Add("FieldBounds", FunctionCategory.Design, "FieldBounds(fileName; layoutName; fieldName)", "Returns layout coordinates of a field."); + Add("FieldComment", FunctionCategory.Design, "FieldComment(fileName; fieldName)", "Returns the comment for a field."); + Add("FieldIDs", FunctionCategory.Design, "FieldIDs(fileName; layoutName)", "Returns field IDs."); + Add("FieldNames", FunctionCategory.Design, "FieldNames(fileName; layoutName)", "Returns field names."); + Add("FieldRepetitions", FunctionCategory.Design, "FieldRepetitions(fileName; layoutName; fieldName)", "Returns repetitions for a field."); + Add("FieldStyle", FunctionCategory.Design, "FieldStyle(fileName; layoutName; fieldName)", "Returns the style applied to a field."); + Add("FieldType", FunctionCategory.Design, "FieldType(fileName; fieldName)", "Returns the type of a field."); + Add("GetNextSerialValue", FunctionCategory.Design, "GetNextSerialValue(fileName; fieldName)", "Returns the next auto-enter serial value."); + Add("LayoutIDs", FunctionCategory.Design, "LayoutIDs(fileName)", "Returns layout IDs."); + Add("LayoutNames", FunctionCategory.Design, "LayoutNames(fileName)", "Returns layout names."); + Add("LayoutObjectNames", FunctionCategory.Design, "LayoutObjectNames(fileName; layoutName)", "Returns named objects on a layout."); + Add("RelationInfo", FunctionCategory.Design, "RelationInfo(fileName; tableOccurrenceName)", "Returns information about relationships."); + Add("ScriptIDs", FunctionCategory.Design, "ScriptIDs(fileName)", "Returns script IDs."); + Add("ScriptNames", FunctionCategory.Design, "ScriptNames(fileName)", "Returns script names."); + Add("TableIDs", FunctionCategory.Design, "TableIDs(fileName)", "Returns table occurrence IDs."); + Add("TableNames", FunctionCategory.Design, "TableNames(fileName)", "Returns table occurrence names."); + Add("ValueListIDs", FunctionCategory.Design, "ValueListIDs(fileName)", "Returns value list IDs."); + Add("ValueListItems", FunctionCategory.Design, "ValueListItems(fileName; valueListName)", "Returns the items of a value list."); + Add("ValueListNames", FunctionCategory.Design, "ValueListNames(fileName)", "Returns value list names."); + Add("WindowNames", FunctionCategory.Design, "WindowNames({fileName})", "Returns names of open windows."); + + return new ReadOnlyCollection(list); + } + + private static IReadOnlyList BuildControlForms() + { + return new ReadOnlyCollection(new[] + { + new FmCalcControlForm( + "Let", + "Let([var = expr; ...]; result)", + "Binds variables and evaluates result with them in scope.", + "Let ( [ ${1:var} = ${2:value} ] ; ${3:result} )"), + new FmCalcControlForm( + "Case", + "Case(test1; result1 {; test2; result2 ...} {; defaultResult})", + "Returns result for the first true test; otherwise default.", + "Case ( ${1:test} ; ${2:result} ; ${3:default} )"), + new FmCalcControlForm( + "If", + "If(test; resultIfTrue; resultIfFalse)", + "Branches between two results based on test.", + "If ( ${1:test} ; ${2:trueResult} ; ${3:falseResult} )"), + new FmCalcControlForm( + "While", + "While([initialVars]; condition; [updateVars]; result)", + "Iterates while condition is true and returns result.", + "While ( [ ${1:counter} = 0 ] ; ${2:condition} ; [ ${3:counter} = counter + 1 ] ; ${4:result} )"), + new FmCalcControlForm( + "Choose", + "Choose(test; result0 {; result1 ...})", + "Returns the Nth result based on test (0-indexed).", + "Choose ( ${1:test} ; ${2:result0} ; ${3:result1} )"), + }); + } +} diff --git a/src/SharpFM.Model/Scripting/Calc/FmCalcControlForm.cs b/src/SharpFM.Model/Scripting/Calc/FmCalcControlForm.cs new file mode 100644 index 0000000..340f3e4 --- /dev/null +++ b/src/SharpFM.Model/Scripting/Calc/FmCalcControlForm.cs @@ -0,0 +1,13 @@ +namespace SharpFM.Model.Scripting.Calc; + +/// +/// One of the FileMaker calculation control forms (Let, Case, +/// etc.). uses Monaco-style ${N:placeholder} +/// tab-stops so completion accept inserts the full form with the first slot +/// pre-selected. +/// +public sealed record FmCalcControlForm( + string Name, + string Signature, + string Description, + string Snippet); diff --git a/src/SharpFM.Model/Scripting/Calc/FmCalcFunction.cs b/src/SharpFM.Model/Scripting/Calc/FmCalcFunction.cs new file mode 100644 index 0000000..69f0f4a --- /dev/null +++ b/src/SharpFM.Model/Scripting/Calc/FmCalcFunction.cs @@ -0,0 +1,12 @@ +namespace SharpFM.Model.Scripting.Calc; + +/// +/// One built-in FileMaker calculation function. is +/// a human-readable form (e.g. Length(text)) shown in completion +/// tooltips; is a one-line summary. +/// +public sealed record FmCalcFunction( + string Name, + FunctionCategory Category, + string Signature, + string Description); diff --git a/src/SharpFM.Model/Scripting/Calc/FmCalcGrammarBuilder.cs b/src/SharpFM.Model/Scripting/Calc/FmCalcGrammarBuilder.cs new file mode 100644 index 0000000..2e313ed --- /dev/null +++ b/src/SharpFM.Model/Scripting/Calc/FmCalcGrammarBuilder.cs @@ -0,0 +1,207 @@ +using System; +using System.Linq; +using System.Text.Json.Nodes; + +namespace SharpFM.Model.Scripting.Calc; + +/// +/// Builds the source.fmcalc TextMate grammar JSON from +/// . Called at runtime by the registry — there +/// is no committed grammar file. The catalog is the single source of truth; +/// the completion provider and the grammar both read from it. +/// +public static class FmCalcGrammarBuilder +{ + public static string Build() + { + var root = new JsonObject + { + ["name"] = "FileMaker Calculation", + ["scopeName"] = "source.fmcalc", + ["fileTypes"] = new JsonArray("fmcalc"), + ["patterns"] = new JsonArray(Include("#expression")), + ["repository"] = BuildRepository(), + }; + + var options = new System.Text.Json.JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + return root.ToJsonString(options); + } + + private static JsonObject BuildRepository() => new() + { + ["expression"] = new JsonObject + { + ["patterns"] = new JsonArray( + Include("#comment-block"), + Include("#comment-line"), + Include("#string"), + Include("#number"), + Include("#constant"), + Include("#control-form"), + Include("#operator-word"), + Include("#variable"), + Include("#field-reference"), + Include("#builtin-function"), + Include("#custom-function"), + Include("#operator-symbol"), + Include("#punctuation")), + }, + ["comment-block"] = new JsonObject + { + ["name"] = "comment.block.fmcalc", + ["begin"] = @"/\*", + ["beginCaptures"] = NamedCapture("0", "punctuation.definition.comment.fmcalc"), + ["end"] = @"\*/", + ["endCaptures"] = NamedCapture("0", "punctuation.definition.comment.fmcalc"), + }, + ["comment-line"] = new JsonObject + { + ["name"] = "comment.line.double-slash.fmcalc", + ["begin"] = "//", + ["beginCaptures"] = NamedCapture("0", "punctuation.definition.comment.fmcalc"), + ["end"] = "$", + }, + ["string"] = new JsonObject + { + ["name"] = "string.quoted.double.fmcalc", + ["begin"] = "\"", + ["beginCaptures"] = NamedCapture("0", "punctuation.definition.string.begin.fmcalc"), + ["end"] = "\"", + ["endCaptures"] = NamedCapture("0", "punctuation.definition.string.end.fmcalc"), + ["patterns"] = new JsonArray(new JsonObject + { + ["name"] = "constant.character.escape.fmcalc", + ["match"] = @"\\(""|\\|n|r|t)", + }), + }, + ["number"] = new JsonObject + { + ["name"] = "constant.numeric.fmcalc", + ["match"] = @"\b\d+(\.\d+)?([eE][+-]?\d+)?\b", + }, + ["constant"] = new JsonObject + { + ["name"] = "constant.language.fmcalc", + ["match"] = $@"\b({string.Join("|", FmCalcCatalog.Constants)})\b", + }, + ["control-form"] = new JsonObject + { + ["name"] = "keyword.control.fmcalc", + ["match"] = $@"\b({string.Join("|", FmCalcCatalog.ControlForms.Select(c => c.Name))})(?=\s*\()", + }, + ["operator-word"] = new JsonObject + { + ["name"] = "keyword.operator.word.fmcalc", + ["match"] = $@"\b({string.Join("|", FmCalcCatalog.WordOperators)})\b", + }, + ["variable"] = new JsonObject + { + ["name"] = "variable.other.fmcalc", + ["match"] = @"\${1,2}[A-Za-z_][A-Za-z0-9_.]*", + }, + ["field-reference"] = new JsonObject + { + ["match"] = @"\b([A-Za-z_][A-Za-z0-9_ ]*?)(::)([A-Za-z_][A-Za-z0-9_]*)", + ["captures"] = new JsonObject + { + ["1"] = ScopeNode("entity.name.type.fmcalc"), + ["2"] = ScopeNode("punctuation.separator.field.fmcalc"), + ["3"] = ScopeNode("variable.other.member.fmcalc"), + }, + }, + ["builtin-function"] = BuildBuiltinFunctions(), + ["custom-function"] = new JsonObject + { + ["match"] = @"\b([A-Za-z_][A-Za-z0-9_]*)\s*(?=\()", + ["captures"] = new JsonObject + { + ["1"] = ScopeNode("entity.name.function.fmcalc"), + }, + }, + ["operator-symbol"] = new JsonObject + { + ["name"] = "keyword.operator.fmcalc", + ["match"] = @"(\^|\*|/|\+|-|&|=|≠|<>|≤|<=|≥|>=|<|>)", + }, + ["punctuation"] = new JsonObject + { + ["patterns"] = new JsonArray( + new JsonObject { ["match"] = @"\(", ["name"] = "punctuation.section.parens.begin.fmcalc" }, + new JsonObject { ["match"] = @"\)", ["name"] = "punctuation.section.parens.end.fmcalc" }, + new JsonObject { ["match"] = ";", ["name"] = "punctuation.separator.fmcalc" }, + new JsonObject { ["match"] = ",", ["name"] = "punctuation.separator.fmcalc" }, + new JsonObject { ["match"] = @"\[", ["name"] = "punctuation.section.brackets.begin.fmcalc" }, + new JsonObject { ["match"] = @"\]", ["name"] = "punctuation.section.brackets.end.fmcalc" }), + }, + }; + + private static JsonObject BuildBuiltinFunctions() + { + var patterns = new JsonArray(); + + // One alternation per category so each can carry its own + // support.function..fmcalc scope. Within a category, + // sort length-desc then alphabetical — the standard TextMate idiom + // that ensures longer alternatives win when one name prefixes another. + var groups = FmCalcCatalog.Functions + .GroupBy(f => f.Category) + .OrderBy(g => (int)g.Key); + + foreach (var group in groups) + { + var names = group + .Select(f => f.Name) + .OrderByDescending(n => n.Length) + .ThenBy(n => n, StringComparer.Ordinal) + .ToList(); + + patterns.Add(new JsonObject + { + ["match"] = $@"\b({string.Join("|", names)})\s*(?=\()", + ["captures"] = new JsonObject + { + ["1"] = ScopeNode($"support.function.{ScopeSegment(group.Key)}.fmcalc"), + }, + }); + } + + return new JsonObject { ["patterns"] = patterns }; + } + + /// + /// Map a category enum to its TextMate scope segment. Hyphenated for + /// multi-word categories, matching VS Code grammar conventions. + /// + public static string ScopeSegment(FunctionCategory category) => category switch + { + FunctionCategory.Text => "text", + FunctionCategory.TextFormatting => "text-formatting", + FunctionCategory.Number => "number", + FunctionCategory.Date => "date", + FunctionCategory.Time => "time", + FunctionCategory.Aggregate => "aggregate", + FunctionCategory.Summary => "summary", + FunctionCategory.Financial => "financial", + FunctionCategory.Trigonometric => "trigonometric", + FunctionCategory.Logical => "logical", + FunctionCategory.Get => "get", + FunctionCategory.Container => "container", + FunctionCategory.Json => "json", + FunctionCategory.Sql => "sql", + FunctionCategory.External => "external", + FunctionCategory.Design => "design", + _ => throw new ArgumentOutOfRangeException(nameof(category), category, null), + }; + + private static JsonObject Include(string reference) => new() { ["include"] = reference }; + private static JsonObject ScopeNode(string name) => new() { ["name"] = name }; + + private static JsonObject NamedCapture(string group, string scope) => new() + { + [group] = ScopeNode(scope), + }; +} diff --git a/src/SharpFM.Model/Scripting/Calc/FunctionCategory.cs b/src/SharpFM.Model/Scripting/Calc/FunctionCategory.cs new file mode 100644 index 0000000..0b12330 --- /dev/null +++ b/src/SharpFM.Model/Scripting/Calc/FunctionCategory.cs @@ -0,0 +1,26 @@ +namespace SharpFM.Model.Scripting.Calc; + +/// +/// FileMaker calculation function categories. Mirrors the grouping in +/// FileMaker's calculation dialog so completion menus and TextMate scopes +/// (support.function.<category>.fmcalc) line up. +/// +public enum FunctionCategory +{ + Text, + TextFormatting, + Number, + Date, + Time, + Aggregate, + Summary, + Financial, + Trigonometric, + Logical, + Get, + Container, + Json, + Sql, + External, + Design, +} diff --git a/src/SharpFM/Scripting/Editor/FmLanguageRegistryOptions.cs b/src/SharpFM/Scripting/Editor/FmLanguageRegistryOptions.cs index a5180f8..1812195 100644 --- a/src/SharpFM/Scripting/Editor/FmLanguageRegistryOptions.cs +++ b/src/SharpFM/Scripting/Editor/FmLanguageRegistryOptions.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Reflection; using System.Threading; +using SharpFM.Model.Scripting.Calc; using TextMateSharp.Grammars; using TextMateSharp.Internal.Grammars.Reader; using TextMateSharp.Internal.Types; @@ -14,11 +15,14 @@ namespace SharpFM.Scripting.Editor; /// -/// Serves the embedded FileMaker TextMate grammars (source.fmscript -/// and source.fmcalc) and delegates everything else to an inner -/// . Cross-grammar includes — e.g. the -/// script grammar embedding the calc grammar inside [ ... ] — resolve -/// through this method. +/// Serves the FileMaker TextMate grammars (source.fmscript and +/// source.fmcalc) and delegates everything else to an inner +/// . The script grammar is hand-authored and +/// embedded as a resource; the calc grammar is built at first use from +/// via so the +/// catalog is the single source of truth for both grammar and completions. +/// Cross-grammar includes — e.g. the script grammar embedding the +/// calc grammar inside [ ... ] — resolve through this method. /// [ExcludeFromCodeCoverage] public class FmLanguageRegistryOptions : IRegistryOptions @@ -29,10 +33,10 @@ public class FmLanguageRegistryOptions : IRegistryOptions private readonly RegistryOptions _inner; private static readonly Lazy ScriptGrammar = - new(() => LoadGrammar("fmscript.tmLanguage.json"), LazyThreadSafetyMode.ExecutionAndPublication); + new(LoadEmbeddedScriptGrammar, LazyThreadSafetyMode.ExecutionAndPublication); private static readonly Lazy CalcGrammar = - new(() => LoadGrammar("fmcalc.tmLanguage.json"), LazyThreadSafetyMode.ExecutionAndPublication); + new(BuildCalcGrammar, LazyThreadSafetyMode.ExecutionAndPublication); public FmLanguageRegistryOptions(RegistryOptions inner) { @@ -52,15 +56,23 @@ public FmLanguageRegistryOptions(RegistryOptions inner) _ => _inner.GetGrammar(scopeName), }; - private static IRawGrammar LoadGrammar(string fileName) + private static IRawGrammar LoadEmbeddedScriptGrammar() { var assembly = Assembly.GetExecutingAssembly(); var resourceName = assembly.GetManifestResourceNames() - .FirstOrDefault(n => n.EndsWith(fileName, StringComparison.Ordinal)) - ?? throw new InvalidOperationException($"Embedded grammar resource not found: {fileName}"); + .FirstOrDefault(n => n.EndsWith("fmscript.tmLanguage.json", StringComparison.Ordinal)) + ?? throw new InvalidOperationException("Embedded fmscript grammar resource not found."); using var stream = assembly.GetManifestResourceStream(resourceName)!; using var reader = new StreamReader(stream); return GrammarReader.ReadGrammarSync(reader); } + + private static IRawGrammar BuildCalcGrammar() + { + var bytes = System.Text.Encoding.UTF8.GetBytes(FmCalcGrammarBuilder.Build()); + using var stream = new MemoryStream(bytes); + using var reader = new StreamReader(stream); + return GrammarReader.ReadGrammarSync(reader); + } } diff --git a/src/SharpFM/Scripting/Editor/fmcalc.tmLanguage.json b/src/SharpFM/Scripting/Editor/fmcalc.tmLanguage.json deleted file mode 100644 index a828241..0000000 --- a/src/SharpFM/Scripting/Editor/fmcalc.tmLanguage.json +++ /dev/null @@ -1,211 +0,0 @@ -{ - "name": "FileMaker Calculation", - "scopeName": "source.fmcalc", - "fileTypes": ["fmcalc"], - "patterns": [ - { "include": "#expression" } - ], - "repository": { - "expression": { - "patterns": [ - { "include": "#comment-block" }, - { "include": "#comment-line" }, - { "include": "#string" }, - { "include": "#number" }, - { "include": "#constant" }, - { "include": "#control-form" }, - { "include": "#operator-word" }, - { "include": "#variable" }, - { "include": "#field-reference" }, - { "include": "#builtin-function" }, - { "include": "#custom-function" }, - { "include": "#operator-symbol" }, - { "include": "#punctuation" } - ] - }, - "comment-block": { - "name": "comment.block.fmcalc", - "begin": "/\\*", - "beginCaptures": { - "0": { "name": "punctuation.definition.comment.fmcalc" } - }, - "end": "\\*/", - "endCaptures": { - "0": { "name": "punctuation.definition.comment.fmcalc" } - } - }, - "comment-line": { - "name": "comment.line.double-slash.fmcalc", - "begin": "//", - "beginCaptures": { - "0": { "name": "punctuation.definition.comment.fmcalc" } - }, - "end": "$" - }, - "string": { - "name": "string.quoted.double.fmcalc", - "begin": "\"", - "beginCaptures": { - "0": { "name": "punctuation.definition.string.begin.fmcalc" } - }, - "end": "\"", - "endCaptures": { - "0": { "name": "punctuation.definition.string.end.fmcalc" } - }, - "patterns": [ - { - "name": "constant.character.escape.fmcalc", - "match": "\\\\(\"|\\\\|n|r|t)" - } - ] - }, - "number": { - "name": "constant.numeric.fmcalc", - "match": "\\b\\d+(\\.\\d+)?([eE][+-]?\\d+)?\\b" - }, - "constant": { - "name": "constant.language.fmcalc", - "match": "\\b(True|False|Pi)\\b" - }, - "control-form": { - "name": "keyword.control.fmcalc", - "match": "\\b(Let|Case|If|While|Choose)(?=\\s*\\()" - }, - "operator-word": { - "name": "keyword.operator.word.fmcalc", - "match": "\\b(and|or|not|xor)\\b" - }, - "variable": { - "name": "variable.other.fmcalc", - "match": "\\${1,2}[A-Za-z_][A-Za-z0-9_.]*" - }, - "field-reference": { - "match": "\\b([A-Za-z_][A-Za-z0-9_ ]*?)(::)([A-Za-z_][A-Za-z0-9_]*)", - "captures": { - "1": { "name": "entity.name.type.fmcalc" }, - "2": { "name": "punctuation.separator.field.fmcalc" }, - "3": { "name": "variable.other.member.fmcalc" } - } - }, - "builtin-function": { - "patterns": [ - { - "match": "\\b(Char|Code|Exact|Filter|FilterValues|GetAsCSS|GetAsDate|GetAsNumber|GetAsSVG|GetAsText|GetAsTime|GetAsTimestamp|GetAsURLEncoded|Hiragana|KanaHankaku|KanaZenkaku|KanjiNumeral|KatakanaToRoman|Left|LeftValues|LeftWords|Length|Lower|Middle|MiddleValues|MiddleWords|NumToJText|PatternCount|Position|Proper|Quote|Replace|Right|RightValues|RightWords|RomanHankaku|RomanZenkaku|SerialIncrement|SortValues|Substitute|Trim|TrimAll|UniqueValues|Upper|ValueCount|VerifyID|WordCount)\\s*(?=\\()", - "captures": { - "1": { "name": "support.function.text.fmcalc" } - } - }, - { - "match": "\\b(RGB|TextColor|TextColorRemove|TextFont|TextFontRemove|TextFormatRemove|TextSize|TextSizeRemove|TextStyleAdd|TextStyleRemove)\\s*(?=\\()", - "captures": { - "1": { "name": "support.function.text-formatting.fmcalc" } - } - }, - { - "match": "\\b(Abs|Ceiling|Combination|Div|Exp|Factorial|Floor|Int|Lg|Ln|Log|Mod|Random|Round|SetPrecision|Sign|Sqrt|Truncate)\\s*(?=\\()", - "captures": { - "1": { "name": "support.function.number.fmcalc" } - } - }, - { - "match": "\\b(Date|Day|DayName|DayNameJ|DayOfWeek|DayOfYear|Month|MonthName|MonthNameJ|WeekOfYear|WeekOfYearFiscal|Year|YearName)\\s*(?=\\()", - "captures": { - "1": { "name": "support.function.date.fmcalc" } - } - }, - { - "match": "\\b(Hour|Minute|Seconds|Time|Timestamp)\\s*(?=\\()", - "captures": { - "1": { "name": "support.function.time.fmcalc" } - } - }, - { - "match": "\\b(Average|Count|List|Max|Min|StDev|StDevP|Sum|Variance|VarianceP)\\s*(?=\\()", - "captures": { - "1": { "name": "support.function.aggregate.fmcalc" } - } - }, - { - "match": "\\b(GetSummary|GetNthRecord|Last|GetRepetition|Extend)\\s*(?=\\()", - "captures": { - "1": { "name": "support.function.summary.fmcalc" } - } - }, - { - "match": "\\b(FV|NPV|PMT|PV)\\s*(?=\\()", - "captures": { - "1": { "name": "support.function.financial.fmcalc" } - } - }, - { - "match": "\\b(Acos|Asin|Atan|Cos|Degrees|Radians|Sin|Tan)\\s*(?=\\()", - "captures": { - "1": { "name": "support.function.trigonometric.fmcalc" } - } - }, - { - "match": "\\b(Evaluate|EvaluationError|GetAsBoolean|GetField|GetFieldName|IsEmpty|IsValid|IsValidExpression|Lookup|LookupNext|Self|SetField)\\s*(?=\\()", - "captures": { - "1": { "name": "support.function.logical.fmcalc" } - } - }, - { - "match": "\\b(Get)\\s*(?=\\()", - "captures": { - "1": { "name": "support.function.get.fmcalc" } - } - }, - { - "match": "\\b(Base64Decode|Base64Encode|Base64EncodeRFC|CryptAuthCode|CryptDecrypt|CryptDecryptBase64|CryptDigest|CryptEncrypt|CryptEncryptBase64|CryptGenerateSignature|CryptVerifySignature|GetContainerAttribute|GetHeight|GetThumbnail|GetWidth|HexDecode|HexEncode|VerifyContainer)\\s*(?=\\()", - "captures": { - "1": { "name": "support.function.container.fmcalc" } - } - }, - { - "match": "\\b(JSONDeleteElement|JSONFormatElements|JSONGetElement|JSONListKeys|JSONListValues|JSONSetElement)\\s*(?=\\()", - "captures": { - "1": { "name": "support.function.json.fmcalc" } - } - }, - { - "match": "\\b(ExecuteSQL)\\s*(?=\\()", - "captures": { - "1": { "name": "support.function.sql.fmcalc" } - } - }, - { - "match": "\\b(GetSensor|GetLiveRemoteCallResult|GetLiveRemoteCallStatus)\\s*(?=\\()", - "captures": { - "1": { "name": "support.function.external.fmcalc" } - } - }, - { - "match": "\\b(DatabaseNames|FieldBounds|FieldComment|FieldIDs|FieldNames|FieldRepetitions|FieldStyle|FieldType|GetNextSerialValue|LayoutIDs|LayoutNames|LayoutObjectNames|RelationInfo|ScriptIDs|ScriptNames|TableIDs|TableNames|ValueListIDs|ValueListItems|ValueListNames|WindowNames)\\s*(?=\\()", - "captures": { - "1": { "name": "support.function.design.fmcalc" } - } - } - ] - }, - "custom-function": { - "match": "\\b([A-Za-z_][A-Za-z0-9_]*)\\s*(?=\\()", - "captures": { - "1": { "name": "entity.name.function.fmcalc" } - } - }, - "operator-symbol": { - "name": "keyword.operator.fmcalc", - "match": "(\\^|\\*|/|\\+|-|&|=|≠|<>|≤|<=|≥|>=|<|>)" - }, - "punctuation": { - "patterns": [ - { "match": "\\(", "name": "punctuation.section.parens.begin.fmcalc" }, - { "match": "\\)", "name": "punctuation.section.parens.end.fmcalc" }, - { "match": ";", "name": "punctuation.separator.fmcalc" }, - { "match": ",", "name": "punctuation.separator.fmcalc" }, - { "match": "\\[", "name": "punctuation.section.brackets.begin.fmcalc" }, - { "match": "\\]", "name": "punctuation.section.brackets.end.fmcalc" } - ] - } - } -} diff --git a/src/SharpFM/SharpFM.csproj b/src/SharpFM/SharpFM.csproj index 39768c5..489473a 100644 --- a/src/SharpFM/SharpFM.csproj +++ b/src/SharpFM/SharpFM.csproj @@ -52,7 +52,6 @@ - diff --git a/tests/SharpFM.Tests/Scripting/FmCalcCatalogTests.cs b/tests/SharpFM.Tests/Scripting/FmCalcCatalogTests.cs new file mode 100644 index 0000000..1af4a0f --- /dev/null +++ b/tests/SharpFM.Tests/Scripting/FmCalcCatalogTests.cs @@ -0,0 +1,59 @@ +using System.Linq; +using SharpFM.Model.Scripting.Calc; +using Xunit; + +namespace SharpFM.Tests.ScriptConverter; + +/// +/// Sanity checks on the calculation catalog and the grammar built from it. +/// The catalog is the single source of truth for the grammar and (next) the +/// completion provider, so duplicates or empty groups would silently +/// degrade both consumers. +/// +public class FmCalcCatalogTests +{ + [Fact] + public void Catalog_HasNoDuplicateFunctionNames() + { + var dupes = FmCalcCatalog.Functions + .GroupBy(f => f.Name) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + Assert.Empty(dupes); + } + + [Fact] + public void Catalog_EveryFunctionHasSignatureAndDescription() + { + Assert.All(FmCalcCatalog.Functions, f => + { + Assert.False(string.IsNullOrWhiteSpace(f.Signature), $"{f.Name} signature"); + Assert.False(string.IsNullOrWhiteSpace(f.Description), $"{f.Name} description"); + }); + } + + [Fact] + public void Catalog_ControlFormSnippetsContainTabStops() + { + Assert.All(FmCalcCatalog.ControlForms, c => + Assert.Contains("${1:", c.Snippet)); + } + + [Fact] + public void GrammarBuilder_ProducesValidJson() + { + var json = FmCalcGrammarBuilder.Build(); + var parsed = System.Text.Json.JsonDocument.Parse(json); + Assert.Equal("source.fmcalc", parsed.RootElement.GetProperty("scopeName").GetString()); + } + + [Fact] + public void GrammarBuilder_IncludesEveryFunctionNameInItsCategoryRegex() + { + var json = FmCalcGrammarBuilder.Build(); + // Cheap check: every name should appear at least once in the JSON. + // Misses would mean a function silently dropped out of the grammar. + Assert.All(FmCalcCatalog.Functions, f => Assert.Contains(f.Name, json)); + } +} From 7c2836e4a7575bd8d3fc94bc8b764ee8046541e6 Mon Sep 17 00:00:00 2001 From: Nate Bross Date: Fri, 24 Apr 2026 19:21:05 -0500 Subject: [PATCH 03/10] feat: intellisense for the FileMaker calculation editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an AvaloniaEdit completion window to CalculationEditorWindow, backed by FmCalcCompletionProvider reading from FmCalcCatalog. Items carry a signature + one-line description in their tooltip; control forms (Let, Case, If, While, Choose) expand from snippet templates with the first parameter pre-selected for editing. Detect what's being typed and switch sources: - Identifier prefix → built-in functions, control forms, constants - After $ / $$ → variables harvested from the document - After Table:: → fields of the current table - Inside a string or // comment → suppress completions CalcCompletionContextProvider supplies the document-derived data: $variables are scraped from the calculation text (bag-of-names, no Let-scope analysis — calcs are short enough that this covers the common case), and field names come from the FmTable passed in by TableEditorViewModel. Table enumeration is stubbed out until a schema container exists that can list table occurrences. Tests: 14 cases for the provider's context detection and filtering, 6 for the document-aware context provider. --- .../Editor/CalculationEditorWindow.axaml.cs | 82 ++++++- .../Schema/Editor/TableEditorViewModel.cs | 2 +- .../Editor/CalcCompletionContextProvider.cs | 61 ++++++ .../Editor/FmCalcCompletionProvider.cs | 205 ++++++++++++++++++ .../CalcCompletionContextProviderTests.cs | 67 ++++++ .../FmCalcCompletionProviderTests.cs | 155 +++++++++++++ 6 files changed, 562 insertions(+), 10 deletions(-) create mode 100644 src/SharpFM/Scripting/Editor/CalcCompletionContextProvider.cs create mode 100644 src/SharpFM/Scripting/Editor/FmCalcCompletionProvider.cs create mode 100644 tests/SharpFM.Tests/Scripting/CalcCompletionContextProviderTests.cs create mode 100644 tests/SharpFM.Tests/Scripting/FmCalcCompletionProviderTests.cs diff --git a/src/SharpFM/Schema/Editor/CalculationEditorWindow.axaml.cs b/src/SharpFM/Schema/Editor/CalculationEditorWindow.axaml.cs index f4ff00a..c623673 100644 --- a/src/SharpFM/Schema/Editor/CalculationEditorWindow.axaml.cs +++ b/src/SharpFM/Schema/Editor/CalculationEditorWindow.axaml.cs @@ -1,10 +1,13 @@ using System.Diagnostics.CodeAnalysis; using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Interactivity; using AvaloniaEdit; +using AvaloniaEdit.CodeCompletion; using AvaloniaEdit.TextMate; using SharpFM.Model.Schema; using SharpFM.Scripting; +using SharpFM.Scripting.Editor; using TextMateSharp.Grammars; namespace SharpFM.Schema.Editor; @@ -14,48 +17,109 @@ public partial class CalculationEditorWindow : Window { private readonly FmField _field; private readonly TextMate.Installation _textMateInstallation; + private readonly TextEditor _editor; + private readonly CalcCompletionContextProvider _completionContext; + private CompletionWindow? _completionWindow; public bool Saved { get; private set; } // Required by XAML loader - public CalculationEditorWindow() : this(new FmField()) { } + public CalculationEditorWindow() : this(new FmField(), null) { } - public CalculationEditorWindow(FmField field) + public CalculationEditorWindow(FmField field) : this(field, null) { } + + public CalculationEditorWindow(FmField field, FmTable? currentTable) { InitializeComponent(); _field = field; - // Set up FM script syntax highlighting for calculations + // Set up FM calculation syntax highlighting var registryOptions = new RegistryOptions((ThemeName)(int)ThemeName.DarkPlus); var fmRegistry = new FmLanguageRegistryOptions(registryOptions); - var editor = this.FindControl("calcEditor")!; - _textMateInstallation = editor.InstallTextMate(fmRegistry); + _editor = this.FindControl("calcEditor")!; + _textMateInstallation = _editor.InstallTextMate(fmRegistry); _textMateInstallation.SetGrammar(FmLanguageRegistryOptions.CalcScopeName); // Populate fields - editor.Text = field.Calculation ?? ""; + _editor.Text = field.Calculation ?? ""; var contextBox = this.FindControl("contextTableBox")!; contextBox.Text = field.CalculationContext ?? ""; var alwaysCheck = this.FindControl("alwaysEvaluateCheck")!; alwaysCheck.IsChecked = field.AlwaysEvaluate; + _completionContext = new CalcCompletionContextProvider( + getDocumentText: () => _editor.Document.Text, + currentTable: currentTable); + + // Completion: pop the menu on every text input. Same trigger model + // the script editor uses, kept inline rather than introducing a + // controller — calc editor has no other adornments to coordinate. + _editor.TextArea.TextEntered += OnTextEntered; + _editor.TextArea.TextEntering += OnTextEntering; + // Wire buttons this.FindControl