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); + } }