Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
554 changes: 554 additions & 0 deletions src/SharpFM.Model/Scripting/Calc/FmCalcCatalog.cs

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions src/SharpFM.Model/Scripting/Calc/FmCalcControlForm.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace SharpFM.Model.Scripting.Calc;

/// <summary>
/// One of the FileMaker calculation control forms (<c>Let</c>, <c>Case</c>,
/// etc.). <see cref="Snippet"/> uses Monaco-style <c>${N:placeholder}</c>
/// tab-stops so completion accept inserts the full form with the first slot
/// pre-selected.
/// </summary>
public sealed record FmCalcControlForm(
string Name,
string Signature,
string Description,
string Snippet);
8 changes: 8 additions & 0 deletions src/SharpFM.Model/Scripting/Calc/FmCalcEnumValue.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace SharpFM.Model.Scripting.Calc;

/// <summary>
/// One valid value for a function parameter that takes an enumerated keyword.
/// <see cref="Description"/> is optional and surfaces in completion tooltips
/// when present (e.g. each <c>Get(...)</c> selector has its own one-liner).
/// </summary>
public sealed record FmCalcEnumValue(string Name, string? Description = null);
22 changes: 22 additions & 0 deletions src/SharpFM.Model/Scripting/Calc/FmCalcFunction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Collections.Generic;

namespace SharpFM.Model.Scripting.Calc;

/// <summary>
/// One built-in FileMaker calculation function. <see cref="Signature"/> is a
/// human-readable form (e.g. <c>Length(text)</c>) shown in completion
/// tooltips; <see cref="Description"/> is a one-line summary.
///
/// <para><see cref="Params"/> describes each positional parameter. When a
/// parameter has a <see cref="FmCalcFunctionParam.ValidValues"/> list, the
/// completion provider offers those keywords when the caret is inside that
/// argument position. Functions whose params are open-ended (numbers,
/// fields, expressions) leave <see cref="Params"/> empty — the catalog only
/// models keyword arguments.</para>
/// </summary>
public sealed record FmCalcFunction(
string Name,
FunctionCategory Category,
string Signature,
string Description,
IReadOnlyList<FmCalcFunctionParam> Params);
14 changes: 14 additions & 0 deletions src/SharpFM.Model/Scripting/Calc/FmCalcFunctionParam.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Collections.Generic;

namespace SharpFM.Model.Scripting.Calc;

/// <summary>
/// One parameter of a built-in calculation function. <see cref="ValidValues"/>
/// is the keyword set the parameter accepts (e.g. <c>Get(parameter)</c>'s
/// selectors, <c>JSONSetElement</c>'s <c>type</c> values); <c>null</c> when
/// the parameter is open-ended (a number, string, field, expression, …).
/// </summary>
public sealed record FmCalcFunctionParam(
string Name,
string? Description = null,
IReadOnlyList<FmCalcEnumValue>? ValidValues = null);
207 changes: 207 additions & 0 deletions src/SharpFM.Model/Scripting/Calc/FmCalcGrammarBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
using System;
using System.Linq;
using System.Text.Json.Nodes;

namespace SharpFM.Model.Scripting.Calc;

/// <summary>
/// Builds the <c>source.fmcalc</c> TextMate grammar JSON from
/// <see cref="FmCalcCatalog"/>. 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.
/// </summary>
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.<category>.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 };
}

/// <summary>
/// Map a category enum to its TextMate scope segment. Hyphenated for
/// multi-word categories, matching VS Code grammar conventions.
/// </summary>
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),
};
}
41 changes: 41 additions & 0 deletions src/SharpFM.Model/Scripting/Calc/FmCalcSignatureParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.Collections.Generic;

namespace SharpFM.Model.Scripting.Calc;

/// <summary>
/// Parses the param list out of a function signature string like
/// <c>JSONGetElement(json; keyOrIndexOrPath)</c> so completion items can
/// tab through placeholders without us hand-authoring a per-function param
/// list. Variadic markers like <c>{; field...}</c> are dropped — anything
/// before the first <c>{</c> is taken as the named portion.
/// </summary>
public static class FmCalcSignatureParser
{
public static IReadOnlyList<FmCalcFunctionParam> ParseParams(string signature)
{
if (string.IsNullOrWhiteSpace(signature)) return System.Array.Empty<FmCalcFunctionParam>();

var openParen = signature.IndexOf('(');
var closeParen = signature.LastIndexOf(')');
if (openParen < 0 || closeParen < openParen) return System.Array.Empty<FmCalcFunctionParam>();

var inner = signature.Substring(openParen + 1, closeParen - openParen - 1);

// Drop any variadic / optional region. Catalog signatures use
// {; ...} for "and more like this" — we don't model that as a
// separate stop, so trim it off.
var brace = inner.IndexOf('{');
if (brace >= 0) inner = inner.Substring(0, brace);

if (string.IsNullOrWhiteSpace(inner)) return System.Array.Empty<FmCalcFunctionParam>();

var result = new List<FmCalcFunctionParam>();
foreach (var part in inner.Split(';'))
{
var name = part.Trim().TrimStart('[').TrimEnd(']').Trim();
if (name.Length == 0) continue;
result.Add(new FmCalcFunctionParam(name));
}
return result;
}
}
26 changes: 26 additions & 0 deletions src/SharpFM.Model/Scripting/Calc/FunctionCategory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace SharpFM.Model.Scripting.Calc;

/// <summary>
/// FileMaker calculation function categories. Mirrors the grouping in
/// FileMaker's calculation dialog so completion menus and TextMate scopes
/// (<c>support.function.&lt;category&gt;.fmcalc</c>) line up.
/// </summary>
public enum FunctionCategory
{
Text,
TextFormatting,
Number,
Date,
Time,
Aggregate,
Summary,
Financial,
Trigonometric,
Logical,
Get,
Container,
Json,
Sql,
External,
Design,
}
4 changes: 2 additions & 2 deletions src/SharpFM/Editors/ScriptTextEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Loading
Loading