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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user