diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/ApiMethodForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/ApiMethodForm.razor index 0e2439f..d01ecbf 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/ApiMethodForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/ApiMethodForm.razor @@ -38,9 +38,12 @@
- + DeclaredParameters="@ScriptParameterNames.Parse(_params)" + DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_params)" + MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" /> +
@if (_formError != null) @@ -65,6 +68,9 @@ private int _timeoutSeconds = 30; private string? _params, _returns; private string? _formError; + private MonacoEditor? _editor; + private IReadOnlyList _markers + = Array.Empty(); private ApiMethod? _existing; diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScriptForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScriptForm.razor index 06ca9f6..3138b1e 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScriptForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScriptForm.razor @@ -4,6 +4,7 @@ @using ScadaLink.Commons.Entities.Scripts @using ScadaLink.Commons.Interfaces.Repositories @using ScadaLink.TemplateEngine +@using ScriptAnalysis = ScadaLink.CentralUI.ScriptAnalysis @attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)] @inject ITemplateEngineRepository TemplateEngineRepository @inject SharedScriptService SharedScriptService @@ -39,9 +40,12 @@
- + DeclaredParameters="@ScriptParameterNames.Parse(_formParameters)" + DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_formParameters)" + MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" /> +
@if (_formError != null) { @@ -72,6 +76,8 @@ private string? _formError; private string? _syntaxCheckResult; private bool _syntaxCheckPassed; + private MonacoEditor? _editor; + private IReadOnlyList _markers = Array.Empty(); private async Task GetCurrentUserAsync() { diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor index 6e1f31d..267e4f0 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor @@ -89,6 +89,9 @@ private string? _scriptReturn; private bool _scriptIsLocked; private string? _scriptFormError; + private MonacoEditor? _scriptEditor; + private IReadOnlyList _scriptMarkers + = Array.Empty(); private bool _showCompForm; private int _compComposedTemplateId; @@ -654,10 +657,13 @@
- + DeclaredParameterShapes="@ScriptParameterNames.ParseShapes(_scriptParameters)" + SiblingScripts="@(_scripts.Select(s => ScadaLink.CentralUI.ScriptAnalysis.ScriptShapeParser.Parse(s.Name, s.ParameterDefinitions, s.ReturnDefinition)).ToArray())" + MarkersChanged="@(m => { _scriptMarkers = m; StateHasChanged(); })" /> +
@if (_scriptFormError != null) { diff --git a/src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor b/src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor index 1699c6e..115409d 100644 --- a/src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor +++ b/src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor @@ -2,6 +2,20 @@ @implements IAsyncDisposable @inject IJSRuntime JS +@if (ShowToolbar) +{ +
+ + + + +
+} +
@@ -11,13 +25,22 @@ [Parameter] public string Language { get; set; } = "csharp"; [Parameter] public string Height { get; set; } = "320px"; [Parameter] public bool ReadOnly { get; set; } = false; + [Parameter] public bool ShowToolbar { get; set; } = true; /// /// Parameter names declared on the form (from the ParameterListEditor), - /// surfaced as completions inside Parameters["..."] literals. + /// surfaced as completions inside Parameters["..."] literals and used by + /// the unknown-key diagnostic. /// [Parameter] public IReadOnlyList? DeclaredParameters { get; set; } + /// + /// Full shapes (name + type + required) for the declared parameters. + /// Used by Parameters["name"] hover to show the declared type. If null, + /// derived from with type "Object". + /// + [Parameter] public IReadOnlyList? DeclaredParameterShapes { get; set; } + /// /// Shapes (name + parameter list + return type) of other scripts on the /// same template. Surfaced inside CallScript("...") for completion, @@ -25,11 +48,21 @@ /// [Parameter] public IReadOnlyList? SiblingScripts { get; set; } + /// + /// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic + /// debounce). Hosts can render a with the same + /// data. + /// + [Parameter] public EventCallback> MarkersChanged { get; set; } + private ElementReference _hostRef; private DotNetObjectReference? _dotNetRef; private readonly string _id = Guid.NewGuid().ToString("N"); private string _lastSentValue = ""; private bool _initialized; + private bool _wrap; + private bool _minimap; + private bool _dark; protected override async Task OnAfterRenderAsync(bool firstRender) { @@ -71,6 +104,17 @@ return ValueChanged.InvokeAsync(_lastSentValue); } + [JSInvokable] + public Task OnMarkersChanged(ScriptAnalysis.DiagnosticMarker[] markers) => + MarkersChanged.InvokeAsync(markers ?? Array.Empty()); + + /// Programmatic scroll-to-line (called by the problems panel). + public async Task RevealLineAsync(int line, int column = 1) + { + if (!_initialized) return; + try { await JS.InvokeVoidAsync("MonacoBlazor.revealLine", _id, line, column); } catch { } + } + /// /// Called from JS at completion-request time so the form's latest state is /// passed through, not whatever was captured when the editor was created. @@ -78,7 +122,34 @@ [JSInvokable] public ScadaContext GetContext() => new( DeclaredParameters?.ToArray() ?? Array.Empty(), - SiblingScripts?.ToArray() ?? Array.Empty()); + SiblingScripts?.ToArray() ?? Array.Empty(), + DeclaredParameterShapes?.ToArray() + ?? DeclaredParameters?.Select(n => new ScriptAnalysis.ParameterShape(n, "Object", true)).ToArray() + ?? Array.Empty()); + + private async Task FormatAsync() + { + if (!_initialized) return; + try { await JS.InvokeVoidAsync("MonacoBlazor.format", _id); } catch { } + } + + private async Task ToggleWrap() + { + _wrap = !_wrap; + try { await JS.InvokeVoidAsync("MonacoBlazor.setEditorOption", _id, "wordWrap", _wrap ? "on" : "off"); } catch { } + } + + private async Task ToggleMinimap() + { + _minimap = !_minimap; + try { await JS.InvokeVoidAsync("MonacoBlazor.setEditorOption", _id, "minimap", new { enabled = _minimap }); } catch { } + } + + private async Task ToggleTheme() + { + _dark = !_dark; + try { await JS.InvokeVoidAsync("MonacoBlazor.setEditorOption", _id, "theme", _dark ? "vs-dark" : "vs"); } catch { } + } public async ValueTask DisposeAsync() { @@ -89,5 +160,8 @@ _dotNetRef?.Dispose(); } - public record ScadaContext(string[] DeclaredParameters, ScriptAnalysis.ScriptShape[] SiblingScripts); + public record ScadaContext( + string[] DeclaredParameters, + ScriptAnalysis.ScriptShape[] SiblingScripts, + ScriptAnalysis.ParameterShape[] DeclaredParameterShapes); } diff --git a/src/ScadaLink.CentralUI/Components/Shared/ProblemsPanel.razor b/src/ScadaLink.CentralUI/Components/Shared/ProblemsPanel.razor new file mode 100644 index 0000000..b7f553d --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Shared/ProblemsPanel.razor @@ -0,0 +1,68 @@ +@namespace ScadaLink.CentralUI.Components.Shared +@using ScadaLink.CentralUI.ScriptAnalysis + +@if (Markers.Count > 0) +{ +
+
+ + @if (_errorCount > 0) + { + @_errorCount error@(_errorCount == 1 ? "" : "s") + } + @if (_warningCount > 0) + { + @_warningCount warning@(_warningCount == 1 ? "" : "s") + } + @if (_infoCount > 0) + { + @_infoCount info + } + + Problems +
+
    + @foreach (var m in Markers) + { +
  • + @SeverityLabel(m.Severity) + + @m.Code +
  • + } +
+
+} + +@code { + [Parameter, EditorRequired] public IReadOnlyList Markers { get; set; } = Array.Empty(); + [Parameter] public EventCallback OnNavigate { get; set; } + + private int _errorCount; + private int _warningCount; + private int _infoCount; + + protected override void OnParametersSet() + { + _errorCount = Markers.Count(m => m.Severity >= 8); + _warningCount = Markers.Count(m => m.Severity == 4); + _infoCount = Markers.Count(m => m.Severity > 0 && m.Severity < 4); + } + + private static string SeverityBadge(int sev) => sev switch + { + >= 8 => "bg-danger", + 4 => "bg-warning text-dark", + _ => "bg-info text-dark" + }; + + private static string SeverityLabel(int sev) => sev switch + { + >= 8 => "Error", + 4 => "Warning", + _ => "Info" + }; +} diff --git a/src/ScadaLink.CentralUI/Components/Shared/ScriptParameterNames.cs b/src/ScadaLink.CentralUI/Components/Shared/ScriptParameterNames.cs index d6c6e22..cd52412 100644 --- a/src/ScadaLink.CentralUI/Components/Shared/ScriptParameterNames.cs +++ b/src/ScadaLink.CentralUI/Components/Shared/ScriptParameterNames.cs @@ -1,11 +1,12 @@ using System.Text.Json; +using ScadaLink.CentralUI.ScriptAnalysis; namespace ScadaLink.CentralUI.Components.Shared; /// /// Parses the parameter-definitions JSON written by ParameterListEditor and -/// returns the declared parameter names. Used by script-edit pages to feed -/// the Monaco editor's Parameters["..."] completion provider. +/// returns the declared parameter names (and shapes). Used by script-edit +/// pages to feed the Monaco editor's Parameters["..."] context. /// public static class ScriptParameterNames { @@ -26,4 +27,25 @@ public static class ScriptParameterNames return Array.Empty(); } } + + public static IReadOnlyList ParseShapes(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(); + } + } } diff --git a/src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj b/src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj index 15462a9..0bb1e18 100644 --- a/src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj +++ b/src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj @@ -13,6 +13,7 @@ + diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs index c7aa02d..46c55f6 100644 --- a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs @@ -41,7 +41,8 @@ public record HoverRequest( string CodeText, int Line, int Column, - IReadOnlyList? SiblingScripts = null); + IReadOnlyList? SiblingScripts = null, + IReadOnlyList? DeclaredParameters = null); public record HoverResponse(string? Markdown); @@ -70,3 +71,14 @@ public record ScriptShape( string? ReturnType); public record ParameterShape(string Name, string Type, bool Required); + +public record FormatRequest(string Code); +public record FormatResponse(string Code); + +public record InlayHintsRequest( + string Code, + IReadOnlyList? SiblingScripts = null); + +public record InlayHintsResponse(IReadOnlyList Hints); + +public record InlayHint(int Line, int Column, string Label); diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs index 459302c..e41d7ba 100644 --- a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs @@ -25,6 +25,12 @@ public static class ScriptAnalysisEndpoints group.MapPost("/signature-help", (SignatureHelpRequest req, ScriptAnalysisService svc) => Results.Ok(svc.SignatureHelp(req))); + group.MapPost("/format", (FormatRequest req, ScriptAnalysisService svc) => + Results.Ok(svc.Format(req))); + + group.MapPost("/inlay-hints", (InlayHintsRequest req, ScriptAnalysisService svc) => + Results.Ok(svc.InlayHints(req))); + return endpoints; } } diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs index 092fa00..e3ecf0f 100644 --- a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -4,7 +4,9 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; using Microsoft.CodeAnalysis.Scripting; +using Microsoft.CodeAnalysis.Text; using Microsoft.Extensions.Caching.Memory; namespace ScadaLink.CentralUI.ScriptAnalysis; @@ -104,6 +106,7 @@ public class ScriptAnalysisService markers.AddRange(FindForbiddenApiUsages(tree, model)); markers.AddRange(FindUnknownParameterKeys(tree, request.DeclaredParameters)); markers.AddRange(FindArgumentCountMismatches(tree, request.SiblingScripts)); + markers.AddRange(FindArgumentTypeMismatches(tree, request.SiblingScripts)); } return Cache(cacheKey, new DiagnoseResponse(markers)); @@ -256,6 +259,71 @@ public class ScriptAnalysisService InsertTextRules: insertRules); } + public FormatResponse Format(FormatRequest request) + { + if (string.IsNullOrEmpty(request.Code)) + return new FormatResponse(request.Code); + try + { + using var workspace = new AdhocWorkspace(); + var tree = CSharpSyntaxTree.ParseText( + request.Code, + new CSharpParseOptions(LanguageVersion.Latest, kind: SourceCodeKind.Script)); + var formatted = Formatter.Format(tree.GetRoot(), workspace); + return new FormatResponse(formatted.ToFullString()); + } + catch + { + return new FormatResponse(request.Code); + } + } + + public InlayHintsResponse InlayHints(InlayHintsRequest request) + { + if (string.IsNullOrEmpty(request.Code)) + return new InlayHintsResponse(Array.Empty()); + + var script = TryParse(request.Code); + if (script == null) return new InlayHintsResponse(Array.Empty()); + var (tree, _) = script.Value; + + IReadOnlyList? sharedShapes = null; + IReadOnlyList SharedShapes() => + sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult(); + + var hints = new List(); + foreach (var inv in tree.GetRoot().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) + : request.SiblingScripts?.FirstOrDefault(s => s.Name == scriptName); + if (shape == null) continue; + + for (var i = 1; i < inv.ArgumentList.Arguments.Count && i - 1 < shape.Parameters.Count; i++) + { + var arg = inv.ArgumentList.Arguments[i]; + var p = shape.Parameters[i - 1]; + var pos = arg.Span.Start; + var lineSpan = tree.GetLineSpan(new TextSpan(pos, 0)).Span; + hints.Add(new InlayHint( + Line: lineSpan.Start.Line + 1, + Column: lineSpan.Start.Character + 1, + Label: $"{p.Name}:")); + } + } + + return new InlayHintsResponse(hints); + } + public HoverResponse Hover(HoverRequest request) { var script = TryParse(request.CodeText); @@ -272,6 +340,23 @@ public class ScriptAnalysisService var argument = literalNode?.Parent as ArgumentSyntax; var argumentList = argument?.Parent; var owner = argumentList?.Parent; + + // Parameters["name"] → show declared type + if (owner is ElementAccessExpressionSyntax elem + && elem.Expression is IdentifierNameSyntax pid + && pid.Identifier.ValueText == "Parameters") + { + var key = token.ValueText; + var p = request.DeclaredParameters?.FirstOrDefault(x => x.Name == key); + if (p != null) + { + var req = p.Required ? "" : "?"; + return new HoverResponse( + $"**parameter** `{p.Name}: {p.Type}{req}`"); + } + return new HoverResponse(null); + } + if (owner is not InvocationExpressionSyntax inv) return new HoverResponse(null); var calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText; @@ -474,6 +559,112 @@ public class ScriptAnalysisService } } + private IEnumerable FindArgumentTypeMismatches(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; + + for (var i = 1; i < inv.ArgumentList.Arguments.Count && i - 1 < shape.Parameters.Count; i++) + { + var arg = inv.ArgumentList.Arguments[i].Expression; + var p = shape.Parameters[i - 1]; + var literalType = LiteralTypeOf(arg); + if (literalType == null) continue; // Not a literal we can check. + if (TypeAccepts(p.Type, literalType.Value)) continue; + var span = arg.GetLocation().GetLineSpan().Span; + 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: $"Argument {i} of {callee}('{scriptName}') expects {p.Type} but got {literalType}.", + Code: "SCADA005"); + } + } + } + + private enum LiteralKind { String, Integer, Float, Boolean, Null } + + private static LiteralKind? LiteralTypeOf(ExpressionSyntax expr) + { + if (expr is LiteralExpressionSyntax lit) + { + if (lit.IsKind(SyntaxKind.StringLiteralExpression)) return LiteralKind.String; + if (lit.IsKind(SyntaxKind.TrueLiteralExpression) || lit.IsKind(SyntaxKind.FalseLiteralExpression)) + return LiteralKind.Boolean; + if (lit.IsKind(SyntaxKind.NullLiteralExpression)) return LiteralKind.Null; + if (lit.IsKind(SyntaxKind.NumericLiteralExpression)) + { + var text = lit.Token.Text; + return text.Contains('.') || text.EndsWith("f", StringComparison.OrdinalIgnoreCase) + || text.EndsWith("d", StringComparison.OrdinalIgnoreCase) + ? LiteralKind.Float + : LiteralKind.Integer; + } + } + if (expr is InterpolatedStringExpressionSyntax) return LiteralKind.String; + return null; + } + + /// + /// True when a literal of is acceptable for a + /// parameter declared as . Object/List always + /// accept (we don't introspect collection literals); Null is acceptable + /// for any non-value type. + /// + private static bool TypeAccepts(string declared, LiteralKind literal) + { + var d = NormalizeDeclaredType(declared); + if (literal == LiteralKind.Null) return d is "Object" or "List" or "String"; + return d switch + { + "Boolean" => literal == LiteralKind.Boolean, + "Integer" => literal == LiteralKind.Integer, + "Float" => literal is LiteralKind.Float or LiteralKind.Integer, + "String" => literal == LiteralKind.String, + "Object" or "List" => true, + _ => true // unknown SCADA type — assume compatible + }; + } + + /// + /// Normalizes legacy / .NET type names from stored ParameterDefinitions + /// JSON to the canonical Inbound API set. Mirrors the frontend + /// ParameterListEditor's normalization so SCADA005 doesn't false-negative + /// on data still in the legacy shape. + /// + private static string NormalizeDeclaredType(string declared) => + declared.ToLowerInvariant() switch + { + "boolean" or "bool" => "Boolean", + "integer" or "int" or "int32" or "int64" or "int16" or "byte" + or "sbyte" or "uint32" or "uint64" or "uint16" => "Integer", + "float" or "double" or "single" or "decimal" => "Float", + "string" or "datetime" => "String", + "object" => "Object", + "list" => "List", + _ => declared + }; + private static IEnumerable FindForbiddenApiUsages(SyntaxTree tree, SemanticModel model) { var root = tree.GetRoot(); diff --git a/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js b/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js index 5e20b76..8dc86f3 100644 --- a/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js +++ b/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js @@ -38,7 +38,7 @@ // 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: [] }; + const empty = { declaredParameters: [], siblingScripts: [], declaredParameterShapes: [] }; for (const key in editors) { if (editors[key].editor.getModel() === model) { try { @@ -46,7 +46,8 @@ if (got) { return { declaredParameters: got.DeclaredParameters || got.declaredParameters || [], - siblingScripts: got.SiblingScripts || got.siblingScripts || [] + siblingScripts: got.SiblingScripts || got.siblingScripts || [], + declaredParameterShapes: got.DeclaredParameterShapes || got.declaredParameterShapes || [] }; } } catch (e) { /* fall through */ } @@ -139,7 +140,8 @@ codeText: model.getValue(), line: position.lineNumber, column: position.column, - siblingScripts: ctx.siblingScripts + siblingScripts: ctx.siblingScripts, + declaredParameters: ctx.declaredParameterShapes }) }); if (!resp.ok) return null; @@ -150,6 +152,58 @@ } }); + // ----- Document formatting ------------------------------------------ + monaco.languages.registerDocumentFormattingEditProvider("csharp", { + provideDocumentFormattingEdits: async function (model) { + try { + const resp = await fetch("/api/script-analysis/format", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: model.getValue() }) + }); + if (!resp.ok) return []; + const data = await resp.json(); + if (typeof data.code !== "string" || data.code === model.getValue()) return []; + return [{ + range: model.getFullModelRange(), + text: data.code + }]; + } catch (e) { return []; } + } + }); + + // ----- Inlay hints -------------------------------------------------- + monaco.languages.registerInlayHintsProvider("csharp", { + provideInlayHints: async function (model) { + try { + const ctx = await lookupContext(model); + const resp = await fetch("/api/script-analysis/inlay-hints", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code: model.getValue(), + siblingScripts: ctx.siblingScripts + }) + }); + if (!resp.ok) return { hints: [], dispose: function () {} }; + const data = await resp.json(); + return { + hints: (data.hints || []).map(function (h) { + return { + position: { lineNumber: h.line, column: h.column }, + label: h.label, + kind: 2, + paddingRight: true + }; + }), + dispose: function () {} + }; + } catch (e) { return { hints: [], dispose: function () {} }; } + } + }); + // ----- Signature help ------------------------------------------------ monaco.languages.registerSignatureHelpProvider("csharp", { signatureHelpTriggerCharacters: ["(", ","], @@ -237,6 +291,7 @@ if (!model) return; const markers = await fetchDiagnostics(model); monaco.editor.setModelMarkers(model, "scadalink", markers); + dotNetRef.invokeMethodAsync("OnMarkersChanged", markers).catch(function () {}); }, 500); }; @@ -251,6 +306,32 @@ if (options.language === "csharp") scheduleDiagnostics(); } + function setEditorOption(id, optionName, value) { + const entry = editors[id]; + if (!entry) return; + if (optionName === "theme") { + monaco.editor.setTheme(value); + return; + } + const update = {}; + update[optionName] = value; + entry.editor.updateOptions(update); + } + + function format(id) { + const entry = editors[id]; + if (!entry) return; + entry.editor.getAction("editor.action.formatDocument")?.run(); + } + + function revealLine(id, line, column) { + const entry = editors[id]; + if (!entry) return; + entry.editor.revealLineInCenter(line); + entry.editor.setPosition({ lineNumber: line, column: column || 1 }); + entry.editor.focus(); + } + function setValue(id, value) { const entry = editors[id]; if (!entry) return; @@ -284,6 +365,9 @@ setValue: setValue, getValue: getValue, setMarkers: setMarkers, + setEditorOption: setEditorOption, + format: format, + revealLine: revealLine, dispose: dispose }; })(); diff --git a/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs b/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs index 4bfd0f3..67fb93f 100644 --- a/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs @@ -289,4 +289,111 @@ public class ScriptAnalysisServiceTests Column: 5)); Assert.Null(resp.Label); } + + // ── Format ──────────────────────────────────────────────────────────── + + [Fact] + public void Format_ScrambledCode_ReturnsPrettyPrinted() + { + var resp = _svc.Format(new FormatRequest("if(x){return 1;}else{return 2;}")); + // Roslyn's default formatter adds spaces around keywords/braces. + Assert.Contains("if (x)", resp.Code); + Assert.NotEqual("if(x){return 1;}else{return 2;}", resp.Code); + } + + [Fact] + public void Format_EmptyCode_ReturnsEmpty() + { + Assert.Equal("", _svc.Format(new FormatRequest("")).Code); + } + + // ── Inlay hints ─────────────────────────────────────────────────────── + + [Fact] + public void InlayHints_OnCallScript_EmitsParameterLabels() + { + var siblings = new[] { Shape("Calc", Param("x"), Param("y")) }; + var resp = _svc.InlayHints(new InlayHintsRequest( + Code: "var r = CallScript(\"Calc\", 1, 2);", + SiblingScripts: siblings)); + Assert.Equal(2, resp.Hints.Count); + Assert.Equal("x:", resp.Hints[0].Label); + Assert.Equal("y:", resp.Hints[1].Label); + } + + [Fact] + public void InlayHints_OnUnknownSibling_Skipped() + { + var resp = _svc.InlayHints(new InlayHintsRequest( + Code: "var r = CallScript(\"NotKnown\", 1, 2);", + SiblingScripts: Array.Empty())); + Assert.Empty(resp.Hints); + } + + // ── Argument-type diagnostic (SCADA005) ─────────────────────────────── + + [Fact] + public void ArgumentTypeMismatch_StringExpectedIntegerGiven() + { + var siblings = new[] { Shape("Greet", Param("name", "String")) }; + var resp = _svc.Diagnose(new DiagnoseRequest( + Code: "var r = CallScript(\"Greet\", 42);", + SiblingScripts: siblings)); + Assert.Contains(resp.Markers, m => m.Code == "SCADA005" && m.Message.Contains("String")); + } + + [Fact] + public void ArgumentTypeMismatch_IntegerExpectedStringGiven() + { + var siblings = new[] { Shape("Calc", Param("n", "Integer")) }; + var resp = _svc.Diagnose(new DiagnoseRequest( + Code: "var r = CallScript(\"Calc\", \"oops\");", + SiblingScripts: siblings)); + Assert.Contains(resp.Markers, m => m.Code == "SCADA005"); + } + + [Fact] + public void ArgumentType_FloatAcceptsInteger() + { + var siblings = new[] { Shape("Calc", Param("ratio", "Float")) }; + var resp = _svc.Diagnose(new DiagnoseRequest( + Code: "var r = CallScript(\"Calc\", 1);", + SiblingScripts: siblings)); + Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA005"); + } + + [Fact] + public void ArgumentType_ObjectAcceptsAnyLiteral() + { + var siblings = new[] { Shape("Log", Param("v", "Object")) }; + var resp = _svc.Diagnose(new DiagnoseRequest( + Code: "var r = CallScript(\"Log\", 1); CallScript(\"Log\", \"x\"); CallScript(\"Log\", true);", + SiblingScripts: siblings)); + Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA005"); + } + + [Fact] + public void ArgumentType_NonLiteralExpression_SkipsCheck() + { + var siblings = new[] { Shape("Calc", Param("n", "Integer")) }; + var resp = _svc.Diagnose(new DiagnoseRequest( + Code: "var x = \"hi\"; var r = CallScript(\"Calc\", x);", + SiblingScripts: siblings)); + Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA005"); + } + + // ── Hover on Parameters["name"] ─────────────────────────────────────── + + [Fact] + public void Hover_OnParametersKey_ShowsDeclaredType() + { + var resp = _svc.Hover(new HoverRequest( + CodeText: "var x = Parameters[\"name\"];", + Line: 1, + Column: 22, + DeclaredParameters: new[] { new ParameterShape("name", "String", true) })); + Assert.NotNull(resp.Markdown); + Assert.Contains("name", resp.Markdown); + Assert.Contains("String", resp.Markdown); + } }