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