295150751f
The Test Run sandbox and Monaco analysis modelled a script API that had drifted from the site runtime's ScriptGlobals, so real scripts failed to compile in Test Run. Realign both to the runtime surface (Instance/Scripts/ExternalSystem/Attributes/Children/Parent) and drop the duplicate ScriptHost stub so the two cannot diverge again. - Script calls (Scripts.CallShared, Instance.CallScript, Route.To().Call) accept an anonymous object instead of a hand-built dictionary, via a shared ScriptArgs normalizer; existing dictionary calls still compile. - Test Run can optionally bind to a deployed instance, so Instance/ Attributes/CallScript route to it cross-site; adds site-side RouteToGetAttributes/RouteToSetAttributes handlers. - Adds Test Run panels to the API method and template script editors. - Fixes the TestDatabaseQuery seed script, which queried a table that never existed. Also commits unrelated in-progress work already in the tree: the health monitoring report loop, site streaming changes, and the Admin/Design data-connection and SMTP page reorganization.
1194 lines
52 KiB
C#
1194 lines
52 KiB
C#
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.CodeAnalysis;
|
|
using Microsoft.CodeAnalysis.CSharp;
|
|
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
|
using Microsoft.CodeAnalysis.Scripting;
|
|
using Microsoft.CodeAnalysis.Text;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
|
|
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
|
|
|
/// <summary>
|
|
/// Compiles user scripts as Roslyn C# Scripting fragments against
|
|
/// <see cref="SandboxScriptHost"/> globals (template/shared) or
|
|
/// <see cref="InboundScriptHost"/> (inbound API) and surfaces diagnostics +
|
|
/// completions in the shape Monaco's provider APIs expect.
|
|
///
|
|
/// Diagnostics are cached by code hash via IMemoryCache — Monaco debounces
|
|
/// keystrokes at 500 ms but a typing-then-pausing flow can still re-issue
|
|
/// requests for the same content (window blur/focus, etc.), so the cache
|
|
/// short-circuits repeats. Completions aren't cached: position + form
|
|
/// context vary too much for the hit rate to be useful.
|
|
///
|
|
/// Beyond plain C# analysis, layers SCADA-specific extensions:
|
|
/// - In-string completion of Parameters["..."] keys (from the request's
|
|
/// DeclaredParameters), Scripts.CallShared("...") names (from
|
|
/// <see cref="ISharedScriptCatalog"/>), and Instance.CallScript("...") /
|
|
/// Children["X"].CallScript("...") / Parent.CallScript("...") names
|
|
/// (from the request's SiblingScripts / Children / Parent).
|
|
/// - Forbidden-API diagnostic for the documented script trust model,
|
|
/// resolved against the SemanticModel so user identifiers that happen
|
|
/// to share names with forbidden types (e.g. <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(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
|
|
typeof(Commons.Types.ScriptParameters).Assembly,
|
|
typeof(SandboxScriptHost).Assembly)
|
|
.AddImports(
|
|
"System",
|
|
"System.Collections.Generic",
|
|
"System.Linq",
|
|
"System.Text",
|
|
"System.Threading.Tasks");
|
|
|
|
// Namespaces and types banned by the script trust model.
|
|
// Tasks live under System.Threading.Tasks and remain allowed.
|
|
private static readonly string[] ForbiddenNamespacePrefixes =
|
|
{
|
|
"System.IO",
|
|
"System.Diagnostics",
|
|
"System.Reflection",
|
|
"System.Net",
|
|
"System.Threading.Thread",
|
|
"System.Threading.Tasks.Sources",
|
|
};
|
|
|
|
private readonly ISharedScriptCatalog _sharedScripts;
|
|
private readonly IMemoryCache _cache;
|
|
private readonly IServiceProvider _services;
|
|
|
|
public ScriptAnalysisService(
|
|
ISharedScriptCatalog sharedScripts,
|
|
IMemoryCache cache,
|
|
IServiceProvider services)
|
|
{
|
|
_sharedScripts = sharedScripts;
|
|
_cache = cache;
|
|
_services = services;
|
|
}
|
|
|
|
/// <summary>Globals type a script of the given kind is compiled against.</summary>
|
|
private static Type GlobalsTypeFor(ScriptKind kind) =>
|
|
kind == ScriptKind.InboundApi ? typeof(InboundScriptHost) : typeof(SandboxScriptHost);
|
|
|
|
/// <summary>
|
|
/// Re-enables the nullable annotation context for an analysis compilation.
|
|
/// Roslyn scripting defaults to a disabled nullable context, which makes any
|
|
/// <c>?</c> annotation in a user script raise CS8632. Annotations-only keeps
|
|
/// <c>string?</c> legal without surfacing the nullable-flow warnings.
|
|
/// </summary>
|
|
private static Compilation WithNullableAnnotations(Compilation compilation) =>
|
|
compilation is CSharpCompilation cs
|
|
? cs.WithOptions(cs.Options.WithNullableContextOptions(NullableContextOptions.Annotations))
|
|
: compilation;
|
|
|
|
public DiagnoseResponse Diagnose(DiagnoseRequest request)
|
|
{
|
|
if (string.IsNullOrEmpty(request.Code))
|
|
return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
|
|
|
|
var cacheKey = "diag:" + (int)request.Kind + ":" + 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: GlobalsTypeFor(request.Kind));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
var failure = new DiagnoseResponse(new[]
|
|
{
|
|
new DiagnosticMarker(8, 1, 1, 1, 2, ex.Message, "SCRIPT_BUILD")
|
|
});
|
|
return Cache(cacheKey, failure);
|
|
}
|
|
|
|
var compilation = WithNullableAnnotations(script.GetCompilation());
|
|
var markers = compilation
|
|
.GetDiagnostics()
|
|
.Where(d => d.Severity >= DiagnosticSeverity.Info && d.Location.IsInSource)
|
|
.Select(ToMarker)
|
|
.ToList();
|
|
|
|
var tree = compilation.SyntaxTrees.FirstOrDefault();
|
|
if (tree != null)
|
|
{
|
|
var model = compilation.GetSemanticModel(tree);
|
|
markers.AddRange(FindForbiddenApiUsages(tree, model));
|
|
markers.AddRange(FindUnknownParameterKeys(tree, request.DeclaredParameters));
|
|
markers.AddRange(FindUnknownAttributeKeys(tree, request));
|
|
markers.AddRange(FindUnknownChildren(tree, request.Children));
|
|
}
|
|
|
|
return Cache(cacheKey, new DiagnoseResponse(markers));
|
|
}
|
|
|
|
private const int SandboxMaxTimeoutSeconds = 10;
|
|
private const int SandboxDefaultTimeoutSeconds = 5;
|
|
private const int SandboxMaxConsoleChars = 32_000;
|
|
private const int SandboxMaxReturnJsonChars = 32_000;
|
|
|
|
private const int SandboxMaxCallSharedDepth = 16;
|
|
|
|
/// <summary>
|
|
/// Compiles and runs a script in the central process. The globals surface
|
|
/// depends on <see cref="SandboxRunRequest.Kind"/>: template and shared
|
|
/// scripts run against <see cref="SandboxScriptHost"/>, inbound API method
|
|
/// scripts against <see cref="SandboxInboundScriptHost"/>.
|
|
/// Pure logic + the supplied Parameters always work.
|
|
/// For the SandboxScriptHost surface, <c>Attributes</c> still throws while
|
|
/// <c>External</c>, <c>Database</c>, and <c>Notify</c> are wired to
|
|
/// central's real <see cref="IExternalSystemClient"/>,
|
|
/// <see cref="IDatabaseGateway"/>, and
|
|
/// <see cref="INotificationDeliveryService"/> — calls fire for real and
|
|
/// have production-equivalent side effects (HTTP, SQL, SMTP).
|
|
/// <c>CallShared</c> compiles and executes the named shared script in the
|
|
/// same sandbox, with a recursion limit of
|
|
/// <see cref="SandboxMaxCallSharedDepth"/>. <c>CallScript</c> still throws
|
|
/// because a shared script has no template siblings in this context.
|
|
/// For the SandboxInboundScriptHost surface, every <c>Route</c> call throws
|
|
/// because cross-site routing needs a deployed site.
|
|
/// Console.Out / Console.Error are redirected per-call so writes from
|
|
/// the script land in the result.
|
|
/// </summary>
|
|
public async Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.Code))
|
|
{
|
|
return new SandboxRunResult(
|
|
Success: false,
|
|
ReturnValueJson: null,
|
|
ReturnTypeName: null,
|
|
ConsoleOutput: "",
|
|
Error: "Script code is empty.",
|
|
ErrorKind: SandboxErrorKind.CompileError,
|
|
DurationMs: 0,
|
|
Markers: Array.Empty<DiagnosticMarker>());
|
|
}
|
|
|
|
var timeoutSeconds = Math.Clamp(
|
|
request.TimeoutSeconds ?? SandboxDefaultTimeoutSeconds,
|
|
1, SandboxMaxTimeoutSeconds);
|
|
|
|
var options = DefaultOptions.WithReferences(DefaultOptions.MetadataReferences.Concat(new[]
|
|
{
|
|
Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(SandboxScriptHost).Assembly.Location)
|
|
}));
|
|
|
|
var globalsType = request.Kind == ScriptKind.InboundApi
|
|
? typeof(SandboxInboundScriptHost)
|
|
: typeof(SandboxScriptHost);
|
|
|
|
Script<object> script;
|
|
try
|
|
{
|
|
script = CSharpScript.Create(request.Code, options, globalsType: globalsType);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new SandboxRunResult(false, null, null, "", ex.Message,
|
|
SandboxErrorKind.CompileError, 0,
|
|
new[] { new DiagnosticMarker(8, 1, 1, 1, 2, ex.Message, "SCRIPT_BUILD") });
|
|
}
|
|
|
|
var compileDiagnostics = script.Compile(ct);
|
|
var errorDiagnostics = compileDiagnostics
|
|
.Where(d => d.Severity == DiagnosticSeverity.Error && d.Location.IsInSource)
|
|
.ToList();
|
|
if (errorDiagnostics.Count > 0)
|
|
{
|
|
var markers = errorDiagnostics.Select(ToMarker).ToList();
|
|
return new SandboxRunResult(false, null, null, "",
|
|
string.Join("\n", errorDiagnostics.Select(d => d.GetMessage())),
|
|
SandboxErrorKind.CompileError, 0, markers);
|
|
}
|
|
|
|
var parameters = ConvertJsonParameters(request.Parameters);
|
|
|
|
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
|
|
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
|
|
|
|
// Optional instance binding: when the Test Run targets a deployed
|
|
// instance, Instance.GetAttribute/SetAttribute/CallScript and the
|
|
// Attributes/Children/Parent accessors route to it cross-site.
|
|
ISandboxInstanceGateway? instanceGateway = null;
|
|
var instanceLabel = "test-run";
|
|
if (request.Kind != ScriptKind.InboundApi
|
|
&& !string.IsNullOrWhiteSpace(request.BindInstanceUniqueName))
|
|
{
|
|
var bindName = request.BindInstanceUniqueName.Trim();
|
|
var locator = _services.GetService<IInstanceLocator>();
|
|
var comms = _services.GetService<ScadaLink.Communication.CommunicationService>();
|
|
if (locator == null || comms == null)
|
|
return new SandboxRunResult(false, null, null, "",
|
|
"Instance binding is unavailable — cross-site communication is not configured on this node.",
|
|
SandboxErrorKind.SandboxLimitation, 0, null);
|
|
|
|
var siteId = await locator.GetSiteIdForInstanceAsync(bindName, ct);
|
|
if (siteId == null)
|
|
return new SandboxRunResult(false, null, null, "",
|
|
$"Cannot bind to instance '{bindName}' — it is not deployed or has no assigned site.",
|
|
SandboxErrorKind.SandboxLimitation, 0, null);
|
|
|
|
instanceGateway = new SandboxInstanceGateway(comms, siteId, bindName, linkedCts.Token);
|
|
instanceLabel = bindName;
|
|
}
|
|
|
|
var externalClient = _services.GetService<IExternalSystemClient>();
|
|
var databaseGateway = _services.GetService<IDatabaseGateway>();
|
|
var notifyService = _services.GetService<INotificationDeliveryService>();
|
|
var external = new SandboxExternalHelper(externalClient, instanceLabel);
|
|
var database = new SandboxDatabaseHelper(databaseGateway, instanceLabel);
|
|
var notify = new SandboxNotifyHelper(notifyService, instanceLabel);
|
|
|
|
var compileCache = new Dictionary<string, Script<object>>(StringComparer.Ordinal);
|
|
var compileCacheLock = new object();
|
|
var depth = 0;
|
|
|
|
Func<string, IReadOnlyDictionary<string, object?>?, CancellationToken, Task<object?>>? callSharedFunc = null;
|
|
|
|
// Scripts.CallShared and the Instance helpers share one context across
|
|
// the root script and any nested shared scripts — mirroring the site
|
|
// runtime, where a shared script runs against the caller's Instance.
|
|
var scriptsHelper = new SandboxScriptCallHelper(
|
|
(name, ps, nestedCt) => callSharedFunc!(name, ps, nestedCt));
|
|
var instanceContext = new SandboxInstanceContext(
|
|
gateway: instanceGateway,
|
|
external: external,
|
|
database: database,
|
|
notify: notify,
|
|
scripts: scriptsHelper);
|
|
|
|
callSharedFunc = async (name, ps, nestedCt) =>
|
|
{
|
|
if (string.IsNullOrEmpty(name))
|
|
throw new ScriptSandboxException("Scripts.CallShared called with an empty script name.");
|
|
if (depth >= SandboxMaxCallSharedDepth)
|
|
throw new ScriptSandboxException(
|
|
$"Scripts.CallShared(\"{name}\") exceeded the sandbox recursion limit of {SandboxMaxCallSharedDepth} nested calls.");
|
|
|
|
Script<object>? compiled;
|
|
lock (compileCacheLock) compileCache.TryGetValue(name, out compiled);
|
|
|
|
if (compiled == null)
|
|
{
|
|
var src = await _sharedScripts.GetByNameAsync(name, nestedCt);
|
|
if (src == null)
|
|
throw new ScriptSandboxException(
|
|
$"Scripts.CallShared(\"{name}\") — no shared script with that name is registered in central.");
|
|
|
|
Script<object> built;
|
|
try
|
|
{
|
|
built = CSharpScript.Create(src.Code, options, globalsType: typeof(SandboxScriptHost));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
throw new ScriptSandboxException($"Scripts.CallShared(\"{name}\") compile failed: {ex.Message}");
|
|
}
|
|
var nestedDiag = built.Compile(nestedCt);
|
|
var nestedErrors = nestedDiag
|
|
.Where(d => d.Severity == DiagnosticSeverity.Error && d.Location.IsInSource)
|
|
.ToList();
|
|
if (nestedErrors.Count > 0)
|
|
throw new ScriptSandboxException(
|
|
$"Scripts.CallShared(\"{name}\") compile failed: {string.Join("; ", nestedErrors.Select(d => d.GetMessage()))}");
|
|
|
|
lock (compileCacheLock)
|
|
{
|
|
if (!compileCache.TryGetValue(name, out compiled))
|
|
{
|
|
compileCache[name] = built;
|
|
compiled = built;
|
|
}
|
|
}
|
|
}
|
|
|
|
var nestedHost = new SandboxScriptHost
|
|
{
|
|
Parameters = new Commons.Types.ScriptParameters(ps ?? new Dictionary<string, object?>()),
|
|
CancellationToken = nestedCt,
|
|
Instance = instanceContext,
|
|
};
|
|
|
|
Interlocked.Increment(ref depth);
|
|
try
|
|
{
|
|
var nestedState = await compiled!.RunAsync(nestedHost, nestedCt).ConfigureAwait(false);
|
|
return nestedState.ReturnValue;
|
|
}
|
|
finally
|
|
{
|
|
Interlocked.Decrement(ref depth);
|
|
}
|
|
};
|
|
|
|
// Inbound API scripts see a different globals surface (Parameters +
|
|
// Route); template and shared scripts see the SandboxScriptHost surface
|
|
// mirroring the site runtime's ScriptGlobals.
|
|
object host = request.Kind == ScriptKind.InboundApi
|
|
? new SandboxInboundScriptHost
|
|
{
|
|
Parameters = new Commons.Types.ScriptParameters(parameters),
|
|
CancellationToken = linkedCts.Token,
|
|
}
|
|
: new SandboxScriptHost
|
|
{
|
|
Parameters = new Commons.Types.ScriptParameters(parameters),
|
|
CancellationToken = linkedCts.Token,
|
|
Instance = instanceContext,
|
|
};
|
|
|
|
var originalOut = Console.Out;
|
|
var originalError = Console.Error;
|
|
var captured = new StringWriter();
|
|
|
|
var stopwatch = Stopwatch.StartNew();
|
|
try
|
|
{
|
|
Console.SetOut(captured);
|
|
Console.SetError(captured);
|
|
|
|
// Run on a thread-pool thread with no SynchronizationContext: a
|
|
// bound script's Instance.SetAttribute / Attributes[...] block
|
|
// synchronously on cross-site I/O (the API surface is sync by
|
|
// contract), which would deadlock against the Blazor circuit's
|
|
// captured context if the script ran inline.
|
|
var state = await Task.Run(
|
|
() => script.RunAsync(host, linkedCts.Token), linkedCts.Token)
|
|
.ConfigureAwait(false);
|
|
stopwatch.Stop();
|
|
var (returnJson, returnType) = SerializeReturn(state.ReturnValue);
|
|
return new SandboxRunResult(
|
|
Success: true,
|
|
ReturnValueJson: returnJson,
|
|
ReturnTypeName: returnType,
|
|
ConsoleOutput: TruncateConsole(captured.ToString()),
|
|
Error: null,
|
|
ErrorKind: SandboxErrorKind.None,
|
|
DurationMs: stopwatch.ElapsedMilliseconds,
|
|
Markers: null);
|
|
}
|
|
catch (ScriptSandboxException sandboxEx)
|
|
{
|
|
stopwatch.Stop();
|
|
return new SandboxRunResult(false, null, null,
|
|
TruncateConsole(captured.ToString()), sandboxEx.Message,
|
|
SandboxErrorKind.SandboxLimitation, stopwatch.ElapsedMilliseconds, null);
|
|
}
|
|
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
|
|
{
|
|
stopwatch.Stop();
|
|
return new SandboxRunResult(false, null, null,
|
|
TruncateConsole(captured.ToString()),
|
|
$"Script execution exceeded the {timeoutSeconds}-second sandbox timeout.",
|
|
SandboxErrorKind.Timeout, stopwatch.ElapsedMilliseconds, null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
stopwatch.Stop();
|
|
var inner = ex is Microsoft.CodeAnalysis.Scripting.CompilationErrorException ? ex : (ex.InnerException ?? ex);
|
|
if (inner is ScriptSandboxException sx)
|
|
{
|
|
return new SandboxRunResult(false, null, null,
|
|
TruncateConsole(captured.ToString()), sx.Message,
|
|
SandboxErrorKind.SandboxLimitation, stopwatch.ElapsedMilliseconds, null);
|
|
}
|
|
return new SandboxRunResult(false, null, null,
|
|
TruncateConsole(captured.ToString()),
|
|
$"{inner.GetType().Name}: {inner.Message}",
|
|
SandboxErrorKind.RuntimeError, stopwatch.ElapsedMilliseconds, null);
|
|
}
|
|
finally
|
|
{
|
|
Console.SetOut(originalOut);
|
|
Console.SetError(originalError);
|
|
}
|
|
}
|
|
|
|
private static Dictionary<string, object?> ConvertJsonParameters(
|
|
Dictionary<string, JsonElement>? parameters)
|
|
{
|
|
var result = new Dictionary<string, object?>(StringComparer.Ordinal);
|
|
if (parameters == null) return result;
|
|
foreach (var (key, value) in parameters)
|
|
{
|
|
result[key] = JsonElementToObject(value);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private static object? JsonElementToObject(JsonElement element)
|
|
{
|
|
return element.ValueKind switch
|
|
{
|
|
JsonValueKind.String => element.GetString(),
|
|
JsonValueKind.Number => element.TryGetInt64(out var i) ? (object)i : element.GetDouble(),
|
|
JsonValueKind.True => true,
|
|
JsonValueKind.False => false,
|
|
JsonValueKind.Null => null,
|
|
JsonValueKind.Undefined => null,
|
|
JsonValueKind.Array => element.EnumerateArray().Select(JsonElementToObject).ToList(),
|
|
JsonValueKind.Object => element.EnumerateObject()
|
|
.ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)),
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
private static (string Json, string TypeName) SerializeReturn(object? value)
|
|
{
|
|
if (value == null) return ("null", "null");
|
|
var typeName = value.GetType().Name;
|
|
try
|
|
{
|
|
var json = JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true });
|
|
if (json.Length > SandboxMaxReturnJsonChars)
|
|
json = json[..SandboxMaxReturnJsonChars] + "\n… (truncated)";
|
|
return (json, typeName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return ($"\"<unserializable: {ex.Message}>\"", typeName);
|
|
}
|
|
}
|
|
|
|
private static string TruncateConsole(string text)
|
|
{
|
|
if (text.Length <= SandboxMaxConsoleChars) return text;
|
|
return text[..SandboxMaxConsoleChars] + "\n… (truncated)";
|
|
}
|
|
|
|
private DiagnoseResponse Cache(string key, DiagnoseResponse value)
|
|
{
|
|
_cache.Set(key, value, new MemoryCacheEntryOptions
|
|
{
|
|
Size = 1,
|
|
SlidingExpiration = TimeSpan.FromMinutes(5)
|
|
});
|
|
return value;
|
|
}
|
|
|
|
private static string HashCode(string code)
|
|
{
|
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(code));
|
|
return Convert.ToHexString(bytes);
|
|
}
|
|
|
|
public async Task<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: GlobalsTypeFor(request.Kind));
|
|
}
|
|
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();
|
|
}
|
|
}
|
|
|
|
// Scripts.CallShared("...") / Instance.CallScript("...") /
|
|
// Children["X"].CallScript("...") / Parent.CallScript("...")
|
|
if (owner is InvocationExpressionSyntax inv)
|
|
{
|
|
var call = ClassifyScriptCall(inv);
|
|
switch (call.Kind)
|
|
{
|
|
case ScriptCallKind.Shared:
|
|
{
|
|
var shapes = await _sharedScripts.GetShapesAsync();
|
|
return shapes.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
|
|
}
|
|
case ScriptCallKind.Sibling:
|
|
return (request.SiblingScripts ?? Array.Empty<ScriptShape>())
|
|
.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
|
|
case ScriptCallKind.Parent:
|
|
return (request.Parent?.Scripts ?? Array.Empty<ScriptShape>())
|
|
.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
|
|
case ScriptCallKind.Child:
|
|
{
|
|
var comp = (request.Children ?? Array.Empty<CompositionContext>())
|
|
.FirstOrDefault(c => c.Name == call.CompositionName);
|
|
return comp != null
|
|
? comp.Scripts.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList()
|
|
: new List<CompletionItem>();
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a Monaco snippet that fills the call after the name, e.g.
|
|
/// <c>Greet", new { name = ${1:name}, count = ${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)
|
|
{
|
|
// The runtime call API takes the arguments as an anonymous object; the
|
|
// snippet emits one member per declared parameter.
|
|
string insertText;
|
|
const int insertAsSnippet = 4;
|
|
if (shape.Parameters.Count == 0)
|
|
{
|
|
insertText = shape.Name + "\")";
|
|
}
|
|
else
|
|
{
|
|
var entries = string.Join(", ", shape.Parameters.Select((p, i) =>
|
|
$"{p.Name} = ${{{i + 1}:{p.Name}}}"));
|
|
insertText = $"{shape.Name}\", new {{ {entries} }})";
|
|
}
|
|
var paramList = string.Join(", ", shape.Parameters.Select(p => $"{p.Name}: {p.Type}"));
|
|
var returnType = shape.ReturnType ?? "void";
|
|
return new CompletionItem(
|
|
Label: shape.Name,
|
|
InsertText: insertText,
|
|
Detail: $"{detail} ({paramList}) -> {returnType}",
|
|
Kind: "Method",
|
|
InsertTextRules: insertAsSnippet);
|
|
}
|
|
|
|
public FormatResponse Format(FormatRequest request)
|
|
{
|
|
if (string.IsNullOrEmpty(request.Code))
|
|
return new FormatResponse(request.Code);
|
|
try
|
|
{
|
|
var tree = CSharpSyntaxTree.ParseText(
|
|
request.Code,
|
|
new CSharpParseOptions(LanguageVersion.Latest, kind: SourceCodeKind.Script));
|
|
// NormalizeWhitespace produces canonical layout (indentation + line
|
|
// breaks). Formatter.Format alone with an empty workspace only
|
|
// normalizes inter-token spacing — it won't split crammed lines.
|
|
var formatted = tree.GetRoot().NormalizeWhitespace(indentation: " ", eol: "\n");
|
|
return new FormatResponse(formatted.ToFullString());
|
|
}
|
|
catch
|
|
{
|
|
return new FormatResponse(request.Code);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parameter-name inlay hints are obsolete under the runtime call API:
|
|
/// Scripts.CallShared / Instance.CallScript pass arguments as an explicit
|
|
/// <c>IReadOnlyDictionary</c> literal (<c>{ ["p"] = … }</c>), which is
|
|
/// already self-labelling — there are no positional arguments to annotate.
|
|
/// </summary>
|
|
public InlayHintsResponse InlayHints(InlayHintsRequest request) =>
|
|
new(Array.Empty<InlayHint>());
|
|
|
|
public HoverResponse Hover(HoverRequest request)
|
|
{
|
|
var script = TryParse(request.CodeText);
|
|
if (script == null) return new HoverResponse(null);
|
|
var (tree, _) = script.Value;
|
|
var position = PositionToOffset(request.CodeText, request.Line, request.Column);
|
|
position = Math.Clamp(position, 0, request.CodeText.Length);
|
|
|
|
var root = tree.GetRoot();
|
|
var token = root.FindToken(Math.Max(0, position - 1));
|
|
if (!token.IsKind(SyntaxKind.StringLiteralToken)) return new HoverResponse(null);
|
|
|
|
var literalNode = token.Parent as LiteralExpressionSyntax;
|
|
var argument = literalNode?.Parent as ArgumentSyntax;
|
|
var argumentList = argument?.Parent;
|
|
var owner = argumentList?.Parent;
|
|
|
|
// Parameters["name"] → show declared type
|
|
if (owner is ElementAccessExpressionSyntax elem
|
|
&& elem.Expression is IdentifierNameSyntax pid
|
|
&& pid.Identifier.ValueText == "Parameters")
|
|
{
|
|
var key = token.ValueText;
|
|
var p = request.DeclaredParameters?.FirstOrDefault(x => x.Name == key);
|
|
if (p != null)
|
|
{
|
|
var req = p.Required ? "" : "?";
|
|
return new HoverResponse(
|
|
$"**parameter** `{p.Name}: {p.Type}{req}`");
|
|
}
|
|
return new HoverResponse(null);
|
|
}
|
|
|
|
if (owner is not InvocationExpressionSyntax inv) return new HoverResponse(null);
|
|
|
|
var call = ClassifyScriptCall(inv);
|
|
if (call.Kind == ScriptCallKind.None) return new HoverResponse(null);
|
|
var rawName = token.ValueText;
|
|
if (string.IsNullOrEmpty(rawName)) return new HoverResponse(null);
|
|
|
|
var shape = ResolveCalledShape(
|
|
call, rawName, request.SiblingScripts, request.Children, request.Parent);
|
|
if (shape == null) return new HoverResponse(null);
|
|
return new HoverResponse(FormatHover(shape, call));
|
|
}
|
|
|
|
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest request)
|
|
{
|
|
var empty = new SignatureHelpResponse(null, null, 0);
|
|
var script = TryParse(request.CodeText);
|
|
if (script == null) return empty;
|
|
var (tree, _) = script.Value;
|
|
var position = PositionToOffset(request.CodeText, request.Line, request.Column);
|
|
position = Math.Clamp(position, 0, request.CodeText.Length);
|
|
|
|
var root = tree.GetRoot();
|
|
var token = root.FindToken(Math.Max(0, position - 1));
|
|
|
|
// Walk up to the nearest enclosing InvocationExpression. Don't require
|
|
// ArgumentList.Span to strictly contain the cursor — for an incomplete
|
|
// call like CallScript("Calc", 1, ) the span ends before trailing
|
|
// whitespace, so a strict contains-check would miss it.
|
|
InvocationExpressionSyntax? inv = null;
|
|
for (var node = token.Parent; node != null; node = node.Parent)
|
|
{
|
|
if (node is InvocationExpressionSyntax candidate)
|
|
{
|
|
inv = candidate;
|
|
break;
|
|
}
|
|
}
|
|
if (inv == null) return empty;
|
|
|
|
var call = ClassifyScriptCall(inv);
|
|
if (call.Kind == ScriptCallKind.None) return empty;
|
|
|
|
// First argument is the name literal; pull it out.
|
|
if (inv.ArgumentList.Arguments.Count < 1) return empty;
|
|
var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
|
|
var scriptName = nameArg?.Token.ValueText ?? "";
|
|
|
|
var shape = ResolveCalledShape(
|
|
call, scriptName, request.SiblingScripts, request.Children, request.Parent);
|
|
if (shape == null) return empty;
|
|
|
|
var paramLabels = shape.Parameters.Select(p => $"{p.Name}: {p.Type}").ToList();
|
|
var label = $"{CallLabel(call)}(\"{shape.Name}\"" +
|
|
(paramLabels.Count > 0 ? ", " + string.Join(", ", paramLabels) : "") + ")";
|
|
|
|
// ActiveParameter: count commas in ArgumentList before the cursor; subtract 1 because
|
|
// the first arg is the name literal.
|
|
int activeIndex = 0;
|
|
foreach (var arg in inv.ArgumentList.Arguments)
|
|
{
|
|
if (arg.Span.End < position) activeIndex++;
|
|
else break;
|
|
}
|
|
activeIndex = Math.Clamp(activeIndex - 1, 0, Math.Max(0, paramLabels.Count - 1));
|
|
|
|
return new SignatureHelpResponse(
|
|
Label: label,
|
|
Parameters: paramLabels
|
|
.Select((lbl, i) => new SignatureHelpParameter(lbl, shape.Parameters[i].Required ? null : "optional"))
|
|
.ToList(),
|
|
ActiveParameter: activeIndex);
|
|
}
|
|
|
|
private (SyntaxTree tree, Compilation compilation)? TryParse(string code)
|
|
{
|
|
if (string.IsNullOrEmpty(code)) return null;
|
|
try
|
|
{
|
|
var s = CSharpScript.Create(code, DefaultOptions, globalsType: typeof(SandboxScriptHost));
|
|
var compilation = s.GetCompilation();
|
|
var tree = compilation.SyntaxTrees.FirstOrDefault();
|
|
return tree == null ? null : (tree, compilation);
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static string FormatHover(ScriptShape shape, ScriptCallInfo call)
|
|
{
|
|
var ps = shape.Parameters.Count == 0
|
|
? "(no parameters)"
|
|
: string.Join(", ", shape.Parameters.Select(p => $"{p.Name}: {p.Type}{(p.Required ? "" : "?")}"));
|
|
var rt = shape.ReturnType ?? "void";
|
|
return $"**{CallDetail(call)}** `{shape.Name}`\n\n```\n{shape.Name}({ps}): {rt}\n```";
|
|
}
|
|
|
|
private static List<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 enum ScriptCallKind { None, Shared, Sibling, Child, Parent }
|
|
|
|
/// <summary>A classified script-call invocation: which kind, and (for a child) the composition name.</summary>
|
|
private readonly record struct ScriptCallInfo(ScriptCallKind Kind, string? CompositionName);
|
|
|
|
/// <summary>
|
|
/// Classifies an invocation against the runtime call surface:
|
|
/// <c>Scripts.CallShared(...)</c>, <c>Instance.CallScript(...)</c>,
|
|
/// <c>Children["X"].CallScript(...)</c>, and <c>Parent.CallScript(...)</c>.
|
|
/// The first argument of each is the called script's name literal.
|
|
/// </summary>
|
|
private static ScriptCallInfo ClassifyScriptCall(InvocationExpressionSyntax inv)
|
|
{
|
|
if (inv.Expression is not MemberAccessExpressionSyntax ma)
|
|
return new ScriptCallInfo(ScriptCallKind.None, null);
|
|
|
|
var method = ma.Name.Identifier.ValueText;
|
|
|
|
if (method == "CallShared"
|
|
&& ma.Expression is IdentifierNameSyntax sid && sid.Identifier.ValueText == "Scripts")
|
|
return new ScriptCallInfo(ScriptCallKind.Shared, null);
|
|
|
|
if (method == "CallScript")
|
|
{
|
|
if (ma.Expression is IdentifierNameSyntax iid)
|
|
{
|
|
if (iid.Identifier.ValueText == "Instance")
|
|
return new ScriptCallInfo(ScriptCallKind.Sibling, null);
|
|
if (iid.Identifier.ValueText == "Parent")
|
|
return new ScriptCallInfo(ScriptCallKind.Parent, null);
|
|
}
|
|
if (ma.Expression is ElementAccessExpressionSyntax childElem
|
|
&& childElem.Expression is IdentifierNameSyntax cid && cid.Identifier.ValueText == "Children"
|
|
&& childElem.ArgumentList.Arguments.Count == 1
|
|
&& childElem.ArgumentList.Arguments[0].Expression is LiteralExpressionSyntax cLit
|
|
&& cLit.IsKind(SyntaxKind.StringLiteralExpression))
|
|
return new ScriptCallInfo(ScriptCallKind.Child, cLit.Token.ValueText);
|
|
}
|
|
|
|
return new ScriptCallInfo(ScriptCallKind.None, null);
|
|
}
|
|
|
|
/// <summary>Human-readable call expression, e.g. <c>Scripts.CallShared</c>.</summary>
|
|
private static string CallLabel(ScriptCallInfo call) => call.Kind switch
|
|
{
|
|
ScriptCallKind.Shared => "Scripts.CallShared",
|
|
ScriptCallKind.Sibling => "Instance.CallScript",
|
|
ScriptCallKind.Parent => "Parent.CallScript",
|
|
ScriptCallKind.Child => $"Children[\"{call.CompositionName}\"].CallScript",
|
|
_ => "call"
|
|
};
|
|
|
|
/// <summary>Short description of what the call targets, for completions/hover.</summary>
|
|
private static string CallDetail(ScriptCallInfo call) => call.Kind switch
|
|
{
|
|
ScriptCallKind.Shared => "shared script",
|
|
ScriptCallKind.Sibling => "sibling script",
|
|
ScriptCallKind.Parent => "parent script",
|
|
ScriptCallKind.Child => $"script on {call.CompositionName}",
|
|
_ => "script"
|
|
};
|
|
|
|
/// <summary>Resolves the called script's shape from the metadata in scope for its kind.</summary>
|
|
private ScriptShape? ResolveCalledShape(
|
|
ScriptCallInfo call,
|
|
string scriptName,
|
|
IReadOnlyList<ScriptShape>? siblings,
|
|
IReadOnlyList<CompositionContext>? children,
|
|
CompositionContext? parent) => call.Kind switch
|
|
{
|
|
ScriptCallKind.Shared => _sharedScripts.GetShapesAsync().GetAwaiter().GetResult()
|
|
.FirstOrDefault(s => s.Name == scriptName),
|
|
ScriptCallKind.Sibling => siblings?.FirstOrDefault(s => s.Name == scriptName),
|
|
ScriptCallKind.Parent => parent?.Scripts.FirstOrDefault(s => s.Name == scriptName),
|
|
ScriptCallKind.Child => children?.FirstOrDefault(c => c.Name == call.CompositionName)
|
|
?.Scripts.FirstOrDefault(s => s.Name == scriptName),
|
|
_ => null
|
|
};
|
|
|
|
/// <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 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;
|
|
}
|
|
}
|