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