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:
@@ -657,7 +657,7 @@
|
||||
<MonacoEditor Value="@_scriptCode" ValueChanged="@(v => _scriptCode = v)"
|
||||
Language="csharp" Height="320px"
|
||||
DeclaredParameters="@ScriptParameterNames.Parse(_scriptParameters)"
|
||||
SiblingScripts="@(_scripts.Select(s => s.Name).ToArray())" />
|
||||
SiblingScripts="@(_scripts.Select(s => ScadaLink.CentralUI.ScriptAnalysis.ScriptShapeParser.Parse(s.Name, s.ParameterDefinitions, s.ReturnDefinition)).ToArray())" />
|
||||
</div>
|
||||
@if (_scriptFormError != null)
|
||||
{
|
||||
|
||||
@@ -19,10 +19,11 @@
|
||||
[Parameter] public IReadOnlyList<string>? DeclaredParameters { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Names of other scripts on the same template, surfaced as completions
|
||||
/// inside CallScript("...") literals.
|
||||
/// Shapes (name + parameter list + return type) of other scripts on the
|
||||
/// same template. Surfaced inside CallScript("...") for completion,
|
||||
/// signature help, hover, and argument-count diagnostics.
|
||||
/// </summary>
|
||||
[Parameter] public IReadOnlyList<string>? SiblingScripts { get; set; }
|
||||
[Parameter] public IReadOnlyList<ScriptAnalysis.ScriptShape>? SiblingScripts { get; set; }
|
||||
|
||||
private ElementReference _hostRef;
|
||||
private DotNetObjectReference<MonacoEditor>? _dotNetRef;
|
||||
@@ -77,7 +78,7 @@
|
||||
[JSInvokable]
|
||||
public ScadaContext GetContext() => new(
|
||||
DeclaredParameters?.ToArray() ?? Array.Empty<string>(),
|
||||
SiblingScripts?.ToArray() ?? Array.Empty<string>());
|
||||
SiblingScripts?.ToArray() ?? Array.Empty<ScriptAnalysis.ScriptShape>());
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
@@ -88,5 +89,5 @@
|
||||
_dotNetRef?.Dispose();
|
||||
}
|
||||
|
||||
public record ScadaContext(string[] DeclaredParameters, string[] SiblingScripts);
|
||||
public record ScadaContext(string[] DeclaredParameters, ScriptAnalysis.ScriptShape[] SiblingScripts);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||
/// </summary>
|
||||
public interface ISharedScriptCatalog
|
||||
{
|
||||
Task<IReadOnlyList<string>> GetNamesAsync();
|
||||
Task<IReadOnlyList<ScriptShape>> GetShapesAsync();
|
||||
}
|
||||
|
||||
public class SharedScriptCatalog : ISharedScriptCatalog
|
||||
@@ -17,9 +17,11 @@ public class SharedScriptCatalog : ISharedScriptCatalog
|
||||
|
||||
public SharedScriptCatalog(SharedScriptService service) => _service = service;
|
||||
|
||||
public async Task<IReadOnlyList<string>> GetNamesAsync()
|
||||
public async Task<IReadOnlyList<ScriptShape>> GetShapesAsync()
|
||||
{
|
||||
var scripts = await _service.GetAllSharedScriptsAsync();
|
||||
return scripts.Select(s => s.Name).ToList();
|
||||
return scripts
|
||||
.Select(s => ScriptShapeParser.Parse(s.Name, s.ParameterDefinitions, s.ReturnDefinition))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||
|
||||
public record DiagnoseRequest(string Code);
|
||||
public record DiagnoseRequest(
|
||||
string Code,
|
||||
IReadOnlyList<string>? DeclaredParameters = null,
|
||||
IReadOnlyList<ScriptShape>? SiblingScripts = null);
|
||||
|
||||
public record DiagnoseResponse(IReadOnlyList<DiagnosticMarker> Markers);
|
||||
|
||||
@@ -22,7 +25,7 @@ public record CompletionsRequest(
|
||||
int Line,
|
||||
int Column,
|
||||
IReadOnlyList<string>? DeclaredParameters = null,
|
||||
IReadOnlyList<string>? SiblingScripts = null);
|
||||
IReadOnlyList<ScriptShape>? SiblingScripts = null);
|
||||
|
||||
public record CompletionsResponse(IReadOnlyList<CompletionItem> Items);
|
||||
|
||||
@@ -30,4 +33,40 @@ public record CompletionItem(
|
||||
string Label,
|
||||
string InsertText,
|
||||
string Detail,
|
||||
string Kind);
|
||||
string Kind,
|
||||
/// <summary>Monaco CompletionItemInsertTextRule. 4 = InsertAsSnippet.</summary>
|
||||
int InsertTextRules = 0);
|
||||
|
||||
public record HoverRequest(
|
||||
string CodeText,
|
||||
int Line,
|
||||
int Column,
|
||||
IReadOnlyList<ScriptShape>? SiblingScripts = null);
|
||||
|
||||
public record HoverResponse(string? Markdown);
|
||||
|
||||
public record SignatureHelpRequest(
|
||||
string CodeText,
|
||||
int Line,
|
||||
int Column,
|
||||
IReadOnlyList<ScriptShape>? SiblingScripts = null);
|
||||
|
||||
public record SignatureHelpResponse(
|
||||
string? Label,
|
||||
IReadOnlyList<SignatureHelpParameter>? Parameters,
|
||||
int ActiveParameter);
|
||||
|
||||
public record SignatureHelpParameter(string Label, string? Documentation);
|
||||
|
||||
/// <summary>
|
||||
/// Shape metadata for a script. Captured from the form's ParameterListEditor
|
||||
/// and ReturnTypeEditor (for siblings) or from SharedScriptCatalog (for shared
|
||||
/// scripts). Used by hover, signature-help, snippet expansion, and the
|
||||
/// argument-count diagnostic.
|
||||
/// </summary>
|
||||
public record ScriptShape(
|
||||
string Name,
|
||||
IReadOnlyList<ParameterShape> Parameters,
|
||||
string? ReturnType);
|
||||
|
||||
public record ParameterShape(string Name, string Type, bool Required);
|
||||
|
||||
@@ -19,6 +19,12 @@ public static class ScriptAnalysisEndpoints
|
||||
group.MapPost("/completions", async (CompletionsRequest req, ScriptAnalysisService svc) =>
|
||||
Results.Ok(await svc.CompleteAsync(req)));
|
||||
|
||||
group.MapPost("/hover", (HoverRequest req, ScriptAnalysisService svc) =>
|
||||
Results.Ok(svc.Hover(req)));
|
||||
|
||||
group.MapPost("/signature-help", (SignatureHelpRequest req, ScriptAnalysisService svc) =>
|
||||
Results.Ok(svc.SignatureHelp(req)));
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
59
src/ScadaLink.CentralUI/ScriptAnalysis/ScriptShapeParser.cs
Normal file
59
src/ScadaLink.CentralUI/ScriptAnalysis/ScriptShapeParser.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// Parses the parameter-definitions and return-definition JSON written by
|
||||
/// ParameterListEditor / ReturnTypeEditor into a <see cref="ScriptShape"/>.
|
||||
/// Lenient: malformed JSON yields an empty parameter list, not an exception.
|
||||
/// </summary>
|
||||
public static class ScriptShapeParser
|
||||
{
|
||||
public static ScriptShape Parse(string name, string? parametersJson, string? returnJson)
|
||||
{
|
||||
var parameters = ParseParameters(parametersJson);
|
||||
var returnType = ParseReturnType(returnJson);
|
||||
return new ScriptShape(name, parameters, returnType);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ParameterShape> ParseParameters(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<ParameterShape>();
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array) return Array.Empty<ParameterShape>();
|
||||
return doc.RootElement.EnumerateArray()
|
||||
.Select(el => new ParameterShape(
|
||||
Name: el.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "",
|
||||
Type: el.TryGetProperty("type", out var t) ? t.GetString() ?? "String" : "String",
|
||||
Required: !el.TryGetProperty("required", out var rq) || rq.ValueKind != JsonValueKind.False))
|
||||
.Where(p => !string.IsNullOrEmpty(p.Name))
|
||||
.ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<ParameterShape>();
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ParseReturnType(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
if (!doc.RootElement.TryGetProperty("type", out var t)) return null;
|
||||
var type = t.GetString();
|
||||
if (string.IsNullOrEmpty(type)) return null;
|
||||
if (type == "List" && doc.RootElement.TryGetProperty("itemType", out var it))
|
||||
return $"List<{it.GetString() ?? "Object"}>";
|
||||
return type;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,31 +35,34 @@
|
||||
Module: 8, Variable: 4, Text: 18
|
||||
};
|
||||
|
||||
// Look up the SCADA context for a model by walking the editors map. Blazor
|
||||
// JS interop serializes records as PascalCase; we normalize to camelCase.
|
||||
async function lookupContext(model) {
|
||||
const empty = { declaredParameters: [], siblingScripts: [] };
|
||||
for (const key in editors) {
|
||||
if (editors[key].editor.getModel() === model) {
|
||||
try {
|
||||
const got = await editors[key].dotNetRef.invokeMethodAsync("GetContext");
|
||||
if (got) {
|
||||
return {
|
||||
declaredParameters: got.DeclaredParameters || got.declaredParameters || [],
|
||||
siblingScripts: got.SiblingScripts || got.siblingScripts || []
|
||||
};
|
||||
}
|
||||
} catch (e) { /* fall through */ }
|
||||
break;
|
||||
}
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
|
||||
function registerCSharpProviders() {
|
||||
// Completion: triggered on ".", "(", "\"" and on demand (Ctrl-Space).
|
||||
// ----- Completions ---------------------------------------------------
|
||||
monaco.languages.registerCompletionItemProvider("csharp", {
|
||||
triggerCharacters: [".", "(", "\""],
|
||||
provideCompletionItems: async function (model, position) {
|
||||
try {
|
||||
// Find which editor instance owns this model so we can ask
|
||||
// the Blazor side for the latest form context.
|
||||
// Blazor JS interop serializes records as PascalCase; we
|
||||
// normalize to camelCase here.
|
||||
let ctx = { declaredParameters: [], siblingScripts: [] };
|
||||
for (const key in editors) {
|
||||
if (editors[key].editor.getModel() === model) {
|
||||
try {
|
||||
const got = await editors[key].dotNetRef.invokeMethodAsync("GetContext");
|
||||
if (got) {
|
||||
ctx = {
|
||||
declaredParameters: got.DeclaredParameters || got.declaredParameters || [],
|
||||
siblingScripts: got.SiblingScripts || got.siblingScripts || []
|
||||
};
|
||||
}
|
||||
} catch (e) { /* fall through */ }
|
||||
break;
|
||||
}
|
||||
}
|
||||
const ctx = await lookupContext(model);
|
||||
const resp = await fetch("/api/script-analysis/completions", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
@@ -74,21 +77,46 @@
|
||||
});
|
||||
if (!resp.ok) return { suggestions: [] };
|
||||
const data = await resp.json();
|
||||
|
||||
// Snippet items (kind=Method with InsertTextRules=4) need
|
||||
// the range to extend over Monaco's auto-closed `")` so the
|
||||
// snippet replaces them cleanly. Detect it once.
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
const lineLen = model.getLineMaxColumn(position.lineNumber);
|
||||
const lookahead = model.getValueInRange({
|
||||
startLineNumber: position.lineNumber,
|
||||
startColumn: position.column,
|
||||
endLineNumber: position.lineNumber,
|
||||
endColumn: Math.min(position.column + 2, lineLen)
|
||||
});
|
||||
let snippetTail = 0;
|
||||
if (lookahead.startsWith("\")")) snippetTail = 2;
|
||||
else if (lookahead.startsWith("\"")) snippetTail = 1;
|
||||
|
||||
const baseRange = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn
|
||||
};
|
||||
const snippetRange = {
|
||||
...baseRange,
|
||||
endColumn: word.endColumn + snippetTail
|
||||
};
|
||||
|
||||
return {
|
||||
suggestions: (data.items || []).map(function (it) {
|
||||
const isSnippet = it.insertTextRules === 4;
|
||||
return {
|
||||
label: it.label,
|
||||
insertText: it.insertText,
|
||||
detail: it.detail,
|
||||
kind: KIND_MAP[it.kind] != null ? KIND_MAP[it.kind] : 18,
|
||||
range: range
|
||||
insertTextRules: isSnippet ? 4 : 0,
|
||||
range: isSnippet ? snippetRange : baseRange,
|
||||
command: isSnippet
|
||||
? { id: "editor.action.triggerParameterHints", title: "Signature help" }
|
||||
: undefined
|
||||
};
|
||||
})
|
||||
};
|
||||
@@ -97,15 +125,82 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ----- Hover ---------------------------------------------------------
|
||||
monaco.languages.registerHoverProvider("csharp", {
|
||||
provideHover: async function (model, position) {
|
||||
try {
|
||||
const ctx = await lookupContext(model);
|
||||
const resp = await fetch("/api/script-analysis/hover", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
codeText: model.getValue(),
|
||||
line: position.lineNumber,
|
||||
column: position.column,
|
||||
siblingScripts: ctx.siblingScripts
|
||||
})
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
if (!data.markdown) return null;
|
||||
return { contents: [{ value: data.markdown }] };
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
});
|
||||
|
||||
// ----- Signature help ------------------------------------------------
|
||||
monaco.languages.registerSignatureHelpProvider("csharp", {
|
||||
signatureHelpTriggerCharacters: ["(", ","],
|
||||
signatureHelpRetriggerCharacters: [","],
|
||||
provideSignatureHelp: async function (model, position) {
|
||||
try {
|
||||
const ctx = await lookupContext(model);
|
||||
const resp = await fetch("/api/script-analysis/signature-help", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
codeText: model.getValue(),
|
||||
line: position.lineNumber,
|
||||
column: position.column,
|
||||
siblingScripts: ctx.siblingScripts
|
||||
})
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
if (!data.label) return null;
|
||||
return {
|
||||
value: {
|
||||
signatures: [{
|
||||
label: data.label,
|
||||
parameters: (data.parameters || []).map(function (p) {
|
||||
return { label: p.label, documentation: p.documentation };
|
||||
})
|
||||
}],
|
||||
activeSignature: 0,
|
||||
activeParameter: data.activeParameter || 0
|
||||
},
|
||||
dispose: function () {}
|
||||
};
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchDiagnostics(code) {
|
||||
async function fetchDiagnostics(model) {
|
||||
try {
|
||||
const ctx = await lookupContext(model);
|
||||
const resp = await fetch("/api/script-analysis/diagnostics", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code: code })
|
||||
body: JSON.stringify({
|
||||
code: model.getValue(),
|
||||
declaredParameters: ctx.declaredParameters,
|
||||
siblingScripts: ctx.siblingScripts
|
||||
})
|
||||
});
|
||||
if (!resp.ok) return [];
|
||||
const data = await resp.json();
|
||||
@@ -138,9 +233,10 @@
|
||||
const scheduleDiagnostics = function () {
|
||||
if (diagTimer) clearTimeout(diagTimer);
|
||||
diagTimer = setTimeout(async function () {
|
||||
const markers = await fetchDiagnostics(editor.getValue());
|
||||
const model = editor.getModel();
|
||||
if (model) monaco.editor.setModelMarkers(model, "scadalink", markers);
|
||||
if (!model) return;
|
||||
const markers = await fetchDiagnostics(model);
|
||||
monaco.editor.setModelMarkers(model, "scadalink", markers);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user