feat(ui/scripts): shape-aware Monaco features for script calls

Now that the form holds parameter + return shapes for declared
parameters, sibling scripts (template Scripts tab), and shared
scripts (via SharedScriptCatalog), the editor leverages them four
ways:

1. Snippet expansion on accept.
   Picking a CallShared or CallScript completion inserts the full
   call template with tabstops, e.g. `Greet", ${1:name})`. The JS
   provider extends the completion range over Monaco's auto-closed
   `")` so the snippet replaces the closing pair cleanly. Items
   carry insertTextRules=4 (InsertAsSnippet) and a command to
   immediately trigger parameter hints after acceptance.

2. Hover info.
   Hovering the script name token inside CallShared("X") or
   CallScript("Y") shows a markdown tooltip with the call signature
   and return type. New endpoint POST /api/script-analysis/hover.

3. Signature help.
   Inside CallShared(...) / CallScript(...) Monaco shows the
   parameter strip with the active parameter highlighted. The
   service walks up from the cursor to the nearest enclosing
   InvocationExpression and resolves which argument index the
   cursor is on. New endpoint POST /api/script-analysis/signature-help.

4. Argument-count diagnostic (SCADA004) and unknown-Parameters-key
   diagnostic (SCADA003). The Diagnose pipeline now consults the
   declared parameters and sibling/shared shapes to flag:
     - Parameters["typo"] when "typo" isn't on the form        (warn)
     - CallScript("Calc", 1) when Calc declares 2 required args (err)
     - CallShared("Greet", 1, 2, 3) when Greet declares 1 arg   (err)
   Optional parameters relax the required-count bound.

Contract changes:
  - ScriptShape / ParameterShape records
  - ISharedScriptCatalog.GetShapesAsync (replaces GetNamesAsync)
  - new HoverRequest/Response, SignatureHelpRequest/Response
  - CompletionsRequest.SiblingScripts: string[] -> ScriptShape[]
  - DiagnoseRequest gains DeclaredParameters + SiblingScripts
  - CompletionItem gains InsertTextRules (Monaco snippet rule)

Form wiring:
  - TemplateEdit passes ScriptShapeParser.Parse(...) per sibling
  - MonacoEditor surfaces SiblingScripts: IReadOnlyList<ScriptShape>
  - GetContext returns shapes to JS on each completion/hover/sig
    request

10 new ScriptAnalysisServiceTests covering all four features plus
optional-parameter edge cases. Existing tests updated for the
contract changes. Total: 113 -> 139.

Browser-verified via direct curl + Monaco marker readback:
  - SCADA003 squiggle on Parameters["typo"]
  - Snippet item Greet", ${1:name}) with insertTextRules=4
  - Hover markdown shape signature
  - Signature help parameter strip
