using System.Security.Cryptography; using System.Text; 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; namespace ScadaLink.CentralUI.ScriptAnalysis; /// /// Compiles user scripts as Roslyn C# Scripting fragments against /// globals 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), CallShared("...") names (from /// ), and CallScript("...") names /// (from the request's SiblingScripts). /// - 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(ScriptHost).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; public ScriptAnalysisService(ISharedScriptCatalog sharedScripts, IMemoryCache cache) { _sharedScripts = sharedScripts; _cache = cache; } public DiagnoseResponse Diagnose(DiagnoseRequest request) { if (string.IsNullOrEmpty(request.Code)) return new DiagnoseResponse(Array.Empty()); var cacheKey = "diag:" + 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: typeof(ScriptHost)); } 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 = 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(FindArgumentCountMismatches(tree, request.SiblingScripts)); markers.AddRange(FindArgumentTypeMismatches(tree, request.SiblingScripts)); markers.AddRange(FindUnknownAttributeKeys(tree, request)); markers.AddRange(FindUnknownChildren(tree, request.Children)); } return Cache(cacheKey, new DiagnoseResponse(markers)); } 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: typeof(ScriptHost)); } 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(); } } // CallShared("...") / CallScript("...") / Children["X"].CallScript("...") / Parent.CallScript("...") if (owner is InvocationExpressionSyntax inv) { var calleeIdName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText; var calleeMa = inv.Expression as MemberAccessExpressionSyntax; var calleeName = calleeIdName ?? calleeMa?.Name.Identifier.ValueText; if (calleeName == "CallShared") { var shapes = await _sharedScripts.GetShapesAsync(); return shapes.Select(s => MakeCallCompletion(s, "shared script")).ToList(); } if (calleeName == "CallScript") { // Children["X"].CallScript("..." or Parent.CallScript("... if (calleeMa != null) { // Children["X"].CallScript if (calleeMa.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 = (request.Children ?? Array.Empty()) .FirstOrDefault(c => c.Name == compName); if (comp != null) return comp.Scripts.Select(s => MakeCallCompletion(s, $"script on {compName}")).ToList(); return new List(); } // Parent.CallScript if (calleeMa.Expression is IdentifierNameSyntax pid && pid.Identifier.ValueText == "Parent" && request.Parent != null) { return request.Parent.Scripts .Select(s => MakeCallCompletion(s, "parent script")) .ToList(); } } // Plain CallScript("...") — siblings return (request.SiblingScripts ?? Array.Empty()) .Select(s => MakeCallCompletion(s, "sibling script")) .ToList(); } } return null; } /// /// Builds a Monaco snippet that fills the call after the name, e.g. /// Greet", ${1:name}, ${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) { string insertText; int insertRules; if (shape.Parameters.Count == 0) { insertText = shape.Name + "\")"; insertRules = 4; } else { var args = string.Join(", ", shape.Parameters.Select((p, i) => $"${{{i + 1}:{p.Name}}}")); insertText = $"{shape.Name}\", {args})"; insertRules = 4; } 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: insertRules); } 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); } } public InlayHintsResponse InlayHints(InlayHintsRequest request) { if (string.IsNullOrEmpty(request.Code)) return new InlayHintsResponse(Array.Empty()); var script = TryParse(request.Code); if (script == null) return new InlayHintsResponse(Array.Empty()); var (tree, _) = script.Value; IReadOnlyList? sharedShapes = null; IReadOnlyList SharedShapes() => sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult(); var hints = new List(); foreach (var inv in tree.GetRoot().DescendantNodes().OfType()) { var callee = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText; if (callee is not ("CallShared" or "CallScript")) continue; if (inv.ArgumentList.Arguments.Count < 1) continue; var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax; if (nameArg == null || !nameArg.IsKind(SyntaxKind.StringLiteralExpression)) continue; var scriptName = nameArg.Token.ValueText; if (string.IsNullOrEmpty(scriptName)) continue; ScriptShape? shape = callee == "CallShared" ? SharedShapes().FirstOrDefault(s => s.Name == scriptName) : request.SiblingScripts?.FirstOrDefault(s => s.Name == scriptName); if (shape == null) continue; for (var i = 1; i < inv.ArgumentList.Arguments.Count && i - 1 < shape.Parameters.Count; i++) { var arg = inv.ArgumentList.Arguments[i]; var p = shape.Parameters[i - 1]; var pos = arg.Span.Start; var lineSpan = tree.GetLineSpan(new TextSpan(pos, 0)).Span; hints.Add(new InlayHint( Line: lineSpan.Start.Line + 1, Column: lineSpan.Start.Character + 1, Label: $"{p.Name}:")); } } return new InlayHintsResponse(hints); } public HoverResponse Hover(HoverRequest request) { var script = TryParse(request.CodeText); 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 calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText; var rawName = token.ValueText; if (string.IsNullOrEmpty(rawName)) return new HoverResponse(null); ScriptShape? shape = null; if (calleeName == "CallShared") shape = _sharedScripts.GetShapesAsync().GetAwaiter().GetResult() .FirstOrDefault(s => s.Name == rawName); else if (calleeName == "CallScript" && request.SiblingScripts != null) shape = request.SiblingScripts.FirstOrDefault(s => s.Name == rawName); if (shape == null) return new HoverResponse(null); return new HoverResponse(FormatHover(shape, calleeName!)); } 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 calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText; if (calleeName is not ("CallShared" or "CallScript")) 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 ?? ""; ScriptShape? shape = null; if (calleeName == "CallShared") shape = _sharedScripts.GetShapesAsync().GetAwaiter().GetResult() .FirstOrDefault(s => s.Name == scriptName); else if (request.SiblingScripts != null) shape = request.SiblingScripts.FirstOrDefault(s => s.Name == scriptName); if (shape == null) return empty; var paramLabels = shape.Parameters.Select(p => $"{p.Name}: {p.Type}").ToList(); var label = $"{calleeName}(\"{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(ScriptHost)); var compilation = s.GetCompilation(); var tree = compilation.SyntaxTrees.FirstOrDefault(); return tree == null ? null : (tree, compilation); } catch { return null; } } private static string FormatHover(ScriptShape shape, string callee) { 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"; var kind = callee == "CallShared" ? "shared script" : "sibling script"; return $"**{kind}** `{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 IEnumerable FindArgumentCountMismatches(SyntaxTree tree, IReadOnlyList? siblings) { var root = tree.GetRoot(); IReadOnlyList? sharedShapes = null; IReadOnlyList SharedShapes() => sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult(); foreach (var inv in root.DescendantNodes().OfType()) { var callee = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText; if (callee is not ("CallShared" or "CallScript")) continue; if (inv.ArgumentList.Arguments.Count < 1) continue; var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax; if (nameArg == null || !nameArg.IsKind(SyntaxKind.StringLiteralExpression)) continue; var scriptName = nameArg.Token.ValueText; if (string.IsNullOrEmpty(scriptName)) continue; ScriptShape? shape = callee == "CallShared" ? SharedShapes().FirstOrDefault(s => s.Name == scriptName) : siblings?.FirstOrDefault(s => s.Name == scriptName); if (shape == null) continue; var passedCount = inv.ArgumentList.Arguments.Count - 1; // exclude name var expectedRequired = shape.Parameters.Count(p => p.Required); var expectedTotal = shape.Parameters.Count; if (passedCount < expectedRequired || passedCount > expectedTotal) { var span = inv.GetLocation().GetLineSpan().Span; var expected = expectedRequired == expectedTotal ? expectedTotal.ToString() : $"{expectedRequired}–{expectedTotal}"; 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: $"{callee}('{scriptName}') expects {expected} argument(s) but got {passedCount}.", Code: "SCADA004"); } } } /// /// 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 IEnumerable FindArgumentTypeMismatches(SyntaxTree tree, IReadOnlyList? siblings) { var root = tree.GetRoot(); IReadOnlyList? sharedShapes = null; IReadOnlyList SharedShapes() => sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult(); foreach (var inv in root.DescendantNodes().OfType()) { var callee = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText; if (callee is not ("CallShared" or "CallScript")) continue; if (inv.ArgumentList.Arguments.Count < 1) continue; var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax; if (nameArg == null || !nameArg.IsKind(SyntaxKind.StringLiteralExpression)) continue; var scriptName = nameArg.Token.ValueText; if (string.IsNullOrEmpty(scriptName)) continue; ScriptShape? shape = callee == "CallShared" ? SharedShapes().FirstOrDefault(s => s.Name == scriptName) : siblings?.FirstOrDefault(s => s.Name == scriptName); if (shape == null) continue; for (var i = 1; i < inv.ArgumentList.Arguments.Count && i - 1 < shape.Parameters.Count; i++) { var arg = inv.ArgumentList.Arguments[i].Expression; var p = shape.Parameters[i - 1]; var literalType = LiteralTypeOf(arg); if (literalType == null) continue; // Not a literal we can check. if (TypeAccepts(p.Type, literalType.Value)) continue; var span = arg.GetLocation().GetLineSpan().Span; yield return new DiagnosticMarker( Severity: 8, StartLineNumber: span.Start.Line + 1, StartColumn: span.Start.Character + 1, EndLineNumber: span.End.Line + 1, EndColumn: span.End.Character + 1, Message: $"Argument {i} of {callee}('{scriptName}') expects {p.Type} but got {literalType}.", Code: "SCADA005"); } } } private enum LiteralKind { String, Integer, Float, Boolean, Null } private static LiteralKind? LiteralTypeOf(ExpressionSyntax expr) { if (expr is LiteralExpressionSyntax lit) { if (lit.IsKind(SyntaxKind.StringLiteralExpression)) return LiteralKind.String; if (lit.IsKind(SyntaxKind.TrueLiteralExpression) || lit.IsKind(SyntaxKind.FalseLiteralExpression)) return LiteralKind.Boolean; if (lit.IsKind(SyntaxKind.NullLiteralExpression)) return LiteralKind.Null; if (lit.IsKind(SyntaxKind.NumericLiteralExpression)) { var text = lit.Token.Text; return text.Contains('.') || text.EndsWith("f", StringComparison.OrdinalIgnoreCase) || text.EndsWith("d", StringComparison.OrdinalIgnoreCase) ? LiteralKind.Float : LiteralKind.Integer; } } if (expr is InterpolatedStringExpressionSyntax) return LiteralKind.String; return null; } /// /// True when a literal of is acceptable for a /// parameter declared as . Object/List always /// accept (we don't introspect collection literals); Null is acceptable /// for any non-value type. /// private static bool TypeAccepts(string declared, LiteralKind literal) { var d = NormalizeDeclaredType(declared); if (literal == LiteralKind.Null) return d is "Object" or "List" or "String"; return d switch { "Boolean" => literal == LiteralKind.Boolean, "Integer" => literal == LiteralKind.Integer, "Float" => literal is LiteralKind.Float or LiteralKind.Integer, "String" => literal == LiteralKind.String, "Object" or "List" => true, _ => true // unknown SCADA type — assume compatible }; } /// /// Normalizes legacy / .NET type names from stored ParameterDefinitions /// JSON to the canonical Inbound API set. Mirrors the frontend /// ParameterListEditor's normalization so SCADA005 doesn't false-negative /// on data still in the legacy shape. /// private static string NormalizeDeclaredType(string declared) => declared.ToLowerInvariant() switch { "boolean" or "bool" => "Boolean", "integer" or "int" or "int32" or "int64" or "int16" or "byte" or "sbyte" or "uint32" or "uint64" or "uint16" => "Integer", "float" or "double" or "single" or "decimal" => "Float", "string" or "datetime" => "String", "object" => "Object", "list" => "List", _ => declared }; private static IEnumerable FindForbiddenApiUsages(SyntaxTree tree, SemanticModel model) { var root = tree.GetRoot(); // 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; } }