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; /// /// Compiles user scripts as Roslyn C# Scripting fragments against /// globals (template/shared) or /// (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 /// ), 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. var File = ...) /// do not false-positive. /// 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; } /// Globals type a script of the given kind is compiled against. private static Type GlobalsTypeFor(ScriptKind kind) => kind == ScriptKind.InboundApi ? typeof(InboundScriptHost) : typeof(SandboxScriptHost); /// /// Re-enables the nullable annotation context for an analysis compilation. /// Roslyn scripting defaults to a disabled nullable context, which makes any /// ? annotation in a user script raise CS8632. Annotations-only keeps /// string? legal without surfacing the nullable-flow warnings. /// 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()); var cacheKey = "diag:" + (int)request.Kind + ":" + HashCode(request.Code); if (_cache.TryGetValue(cacheKey, out DiagnoseResponse? cached) && cached is not null) return cached; Script 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; /// /// Compiles and runs a script in the central process. The globals surface /// depends on : template and shared /// scripts run against , inbound API method /// scripts against . /// Pure logic + the supplied Parameters always work. /// For the SandboxScriptHost surface, Attributes still throws while /// External, Database, and Notify are wired to /// central's real , /// , and /// — calls fire for real and /// have production-equivalent side effects (HTTP, SQL, SMTP). /// CallShared compiles and executes the named shared script in the /// same sandbox, with a recursion limit of /// . CallScript still throws /// because a shared script has no template siblings in this context. /// For the SandboxInboundScriptHost surface, every Route 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. /// public async Task 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()); } 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 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(); var comms = _services.GetService(); 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(); var databaseGateway = _services.GetService(); var notifyService = _services.GetService(); var external = new SandboxExternalHelper(externalClient, instanceLabel); var database = new SandboxDatabaseHelper(databaseGateway, instanceLabel); var notify = new SandboxNotifyHelper(notifyService, instanceLabel); var compileCache = new Dictionary>(StringComparer.Ordinal); var compileCacheLock = new object(); var depth = 0; Func?, CancellationToken, Task>? 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? 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 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()), 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 ConvertJsonParameters( Dictionary? parameters) { var result = new Dictionary(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 ($"\"\"", 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 CompleteAsync(CompletionsRequest request) { if (string.IsNullOrEmpty(request.CodeText)) return new CompletionsResponse(Array.Empty()); Script script; try { script = CSharpScript.Create(request.CodeText, DefaultOptions, globalsType: GlobalsTypeFor(request.Kind)); } catch { return new CompletionsResponse(Array.Empty()); } var compilation = script.GetCompilation(); var tree = compilation.SyntaxTrees.FirstOrDefault(); if (tree == null) return new CompletionsResponse(Array.Empty()); 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?> 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(); } // Attributes["..."] / Children["X"].Attributes["..."] / Parent.Attributes["..."] if (owner is ElementAccessExpressionSyntax attrElem) { var ctx = ClassifyAttributeContext( attrElem, request.Children ?? Array.Empty(), request.Parent); if (ctx.Kind != AttributeContextKind.None) { IReadOnlyList source = ctx.Kind switch { AttributeContextKind.Self => request.SelfAttributes ?? Array.Empty(), AttributeContextKind.Child => ctx.Composition!.Attributes, AttributeContextKind.Parent => request.Parent?.Attributes ?? Array.Empty(), _ => Array.Empty() }; 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()) .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()) .Select(s => MakeCallCompletion(s, CallDetail(call))).ToList(); case ScriptCallKind.Parent: return (request.Parent?.Scripts ?? Array.Empty()) .Select(s => MakeCallCompletion(s, CallDetail(call))).ToList(); case ScriptCallKind.Child: { var comp = (request.Children ?? Array.Empty()) .FirstOrDefault(c => c.Name == call.CompositionName); return comp != null ? comp.Scripts.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList() : new List(); } } } return null; } /// /// Builds a Monaco snippet that fills the call after the name, e.g. /// Greet", new { name = ${1:name}, count = ${2:count} }). The JS /// provider extends the completion range over the auto-closed ") if /// Monaco inserted one, so the snippet replaces the rest of the call cleanly. /// 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); } } /// /// Parameter-name inlay hints are obsolete under the runtime call API: /// Scripts.CallShared / Instance.CallScript pass arguments as an explicit /// IReadOnlyDictionary literal ({ ["p"] = … }), which is /// already self-labelling — there are no positional arguments to annotate. /// public InlayHintsResponse InlayHints(InlayHintsRequest request) => new(Array.Empty()); 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? 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 FindUnknownParameterKeys(SyntaxTree tree, IReadOnlyList? declared) { if (declared == null) yield break; var declaredSet = new HashSet(declared, StringComparer.Ordinal); var root = tree.GetRoot(); foreach (var elem in root.DescendantNodes().OfType()) { 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 } /// A classified script-call invocation: which kind, and (for a child) the composition name. private readonly record struct ScriptCallInfo(ScriptCallKind Kind, string? CompositionName); /// /// Classifies an invocation against the runtime call surface: /// Scripts.CallShared(...), Instance.CallScript(...), /// Children["X"].CallScript(...), and Parent.CallScript(...). /// The first argument of each is the called script's name literal. /// 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); } /// Human-readable call expression, e.g. Scripts.CallShared. 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" }; /// Short description of what the call targets, for completions/hover. 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" }; /// Resolves the called script's shape from the metadata in scope for its kind. private ScriptShape? ResolveCalledShape( ScriptCallInfo call, string scriptName, IReadOnlyList? siblings, IReadOnlyList? 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 }; /// /// SCADA006 — flag Attributes["typo"], /// Children["X"].Attributes["typo"], and /// Parent.Attributes["typo"] when the literal key isn't declared /// at the relevant scope. Also SCADA007 — flag Children["Unknown"] /// when the composition name isn't declared on the form. /// private IEnumerable FindUnknownAttributeKeys(SyntaxTree tree, DiagnoseRequest request) { var root = tree.GetRoot(); var selfAttrs = request.SelfAttributes ?? Array.Empty(); var children = request.Children ?? Array.Empty(); var parent = request.Parent; foreach (var elem in root.DescendantNodes().OfType()) { 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 known = ctx.Kind switch { AttributeContextKind.Self => selfAttrs, AttributeContextKind.Child => ctx.Composition!.Attributes, AttributeContextKind.Parent => parent?.Attributes ?? Array.Empty(), _ => Array.Empty() }; 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"); } } /// SCADA007 — Children["UnknownComposition"]. private static IEnumerable FindUnknownChildren(SyntaxTree tree, IReadOnlyList? children) { var known = (children ?? Array.Empty()) .Select(c => c.Name) .ToHashSet(StringComparer.Ordinal); if (known.Count == 0) yield break; var root = tree.GetRoot(); foreach (var elem in root.DescendantNodes().OfType()) { 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); /// /// 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 /// private static AttributeContext ClassifyAttributeContext( ElementAccessExpressionSyntax elem, IReadOnlyList 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 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()) { 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()) { // 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; } }