From 004c5da5822df7e97d690a309b92222f61f3121a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 12 May 2026 05:17:59 -0400 Subject: [PATCH] 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 - 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 --- .../Pages/Design/TemplateEdit.razor | 2 +- .../Components/Shared/MonacoEditor.razor | 11 +- .../ScriptAnalysis/ISharedScriptCatalog.cs | 8 +- .../ScriptAnalysis/ScriptAnalysisContracts.cs | 45 +++- .../ScriptAnalysis/ScriptAnalysisEndpoints.cs | 6 + .../ScriptAnalysis/ScriptAnalysisService.cs | 242 +++++++++++++++++- .../ScriptAnalysis/ScriptShapeParser.cs | 59 +++++ .../wwwroot/js/monaco-init.js | 148 +++++++++-- .../ScriptAnalysisServiceTests.cs | 162 +++++++++++- 9 files changed, 625 insertions(+), 58 deletions(-) create mode 100644 src/ScadaLink.CentralUI/ScriptAnalysis/ScriptShapeParser.cs diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor index c1247c6..6e1f31d 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor @@ -657,7 +657,7 @@ + SiblingScripts="@(_scripts.Select(s => ScadaLink.CentralUI.ScriptAnalysis.ScriptShapeParser.Parse(s.Name, s.ParameterDefinitions, s.ReturnDefinition)).ToArray())" /> @if (_scriptFormError != null) { diff --git a/src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor b/src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor index 2a62ba5..1699c6e 100644 --- a/src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor +++ b/src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor @@ -19,10 +19,11 @@ [Parameter] public IReadOnlyList? DeclaredParameters { get; set; } /// - /// 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. /// - [Parameter] public IReadOnlyList? SiblingScripts { get; set; } + [Parameter] public IReadOnlyList? SiblingScripts { get; set; } private ElementReference _hostRef; private DotNetObjectReference? _dotNetRef; @@ -77,7 +78,7 @@ [JSInvokable] public ScadaContext GetContext() => new( DeclaredParameters?.ToArray() ?? Array.Empty(), - SiblingScripts?.ToArray() ?? Array.Empty()); + SiblingScripts?.ToArray() ?? Array.Empty()); 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); } diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ISharedScriptCatalog.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ISharedScriptCatalog.cs index ba19970..0e7b84a 100644 --- a/src/ScadaLink.CentralUI/ScriptAnalysis/ISharedScriptCatalog.cs +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ISharedScriptCatalog.cs @@ -8,7 +8,7 @@ namespace ScadaLink.CentralUI.ScriptAnalysis; /// public interface ISharedScriptCatalog { - Task> GetNamesAsync(); + Task> GetShapesAsync(); } public class SharedScriptCatalog : ISharedScriptCatalog @@ -17,9 +17,11 @@ public class SharedScriptCatalog : ISharedScriptCatalog public SharedScriptCatalog(SharedScriptService service) => _service = service; - public async Task> GetNamesAsync() + public async Task> 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(); } } diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs index 628df21..c7aa02d 100644 --- a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs @@ -1,6 +1,9 @@ namespace ScadaLink.CentralUI.ScriptAnalysis; -public record DiagnoseRequest(string Code); +public record DiagnoseRequest( + string Code, + IReadOnlyList? DeclaredParameters = null, + IReadOnlyList? SiblingScripts = null); public record DiagnoseResponse(IReadOnlyList Markers); @@ -22,7 +25,7 @@ public record CompletionsRequest( int Line, int Column, IReadOnlyList? DeclaredParameters = null, - IReadOnlyList? SiblingScripts = null); + IReadOnlyList? SiblingScripts = null); public record CompletionsResponse(IReadOnlyList Items); @@ -30,4 +33,40 @@ public record CompletionItem( string Label, string InsertText, string Detail, - string Kind); + string Kind, + /// Monaco CompletionItemInsertTextRule. 4 = InsertAsSnippet. + int InsertTextRules = 0); + +public record HoverRequest( + string CodeText, + int Line, + int Column, + IReadOnlyList? SiblingScripts = null); + +public record HoverResponse(string? Markdown); + +public record SignatureHelpRequest( + string CodeText, + int Line, + int Column, + IReadOnlyList? SiblingScripts = null); + +public record SignatureHelpResponse( + string? Label, + IReadOnlyList? Parameters, + int ActiveParameter); + +public record SignatureHelpParameter(string Label, string? Documentation); + +/// +/// 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. +/// +public record ScriptShape( + string Name, + IReadOnlyList Parameters, + string? ReturnType); + +public record ParameterShape(string Name, string Type, bool Required); diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs index 260f297..459302c 100644 --- a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs @@ -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; } } diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs index e62fd75..092fa00 100644 --- a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -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()) - .Distinct() - .Select(n => new CompletionItem(n, n, "sibling script", "Method")) + return (request.SiblingScripts ?? Array.Empty()) + .Select(s => MakeCallCompletion(s, "sibling script")) .ToList(); } } @@ -226,6 +225,161 @@ public class ScriptAnalysisService return null; } + /// + /// Builds a Monaco snippet that fills the call after the name, e.g. + /// Greet", ${1:name}, ${2:count}). The JS provider extends the + /// completion range over the auto-closed ") if Monaco inserted + /// one, so the snippet replaces the rest of the call cleanly. + /// + 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? TryGetDotMembers(SyntaxToken token, SemanticModel model) { var memberAccess = token.Parent as MemberAccessExpressionSyntax @@ -246,6 +400,80 @@ public class ScriptAnalysisService .ToList(); } + private IEnumerable FindUnknownParameterKeys(SyntaxTree tree, IReadOnlyList? declared) + { + if (declared == null) yield break; + var declaredSet = new HashSet(declared, StringComparer.Ordinal); + var root = tree.GetRoot(); + + foreach (var elem in root.DescendantNodes().OfType()) + { + 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 FindArgumentCountMismatches(SyntaxTree tree, IReadOnlyList? siblings) + { + var root = tree.GetRoot(); + + IReadOnlyList? sharedShapes = null; + IReadOnlyList SharedShapes() => + sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult(); + + foreach (var inv in root.DescendantNodes().OfType()) + { + 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 FindForbiddenApiUsages(SyntaxTree tree, SemanticModel model) { var root = tree.GetRoot(); diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptShapeParser.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptShapeParser.cs new file mode 100644 index 0000000..f7e9967 --- /dev/null +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptShapeParser.cs @@ -0,0 +1,59 @@ +using System.Text.Json; + +namespace ScadaLink.CentralUI.ScriptAnalysis; + +/// +/// Parses the parameter-definitions and return-definition JSON written by +/// ParameterListEditor / ReturnTypeEditor into a . +/// Lenient: malformed JSON yields an empty parameter list, not an exception. +/// +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 ParseParameters(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return Array.Empty(); + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Array) return Array.Empty(); + 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(); + } + } + + 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; + } + } +} diff --git a/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js b/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js index 40a75a7..5e20b76 100644 --- a/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js +++ b/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js @@ -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); }; diff --git a/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs b/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs index de769ee..4bfd0f3 100644 --- a/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs @@ -10,12 +10,20 @@ public class ScriptAnalysisServiceTests private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 100 }); private readonly ScriptAnalysisService _svc; + private static ScriptShape Shape(string name, params ParameterShape[] ps) => + new(name, ps, null); + + private static ParameterShape Param(string name, string type = "String", bool required = true) => + new(name, type, required); + public ScriptAnalysisServiceTests() { - _catalog.GetNamesAsync().Returns(Array.Empty()); + _catalog.GetShapesAsync().Returns(Array.Empty()); _svc = new ScriptAnalysisService(_catalog, _cache); } + // ── Diagnose ────────────────────────────────────────────────────────── + [Fact] public void EmptyCode_NoMarkers() { @@ -65,7 +73,6 @@ public class ScriptAnalysisServiceTests [Fact] public void UserIdentifierNamedFile_DoesNotFalsePositive() { - // No System.IO import; user defines their own 'File' local. var resp = _svc.Diagnose(new DiagnoseRequest( "var File = \"hello\"; return File.Length;")); Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA002"); @@ -85,8 +92,6 @@ public class ScriptAnalysisServiceTests var req = new DiagnoseRequest("using System.IO;"); var first = _svc.Diagnose(req); var second = _svc.Diagnose(req); - - // Same instance reference indicates the cache returned the prior result. Assert.Same(first, second); } @@ -98,6 +103,75 @@ public class ScriptAnalysisServiceTests Assert.NotSame(a, b); } + [Fact] + public void UnknownParameterKey_RaisesSCADA003() + { + var resp = _svc.Diagnose(new DiagnoseRequest( + Code: "var x = Parameters[\"typo\"];", + DeclaredParameters: new[] { "name", "temperature" })); + Assert.Contains(resp.Markers, m => m.Code == "SCADA003" && m.Message.Contains("'typo'")); + } + + [Fact] + public void DeclaredParameterKey_NoMarker() + { + var resp = _svc.Diagnose(new DiagnoseRequest( + Code: "var x = Parameters[\"name\"];", + DeclaredParameters: new[] { "name", "temperature" })); + Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA003"); + } + + [Fact] + public void ArgumentCountTooFew_RaisesSCADA004() + { + var siblings = new[] { Shape("Calc", Param("x"), Param("y")) }; + var resp = _svc.Diagnose(new DiagnoseRequest( + Code: "var r = CallScript(\"Calc\", 1);", + SiblingScripts: siblings)); + Assert.Contains(resp.Markers, m => m.Code == "SCADA004" && m.Message.Contains("expects 2")); + } + + [Fact] + public void ArgumentCountTooMany_RaisesSCADA004() + { + var siblings = new[] { Shape("Ping") }; + var resp = _svc.Diagnose(new DiagnoseRequest( + Code: "var r = CallScript(\"Ping\", 1, 2);", + SiblingScripts: siblings)); + Assert.Contains(resp.Markers, m => m.Code == "SCADA004" && m.Message.Contains("got 2")); + } + + [Fact] + public void ArgumentCountCorrect_NoMarker() + { + var siblings = new[] { Shape("Calc", Param("x"), Param("y")) }; + var resp = _svc.Diagnose(new DiagnoseRequest( + Code: "var r = CallScript(\"Calc\", 1, 2);", + SiblingScripts: siblings)); + Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA004"); + } + + [Fact] + public void OptionalParameter_AcceptsBothOmittedAndPresent() + { + var siblings = new[] + { + Shape("Calc", Param("x"), Param("y", required: false)) + }; + // Required only (1) — OK. + var with1 = _svc.Diagnose(new DiagnoseRequest( + Code: "var r = CallScript(\"Calc\", 1);", + SiblingScripts: siblings)); + Assert.DoesNotContain(with1.Markers, m => m.Code == "SCADA004"); + // Both passed (2) — OK. + var with2 = _svc.Diagnose(new DiagnoseRequest( + Code: "var r = CallScript(\"Calc\", 1, 2);", + SiblingScripts: siblings)); + Assert.DoesNotContain(with2.Markers, m => m.Code == "SCADA004"); + } + + // ── Completions ─────────────────────────────────────────────────────── + [Fact] public async Task ParametersStringLiteral_ReturnsDeclaredParameterNames() { @@ -114,24 +188,31 @@ public class ScriptAnalysisServiceTests } [Fact] - public async Task CallScriptStringLiteral_ReturnsSiblingNames() + public async Task CallScriptStringLiteral_ReturnsSiblingNamesWithSnippet() { + var siblings = new[] { Shape("SiblingA", Param("x")) }; var req = new CompletionsRequest( CodeText: "var x = CallScript(\"", Line: 1, Column: 21, - SiblingScripts: new[] { "SiblingA", "SiblingB" }); + SiblingScripts: siblings); var resp = await _svc.CompleteAsync(req); - Assert.Contains(resp.Items, i => i.Label == "SiblingA" && i.Detail == "sibling script"); - Assert.Contains(resp.Items, i => i.Label == "SiblingB"); + var item = Assert.Single(resp.Items, i => i.Label == "SiblingA"); + Assert.Equal(4, item.InsertTextRules); + Assert.Contains("${1:x}", item.InsertText); + Assert.Contains("sibling script", item.Detail); } [Fact] - public async Task CallSharedStringLiteral_ResolvesViaCatalog() + public async Task CallSharedStringLiteral_ResolvesViaCatalogWithShapes() { - _catalog.GetNamesAsync().Returns(new[] { "GetWeather", "Greet" }); + _catalog.GetShapesAsync().Returns(new[] + { + Shape("GetWeather"), + Shape("Greet", Param("name")) + }); var req = new CompletionsRequest( CodeText: "var x = CallShared(\"", @@ -140,17 +221,72 @@ public class ScriptAnalysisServiceTests var resp = await _svc.CompleteAsync(req); - Assert.Contains(resp.Items, i => i.Label == "GetWeather" && i.Detail == "shared script"); - Assert.Contains(resp.Items, i => i.Label == "Greet"); + Assert.Contains(resp.Items, i => i.Label == "GetWeather"); + var greet = Assert.Single(resp.Items, i => i.Label == "Greet"); + Assert.Contains("${1:name}", greet.InsertText); } [Fact] public async Task GeneralCompletion_ReturnsInScopeSymbols() { - // At file scope of a script, ScriptHost members + the System namespace are visible. var req = new CompletionsRequest("var x = ", 1, 9); var resp = await _svc.CompleteAsync(req); Assert.Contains(resp.Items, i => i.Label == "Parameters"); Assert.Contains(resp.Items, i => i.Label == "CallShared"); } + + // ── Hover ───────────────────────────────────────────────────────────── + + [Fact] + public void Hover_OnSiblingName_ReturnsSignature() + { + var siblings = new[] { Shape("Calc", Param("x", "Integer"), Param("y", "Float")) }; + var resp = _svc.Hover(new HoverRequest( + CodeText: "var r = CallScript(\"Calc\", 1, 2);", + Line: 1, + Column: 23, + SiblingScripts: siblings)); + Assert.NotNull(resp.Markdown); + Assert.Contains("Calc", resp.Markdown); + Assert.Contains("x: Integer", resp.Markdown); + Assert.Contains("y: Float", resp.Markdown); + } + + [Fact] + public void Hover_OnUnrelatedToken_ReturnsNull() + { + var resp = _svc.Hover(new HoverRequest( + CodeText: "var r = 1 + 2;", + Line: 1, + Column: 5)); + Assert.Null(resp.Markdown); + } + + // ── Signature help ──────────────────────────────────────────────────── + + [Fact] + public void SignatureHelp_InsideCallScript_ReturnsParameterStrip() + { + var siblings = new[] { Shape("Calc", Param("x", "Integer"), Param("y", "Float")) }; + var resp = _svc.SignatureHelp(new SignatureHelpRequest( + CodeText: "var r = CallScript(\"Calc\", 1, ", + Line: 1, + Column: 31, + SiblingScripts: siblings)); + Assert.NotNull(resp.Label); + Assert.Equal(2, resp.Parameters!.Count); + Assert.Equal("x: Integer", resp.Parameters[0].Label); + Assert.Equal("y: Float", resp.Parameters[1].Label); + Assert.Equal(1, resp.ActiveParameter); + } + + [Fact] + public void SignatureHelp_OutsideCall_ReturnsNull() + { + var resp = _svc.SignatureHelp(new SignatureHelpRequest( + CodeText: "var r = 1 + 2;", + Line: 1, + Column: 5)); + Assert.Null(resp.Label); + } }