diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/ApiMethodForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/ApiMethodForm.razor index de204c8..0e2439f 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/ApiMethodForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/ApiMethodForm.razor @@ -38,7 +38,9 @@
- +
@if (_formError != null) diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScriptForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScriptForm.razor index 53905ab..06ca9f6 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScriptForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScriptForm.razor @@ -39,7 +39,9 @@
- +
@if (_formError != null) { diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor index 7801e28..c1247c6 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor @@ -654,7 +654,10 @@
- +
@if (_scriptFormError != null) { diff --git a/src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor b/src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor index 5777b6b..2a62ba5 100644 --- a/src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor +++ b/src/ScadaLink.CentralUI/Components/Shared/MonacoEditor.razor @@ -12,6 +12,18 @@ [Parameter] public string Height { get; set; } = "320px"; [Parameter] public bool ReadOnly { get; set; } = false; + /// + /// Parameter names declared on the form (from the ParameterListEditor), + /// surfaced as completions inside Parameters["..."] literals. + /// + [Parameter] public IReadOnlyList? DeclaredParameters { get; set; } + + /// + /// Names of other scripts on the same template, surfaced as completions + /// inside CallScript("...") literals. + /// + [Parameter] public IReadOnlyList? SiblingScripts { get; set; } + private ElementReference _hostRef; private DotNetObjectReference? _dotNetRef; private readonly string _id = Guid.NewGuid().ToString("N"); @@ -52,12 +64,21 @@ } [JSInvokable] - public async Task OnValueChanged(string newValue) + public Task OnValueChanged(string newValue) { _lastSentValue = newValue ?? ""; - await ValueChanged.InvokeAsync(_lastSentValue); + return ValueChanged.InvokeAsync(_lastSentValue); } + /// + /// 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. + /// + [JSInvokable] + public ScadaContext GetContext() => new( + DeclaredParameters?.ToArray() ?? Array.Empty(), + SiblingScripts?.ToArray() ?? Array.Empty()); + public async ValueTask DisposeAsync() { if (_initialized) @@ -66,4 +87,6 @@ } _dotNetRef?.Dispose(); } + + public record ScadaContext(string[] DeclaredParameters, string[] SiblingScripts); } diff --git a/src/ScadaLink.CentralUI/Components/Shared/ScriptParameterNames.cs b/src/ScadaLink.CentralUI/Components/Shared/ScriptParameterNames.cs new file mode 100644 index 0000000..d6c6e22 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Shared/ScriptParameterNames.cs @@ -0,0 +1,29 @@ +using System.Text.Json; + +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. +/// +public static class ScriptParameterNames +{ + public static IReadOnlyList Parse(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(e => e.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "") + .Where(s => !string.IsNullOrEmpty(s)) + .ToList(); + } + catch + { + return Array.Empty(); + } + } +} diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs index 71535b7..628df21 100644 --- a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisContracts.cs @@ -17,7 +17,12 @@ public record DiagnosticMarker( string Message, string Code); -public record CompletionsRequest(string CodeText, int Line, int Column); +public record CompletionsRequest( + string CodeText, + int Line, + int Column, + IReadOnlyList? DeclaredParameters = null, + IReadOnlyList? SiblingScripts = null); public record CompletionsResponse(IReadOnlyList Items); diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs index 611b72e..260f297 100644 --- a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs @@ -16,8 +16,8 @@ public static class ScriptAnalysisEndpoints group.MapPost("/diagnostics", (DiagnoseRequest req, ScriptAnalysisService svc) => Results.Ok(svc.Diagnose(req))); - group.MapPost("/completions", (CompletionsRequest req, ScriptAnalysisService svc) => - Results.Ok(svc.Complete(req))); + group.MapPost("/completions", async (CompletionsRequest req, ScriptAnalysisService svc) => + Results.Ok(await svc.CompleteAsync(req))); return endpoints; } diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs index 8c56141..4eaa35a 100644 --- a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -3,6 +3,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Scripting; +using ScadaLink.TemplateEngine; namespace ScadaLink.CentralUI.ScriptAnalysis; @@ -11,6 +12,12 @@ namespace ScadaLink.CentralUI.ScriptAnalysis; /// globals and surfaces diagnostics + completions /// in the shape Monaco's provider APIs expect. Lightweight — no caching; /// each request rebuilds the script. Acceptable for human-paced edits. +/// +/// Beyond plain C# analysis, layers SCADA-specific extensions: +/// - In-string completion of Parameters["..."] keys (from the request's +/// DeclaredParameters), CallShared("...") names (from SharedScriptService), +/// and CallScript("...") names (from the request's SiblingScripts). +/// - Forbidden-API diagnostic for the documented script trust model. /// public class ScriptAnalysisService { @@ -28,6 +35,34 @@ public class ScriptAnalysisService "System.Text", "System.Threading.Tasks"); + // Namespaces and types banned by the script trust model. + // Tasks live under System.Threading.Tasks and remain allowed. + private static readonly string[] ForbiddenNamespacePrefixes = + { + "System.IO", + "System.Diagnostics", + "System.Reflection", + "System.Net", + "System.Threading.Thread", + "System.Threading.Tasks.Sources", + }; + + private static readonly HashSet ForbiddenTypeNames = new(StringComparer.Ordinal) + { + "File", "Directory", "Path", "StreamReader", "StreamWriter", "FileStream", + "Process", "ProcessStartInfo", + "Assembly", "Type", "MethodInfo", "PropertyInfo", "FieldInfo", + "Socket", "TcpClient", "UdpClient", "TcpListener", + "Thread", "ThreadPool", "Mutex", "Semaphore", + }; + + private readonly SharedScriptService _sharedScripts; + + public ScriptAnalysisService(SharedScriptService sharedScripts) + { + _sharedScripts = sharedScripts; + } + public DiagnoseResponse Diagnose(DiagnoseRequest request) { if (string.IsNullOrEmpty(request.Code)) @@ -53,10 +88,16 @@ public class ScriptAnalysisService .Select(ToMarker) .ToList(); + var tree = compilation.SyntaxTrees.FirstOrDefault(); + if (tree != null) + { + markers.AddRange(FindForbiddenApiUsages(tree)); + } + return new DiagnoseResponse(markers); } - public CompletionsResponse Complete(CompletionsRequest request) + public async Task CompleteAsync(CompletionsRequest request) { if (string.IsNullOrEmpty(request.CodeText)) return new CompletionsResponse(Array.Empty()); @@ -82,7 +123,13 @@ public class ScriptAnalysisService var root = tree.GetRoot(); var token = root.FindToken(Math.Max(0, position - 1)); - // Dot completion: look up members of the type on the left of the dot. + // SCADA-specific string-literal completions take priority over plain C# + // because they're the actually useful suggestions inside those literals. + var stringMatches = await TryStringLiteralCompletions(token, request); + if (stringMatches != null) + return new CompletionsResponse(stringMatches); + + // Dot completion: members of the type on the left of the dot. var dotMembers = TryGetDotMembers(token, semanticModel); if (dotMembers != null) return new CompletionsResponse(dotMembers); @@ -99,10 +146,62 @@ public class ScriptAnalysisService return new CompletionsResponse(scoped); } + private async Task?> TryStringLiteralCompletions( + SyntaxToken token, CompletionsRequest request) + { + // The token at the cursor must be (or be adjacent to) a string literal. + var literal = token.IsKind(SyntaxKind.StringLiteralToken) + ? token + : token.GetPreviousToken().IsKind(SyntaxKind.StringLiteralToken) + ? token.GetPreviousToken() + : default; + if (literal == default) return null; + + // Token tree shape: StringLiteralToken → LiteralExpression → Argument → + // (ArgumentList | BracketedArgumentList) → invocation or element-access. + var argument = literal.Parent?.Parent as ArgumentSyntax; + var argumentList = argument?.Parent; + var owner = argumentList?.Parent; + + // Parameters["..."] + if (owner is ElementAccessExpressionSyntax elem + && elem.Expression is IdentifierNameSyntax id + && id.Identifier.ValueText == "Parameters") + { + return (request.DeclaredParameters ?? Array.Empty()) + .Distinct() + .Select(n => new CompletionItem(n, n, "declared parameter", "Variable")) + .ToList(); + } + + // CallShared("...") / CallScript("...") + if (owner is InvocationExpressionSyntax inv) + { + var calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText + ?? (inv.Expression as MemberAccessExpressionSyntax)?.Name.Identifier.ValueText; + + if (calleeName == "CallShared") + { + var scripts = await _sharedScripts.GetAllSharedScriptsAsync(); + return scripts + .Select(s => new CompletionItem(s.Name, s.Name, "shared script", "Method")) + .ToList(); + } + + if (calleeName == "CallScript") + { + return (request.SiblingScripts ?? Array.Empty()) + .Distinct() + .Select(n => new CompletionItem(n, n, "sibling script", "Method")) + .ToList(); + } + } + + return null; + } + private static List? TryGetDotMembers(SyntaxToken token, SemanticModel model) { - // The cursor may be positioned right after a '.'; resolve the - // member-access node and look up the left-hand side's type members. var memberAccess = token.Parent as MemberAccessExpressionSyntax ?? token.GetPreviousToken().Parent as MemberAccessExpressionSyntax; if (memberAccess == null) return null; @@ -121,6 +220,57 @@ public class ScriptAnalysisService .ToList(); } + private static IEnumerable FindForbiddenApiUsages(SyntaxTree tree) + { + var root = tree.GetRoot(); + + // Banned using directives. + foreach (var u in root.DescendantNodes().OfType()) + { + var name = u.Name?.ToString() ?? ""; + if (ForbiddenNamespacePrefixes.Any(p => name == p || name.StartsWith(p + "."))) + { + var span = u.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: $"Forbidden namespace '{name}' is not allowed in scripts (script trust model).", + Code: "SCADA001"); + } + } + + // Banned type identifiers (e.g., new Process(), File.ReadAllText, etc.). + // Note: this is a name-based heuristic — false positives are possible for + // user identifiers that happen to share names with forbidden types. + foreach (var ident in root.DescendantNodes().OfType()) + { + var name = ident.Identifier.ValueText; + if (ForbiddenTypeNames.Contains(name)) + { + // Filter: only flag when used as a type or as a member-access target. + var parent = ident.Parent; + var isTypeOrAccess = + parent is MemberAccessExpressionSyntax m && m.Expression == ident || + parent is QualifiedNameSyntax || + parent is ObjectCreationExpressionSyntax; + if (!isTypeOrAccess) continue; + + var span = ident.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: $"Type '{name}' is forbidden in scripts (script trust model).", + Code: "SCADA002"); + } + } + } + private static CompletionItem ToCompletionItem(ISymbol symbol) { var kind = symbol.Kind switch diff --git a/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs b/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs index 5bb8069..783f70f 100644 --- a/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.CentralUI/ServiceCollectionExtensions.cs @@ -24,7 +24,8 @@ public static class ServiceCollectionExtensions services.AddScoped(); // Roslyn-backed C# analysis for the Monaco script editor. - services.AddSingleton(); + // Scoped because SharedScriptService (a dependency) is scoped. + services.AddScoped(); return services; } diff --git a/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js b/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js index 355976c..40a75a7 100644 --- a/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js +++ b/src/ScadaLink.CentralUI/wwwroot/js/monaco-init.js @@ -41,6 +41,25 @@ 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 resp = await fetch("/api/script-analysis/completions", { method: "POST", credentials: "same-origin", @@ -48,7 +67,9 @@ body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, - column: position.column + column: position.column, + declaredParameters: ctx.declaredParameters, + siblingScripts: ctx.siblingScripts }) }); if (!resp.ok) return { suggestions: [] };