Files
ScadaBridge/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs
T
Joseph Doherty 783da8e21a feat(ui): structured editors for script schemas and alarm triggers
Replace raw-JSON text inputs with rich UI: script parameter/return types use
a JSON Schema builder (SchemaBuilder + JsonSchemaShapeParser, with a migration
to convert existing definitions); alarm trigger config uses a type-aware
editor with a flattened attribute picker (AlarmTriggerEditor). AlarmActor
gains optional direction (rising/falling/either) on RateOfChange triggers.
2026-05-13 00:33:00 -04:00

972 lines
42 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Compiles user scripts as Roslyn C# Scripting fragments against
/// <see cref="ScriptHost"/> 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
/// <see cref="ISharedScriptCatalog"/>), 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. <c>var File = ...</c>)
/// do not false-positive.
/// </summary>
public class ScriptAnalysisService
{
private static readonly ScriptOptions DefaultOptions = ScriptOptions.Default
.AddReferences(
typeof(object).Assembly,
typeof(Enumerable).Assembly,
typeof(System.Collections.Generic.Dictionary<,>).Assembly,
typeof(System.ComponentModel.DescriptionAttribute).Assembly,
typeof(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<DiagnosticMarker>());
var cacheKey = "diag:" + HashCode(request.Code);
if (_cache.TryGetValue(cacheKey, out DiagnoseResponse? cached) && cached is not null)
return cached;
Script<object> script;
try
{
script = CSharpScript.Create(request.Code, DefaultOptions, globalsType: 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<CompletionsResponse> CompleteAsync(CompletionsRequest request)
{
if (string.IsNullOrEmpty(request.CodeText))
return new CompletionsResponse(Array.Empty<CompletionItem>());
Script<object> script;
try
{
script = CSharpScript.Create(request.CodeText, DefaultOptions, globalsType: typeof(ScriptHost));
}
catch
{
return new CompletionsResponse(Array.Empty<CompletionItem>());
}
var compilation = script.GetCompilation();
var tree = compilation.SyntaxTrees.FirstOrDefault();
if (tree == null) return new CompletionsResponse(Array.Empty<CompletionItem>());
var semanticModel = compilation.GetSemanticModel(tree);
var position = PositionToOffset(request.CodeText, request.Line, request.Column);
position = Math.Clamp(position, 0, request.CodeText.Length);
var root = tree.GetRoot();
var token = root.FindToken(Math.Max(0, position - 1));
// SCADA-specific string-literal completions take priority over plain C#
// because they're the actually useful suggestions inside those literals.
var stringMatches = await TryStringLiteralCompletions(token, request);
if (stringMatches != null)
return new CompletionsResponse(stringMatches);
// Dot completion: members of the type on the left of the dot.
var dotMembers = TryGetDotMembers(token, semanticModel);
if (dotMembers != null)
return new CompletionsResponse(dotMembers);
// General completion: in-scope symbols at position.
var scoped = semanticModel.LookupSymbols(position)
.Where(s => !s.IsImplicitlyDeclared && !string.IsNullOrEmpty(s.Name))
.GroupBy(s => s.Name)
.Select(g => g.First())
.Select(ToCompletionItem)
.Take(200)
.ToList();
return new CompletionsResponse(scoped);
}
private async Task<List<CompletionItem>?> TryStringLiteralCompletions(
SyntaxToken token, CompletionsRequest request)
{
// The token at the cursor must be (or be adjacent to) a string literal.
var literal = token.IsKind(SyntaxKind.StringLiteralToken)
? token
: token.GetPreviousToken().IsKind(SyntaxKind.StringLiteralToken)
? token.GetPreviousToken()
: default;
if (literal == default) return null;
// Token tree shape: StringLiteralToken → LiteralExpression → Argument →
// (ArgumentList | BracketedArgumentList) → invocation or element-access.
var argument = literal.Parent?.Parent as ArgumentSyntax;
var argumentList = argument?.Parent;
var owner = argumentList?.Parent;
// Parameters["..."]
if (owner is ElementAccessExpressionSyntax elem
&& elem.Expression is IdentifierNameSyntax id
&& id.Identifier.ValueText == "Parameters")
{
return (request.DeclaredParameters ?? Array.Empty<string>())
.Distinct()
.Select(n => new CompletionItem(n, n, "declared parameter", "Variable"))
.ToList();
}
// Attributes["..."] / Children["X"].Attributes["..."] / Parent.Attributes["..."]
if (owner is ElementAccessExpressionSyntax attrElem)
{
var ctx = ClassifyAttributeContext(
attrElem,
request.Children ?? Array.Empty<CompositionContext>(),
request.Parent);
if (ctx.Kind != AttributeContextKind.None)
{
IReadOnlyList<AttributeShape> source = ctx.Kind switch
{
AttributeContextKind.Self => request.SelfAttributes ?? Array.Empty<AttributeShape>(),
AttributeContextKind.Child => ctx.Composition!.Attributes,
AttributeContextKind.Parent => request.Parent?.Attributes ?? Array.Empty<AttributeShape>(),
_ => Array.Empty<AttributeShape>()
};
var label = ctx.Kind switch
{
AttributeContextKind.Self => "attribute",
AttributeContextKind.Child => $"attribute on {ctx.Composition!.Name}",
AttributeContextKind.Parent => "parent attribute",
_ => "attribute"
};
return source.Select(a =>
new CompletionItem(a.Name, a.Name, $"{label}: {a.Type}", "Field")).ToList();
}
// Children["..."] → suggest composition names
if (attrElem.Expression is IdentifierNameSyntax cid && cid.Identifier.ValueText == "Children")
{
return (request.Children ?? Array.Empty<CompositionContext>())
.Select(c => new CompletionItem(c.Name, c.Name, "composition", "Class"))
.ToList();
}
}
// 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<CompositionContext>())
.FirstOrDefault(c => c.Name == compName);
if (comp != null)
return comp.Scripts.Select(s => MakeCallCompletion(s, $"script on {compName}")).ToList();
return new List<CompletionItem>();
}
// 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<ScriptShape>())
.Select(s => MakeCallCompletion(s, "sibling script"))
.ToList();
}
}
return null;
}
/// <summary>
/// Builds a Monaco snippet that fills the call after the name, e.g.
/// <c>Greet", ${1:name}, ${2:count})</c>. The JS provider extends the
/// completion range over the auto-closed <c>")</c> if Monaco inserted
/// one, so the snippet replaces the rest of the call cleanly.
/// </summary>
private static CompletionItem MakeCallCompletion(ScriptShape shape, string detail)
{
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<InlayHint>());
var script = TryParse(request.Code);
if (script == null) return new InlayHintsResponse(Array.Empty<InlayHint>());
var (tree, _) = script.Value;
IReadOnlyList<ScriptShape>? sharedShapes = null;
IReadOnlyList<ScriptShape> SharedShapes() =>
sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult();
var hints = new List<InlayHint>();
foreach (var inv in tree.GetRoot().DescendantNodes().OfType<InvocationExpressionSyntax>())
{
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<CompletionItem>? TryGetDotMembers(SyntaxToken token, SemanticModel model)
{
var memberAccess = token.Parent as MemberAccessExpressionSyntax
?? token.GetPreviousToken().Parent as MemberAccessExpressionSyntax;
if (memberAccess == null) return null;
var typeInfo = model.GetTypeInfo(memberAccess.Expression);
var type = typeInfo.Type ?? typeInfo.ConvertedType;
if (type == null) return null;
return type.GetMembers()
.Where(m => m.CanBeReferencedByName && !m.IsImplicitlyDeclared)
.Where(m => m.DeclaredAccessibility == Accessibility.Public || m.DeclaredAccessibility == Accessibility.NotApplicable)
.GroupBy(m => m.Name)
.Select(g => g.First())
.Select(ToCompletionItem)
.Take(200)
.ToList();
}
private IEnumerable<DiagnosticMarker> FindUnknownParameterKeys(SyntaxTree tree, IReadOnlyList<string>? declared)
{
if (declared == null) yield break;
var declaredSet = new HashSet<string>(declared, StringComparer.Ordinal);
var root = tree.GetRoot();
foreach (var elem in root.DescendantNodes().OfType<ElementAccessExpressionSyntax>())
{
if (elem.Expression is not IdentifierNameSyntax id || id.Identifier.ValueText != "Parameters")
continue;
if (elem.ArgumentList.Arguments.Count != 1) continue;
if (elem.ArgumentList.Arguments[0].Expression is not LiteralExpressionSyntax lit) continue;
if (!lit.IsKind(SyntaxKind.StringLiteralExpression)) continue;
var key = lit.Token.ValueText;
if (string.IsNullOrEmpty(key) || declaredSet.Contains(key)) continue;
var span = lit.GetLocation().GetLineSpan().Span;
yield return new DiagnosticMarker(
Severity: 4,
StartLineNumber: span.Start.Line + 1,
StartColumn: span.Start.Character + 1,
EndLineNumber: span.End.Line + 1,
EndColumn: span.End.Character + 1,
Message: $"Parameter '{key}' is not declared on this script.",
Code: "SCADA003");
}
}
private IEnumerable<DiagnosticMarker> FindArgumentCountMismatches(SyntaxTree tree, IReadOnlyList<ScriptShape>? siblings)
{
var root = tree.GetRoot();
IReadOnlyList<ScriptShape>? sharedShapes = null;
IReadOnlyList<ScriptShape> SharedShapes() =>
sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult();
foreach (var inv in root.DescendantNodes().OfType<InvocationExpressionSyntax>())
{
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");
}
}
}
/// <summary>
/// SCADA006 — flag <c>Attributes["typo"]</c>,
/// <c>Children["X"].Attributes["typo"]</c>, and
/// <c>Parent.Attributes["typo"]</c> when the literal key isn't declared
/// at the relevant scope. Also SCADA007 — flag <c>Children["Unknown"]</c>
/// when the composition name isn't declared on the form.
/// </summary>
private IEnumerable<DiagnosticMarker> FindUnknownAttributeKeys(SyntaxTree tree, DiagnoseRequest request)
{
var root = tree.GetRoot();
var selfAttrs = request.SelfAttributes ?? Array.Empty<AttributeShape>();
var children = request.Children ?? Array.Empty<CompositionContext>();
var parent = request.Parent;
foreach (var elem in root.DescendantNodes().OfType<ElementAccessExpressionSyntax>())
{
if (elem.ArgumentList.Arguments.Count != 1) continue;
if (elem.ArgumentList.Arguments[0].Expression is not LiteralExpressionSyntax lit) continue;
if (!lit.IsKind(SyntaxKind.StringLiteralExpression)) continue;
var key = lit.Token.ValueText;
if (string.IsNullOrEmpty(key)) continue;
var ctx = ClassifyAttributeContext(elem, children, parent);
if (ctx.Kind == AttributeContextKind.None) continue;
IReadOnlyList<AttributeShape> known = ctx.Kind switch
{
AttributeContextKind.Self => selfAttrs,
AttributeContextKind.Child => ctx.Composition!.Attributes,
AttributeContextKind.Parent => parent?.Attributes ?? Array.Empty<AttributeShape>(),
_ => Array.Empty<AttributeShape>()
};
if (known.Count == 0) continue; // No metadata — don't false-alarm.
if (known.Any(a => a.Name == key)) continue;
var span = lit.GetLocation().GetLineSpan().Span;
var scopeLabel = ctx.Kind switch
{
AttributeContextKind.Self => "this template",
AttributeContextKind.Child => $"child composition '{ctx.Composition!.Name}'",
AttributeContextKind.Parent => "the parent",
_ => "unknown"
};
yield return new DiagnosticMarker(
Severity: 4,
StartLineNumber: span.Start.Line + 1,
StartColumn: span.Start.Character + 1,
EndLineNumber: span.End.Line + 1,
EndColumn: span.End.Character + 1,
Message: $"Attribute '{key}' is not declared on {scopeLabel}.",
Code: "SCADA006");
}
}
/// <summary>SCADA007 — <c>Children["UnknownComposition"]</c>.</summary>
private static IEnumerable<DiagnosticMarker> FindUnknownChildren(SyntaxTree tree, IReadOnlyList<CompositionContext>? children)
{
var known = (children ?? Array.Empty<CompositionContext>())
.Select(c => c.Name)
.ToHashSet(StringComparer.Ordinal);
if (known.Count == 0) yield break;
var root = tree.GetRoot();
foreach (var elem in root.DescendantNodes().OfType<ElementAccessExpressionSyntax>())
{
if (elem.Expression is not IdentifierNameSyntax id || id.Identifier.ValueText != "Children")
continue;
if (elem.ArgumentList.Arguments.Count != 1) continue;
if (elem.ArgumentList.Arguments[0].Expression is not LiteralExpressionSyntax lit) continue;
if (!lit.IsKind(SyntaxKind.StringLiteralExpression)) continue;
var key = lit.Token.ValueText;
if (string.IsNullOrEmpty(key) || known.Contains(key)) continue;
var span = lit.GetLocation().GetLineSpan().Span;
yield return new DiagnosticMarker(
Severity: 4,
StartLineNumber: span.Start.Line + 1,
StartColumn: span.Start.Character + 1,
EndLineNumber: span.End.Line + 1,
EndColumn: span.End.Character + 1,
Message: $"Composition '{key}' is not declared on this template.",
Code: "SCADA007");
}
}
private enum AttributeContextKind { None, Self, Child, Parent }
private record AttributeContext(AttributeContextKind Kind, CompositionContext? Composition);
/// <summary>
/// Classifies an element-access expression as one of the scope-aware
/// attribute contexts. Recognized shapes:
/// Attributes["..."] Self
/// Children["X"].Attributes["..."] Child (composition X)
/// Parent.Attributes["..."] Parent
/// </summary>
private static AttributeContext ClassifyAttributeContext(
ElementAccessExpressionSyntax elem,
IReadOnlyList<CompositionContext> children,
CompositionContext? parent)
{
// Attributes[".."]
if (elem.Expression is IdentifierNameSyntax id && id.Identifier.ValueText == "Attributes")
return new(AttributeContextKind.Self, null);
// (something).Attributes[".."]
if (elem.Expression is MemberAccessExpressionSyntax ma && ma.Name.Identifier.ValueText == "Attributes")
{
// Children["X"].Attributes[".."]
if (ma.Expression is ElementAccessExpressionSyntax childElem
&& childElem.Expression is IdentifierNameSyntax cid
&& cid.Identifier.ValueText == "Children"
&& childElem.ArgumentList.Arguments.Count == 1
&& childElem.ArgumentList.Arguments[0].Expression is LiteralExpressionSyntax cLit
&& cLit.IsKind(SyntaxKind.StringLiteralExpression))
{
var compName = cLit.Token.ValueText;
var comp = children.FirstOrDefault(c => c.Name == compName);
if (comp != null) return new(AttributeContextKind.Child, comp);
return new(AttributeContextKind.None, null);
}
// Parent.Attributes[".."]
if (ma.Expression is IdentifierNameSyntax pid && pid.Identifier.ValueText == "Parent")
return parent != null ? new(AttributeContextKind.Parent, parent) : new(AttributeContextKind.None, null);
}
return new(AttributeContextKind.None, null);
}
private IEnumerable<DiagnosticMarker> FindArgumentTypeMismatches(SyntaxTree tree, IReadOnlyList<ScriptShape>? siblings)
{
var root = tree.GetRoot();
IReadOnlyList<ScriptShape>? sharedShapes = null;
IReadOnlyList<ScriptShape> SharedShapes() =>
sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult();
foreach (var inv in root.DescendantNodes().OfType<InvocationExpressionSyntax>())
{
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;
}
/// <summary>
/// True when a literal of <paramref name="literal"/> is acceptable for a
/// parameter declared as <paramref name="declared"/>. Object/List always
/// accept (we don't introspect collection literals); Null is acceptable
/// for any non-value type.
/// </summary>
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
};
}
/// <summary>
/// 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.
/// </summary>
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<DiagnosticMarker> FindForbiddenApiUsages(SyntaxTree tree, SemanticModel model)
{
var root = tree.GetRoot();
// Banned using directives — pure namespace string match is fine here.
foreach (var u in root.DescendantNodes().OfType<UsingDirectiveSyntax>())
{
var name = u.Name?.ToString() ?? "";
if (ForbiddenNamespacePrefixes.Any(p => name == p || name.StartsWith(p + ".")))
{
var span = u.GetLocation().GetLineSpan().Span;
yield return new DiagnosticMarker(
Severity: 8,
StartLineNumber: span.Start.Line + 1,
StartColumn: span.Start.Character + 1,
EndLineNumber: span.End.Line + 1,
EndColumn: span.End.Character + 1,
Message: $"Forbidden namespace '{name}' is not allowed in scripts (script trust model).",
Code: "SCADA001");
}
}
// Banned type usages — resolved via the semantic model so a user
// identifier named "File" or "Thread" does NOT trigger the diagnostic
// unless it actually resolves to a forbidden type.
foreach (var ident in root.DescendantNodes().OfType<IdentifierNameSyntax>())
{
// Skip the identifier on the right side of a member access — only
// the leftmost (the type or qualifier) is what we want to check.
if (ident.Parent is MemberAccessExpressionSyntax m && m.Name == ident) continue;
var symbol = model.GetSymbolInfo(ident).Symbol;
if (symbol is not INamedTypeSymbol type) continue;
var ns = type.ContainingNamespace?.ToDisplayString() ?? "";
if (!ForbiddenNamespacePrefixes.Any(p => ns == p || ns.StartsWith(p + "."))) continue;
var span = ident.GetLocation().GetLineSpan().Span;
yield return new DiagnosticMarker(
Severity: 8,
StartLineNumber: span.Start.Line + 1,
StartColumn: span.Start.Character + 1,
EndLineNumber: span.End.Line + 1,
EndColumn: span.End.Character + 1,
Message: $"Type '{type.Name}' from forbidden namespace '{ns}' is not allowed in scripts.",
Code: "SCADA002");
}
}
private static CompletionItem ToCompletionItem(ISymbol symbol)
{
var kind = symbol.Kind switch
{
SymbolKind.Method => "Method",
SymbolKind.Property => "Property",
SymbolKind.Field => "Field",
SymbolKind.Event => "Event",
SymbolKind.NamedType => "Class",
SymbolKind.Local => "Variable",
SymbolKind.Parameter => "Variable",
SymbolKind.Namespace => "Module",
_ => "Text"
};
return new CompletionItem(
Label: symbol.Name,
InsertText: symbol.Name,
Detail: symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat),
Kind: kind);
}
private static DiagnosticMarker ToMarker(Diagnostic d)
{
var span = d.Location.GetLineSpan().Span;
var severity = d.Severity switch
{
DiagnosticSeverity.Error => 8,
DiagnosticSeverity.Warning => 4,
DiagnosticSeverity.Info => 2,
_ => 1
};
return new DiagnosticMarker(
Severity: severity,
StartLineNumber: span.Start.Line + 1,
StartColumn: span.Start.Character + 1,
EndLineNumber: span.End.Line + 1,
EndColumn: span.End.Character + 1,
Message: d.GetMessage(),
Code: d.Id);
}
private static int PositionToOffset(string code, int line, int column)
{
var offset = 0;
var currentLine = 1;
var currentCol = 1;
for (int i = 0; i < code.Length; i++)
{
if (currentLine == line && currentCol == column) return offset;
if (code[i] == '\n') { currentLine++; currentCol = 1; }
else { currentCol++; }
offset = i + 1;
}
return code.Length;
}
}