Files
ScadaBridge/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs
T
Joseph Doherty 295150751f feat(scripts): realign Test Run with runtime API, add anonymous-object calls and instance binding
The Test Run sandbox and Monaco analysis modelled a script API that had
drifted from the site runtime's ScriptGlobals, so real scripts failed to
compile in Test Run. Realign both to the runtime surface
(Instance/Scripts/ExternalSystem/Attributes/Children/Parent) and drop the
duplicate ScriptHost stub so the two cannot diverge again.

- Script calls (Scripts.CallShared, Instance.CallScript, Route.To().Call)
  accept an anonymous object instead of a hand-built dictionary, via a
  shared ScriptArgs normalizer; existing dictionary calls still compile.
- Test Run can optionally bind to a deployed instance, so Instance/
  Attributes/CallScript route to it cross-site; adds site-side
  RouteToGetAttributes/RouteToSetAttributes handlers.
- Adds Test Run panels to the API method and template script editors.
- Fixes the TestDatabaseQuery seed script, which queried a table that
  never existed.

Also commits unrelated in-progress work already in the tree: the health
monitoring report loop, site streaming changes, and the Admin/Design
data-connection and SMTP page reorganization.
2026-05-16 03:37:56 -04:00

1194 lines
52 KiB
C#

using System.Diagnostics;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.CodeAnalysis.Text;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using ScadaLink.Commons.Interfaces.Services;
namespace ScadaLink.CentralUI.ScriptAnalysis;
/// <summary>
/// Compiles user scripts as Roslyn C# Scripting fragments against
/// <see cref="SandboxScriptHost"/> globals (template/shared) or
/// <see cref="InboundScriptHost"/> (inbound API) and surfaces diagnostics +
/// completions in the shape Monaco's provider APIs expect.
///
/// Diagnostics are cached by code hash via IMemoryCache — Monaco debounces
/// keystrokes at 500 ms but a typing-then-pausing flow can still re-issue
/// requests for the same content (window blur/focus, etc.), so the cache
/// short-circuits repeats. Completions aren't cached: position + form
/// context vary too much for the hit rate to be useful.
///
/// Beyond plain C# analysis, layers SCADA-specific extensions:
/// - In-string completion of Parameters["..."] keys (from the request's
/// DeclaredParameters), Scripts.CallShared("...") names (from
/// <see cref="ISharedScriptCatalog"/>), and Instance.CallScript("...") /
/// Children["X"].CallScript("...") / Parent.CallScript("...") names
/// (from the request's SiblingScripts / Children / Parent).
/// - Forbidden-API diagnostic for the documented script trust model,
/// resolved against the SemanticModel so user identifiers that happen
/// to share names with forbidden types (e.g. <c>var File = ...</c>)
/// do not false-positive.
/// </summary>
public class ScriptAnalysisService
{
private static readonly ScriptOptions DefaultOptions = ScriptOptions.Default
.AddReferences(
typeof(object).Assembly,
typeof(Enumerable).Assembly,
typeof(System.Collections.Generic.Dictionary<,>).Assembly,
typeof(System.ComponentModel.DescriptionAttribute).Assembly,
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
typeof(Commons.Types.ScriptParameters).Assembly,
typeof(SandboxScriptHost).Assembly)
.AddImports(
"System",
"System.Collections.Generic",
"System.Linq",
"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 readonly ISharedScriptCatalog _sharedScripts;
private readonly IMemoryCache _cache;
private readonly IServiceProvider _services;
public ScriptAnalysisService(
ISharedScriptCatalog sharedScripts,
IMemoryCache cache,
IServiceProvider services)
{
_sharedScripts = sharedScripts;
_cache = cache;
_services = services;
}
/// <summary>Globals type a script of the given kind is compiled against.</summary>
private static Type GlobalsTypeFor(ScriptKind kind) =>
kind == ScriptKind.InboundApi ? typeof(InboundScriptHost) : typeof(SandboxScriptHost);
/// <summary>
/// Re-enables the nullable annotation context for an analysis compilation.
/// Roslyn scripting defaults to a disabled nullable context, which makes any
/// <c>?</c> annotation in a user script raise CS8632. Annotations-only keeps
/// <c>string?</c> legal without surfacing the nullable-flow warnings.
/// </summary>
private static Compilation WithNullableAnnotations(Compilation compilation) =>
compilation is CSharpCompilation cs
? cs.WithOptions(cs.Options.WithNullableContextOptions(NullableContextOptions.Annotations))
: compilation;
public DiagnoseResponse Diagnose(DiagnoseRequest request)
{
if (string.IsNullOrEmpty(request.Code))
return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
var cacheKey = "diag:" + (int)request.Kind + ":" + HashCode(request.Code);
if (_cache.TryGetValue(cacheKey, out DiagnoseResponse? cached) && cached is not null)
return cached;
Script<object> script;
try
{
script = CSharpScript.Create(request.Code, DefaultOptions, globalsType: GlobalsTypeFor(request.Kind));
}
catch (Exception ex)
{
var failure = new DiagnoseResponse(new[]
{
new DiagnosticMarker(8, 1, 1, 1, 2, ex.Message, "SCRIPT_BUILD")
});
return Cache(cacheKey, failure);
}
var compilation = WithNullableAnnotations(script.GetCompilation());
var markers = compilation
.GetDiagnostics()
.Where(d => d.Severity >= DiagnosticSeverity.Info && d.Location.IsInSource)
.Select(ToMarker)
.ToList();
var tree = compilation.SyntaxTrees.FirstOrDefault();
if (tree != null)
{
var model = compilation.GetSemanticModel(tree);
markers.AddRange(FindForbiddenApiUsages(tree, model));
markers.AddRange(FindUnknownParameterKeys(tree, request.DeclaredParameters));
markers.AddRange(FindUnknownAttributeKeys(tree, request));
markers.AddRange(FindUnknownChildren(tree, request.Children));
}
return Cache(cacheKey, new DiagnoseResponse(markers));
}
private const int SandboxMaxTimeoutSeconds = 10;
private const int SandboxDefaultTimeoutSeconds = 5;
private const int SandboxMaxConsoleChars = 32_000;
private const int SandboxMaxReturnJsonChars = 32_000;
private const int SandboxMaxCallSharedDepth = 16;
/// <summary>
/// Compiles and runs a script in the central process. The globals surface
/// depends on <see cref="SandboxRunRequest.Kind"/>: template and shared
/// scripts run against <see cref="SandboxScriptHost"/>, inbound API method
/// scripts against <see cref="SandboxInboundScriptHost"/>.
/// Pure logic + the supplied Parameters always work.
/// For the SandboxScriptHost surface, <c>Attributes</c> still throws while
/// <c>External</c>, <c>Database</c>, and <c>Notify</c> are wired to
/// central's real <see cref="IExternalSystemClient"/>,
/// <see cref="IDatabaseGateway"/>, and
/// <see cref="INotificationDeliveryService"/> — calls fire for real and
/// have production-equivalent side effects (HTTP, SQL, SMTP).
/// <c>CallShared</c> compiles and executes the named shared script in the
/// same sandbox, with a recursion limit of
/// <see cref="SandboxMaxCallSharedDepth"/>. <c>CallScript</c> still throws
/// because a shared script has no template siblings in this context.
/// For the SandboxInboundScriptHost surface, every <c>Route</c> call throws
/// because cross-site routing needs a deployed site.
/// Console.Out / Console.Error are redirected per-call so writes from
/// the script land in the result.
/// </summary>
public async Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Code))
{
return new SandboxRunResult(
Success: false,
ReturnValueJson: null,
ReturnTypeName: null,
ConsoleOutput: "",
Error: "Script code is empty.",
ErrorKind: SandboxErrorKind.CompileError,
DurationMs: 0,
Markers: Array.Empty<DiagnosticMarker>());
}
var timeoutSeconds = Math.Clamp(
request.TimeoutSeconds ?? SandboxDefaultTimeoutSeconds,
1, SandboxMaxTimeoutSeconds);
var options = DefaultOptions.WithReferences(DefaultOptions.MetadataReferences.Concat(new[]
{
Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(SandboxScriptHost).Assembly.Location)
}));
var globalsType = request.Kind == ScriptKind.InboundApi
? typeof(SandboxInboundScriptHost)
: typeof(SandboxScriptHost);
Script<object> script;
try
{
script = CSharpScript.Create(request.Code, options, globalsType: globalsType);
}
catch (Exception ex)
{
return new SandboxRunResult(false, null, null, "", ex.Message,
SandboxErrorKind.CompileError, 0,
new[] { new DiagnosticMarker(8, 1, 1, 1, 2, ex.Message, "SCRIPT_BUILD") });
}
var compileDiagnostics = script.Compile(ct);
var errorDiagnostics = compileDiagnostics
.Where(d => d.Severity == DiagnosticSeverity.Error && d.Location.IsInSource)
.ToList();
if (errorDiagnostics.Count > 0)
{
var markers = errorDiagnostics.Select(ToMarker).ToList();
return new SandboxRunResult(false, null, null, "",
string.Join("\n", errorDiagnostics.Select(d => d.GetMessage())),
SandboxErrorKind.CompileError, 0, markers);
}
var parameters = ConvertJsonParameters(request.Parameters);
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
// Optional instance binding: when the Test Run targets a deployed
// instance, Instance.GetAttribute/SetAttribute/CallScript and the
// Attributes/Children/Parent accessors route to it cross-site.
ISandboxInstanceGateway? instanceGateway = null;
var instanceLabel = "test-run";
if (request.Kind != ScriptKind.InboundApi
&& !string.IsNullOrWhiteSpace(request.BindInstanceUniqueName))
{
var bindName = request.BindInstanceUniqueName.Trim();
var locator = _services.GetService<IInstanceLocator>();
var comms = _services.GetService<ScadaLink.Communication.CommunicationService>();
if (locator == null || comms == null)
return new SandboxRunResult(false, null, null, "",
"Instance binding is unavailable — cross-site communication is not configured on this node.",
SandboxErrorKind.SandboxLimitation, 0, null);
var siteId = await locator.GetSiteIdForInstanceAsync(bindName, ct);
if (siteId == null)
return new SandboxRunResult(false, null, null, "",
$"Cannot bind to instance '{bindName}' — it is not deployed or has no assigned site.",
SandboxErrorKind.SandboxLimitation, 0, null);
instanceGateway = new SandboxInstanceGateway(comms, siteId, bindName, linkedCts.Token);
instanceLabel = bindName;
}
var externalClient = _services.GetService<IExternalSystemClient>();
var databaseGateway = _services.GetService<IDatabaseGateway>();
var notifyService = _services.GetService<INotificationDeliveryService>();
var external = new SandboxExternalHelper(externalClient, instanceLabel);
var database = new SandboxDatabaseHelper(databaseGateway, instanceLabel);
var notify = new SandboxNotifyHelper(notifyService, instanceLabel);
var compileCache = new Dictionary<string, Script<object>>(StringComparer.Ordinal);
var compileCacheLock = new object();
var depth = 0;
Func<string, IReadOnlyDictionary<string, object?>?, CancellationToken, Task<object?>>? callSharedFunc = null;
// Scripts.CallShared and the Instance helpers share one context across
// the root script and any nested shared scripts — mirroring the site
// runtime, where a shared script runs against the caller's Instance.
var scriptsHelper = new SandboxScriptCallHelper(
(name, ps, nestedCt) => callSharedFunc!(name, ps, nestedCt));
var instanceContext = new SandboxInstanceContext(
gateway: instanceGateway,
external: external,
database: database,
notify: notify,
scripts: scriptsHelper);
callSharedFunc = async (name, ps, nestedCt) =>
{
if (string.IsNullOrEmpty(name))
throw new ScriptSandboxException("Scripts.CallShared called with an empty script name.");
if (depth >= SandboxMaxCallSharedDepth)
throw new ScriptSandboxException(
$"Scripts.CallShared(\"{name}\") exceeded the sandbox recursion limit of {SandboxMaxCallSharedDepth} nested calls.");
Script<object>? compiled;
lock (compileCacheLock) compileCache.TryGetValue(name, out compiled);
if (compiled == null)
{
var src = await _sharedScripts.GetByNameAsync(name, nestedCt);
if (src == null)
throw new ScriptSandboxException(
$"Scripts.CallShared(\"{name}\") — no shared script with that name is registered in central.");
Script<object> built;
try
{
built = CSharpScript.Create(src.Code, options, globalsType: typeof(SandboxScriptHost));
}
catch (Exception ex)
{
throw new ScriptSandboxException($"Scripts.CallShared(\"{name}\") compile failed: {ex.Message}");
}
var nestedDiag = built.Compile(nestedCt);
var nestedErrors = nestedDiag
.Where(d => d.Severity == DiagnosticSeverity.Error && d.Location.IsInSource)
.ToList();
if (nestedErrors.Count > 0)
throw new ScriptSandboxException(
$"Scripts.CallShared(\"{name}\") compile failed: {string.Join("; ", nestedErrors.Select(d => d.GetMessage()))}");
lock (compileCacheLock)
{
if (!compileCache.TryGetValue(name, out compiled))
{
compileCache[name] = built;
compiled = built;
}
}
}
var nestedHost = new SandboxScriptHost
{
Parameters = new Commons.Types.ScriptParameters(ps ?? new Dictionary<string, object?>()),
CancellationToken = nestedCt,
Instance = instanceContext,
};
Interlocked.Increment(ref depth);
try
{
var nestedState = await compiled!.RunAsync(nestedHost, nestedCt).ConfigureAwait(false);
return nestedState.ReturnValue;
}
finally
{
Interlocked.Decrement(ref depth);
}
};
// Inbound API scripts see a different globals surface (Parameters +
// Route); template and shared scripts see the SandboxScriptHost surface
// mirroring the site runtime's ScriptGlobals.
object host = request.Kind == ScriptKind.InboundApi
? new SandboxInboundScriptHost
{
Parameters = new Commons.Types.ScriptParameters(parameters),
CancellationToken = linkedCts.Token,
}
: new SandboxScriptHost
{
Parameters = new Commons.Types.ScriptParameters(parameters),
CancellationToken = linkedCts.Token,
Instance = instanceContext,
};
var originalOut = Console.Out;
var originalError = Console.Error;
var captured = new StringWriter();
var stopwatch = Stopwatch.StartNew();
try
{
Console.SetOut(captured);
Console.SetError(captured);
// Run on a thread-pool thread with no SynchronizationContext: a
// bound script's Instance.SetAttribute / Attributes[...] block
// synchronously on cross-site I/O (the API surface is sync by
// contract), which would deadlock against the Blazor circuit's
// captured context if the script ran inline.
var state = await Task.Run(
() => script.RunAsync(host, linkedCts.Token), linkedCts.Token)
.ConfigureAwait(false);
stopwatch.Stop();
var (returnJson, returnType) = SerializeReturn(state.ReturnValue);
return new SandboxRunResult(
Success: true,
ReturnValueJson: returnJson,
ReturnTypeName: returnType,
ConsoleOutput: TruncateConsole(captured.ToString()),
Error: null,
ErrorKind: SandboxErrorKind.None,
DurationMs: stopwatch.ElapsedMilliseconds,
Markers: null);
}
catch (ScriptSandboxException sandboxEx)
{
stopwatch.Stop();
return new SandboxRunResult(false, null, null,
TruncateConsole(captured.ToString()), sandboxEx.Message,
SandboxErrorKind.SandboxLimitation, stopwatch.ElapsedMilliseconds, null);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
stopwatch.Stop();
return new SandboxRunResult(false, null, null,
TruncateConsole(captured.ToString()),
$"Script execution exceeded the {timeoutSeconds}-second sandbox timeout.",
SandboxErrorKind.Timeout, stopwatch.ElapsedMilliseconds, null);
}
catch (Exception ex)
{
stopwatch.Stop();
var inner = ex is Microsoft.CodeAnalysis.Scripting.CompilationErrorException ? ex : (ex.InnerException ?? ex);
if (inner is ScriptSandboxException sx)
{
return new SandboxRunResult(false, null, null,
TruncateConsole(captured.ToString()), sx.Message,
SandboxErrorKind.SandboxLimitation, stopwatch.ElapsedMilliseconds, null);
}
return new SandboxRunResult(false, null, null,
TruncateConsole(captured.ToString()),
$"{inner.GetType().Name}: {inner.Message}",
SandboxErrorKind.RuntimeError, stopwatch.ElapsedMilliseconds, null);
}
finally
{
Console.SetOut(originalOut);
Console.SetError(originalError);
}
}
private static Dictionary<string, object?> ConvertJsonParameters(
Dictionary<string, JsonElement>? parameters)
{
var result = new Dictionary<string, object?>(StringComparer.Ordinal);
if (parameters == null) return result;
foreach (var (key, value) in parameters)
{
result[key] = JsonElementToObject(value);
}
return result;
}
private static object? JsonElementToObject(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => element.TryGetInt64(out var i) ? (object)i : element.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
JsonValueKind.Undefined => null,
JsonValueKind.Array => element.EnumerateArray().Select(JsonElementToObject).ToList(),
JsonValueKind.Object => element.EnumerateObject()
.ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)),
_ => null
};
}
private static (string Json, string TypeName) SerializeReturn(object? value)
{
if (value == null) return ("null", "null");
var typeName = value.GetType().Name;
try
{
var json = JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true });
if (json.Length > SandboxMaxReturnJsonChars)
json = json[..SandboxMaxReturnJsonChars] + "\n… (truncated)";
return (json, typeName);
}
catch (Exception ex)
{
return ($"\"<unserializable: {ex.Message}>\"", typeName);
}
}
private static string TruncateConsole(string text)
{
if (text.Length <= SandboxMaxConsoleChars) return text;
return text[..SandboxMaxConsoleChars] + "\n… (truncated)";
}
private DiagnoseResponse Cache(string key, DiagnoseResponse value)
{
_cache.Set(key, value, new MemoryCacheEntryOptions
{
Size = 1,
SlidingExpiration = TimeSpan.FromMinutes(5)
});
return value;
}
private static string HashCode(string code)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(code));
return Convert.ToHexString(bytes);
}
public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest request)
{
if (string.IsNullOrEmpty(request.CodeText))
return new CompletionsResponse(Array.Empty<CompletionItem>());
Script<object> script;
try
{
script = CSharpScript.Create(request.CodeText, DefaultOptions, globalsType: GlobalsTypeFor(request.Kind));
}
catch
{
return new CompletionsResponse(Array.Empty<CompletionItem>());
}
var compilation = script.GetCompilation();
var tree = compilation.SyntaxTrees.FirstOrDefault();
if (tree == null) return new CompletionsResponse(Array.Empty<CompletionItem>());
var semanticModel = compilation.GetSemanticModel(tree);
var position = PositionToOffset(request.CodeText, request.Line, request.Column);
position = Math.Clamp(position, 0, request.CodeText.Length);
var root = tree.GetRoot();
var token = root.FindToken(Math.Max(0, position - 1));
// 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);
// General completion: in-scope symbols at position.
var scoped = semanticModel.LookupSymbols(position)
.Where(s => !s.IsImplicitlyDeclared && !string.IsNullOrEmpty(s.Name))
.GroupBy(s => s.Name)
.Select(g => g.First())
.Select(ToCompletionItem)
.Take(200)
.ToList();
return new CompletionsResponse(scoped);
}
private async Task<List<CompletionItem>?> 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<string>())
.Distinct()
.Select(n => new CompletionItem(n, n, "declared parameter", "Variable"))
.ToList();
}
// Attributes["..."] / Children["X"].Attributes["..."] / Parent.Attributes["..."]
if (owner is ElementAccessExpressionSyntax attrElem)
{
var ctx = ClassifyAttributeContext(
attrElem,
request.Children ?? Array.Empty<CompositionContext>(),
request.Parent);
if (ctx.Kind != AttributeContextKind.None)
{
IReadOnlyList<AttributeShape> source = ctx.Kind switch
{
AttributeContextKind.Self => request.SelfAttributes ?? Array.Empty<AttributeShape>(),
AttributeContextKind.Child => ctx.Composition!.Attributes,
AttributeContextKind.Parent => request.Parent?.Attributes ?? Array.Empty<AttributeShape>(),
_ => Array.Empty<AttributeShape>()
};
var label = ctx.Kind switch
{
AttributeContextKind.Self => "attribute",
AttributeContextKind.Child => $"attribute on {ctx.Composition!.Name}",
AttributeContextKind.Parent => "parent attribute",
_ => "attribute"
};
return source.Select(a =>
new CompletionItem(a.Name, a.Name, $"{label}: {a.Type}", "Field")).ToList();
}
// Children["..."] → suggest composition names
if (attrElem.Expression is IdentifierNameSyntax cid && cid.Identifier.ValueText == "Children")
{
return (request.Children ?? Array.Empty<CompositionContext>())
.Select(c => new CompletionItem(c.Name, c.Name, "composition", "Class"))
.ToList();
}
}
// Scripts.CallShared("...") / Instance.CallScript("...") /
// Children["X"].CallScript("...") / Parent.CallScript("...")
if (owner is InvocationExpressionSyntax inv)
{
var call = ClassifyScriptCall(inv);
switch (call.Kind)
{
case ScriptCallKind.Shared:
{
var shapes = await _sharedScripts.GetShapesAsync();
return shapes.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
}
case ScriptCallKind.Sibling:
return (request.SiblingScripts ?? Array.Empty<ScriptShape>())
.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
case ScriptCallKind.Parent:
return (request.Parent?.Scripts ?? Array.Empty<ScriptShape>())
.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
case ScriptCallKind.Child:
{
var comp = (request.Children ?? Array.Empty<CompositionContext>())
.FirstOrDefault(c => c.Name == call.CompositionName);
return comp != null
? comp.Scripts.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList()
: new List<CompletionItem>();
}
}
}
return null;
}
/// <summary>
/// Builds a Monaco snippet that fills the call after the name, e.g.
/// <c>Greet", new { name = ${1:name}, count = ${2:count} })</c>. The JS
/// provider extends the completion range over the auto-closed <c>")</c> if
/// Monaco inserted one, so the snippet replaces the rest of the call cleanly.
/// </summary>
private static CompletionItem MakeCallCompletion(ScriptShape shape, string detail)
{
// The runtime call API takes the arguments as an anonymous object; the
// snippet emits one member per declared parameter.
string insertText;
const int insertAsSnippet = 4;
if (shape.Parameters.Count == 0)
{
insertText = shape.Name + "\")";
}
else
{
var entries = string.Join(", ", shape.Parameters.Select((p, i) =>
$"{p.Name} = ${{{i + 1}:{p.Name}}}"));
insertText = $"{shape.Name}\", new {{ {entries} }})";
}
var paramList = string.Join(", ", shape.Parameters.Select(p => $"{p.Name}: {p.Type}"));
var returnType = shape.ReturnType ?? "void";
return new CompletionItem(
Label: shape.Name,
InsertText: insertText,
Detail: $"{detail} ({paramList}) -> {returnType}",
Kind: "Method",
InsertTextRules: insertAsSnippet);
}
public FormatResponse Format(FormatRequest request)
{
if (string.IsNullOrEmpty(request.Code))
return new FormatResponse(request.Code);
try
{
var tree = CSharpSyntaxTree.ParseText(
request.Code,
new CSharpParseOptions(LanguageVersion.Latest, kind: SourceCodeKind.Script));
// NormalizeWhitespace produces canonical layout (indentation + line
// breaks). Formatter.Format alone with an empty workspace only
// normalizes inter-token spacing — it won't split crammed lines.
var formatted = tree.GetRoot().NormalizeWhitespace(indentation: " ", eol: "\n");
return new FormatResponse(formatted.ToFullString());
}
catch
{
return new FormatResponse(request.Code);
}
}
/// <summary>
/// Parameter-name inlay hints are obsolete under the runtime call API:
/// Scripts.CallShared / Instance.CallScript pass arguments as an explicit
/// <c>IReadOnlyDictionary</c> literal (<c>{ ["p"] = … }</c>), which is
/// already self-labelling — there are no positional arguments to annotate.
/// </summary>
public InlayHintsResponse InlayHints(InlayHintsRequest request) =>
new(Array.Empty<InlayHint>());
public HoverResponse Hover(HoverRequest request)
{
var script = TryParse(request.CodeText);
if (script == null) return new HoverResponse(null);
var (tree, _) = script.Value;
var position = PositionToOffset(request.CodeText, request.Line, request.Column);
position = Math.Clamp(position, 0, request.CodeText.Length);
var root = tree.GetRoot();
var token = root.FindToken(Math.Max(0, position - 1));
if (!token.IsKind(SyntaxKind.StringLiteralToken)) return new HoverResponse(null);
var literalNode = token.Parent as LiteralExpressionSyntax;
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 call = ClassifyScriptCall(inv);
if (call.Kind == ScriptCallKind.None) return new HoverResponse(null);
var rawName = token.ValueText;
if (string.IsNullOrEmpty(rawName)) return new HoverResponse(null);
var shape = ResolveCalledShape(
call, rawName, request.SiblingScripts, request.Children, request.Parent);
if (shape == null) return new HoverResponse(null);
return new HoverResponse(FormatHover(shape, call));
}
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest request)
{
var empty = new SignatureHelpResponse(null, null, 0);
var script = TryParse(request.CodeText);
if (script == null) return empty;
var (tree, _) = script.Value;
var position = PositionToOffset(request.CodeText, request.Line, request.Column);
position = Math.Clamp(position, 0, request.CodeText.Length);
var root = tree.GetRoot();
var token = root.FindToken(Math.Max(0, position - 1));
// Walk up to the nearest enclosing InvocationExpression. Don't require
// ArgumentList.Span to strictly contain the cursor — for an incomplete
// call like CallScript("Calc", 1, ) the span ends before trailing
// whitespace, so a strict contains-check would miss it.
InvocationExpressionSyntax? inv = null;
for (var node = token.Parent; node != null; node = node.Parent)
{
if (node is InvocationExpressionSyntax candidate)
{
inv = candidate;
break;
}
}
if (inv == null) return empty;
var call = ClassifyScriptCall(inv);
if (call.Kind == ScriptCallKind.None) return empty;
// First argument is the name literal; pull it out.
if (inv.ArgumentList.Arguments.Count < 1) return empty;
var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
var scriptName = nameArg?.Token.ValueText ?? "";
var shape = ResolveCalledShape(
call, scriptName, request.SiblingScripts, request.Children, request.Parent);
if (shape == null) return empty;
var paramLabels = shape.Parameters.Select(p => $"{p.Name}: {p.Type}").ToList();
var label = $"{CallLabel(call)}(\"{shape.Name}\"" +
(paramLabels.Count > 0 ? ", " + string.Join(", ", paramLabels) : "") + ")";
// ActiveParameter: count commas in ArgumentList before the cursor; subtract 1 because
// the first arg is the name literal.
int activeIndex = 0;
foreach (var arg in inv.ArgumentList.Arguments)
{
if (arg.Span.End < position) activeIndex++;
else break;
}
activeIndex = Math.Clamp(activeIndex - 1, 0, Math.Max(0, paramLabels.Count - 1));
return new SignatureHelpResponse(
Label: label,
Parameters: paramLabels
.Select((lbl, i) => new SignatureHelpParameter(lbl, shape.Parameters[i].Required ? null : "optional"))
.ToList(),
ActiveParameter: activeIndex);
}
private (SyntaxTree tree, Compilation compilation)? TryParse(string code)
{
if (string.IsNullOrEmpty(code)) return null;
try
{
var s = CSharpScript.Create(code, DefaultOptions, globalsType: typeof(SandboxScriptHost));
var compilation = s.GetCompilation();
var tree = compilation.SyntaxTrees.FirstOrDefault();
return tree == null ? null : (tree, compilation);
}
catch
{
return null;
}
}
private static string FormatHover(ScriptShape shape, ScriptCallInfo call)
{
var ps = shape.Parameters.Count == 0
? "(no parameters)"
: string.Join(", ", shape.Parameters.Select(p => $"{p.Name}: {p.Type}{(p.Required ? "" : "?")}"));
var rt = shape.ReturnType ?? "void";
return $"**{CallDetail(call)}** `{shape.Name}`\n\n```\n{shape.Name}({ps}): {rt}\n```";
}
private static List<CompletionItem>? TryGetDotMembers(SyntaxToken token, SemanticModel model)
{
var memberAccess = token.Parent as MemberAccessExpressionSyntax
?? token.GetPreviousToken().Parent as MemberAccessExpressionSyntax;
if (memberAccess == null) return null;
var typeInfo = model.GetTypeInfo(memberAccess.Expression);
var type = typeInfo.Type ?? typeInfo.ConvertedType;
if (type == null) return null;
return type.GetMembers()
.Where(m => m.CanBeReferencedByName && !m.IsImplicitlyDeclared)
.Where(m => m.DeclaredAccessibility == Accessibility.Public || m.DeclaredAccessibility == Accessibility.NotApplicable)
.GroupBy(m => m.Name)
.Select(g => g.First())
.Select(ToCompletionItem)
.Take(200)
.ToList();
}
private IEnumerable<DiagnosticMarker> FindUnknownParameterKeys(SyntaxTree tree, IReadOnlyList<string>? declared)
{
if (declared == null) yield break;
var declaredSet = new HashSet<string>(declared, StringComparer.Ordinal);
var root = tree.GetRoot();
foreach (var elem in root.DescendantNodes().OfType<ElementAccessExpressionSyntax>())
{
if (elem.Expression is not IdentifierNameSyntax id || id.Identifier.ValueText != "Parameters")
continue;
if (elem.ArgumentList.Arguments.Count != 1) continue;
if (elem.ArgumentList.Arguments[0].Expression is not LiteralExpressionSyntax lit) continue;
if (!lit.IsKind(SyntaxKind.StringLiteralExpression)) continue;
var key = lit.Token.ValueText;
if (string.IsNullOrEmpty(key) || declaredSet.Contains(key)) continue;
var span = lit.GetLocation().GetLineSpan().Span;
yield return new DiagnosticMarker(
Severity: 4,
StartLineNumber: span.Start.Line + 1,
StartColumn: span.Start.Character + 1,
EndLineNumber: span.End.Line + 1,
EndColumn: span.End.Character + 1,
Message: $"Parameter '{key}' is not declared on this script.",
Code: "SCADA003");
}
}
private enum ScriptCallKind { None, Shared, Sibling, Child, Parent }
/// <summary>A classified script-call invocation: which kind, and (for a child) the composition name.</summary>
private readonly record struct ScriptCallInfo(ScriptCallKind Kind, string? CompositionName);
/// <summary>
/// Classifies an invocation against the runtime call surface:
/// <c>Scripts.CallShared(...)</c>, <c>Instance.CallScript(...)</c>,
/// <c>Children["X"].CallScript(...)</c>, and <c>Parent.CallScript(...)</c>.
/// The first argument of each is the called script's name literal.
/// </summary>
private static ScriptCallInfo ClassifyScriptCall(InvocationExpressionSyntax inv)
{
if (inv.Expression is not MemberAccessExpressionSyntax ma)
return new ScriptCallInfo(ScriptCallKind.None, null);
var method = ma.Name.Identifier.ValueText;
if (method == "CallShared"
&& ma.Expression is IdentifierNameSyntax sid && sid.Identifier.ValueText == "Scripts")
return new ScriptCallInfo(ScriptCallKind.Shared, null);
if (method == "CallScript")
{
if (ma.Expression is IdentifierNameSyntax iid)
{
if (iid.Identifier.ValueText == "Instance")
return new ScriptCallInfo(ScriptCallKind.Sibling, null);
if (iid.Identifier.ValueText == "Parent")
return new ScriptCallInfo(ScriptCallKind.Parent, null);
}
if (ma.Expression is ElementAccessExpressionSyntax childElem
&& childElem.Expression is IdentifierNameSyntax cid && cid.Identifier.ValueText == "Children"
&& childElem.ArgumentList.Arguments.Count == 1
&& childElem.ArgumentList.Arguments[0].Expression is LiteralExpressionSyntax cLit
&& cLit.IsKind(SyntaxKind.StringLiteralExpression))
return new ScriptCallInfo(ScriptCallKind.Child, cLit.Token.ValueText);
}
return new ScriptCallInfo(ScriptCallKind.None, null);
}
/// <summary>Human-readable call expression, e.g. <c>Scripts.CallShared</c>.</summary>
private static string CallLabel(ScriptCallInfo call) => call.Kind switch
{
ScriptCallKind.Shared => "Scripts.CallShared",
ScriptCallKind.Sibling => "Instance.CallScript",
ScriptCallKind.Parent => "Parent.CallScript",
ScriptCallKind.Child => $"Children[\"{call.CompositionName}\"].CallScript",
_ => "call"
};
/// <summary>Short description of what the call targets, for completions/hover.</summary>
private static string CallDetail(ScriptCallInfo call) => call.Kind switch
{
ScriptCallKind.Shared => "shared script",
ScriptCallKind.Sibling => "sibling script",
ScriptCallKind.Parent => "parent script",
ScriptCallKind.Child => $"script on {call.CompositionName}",
_ => "script"
};
/// <summary>Resolves the called script's shape from the metadata in scope for its kind.</summary>
private ScriptShape? ResolveCalledShape(
ScriptCallInfo call,
string scriptName,
IReadOnlyList<ScriptShape>? siblings,
IReadOnlyList<CompositionContext>? children,
CompositionContext? parent) => call.Kind switch
{
ScriptCallKind.Shared => _sharedScripts.GetShapesAsync().GetAwaiter().GetResult()
.FirstOrDefault(s => s.Name == scriptName),
ScriptCallKind.Sibling => siblings?.FirstOrDefault(s => s.Name == scriptName),
ScriptCallKind.Parent => parent?.Scripts.FirstOrDefault(s => s.Name == scriptName),
ScriptCallKind.Child => children?.FirstOrDefault(c => c.Name == call.CompositionName)
?.Scripts.FirstOrDefault(s => s.Name == scriptName),
_ => null
};
/// <summary>
/// SCADA006 — flag <c>Attributes["typo"]</c>,
/// <c>Children["X"].Attributes["typo"]</c>, and
/// <c>Parent.Attributes["typo"]</c> when the literal key isn't declared
/// at the relevant scope. Also SCADA007 — flag <c>Children["Unknown"]</c>
/// when the composition name isn't declared on the form.
/// </summary>
private IEnumerable<DiagnosticMarker> FindUnknownAttributeKeys(SyntaxTree tree, DiagnoseRequest request)
{
var root = tree.GetRoot();
var selfAttrs = request.SelfAttributes ?? Array.Empty<AttributeShape>();
var children = request.Children ?? Array.Empty<CompositionContext>();
var parent = request.Parent;
foreach (var elem in root.DescendantNodes().OfType<ElementAccessExpressionSyntax>())
{
if (elem.ArgumentList.Arguments.Count != 1) continue;
if (elem.ArgumentList.Arguments[0].Expression is not LiteralExpressionSyntax lit) continue;
if (!lit.IsKind(SyntaxKind.StringLiteralExpression)) continue;
var key = lit.Token.ValueText;
if (string.IsNullOrEmpty(key)) continue;
var ctx = ClassifyAttributeContext(elem, children, parent);
if (ctx.Kind == AttributeContextKind.None) continue;
IReadOnlyList<AttributeShape> known = ctx.Kind switch
{
AttributeContextKind.Self => selfAttrs,
AttributeContextKind.Child => ctx.Composition!.Attributes,
AttributeContextKind.Parent => parent?.Attributes ?? Array.Empty<AttributeShape>(),
_ => Array.Empty<AttributeShape>()
};
if (known.Count == 0) continue; // No metadata — don't false-alarm.
if (known.Any(a => a.Name == key)) continue;
var span = lit.GetLocation().GetLineSpan().Span;
var scopeLabel = ctx.Kind switch
{
AttributeContextKind.Self => "this template",
AttributeContextKind.Child => $"child composition '{ctx.Composition!.Name}'",
AttributeContextKind.Parent => "the parent",
_ => "unknown"
};
yield return new DiagnosticMarker(
Severity: 4,
StartLineNumber: span.Start.Line + 1,
StartColumn: span.Start.Character + 1,
EndLineNumber: span.End.Line + 1,
EndColumn: span.End.Character + 1,
Message: $"Attribute '{key}' is not declared on {scopeLabel}.",
Code: "SCADA006");
}
}
/// <summary>SCADA007 — <c>Children["UnknownComposition"]</c>.</summary>
private static IEnumerable<DiagnosticMarker> FindUnknownChildren(SyntaxTree tree, IReadOnlyList<CompositionContext>? children)
{
var known = (children ?? Array.Empty<CompositionContext>())
.Select(c => c.Name)
.ToHashSet(StringComparer.Ordinal);
if (known.Count == 0) yield break;
var root = tree.GetRoot();
foreach (var elem in root.DescendantNodes().OfType<ElementAccessExpressionSyntax>())
{
if (elem.Expression is not IdentifierNameSyntax id || id.Identifier.ValueText != "Children")
continue;
if (elem.ArgumentList.Arguments.Count != 1) continue;
if (elem.ArgumentList.Arguments[0].Expression is not LiteralExpressionSyntax lit) continue;
if (!lit.IsKind(SyntaxKind.StringLiteralExpression)) continue;
var key = lit.Token.ValueText;
if (string.IsNullOrEmpty(key) || known.Contains(key)) continue;
var span = lit.GetLocation().GetLineSpan().Span;
yield return new DiagnosticMarker(
Severity: 4,
StartLineNumber: span.Start.Line + 1,
StartColumn: span.Start.Character + 1,
EndLineNumber: span.End.Line + 1,
EndColumn: span.End.Character + 1,
Message: $"Composition '{key}' is not declared on this template.",
Code: "SCADA007");
}
}
private enum AttributeContextKind { None, Self, Child, Parent }
private record AttributeContext(AttributeContextKind Kind, CompositionContext? Composition);
/// <summary>
/// Classifies an element-access expression as one of the scope-aware
/// attribute contexts. Recognized shapes:
/// Attributes["..."] Self
/// Children["X"].Attributes["..."] Child (composition X)
/// Parent.Attributes["..."] Parent
/// </summary>
private static AttributeContext ClassifyAttributeContext(
ElementAccessExpressionSyntax elem,
IReadOnlyList<CompositionContext> children,
CompositionContext? parent)
{
// Attributes[".."]
if (elem.Expression is IdentifierNameSyntax id && id.Identifier.ValueText == "Attributes")
return new(AttributeContextKind.Self, null);
// (something).Attributes[".."]
if (elem.Expression is MemberAccessExpressionSyntax ma && ma.Name.Identifier.ValueText == "Attributes")
{
// Children["X"].Attributes[".."]
if (ma.Expression is ElementAccessExpressionSyntax childElem
&& childElem.Expression is IdentifierNameSyntax cid
&& cid.Identifier.ValueText == "Children"
&& childElem.ArgumentList.Arguments.Count == 1
&& childElem.ArgumentList.Arguments[0].Expression is LiteralExpressionSyntax cLit
&& cLit.IsKind(SyntaxKind.StringLiteralExpression))
{
var compName = cLit.Token.ValueText;
var comp = children.FirstOrDefault(c => c.Name == compName);
if (comp != null) return new(AttributeContextKind.Child, comp);
return new(AttributeContextKind.None, null);
}
// Parent.Attributes[".."]
if (ma.Expression is IdentifierNameSyntax pid && pid.Identifier.ValueText == "Parent")
return parent != null ? new(AttributeContextKind.Parent, parent) : new(AttributeContextKind.None, null);
}
return new(AttributeContextKind.None, null);
}
private static IEnumerable<DiagnosticMarker> FindForbiddenApiUsages(SyntaxTree tree, SemanticModel model)
{
var root = tree.GetRoot();
// Banned using directives — pure namespace string match is fine here.
foreach (var u in root.DescendantNodes().OfType<UsingDirectiveSyntax>())
{
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 usages — resolved via the semantic model so a user
// identifier named "File" or "Thread" does NOT trigger the diagnostic
// unless it actually resolves to a forbidden type.
foreach (var ident in root.DescendantNodes().OfType<IdentifierNameSyntax>())
{
// Skip the identifier on the right side of a member access — only
// the leftmost (the type or qualifier) is what we want to check.
if (ident.Parent is MemberAccessExpressionSyntax m && m.Name == ident) continue;
var symbol = model.GetSymbolInfo(ident).Symbol;
if (symbol is not INamedTypeSymbol type) continue;
var ns = type.ContainingNamespace?.ToDisplayString() ?? "";
if (!ForbiddenNamespacePrefixes.Any(p => ns == p || ns.StartsWith(p + "."))) 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 '{type.Name}' from forbidden namespace '{ns}' is not allowed in scripts.",
Code: "SCADA002");
}
}
private static CompletionItem ToCompletionItem(ISymbol symbol)
{
var kind = symbol.Kind switch
{
SymbolKind.Method => "Method",
SymbolKind.Property => "Property",
SymbolKind.Field => "Field",
SymbolKind.Event => "Event",
SymbolKind.NamedType => "Class",
SymbolKind.Local => "Variable",
SymbolKind.Parameter => "Variable",
SymbolKind.Namespace => "Module",
_ => "Text"
};
return new CompletionItem(
Label: symbol.Name,
InsertText: symbol.Name,
Detail: symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat),
Kind: kind);
}
private static DiagnosticMarker ToMarker(Diagnostic d)
{
var span = d.Location.GetLineSpan().Span;
var severity = d.Severity switch
{
DiagnosticSeverity.Error => 8,
DiagnosticSeverity.Warning => 4,
DiagnosticSeverity.Info => 2,
_ => 1
};
return new DiagnosticMarker(
Severity: severity,
StartLineNumber: span.Start.Line + 1,
StartColumn: span.Start.Character + 1,
EndLineNumber: span.End.Line + 1,
EndColumn: span.End.Character + 1,
Message: d.GetMessage(),
Code: d.Id);
}
private static int PositionToOffset(string code, int line, int column)
{
var offset = 0;
var currentLine = 1;
var currentCol = 1;
for (int i = 0; i < code.Length; i++)
{
if (currentLine == line && currentCol == column) return offset;
if (code[i] == '\n') { currentLine++; currentCol = 1; }
else { currentCol++; }
offset = i + 1;
}
return code.Length;
}
}