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: [] };