783da8e21a
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.
972 lines
42 KiB
C#
972 lines
42 KiB
C#
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;
|
||
}
|
||
}
|