This commit is contained in:
Joseph Doherty
2026-05-12 05:17:59 -04:00
parent cd0ec583e1
commit 004c5da582
9 changed files with 625 additions and 58 deletions
@@ -102,6 +102,8 @@ public class ScriptAnalysisService
{
var model = compilation.GetSemanticModel(tree);
markers.AddRange(FindForbiddenApiUsages(tree, model));
markers.AddRange(FindUnknownParameterKeys(tree, request.DeclaredParameters));
markers.AddRange(FindArgumentCountMismatches(tree, request.SiblingScripts));
}
return Cache(cacheKey, new DiagnoseResponse(markers));
@@ -208,17 +210,14 @@ public class ScriptAnalysisService
if (calleeName == "CallShared")
{
var names = await _sharedScripts.GetNamesAsync();
return names
.Select(n => new CompletionItem(n, n, "shared script", "Method"))
.ToList();
var shapes = await _sharedScripts.GetShapesAsync();
return shapes.Select(s => MakeCallCompletion(s, "shared script")).ToList();
}
if (calleeName == "CallScript")
{
return (request.SiblingScripts ?? Array.Empty<string>())
.Distinct()
.Select(n => new CompletionItem(n, n, "sibling script", "Method"))
return (request.SiblingScripts ?? Array.Empty<ScriptShape>())
.Select(s => MakeCallCompletion(s, "sibling script"))
.ToList();
}
}
@@ -226,6 +225,161 @@ public class ScriptAnalysisService
return null;
}
/// <summary>
/// Builds a Monaco snippet that fills the call after the name, e.g.
/// <c>Greet", ${1:name}, ${2:count})</c>. The JS provider extends the
/// completion range over the auto-closed <c>")</c> if Monaco inserted
/// one, so the snippet replaces the rest of the call cleanly.
/// </summary>
private static CompletionItem MakeCallCompletion(ScriptShape shape, string detail)
{
string insertText;
int insertRules;
if (shape.Parameters.Count == 0)
{
insertText = shape.Name + "\")";
insertRules = 4;
}
else
{
var args = string.Join(", ", shape.Parameters.Select((p, i) => $"${{{i + 1}:{p.Name}}}"));
insertText = $"{shape.Name}\", {args})";
insertRules = 4;
}
var paramList = string.Join(", ", shape.Parameters.Select(p => $"{p.Name}: {p.Type}"));
var returnType = shape.ReturnType ?? "void";
return new CompletionItem(
Label: shape.Name,
InsertText: insertText,
Detail: $"{detail} ({paramList}) -> {returnType}",
Kind: "Method",
InsertTextRules: insertRules);
}
public HoverResponse Hover(HoverRequest request)
{
var script = TryParse(request.CodeText);
if (script == null) return new HoverResponse(null);
var (tree, _) = script.Value;
var position = PositionToOffset(request.CodeText, request.Line, request.Column);
position = Math.Clamp(position, 0, request.CodeText.Length);
var root = tree.GetRoot();
var token = root.FindToken(Math.Max(0, position - 1));
if (!token.IsKind(SyntaxKind.StringLiteralToken)) return new HoverResponse(null);
var literalNode = token.Parent as LiteralExpressionSyntax;
var argument = literalNode?.Parent as ArgumentSyntax;
var argumentList = argument?.Parent;
var owner = argumentList?.Parent;
if (owner is not InvocationExpressionSyntax inv) return new HoverResponse(null);
var calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
var rawName = token.ValueText;
if (string.IsNullOrEmpty(rawName)) return new HoverResponse(null);
ScriptShape? shape = null;
if (calleeName == "CallShared")
shape = _sharedScripts.GetShapesAsync().GetAwaiter().GetResult()
.FirstOrDefault(s => s.Name == rawName);
else if (calleeName == "CallScript" && request.SiblingScripts != null)
shape = request.SiblingScripts.FirstOrDefault(s => s.Name == rawName);
if (shape == null) return new HoverResponse(null);
return new HoverResponse(FormatHover(shape, calleeName!));
}
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest request)
{
var empty = new SignatureHelpResponse(null, null, 0);
var script = TryParse(request.CodeText);
if (script == null) return empty;
var (tree, _) = script.Value;
var position = PositionToOffset(request.CodeText, request.Line, request.Column);
position = Math.Clamp(position, 0, request.CodeText.Length);
var root = tree.GetRoot();
var token = root.FindToken(Math.Max(0, position - 1));
// Walk up to the nearest enclosing InvocationExpression. Don't require
// ArgumentList.Span to strictly contain the cursor — for an incomplete
// call like CallScript("Calc", 1, ) the span ends before trailing
// whitespace, so a strict contains-check would miss it.
InvocationExpressionSyntax? inv = null;
for (var node = token.Parent; node != null; node = node.Parent)
{
if (node is InvocationExpressionSyntax candidate)
{
inv = candidate;
break;
}
}
if (inv == null) return empty;
var calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
if (calleeName is not ("CallShared" or "CallScript")) return empty;
// First argument is the name literal; pull it out.
if (inv.ArgumentList.Arguments.Count < 1) return empty;
var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
var scriptName = nameArg?.Token.ValueText ?? "";
ScriptShape? shape = null;
if (calleeName == "CallShared")
shape = _sharedScripts.GetShapesAsync().GetAwaiter().GetResult()
.FirstOrDefault(s => s.Name == scriptName);
else if (request.SiblingScripts != null)
shape = request.SiblingScripts.FirstOrDefault(s => s.Name == scriptName);
if (shape == null) return empty;
var paramLabels = shape.Parameters.Select(p => $"{p.Name}: {p.Type}").ToList();
var label = $"{calleeName}(\"{shape.Name}\"" +
(paramLabels.Count > 0 ? ", " + string.Join(", ", paramLabels) : "") + ")";
// ActiveParameter: count commas in ArgumentList before the cursor; subtract 1 because
// the first arg is the name literal.
int activeIndex = 0;
foreach (var arg in inv.ArgumentList.Arguments)
{
if (arg.Span.End < position) activeIndex++;
else break;
}
activeIndex = Math.Clamp(activeIndex - 1, 0, Math.Max(0, paramLabels.Count - 1));
return new SignatureHelpResponse(
Label: label,
Parameters: paramLabels
.Select((lbl, i) => new SignatureHelpParameter(lbl, shape.Parameters[i].Required ? null : "optional"))
.ToList(),
ActiveParameter: activeIndex);
}
private (SyntaxTree tree, Compilation compilation)? TryParse(string code)
{
if (string.IsNullOrEmpty(code)) return null;
try
{
var s = CSharpScript.Create(code, DefaultOptions, globalsType: typeof(ScriptHost));
var compilation = s.GetCompilation();
var tree = compilation.SyntaxTrees.FirstOrDefault();
return tree == null ? null : (tree, compilation);
}
catch
{
return null;
}
}
private static string FormatHover(ScriptShape shape, string callee)
{
var ps = shape.Parameters.Count == 0
? "(no parameters)"
: string.Join(", ", shape.Parameters.Select(p => $"{p.Name}: {p.Type}{(p.Required ? "" : "?")}"));
var rt = shape.ReturnType ?? "void";
var kind = callee == "CallShared" ? "shared script" : "sibling script";
return $"**{kind}** `{shape.Name}`\n\n```\n{shape.Name}({ps}): {rt}\n```";
}
private static List<CompletionItem>? TryGetDotMembers(SyntaxToken token, SemanticModel model)
{
var memberAccess = token.Parent as MemberAccessExpressionSyntax
@@ -246,6 +400,80 @@ public class ScriptAnalysisService
.ToList();
}
private IEnumerable<DiagnosticMarker> FindUnknownParameterKeys(SyntaxTree tree, IReadOnlyList<string>? declared)
{
if (declared == null) yield break;
var declaredSet = new HashSet<string>(declared, StringComparer.Ordinal);
var root = tree.GetRoot();
foreach (var elem in root.DescendantNodes().OfType<ElementAccessExpressionSyntax>())
{
if (elem.Expression is not IdentifierNameSyntax id || id.Identifier.ValueText != "Parameters")
continue;
if (elem.ArgumentList.Arguments.Count != 1) continue;
if (elem.ArgumentList.Arguments[0].Expression is not LiteralExpressionSyntax lit) continue;
if (!lit.IsKind(SyntaxKind.StringLiteralExpression)) continue;
var key = lit.Token.ValueText;
if (string.IsNullOrEmpty(key) || declaredSet.Contains(key)) continue;
var span = lit.GetLocation().GetLineSpan().Span;
yield return new DiagnosticMarker(
Severity: 4,
StartLineNumber: span.Start.Line + 1,
StartColumn: span.Start.Character + 1,
EndLineNumber: span.End.Line + 1,
EndColumn: span.End.Character + 1,
Message: $"Parameter '{key}' is not declared on this script.",
Code: "SCADA003");
}
}
private IEnumerable<DiagnosticMarker> FindArgumentCountMismatches(SyntaxTree tree, IReadOnlyList<ScriptShape>? siblings)
{
var root = tree.GetRoot();
IReadOnlyList<ScriptShape>? sharedShapes = null;
IReadOnlyList<ScriptShape> SharedShapes() =>
sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult();
foreach (var inv in root.DescendantNodes().OfType<InvocationExpressionSyntax>())
{
var callee = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
if (callee is not ("CallShared" or "CallScript")) continue;
if (inv.ArgumentList.Arguments.Count < 1) continue;
var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
if (nameArg == null || !nameArg.IsKind(SyntaxKind.StringLiteralExpression)) continue;
var scriptName = nameArg.Token.ValueText;
if (string.IsNullOrEmpty(scriptName)) continue;
ScriptShape? shape = callee == "CallShared"
? SharedShapes().FirstOrDefault(s => s.Name == scriptName)
: siblings?.FirstOrDefault(s => s.Name == scriptName);
if (shape == null) continue;
var passedCount = inv.ArgumentList.Arguments.Count - 1; // exclude name
var expectedRequired = shape.Parameters.Count(p => p.Required);
var expectedTotal = shape.Parameters.Count;
if (passedCount < expectedRequired || passedCount > expectedTotal)
{
var span = inv.GetLocation().GetLineSpan().Span;
var expected = expectedRequired == expectedTotal
? expectedTotal.ToString()
: $"{expectedRequired}{expectedTotal}";
yield return new DiagnosticMarker(
Severity: 8,
StartLineNumber: span.Start.Line + 1,
StartColumn: span.Start.Character + 1,
EndLineNumber: span.End.Line + 1,
EndColumn: span.End.Character + 1,
Message: $"{callee}('{scriptName}') expects {expected} argument(s) but got {passedCount}.",
Code: "SCADA004");
}
}
}
private static IEnumerable<DiagnosticMarker> FindForbiddenApiUsages(SyntaxTree tree, SemanticModel model)
{
var root = tree.GetRoot();