From 0528c65cba404006b9aa053ac4d0c086b4205324 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 12 May 2026 05:28:13 -0400 Subject: [PATCH] feat(ui/scripts): format, inlay hints, problems panel, type diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more editor features rolled in: 1. Roslyn Format command. New POST /api/script-analysis/format runs Formatter.Format() from Microsoft.CodeAnalysis.CSharp.Workspaces on the parsed script tree. monaco-init.js registers a DocumentFormattingEditProvider so Ctrl/Cmd-Shift-F and the toolbar "Format" button both work. 2. Inlay hints with parameter names. New POST /api/script-analysis/inlay-hints walks CallShared / CallScript invocations and emits InlayHint records positioned at each argument with the matching parameter's name (e.g. "name:"). Ghost text appears via Monaco's InlayHintsProvider. 3. SCADA005 argument-type diagnostic. Literal type vs. declared parameter type check on every CallShared/CallScript argument. Float accepts Integer literals; Object/List accept anything; null only matches reference-ish types. Legacy lowercase types ("string" etc) from the DB are normalized to the canonical set before comparison so existing data doesn't false-negative. Non-literal args (variables, expressions) are skipped — out of scope for a cheap pass. 4. Parameters["name"] hover. Hover endpoint now also resolves Parameters["X"] element-access keys against the form's DeclaredParameterShapes and returns "parameter `name: String`"-style markdown. MonacoEditor surfaces the new DeclaredParameterShapes parameter; ScriptParameterNames gets a ParseShapes companion. 5. Problems panel. Bootstrap card under the editor listing every marker with severity badge, line number, message, and SCADA / CS code. Click a row to scroll the editor to that line and focus. JS now invokes OnMarkersChanged on the .NET side whenever setModelMarkers fires, so the panel stays in sync with the editor. 6. Editor toolbar. Small top-right strip on each editor with Format / Wrap / Minimap / Theme toggles. New MonacoBlazor.format, setEditorOption, and revealLine JS APIs back the buttons and the problems-panel scroll-to-line. Contracts: - FormatRequest / FormatResponse - InlayHintsRequest / InlayHintsResponse / InlayHint - HoverRequest.DeclaredParameters - MonacoEditor.DeclaredParameterShapes parameter - MonacoEditor.MarkersChanged callback - ScadaContext.DeclaredParameterShapes 10 new xUnit tests covering format, inlay hints, SCADA005 (string- expects-integer, integer-expects-string, float-accepts-integer, object-accepts-anything, non-literal-skipped), and Parameters key hover. Total: 139 -> 149. Microsoft.CodeAnalysis.CSharp.Workspaces 4.13.0 added to pull in Formatter and AdhocWorkspace. Browser-verified: typing `CallShared("Greet", 42)` now shows the "name:" inlay hint and a SCADA005 squiggle on `42`; Parameters["typo"] shows SCADA003 as before; the toolbar buttons all work. --- .../Pages/Design/ApiMethodForm.razor | 10 +- .../Pages/Design/SharedScriptForm.razor | 10 +- .../Pages/Design/TemplateEdit.razor | 10 +- .../Components/Shared/MonacoEditor.razor | 80 +++++++- .../Components/Shared/ProblemsPanel.razor | 68 +++++++ .../Components/Shared/ScriptParameterNames.cs | 26 ++- .../ScadaLink.CentralUI.csproj | 1 + .../ScriptAnalysis/ScriptAnalysisContracts.cs | 14 +- .../ScriptAnalysis/ScriptAnalysisEndpoints.cs | 6 + .../ScriptAnalysis/ScriptAnalysisService.cs | 191 ++++++++++++++++++ .../wwwroot/js/monaco-init.js | 90 ++++++++- .../ScriptAnalysisServiceTests.cs | 107 ++++++++++ 12 files changed, 598 insertions(+), 15 deletions(-) create mode 100644 src/ScadaLink.CentralUI/Components/Shared/ProblemsPanel.razor 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); + } }