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;
}
}