a9e8f5abfe
I-1 - a non-C# paste produced thousands of Roslyn markers. The Problems-list report sent them all as one JS->.NET message, which could exceed the 1 MB hub limit and kill the circuit. The JS now sends at most MaxReportedMarkers (200) markers, errors first, each message cut to 300 chars, plus the total. ScriptProblemReport.FromEditor clamps again on arrival, and the Problems list shows "showing N of Total" / "+N more". The diagnostics endpoint also bounds its response: Distinct, errors first, MaxDiagnosticMarkers (1000), messages cut to 1000 chars. An error is always kept, so editor-accepts == publish-accepts. M-1 - drop the per-instance compilation lineage; analyses derive from BaseCompilation. M-2 - the VirtualTag modal clears its problems (and editor ref) on close and on a script reload. M-5 - an over-limit text is announced at blur (OnSyncRefused), not only at Save. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
562 lines
33 KiB
C#
562 lines
33 KiB
C#
using Microsoft.CodeAnalysis;
|
|
using Microsoft.CodeAnalysis.CSharp;
|
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
|
using Microsoft.Extensions.Logging;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
|
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
|
|
|
/// <summary>
|
|
/// Roslyn-backed analysis for the Monaco virtual-tag script editor. Re-seats the
|
|
/// scadabridge analysis surface on OtOpcUa's REAL evaluator wrapper: the user's
|
|
/// source is wrapped in the same synthesized <c>CompiledScript.Run(globals)</c> shape
|
|
/// that <see cref="ScriptEvaluator{TContext, TResult}"/> compiles, so diagnostics,
|
|
/// completions, hover, and signature help all see the exact symbol surface a published
|
|
/// script gets. Built against the <see cref="ScriptSandbox"/> allow-list for the
|
|
/// <see cref="VirtualTagContext"/> context.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>All capabilities are fully implemented: Diagnose, Complete, Hover, SignatureHelp, and Format
|
|
/// are wired and backed by the real Roslyn document/analysis pipeline.</para>
|
|
/// <para><b>Cost model.</b> Every analysis derives from one process-wide <see cref="BaseCompilation"/>
|
|
/// via <see cref="CSharpCompilation.ReplaceSyntaxTree"/>, so the sandbox reference set is bound once rather
|
|
/// than per keystroke, and every entry point takes the request's <see cref="CancellationToken"/>
|
|
/// (<c>HttpContext.RequestAborted</c>) so an abandoned request stops instead of running to completion.</para>
|
|
/// </remarks>
|
|
public sealed class ScriptAnalysisService
|
|
{
|
|
private static readonly SandboxConfig Sandbox = ScriptSandbox.Build(typeof(VirtualTagContext));
|
|
private static readonly string Preamble = BuildPreamble();
|
|
private static readonly CSharpCompilationOptions CompileOptions = new(
|
|
OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release,
|
|
allowUnsafe: false, warningLevel: 4, nullableContextOptions: NullableContextOptions.Enable);
|
|
|
|
private readonly IScriptTagCatalog? _catalog;
|
|
private readonly ILogger<ScriptAnalysisService>? _logger;
|
|
/// <summary>Creates the script-analysis service.</summary>
|
|
/// <param name="catalog">The tag catalog used to resolve tag-path completions/hover, or null to disable tag-path features.</param>
|
|
/// <param name="logger">The logger used to record analysis failures, or null to disable logging.</param>
|
|
public ScriptAnalysisService(IScriptTagCatalog? catalog = null, ILogger<ScriptAnalysisService>? logger = null)
|
|
{
|
|
_catalog = catalog;
|
|
_logger = logger;
|
|
}
|
|
|
|
// Mirrors ScriptEvaluator's wrapper EXACTLY (usings from the sandbox + the
|
|
// Run(ScriptGlobals<VirtualTagContext>) shape), except the analysis return type is
|
|
// `object` so a shared script stays valid regardless of any virtual tag's DataType.
|
|
private static string BuildPreamble()
|
|
{
|
|
var usings = string.Join("\n", Sandbox.Imports.Select(i => $"using {i};"));
|
|
var globalsType = $"global::{typeof(ScriptGlobals<>).Namespace}.ScriptGlobals<global::{typeof(VirtualTagContext).FullName}>";
|
|
return usings + "\n\n"
|
|
+ "namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Compiled;\n"
|
|
+ "public static class CompiledScript\n{\n"
|
|
+ $" public static object Run({globalsType} globals)\n {{\n"
|
|
+ " var ctx = globals.ctx;\n#line 1\n";
|
|
}
|
|
|
|
private static SyntaxTree WrapAndParse(string userSource, CancellationToken ct)
|
|
=> CSharpSyntaxTree.ParseText(Preamble + (userSource ?? "") + "\n }\n}\n", cancellationToken: ct);
|
|
|
|
/// <summary>
|
|
/// The process-wide compilation every analysis derives from. Its sandbox reference set is bound ONCE
|
|
/// (forced here by resolving <c>System.Object</c>); <see cref="CSharpCompilation.ReplaceSyntaxTree"/> keeps
|
|
/// that binding, so a request only re-binds the user's tree instead of the whole reference set. It is
|
|
/// immutable, so sharing it across circuits and requests is thread-safe.
|
|
/// </summary>
|
|
internal static readonly CSharpCompilation BaseCompilation = CreateBaseCompilation();
|
|
|
|
private static CSharpCompilation CreateBaseCompilation()
|
|
{
|
|
var compilation = CSharpCompilation.Create(
|
|
"ScriptAnalysis", new[] { WrapAndParse("", CancellationToken.None) }, Sandbox.References, CompileOptions);
|
|
_ = compilation.GetSpecialType(SpecialType.System_Object);
|
|
return compilation;
|
|
}
|
|
|
|
// userSource is appended after Preamble, then the method/class are closed. Every analysis derives straight
|
|
// from the shared BaseCompilation: the analysis endpoints are minimal-API handlers, so a scoped service lives
|
|
// for one HTTP request and a per-instance lineage would never be more than one step long.
|
|
private static (SyntaxTree Tree, CSharpCompilation Compilation, SemanticModel Model, int PreambleLength) Analyze(
|
|
string userSource, CancellationToken ct)
|
|
{
|
|
var tree = WrapAndParse(userSource, ct);
|
|
var compilation = BaseCompilation.ReplaceSyntaxTree(BaseCompilation.SyntaxTrees[0], tree);
|
|
return (tree, compilation, compilation.GetSemanticModel(tree), Preamble.Length);
|
|
}
|
|
|
|
/// <summary>Test seam: the compilation one analysis of <paramref name="userSource"/> runs against.</summary>
|
|
internal CSharpCompilation CompilationFor(string userSource) => Analyze(Normalize(userSource), CancellationToken.None).Compilation;
|
|
|
|
// editor (line,col) in USER-source coords -> physical offset in the wrapped document
|
|
private static int OffsetInWrapped(string userSource, int line, int column, int preambleLength)
|
|
=> preambleLength + Math.Clamp(PositionToOffset(userSource ?? "", line, column), 0, (userSource ?? "").Length);
|
|
|
|
private static int PositionToOffset(string code, int line, int column)
|
|
{
|
|
int offset = 0, curLine = 1, curCol = 1;
|
|
for (int i = 0; i < code.Length; i++)
|
|
{
|
|
if (curLine == line && curCol == column) return offset;
|
|
if (code[i] == '\n') { curLine++; curCol = 1; } else { curCol++; }
|
|
offset = i + 1;
|
|
}
|
|
return code.Length;
|
|
}
|
|
|
|
private static string Normalize(string? s) => (s ?? "").Replace("\r\n", "\n").Replace("\r", "\n");
|
|
|
|
/// <summary>Compiles the script and returns compile-error diagnostics plus sandbox-policy violations
|
|
/// (forbidden types, disallowed dynamic tag paths).</summary>
|
|
/// <param name="req">The diagnose request carrying the script source.</param>
|
|
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
|
|
/// <returns>The list of diagnostic markers, empty when the script is clean or analysis fails.</returns>
|
|
public DiagnoseResponse Diagnose(DiagnoseRequest req, CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrEmpty(req.Code)) return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
|
|
try
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var code = Normalize(req.Code);
|
|
var (tree, compilation, _, preambleLength) = Analyze(code, ct);
|
|
var markers = new List<DiagnosticMarker>();
|
|
|
|
// (1) Roslyn — surface ONLY compile errors, the same severity that actually blocks a
|
|
// publish in ScriptEvaluator.Compile (it filters GetDiagnostics() to
|
|
// DiagnosticSeverity.Error). Mirroring that here keeps editor markers in lockstep with
|
|
// what fails publish: a nullable warning like CS8605 on the canonical passthrough shape
|
|
// `return ctx.GetTag("X").Value;` compiles + publishes fine under the evaluator's
|
|
// nullable-enabled options, so flagging it in the editor would be misleading noise.
|
|
// Restricted to diagnostics physically inside the user source — #line 1 in the preamble
|
|
// makes GetMappedLineSpan report user-source coords (GetLineSpan would return the
|
|
// physical wrapper line and be wrong); the preambleLength guard also drops phantom
|
|
// wrapper diagnostics like CS0161 on Run() when the user hasn't typed `return` yet.
|
|
foreach (var d in compilation.GetDiagnostics(ct))
|
|
{
|
|
if (d.Severity != DiagnosticSeverity.Error || !d.Location.IsInSource) continue;
|
|
if (d.Location.SourceSpan.Start < preambleLength) continue;
|
|
var span = d.Location.GetMappedLineSpan(); // honors #line 1 → user coords
|
|
markers.Add(ToMarker(span, Sev(d.Severity), d.GetMessage(), d.Id));
|
|
}
|
|
// (2) ForbiddenTypeAnalyzer — spans are in the wrapped tree; map via #line.
|
|
ct.ThrowIfCancellationRequested();
|
|
foreach (var r in ForbiddenTypeAnalyzer.Analyze(compilation))
|
|
markers.Add(ToMarker(tree.GetMappedLineSpan(r.Span), 8, r.Message, "OTSCRIPT_FORBIDDEN"));
|
|
ct.ThrowIfCancellationRequested();
|
|
// (3) DependencyExtractor — spans are offsets in the normalized user source; map directly.
|
|
foreach (var r in DependencyExtractor.Extract(code).Rejections)
|
|
markers.Add(ToUserMarker(code, r.Span, r.Message, "OTSCRIPT_DYNPATH"));
|
|
// (4) ctx.SetVirtualTag(...) is inert at runtime — a WARNING (severity 4), never an error: publish
|
|
// accepts the script, so the editor must too; the marker just puts the hover's caveat in the Problems list.
|
|
foreach (var inv in SetVirtualTagCalls(tree.GetRoot(ct), preambleLength))
|
|
markers.Add(ToMarker(tree.GetMappedLineSpan(inv.Span), 4, SetVirtualTagInertMessage, "OTSCRIPT_SETVTAG_INERT"));
|
|
|
|
return new DiagnoseResponse(Bound(markers));
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogWarning(ex, "Script diagnostics failed; returning no markers.");
|
|
return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
|
|
}
|
|
}
|
|
|
|
// ctx.GetTag("…") / ctx.SetVirtualTag("…") first-arg literal — three-part capture, mirroring
|
|
// EquipmentScriptPaths.PathLiteralRegex so the editor's {{equip}}/<RefName> diagnostic is scoped to the
|
|
// SAME path literals the compose seams substitute (never a comment / logger string).
|
|
private static readonly System.Text.RegularExpressions.Regex EquipPathLiteralRegex =
|
|
new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")",
|
|
System.Text.RegularExpressions.RegexOptions.Compiled);
|
|
|
|
/// <summary>Diagnostics plus the v3 equipment-relative <c>{{equip}}/<RefName></c> resolution check.
|
|
/// Runs the synchronous Roslyn/policy <see cref="Diagnose"/> then, when an equipment context + catalog
|
|
/// are present, appends a marker for each <c>{{equip}}/<RefName></c> whose <c><RefName></c> is
|
|
/// not one of the equipment's reference effective names — flagging exactly what the deploy gate
|
|
/// (<c>DraftValidator.ValidateEquipReferenceResolution</c>) rejects, so the editor accepts ⇔ publish
|
|
/// accepts. Inert (base markers only) on the shared ScriptEdit page where <c>EquipmentId</c> is null.</summary>
|
|
/// <param name="req">The diagnose request (its <c>EquipmentId</c> carries the owning-equipment context).</param>
|
|
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
|
|
/// <returns>The base diagnostics plus any unresolved-reference markers.</returns>
|
|
public async Task<DiagnoseResponse> DiagnoseAsync(DiagnoseRequest req, CancellationToken ct = default)
|
|
{
|
|
var baseResp = Diagnose(req, ct);
|
|
if (_catalog is null || string.IsNullOrEmpty(req.EquipmentId) || string.IsNullOrEmpty(req.Code))
|
|
return baseResp;
|
|
try
|
|
{
|
|
var code = Normalize(req.Code);
|
|
if (!code.Contains(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal))
|
|
return baseResp;
|
|
|
|
var names = await _catalog.GetEquipmentReferenceNamesAsync(req.EquipmentId, null, ct);
|
|
var resolvable = new HashSet<string>(names, StringComparer.Ordinal);
|
|
var markers = new List<DiagnosticMarker>(baseResp.Markers);
|
|
|
|
foreach (System.Text.RegularExpressions.Match m in EquipPathLiteralRegex.Matches(code))
|
|
{
|
|
var content = m.Groups[2].Value;
|
|
if (!content.StartsWith(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal)) continue;
|
|
var refName = content.Substring(EquipmentScriptPaths.EquipTokenPrefix.Length);
|
|
if (refName.Length == 0 || resolvable.Contains(refName)) continue;
|
|
// Mark the whole literal content span (the {{equip}}/<RefName> path).
|
|
var span = new Microsoft.CodeAnalysis.Text.TextSpan(m.Groups[2].Index, content.Length);
|
|
markers.Add(ToUserMarker(code, span,
|
|
$"'{EquipmentScriptPaths.EquipTokenPrefix}{refName}' does not resolve — this equipment has no " +
|
|
$"reference named '{refName}'. Add a matching reference or correct the path.",
|
|
"OTSCRIPT_EQUIPREF"));
|
|
}
|
|
return new DiagnoseResponse(Bound(markers));
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogWarning(ex, "Script equip-ref diagnostics failed; returning base markers.");
|
|
return baseResp;
|
|
}
|
|
}
|
|
|
|
// Bounds what the diagnostics endpoint returns (and so what Monaco renders and the Problems list is fed from):
|
|
// de-duplicated, errors first then by position, at most MaxDiagnosticMarkers, each message cut to
|
|
// MaxDiagnosticMessageChars. Ordering before the cut keeps an error whenever one exists, so the editor still
|
|
// rejects exactly what publish rejects even for a pathological (e.g. non-C#) paste.
|
|
private static IReadOnlyList<DiagnosticMarker> Bound(IEnumerable<DiagnosticMarker> markers)
|
|
=> ScriptProblems.Order(markers.Distinct())
|
|
.Take(ScriptEditorLimits.MaxDiagnosticMarkers)
|
|
.Select(m => m.Message.Length <= ScriptEditorLimits.MaxDiagnosticMessageChars
|
|
? m
|
|
: m with { Message = ScriptProblems.Truncate(m.Message, ScriptEditorLimits.MaxDiagnosticMessageChars) })
|
|
.ToList();
|
|
|
|
private const string SetVirtualTagInertMessage =
|
|
"ctx.SetVirtualTag(...) has no effect at runtime: cross-tag writes are dropped in single-tag mode (the " +
|
|
"cascade engine is not wired into the host). The script compiles and publishes, but this write will not " +
|
|
"take effect.";
|
|
|
|
// Every ctx.SetVirtualTag(...) invocation in the user source — the same `ctx`-anchored receiver guard as the
|
|
// tag-path completion (TryGetTagPathLiteral), so comments, string literals and other receivers are ignored.
|
|
private static IEnumerable<InvocationExpressionSyntax> SetVirtualTagCalls(SyntaxNode root, int preambleLength)
|
|
=> root.DescendantNodes()
|
|
.OfType<InvocationExpressionSyntax>()
|
|
.Where(inv => inv.SpanStart >= preambleLength
|
|
&& inv.Expression is MemberAccessExpressionSyntax
|
|
{
|
|
Expression: IdentifierNameSyntax { Identifier.ValueText: "ctx" },
|
|
Name.Identifier.ValueText: nameof(ScriptContext.SetVirtualTag),
|
|
});
|
|
|
|
private static int Sev(DiagnosticSeverity s) => s switch
|
|
{
|
|
DiagnosticSeverity.Error => 8,
|
|
DiagnosticSeverity.Warning => 4,
|
|
DiagnosticSeverity.Info => 2,
|
|
_ => 1,
|
|
};
|
|
|
|
private static DiagnosticMarker ToMarker(FileLinePositionSpan span, int sev, string msg, string code) => new(
|
|
sev, span.StartLinePosition.Line + 1, span.StartLinePosition.Character + 1,
|
|
span.EndLinePosition.Line + 1, span.EndLinePosition.Character + 1, msg, code);
|
|
|
|
private static DiagnosticMarker ToUserMarker(string userSource, Microsoft.CodeAnalysis.Text.TextSpan span, string msg, string code)
|
|
{
|
|
var (sl, sc) = LineCol(userSource, span.Start);
|
|
var (el, ec) = LineCol(userSource, span.End);
|
|
return new DiagnosticMarker(8, sl, sc, el, ec, msg, code);
|
|
}
|
|
|
|
private static (int Line, int Col) LineCol(string code, int offset)
|
|
{
|
|
int line = 1, col = 1;
|
|
for (int i = 0; i < offset && i < code.Length; i++)
|
|
if (code[i] == '\n') { line++; col = 1; } else col++;
|
|
return (line, col);
|
|
}
|
|
/// <summary>Computes completion items (tag-path, dot-member, or in-scope symbol completions) at the caret position.</summary>
|
|
/// <param name="req">The completions request carrying the script source and caret position.</param>
|
|
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
|
|
/// <returns>The resolved list of completion items, empty when none apply.</returns>
|
|
public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest req, CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrEmpty(req.CodeText)) return new CompletionsResponse(Array.Empty<CompletionItem>());
|
|
try
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var code = Normalize(req.CodeText);
|
|
var (tree, _, model, preambleLength) = Analyze(code, ct);
|
|
var position = OffsetInWrapped(code, req.Line, req.Column, preambleLength);
|
|
var root = await tree.GetRootAsync(ct);
|
|
// position is the offset just AFTER the caret; -1 finds the token the caret sits at/just-after (e.g. the dot in "ctx.").
|
|
var token = root.FindToken(Math.Max(0, position - 1));
|
|
|
|
if (_catalog != null && TryGetTagPathLiteral(token, out var pathPrefix, out _))
|
|
{
|
|
// v3: {{equip}}/<RefName> completes the owning equipment's UnsTagReference effective names
|
|
// (needs the equipment context — inert on the shared ScriptEdit page where EquipmentId is null).
|
|
const string equipPrefix = EquipmentScriptPaths.EquipTokenPrefix; // "{{equip}}/"
|
|
if (pathPrefix.StartsWith(equipPrefix, StringComparison.Ordinal))
|
|
{
|
|
if (string.IsNullOrEmpty(req.EquipmentId))
|
|
return new CompletionsResponse(Array.Empty<CompletionItem>());
|
|
var names = await _catalog.GetEquipmentReferenceNamesAsync(
|
|
req.EquipmentId, pathPrefix.Substring(equipPrefix.Length), ct);
|
|
return new CompletionsResponse(names
|
|
.Select(n => new CompletionItem(equipPrefix + n, equipPrefix + n, "tag path", "Field"))
|
|
.ToList());
|
|
}
|
|
|
|
var paths = await _catalog.GetPathsAsync(pathPrefix, ct);
|
|
return new CompletionsResponse(
|
|
paths.Select(p => new CompletionItem(p, p, "tag path", "Field")).ToList());
|
|
}
|
|
|
|
var dot = TryGetDotMembers(token, model, ct);
|
|
if (dot != null) return new CompletionsResponse(dot);
|
|
|
|
ct.ThrowIfCancellationRequested();
|
|
var scoped = model.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);
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogWarning(ex, "Script completion failed; returning none.");
|
|
return new CompletionsResponse(Array.Empty<CompletionItem>());
|
|
}
|
|
}
|
|
|
|
private static List<CompletionItem>? TryGetDotMembers(SyntaxToken token, SemanticModel model, CancellationToken ct)
|
|
{
|
|
var memberAccess = token.Parent as MemberAccessExpressionSyntax
|
|
?? token.GetPreviousToken().Parent as MemberAccessExpressionSyntax;
|
|
if (memberAccess == null) return null;
|
|
var typeInfo = model.GetTypeInfo(memberAccess.Expression, ct);
|
|
var type = typeInfo.Type ?? typeInfo.ConvertedType;
|
|
if (type == null) return null;
|
|
// The type's own members, then the INSTANCE members it inherits (stopping before System.Object, whose
|
|
// ToString/GetHashCode noise was never offered). Without the base chain, a member declared only on
|
|
// ScriptContext — ctx.CancellationToken — compiled and published but never completed. Inherited statics
|
|
// (ScriptContext.Deadband) are left out: they are not reachable through an instance receiver. This applies to
|
|
// EVERY receiver, not only ctx: a struct or enum receiver now also offers the System.ValueType /
|
|
// System.Enum instance members it inherits (CompareTo, HasFlag, ...), all of which compile.
|
|
var inherited = new List<ISymbol>();
|
|
for (var b = type.BaseType; b is not null && b.SpecialType != SpecialType.System_Object; b = b.BaseType)
|
|
inherited.AddRange(b.GetMembers().Where(m => !m.IsStatic));
|
|
return type.GetMembers().Concat(inherited)
|
|
.Where(m => m.CanBeReferencedByName && !m.IsImplicitlyDeclared)
|
|
// NotApplicable covers members (e.g. some BCL/special members) that carry no explicit accessibility but are referenceable.
|
|
.Where(m => m.DeclaredAccessibility == Accessibility.Public || m.DeclaredAccessibility == Accessibility.NotApplicable)
|
|
.GroupBy(m => m.Name).Select(g => g.First())
|
|
.Select(ToCompletionItem).Take(200).ToList();
|
|
}
|
|
|
|
private static bool TryGetTagPathLiteral(SyntaxToken token, out string prefix, out bool isSetVirtualTag)
|
|
{
|
|
prefix = "";
|
|
isSetVirtualTag = false;
|
|
var literal = token.Parent as LiteralExpressionSyntax
|
|
?? token.GetPreviousToken().Parent as LiteralExpressionSyntax;
|
|
if (literal is null || !literal.IsKind(SyntaxKind.StringLiteralExpression)) return false;
|
|
if (literal.Parent is not ArgumentSyntax arg) return false;
|
|
if (arg.Parent is not ArgumentListSyntax argList) return false;
|
|
if (argList.Parent is not InvocationExpressionSyntax inv) return false;
|
|
if (inv.Expression is not MemberAccessExpressionSyntax ma) return false;
|
|
// Receiver guard: only ctx.GetTag(...) / ctx.SetVirtualTag(...) are real tag-path calls. Mirrors the
|
|
// runtime harvest (EquipmentScriptPaths.GetTagRefRegex is syntactically `ctx`-anchored), so the editor
|
|
// offers tag completions/hover for exactly what AddressSpaceComposer harvests — not an unrelated x.GetTag(...).
|
|
if (ma.Expression is not IdentifierNameSyntax { Identifier.ValueText: "ctx" }) return false;
|
|
var method = ma.Name.Identifier.ValueText;
|
|
if (method is not ("GetTag" or "SetVirtualTag")) return false;
|
|
// only the FIRST argument is the path
|
|
if (argList.Arguments.Count > 0 && argList.Arguments[0] != arg) return false;
|
|
prefix = literal.Token.ValueText ?? "";
|
|
isSetVirtualTag = method == "SetVirtualTag";
|
|
return true;
|
|
}
|
|
|
|
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(symbol.Name, symbol.Name,
|
|
symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), kind);
|
|
}
|
|
/// <summary>Computes hover information (tag-path info or C# symbol info) for the position under the caret.</summary>
|
|
/// <param name="req">The hover request carrying the script source and caret position.</param>
|
|
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
|
|
/// <returns>The resolved hover markdown, or a response with a null value when nothing resolves.</returns>
|
|
public async Task<HoverResponse> Hover(HoverRequest req, CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(req.CodeText)) return new HoverResponse(null);
|
|
try
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var code = Normalize(req.CodeText);
|
|
var (tree, _, model, preambleLength) = Analyze(code, ct);
|
|
var position = OffsetInWrapped(code, req.Line, req.Column, preambleLength);
|
|
var token = tree.GetRoot(ct).FindToken(Math.Max(0, position - 1));
|
|
|
|
// Tag-path hover takes priority over C# symbol resolution: when the caret sits on a
|
|
// ctx.GetTag("…")/ctx.SetVirtualTag("…") path literal, show the resolved tag's info
|
|
// (or note it's not a known configured tag path) instead of the string-literal symbol.
|
|
if (_catalog is not null && TryGetTagPathLiteral(token, out var tagPath, out var isSetVirtualTag) && !string.IsNullOrEmpty(tagPath))
|
|
{
|
|
static string Code(string s) => s.Replace("`", "\\`");
|
|
// Equipment-relative literal (contains the {{equip}} token): it cannot resolve to a
|
|
// configured path as-typed (the token is only substituted at deploy), so short-circuit
|
|
// before GetTagInfoAsync and explain the token instead of warning "not a known path".
|
|
if (tagPath.Contains(EquipmentScriptPaths.EquipToken, StringComparison.Ordinal))
|
|
{
|
|
return new HoverResponse(
|
|
$"**Equipment-relative path** `{Code(tagPath)}`\n\n" +
|
|
"`{{equip}}/<RefName>` resolves through the owning equipment's tag reference " +
|
|
"(by effective name) to the backing raw tag's path when the VirtualTag is deployed.");
|
|
}
|
|
|
|
var info = await _catalog.GetTagInfoAsync(tagPath, ct);
|
|
var tagMd = info is null
|
|
? $"**Tag path** `{Code(tagPath)}`\n\n⚠ Not a known configured tag path."
|
|
: $"**Tag path** `{Code(info.Path)}`\n\n{info.Kind} · Type **{info.DataType}**"
|
|
+ (info.DriverInstanceId is null ? "" : $" · Driver `{Code(info.DriverInstanceId)}`");
|
|
// Truthfulness: ctx.SetVirtualTag(...) is a no-op in the live single-tag evaluator
|
|
// (RoslynVirtualTagEvaluator drops cross-tag writes).
|
|
if (isSetVirtualTag)
|
|
tagMd += "\n\n⚠ Cross-tag `SetVirtualTag` writes are currently dropped in single-tag mode " +
|
|
"(the cascade engine is not wired into the host); this write will not take effect at runtime.";
|
|
return new HoverResponse(tagMd);
|
|
}
|
|
|
|
var node = token.Parent;
|
|
if (node is null) return new HoverResponse(null);
|
|
// Resolve the token's node; if that yields nothing, climb to the enclosing
|
|
// member-access (e.g. hovering the `GetTag` identifier — the IdentifierName itself
|
|
// carries no symbol info, but its parent member-access binds to the method).
|
|
var symbol = ResolveSymbol(model, node, ct);
|
|
if (symbol is null) return new HoverResponse(null);
|
|
var display = symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat);
|
|
var summary = TryGetXmlSummary(symbol);
|
|
var md = "```csharp\n" + display + "\n```" + (summary is null ? "" : "\n\n" + summary);
|
|
return new HoverResponse(md);
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
|
catch (Exception ex) { _logger?.LogWarning(ex, "Script hover failed."); return new HoverResponse(null); }
|
|
}
|
|
|
|
private static ISymbol? ResolveSymbol(SemanticModel model, SyntaxNode node, CancellationToken ct)
|
|
{
|
|
foreach (var candidate in ResolutionCandidates(node))
|
|
{
|
|
var info = model.GetSymbolInfo(candidate, ct);
|
|
var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault();
|
|
if (symbol is not null) return symbol;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// The node under the caret, then — only if it is the name of a member access — that member access,
|
|
// and then its enclosing invocation. Bounded so a hover never resolves an unrelated ancestor symbol.
|
|
private static IEnumerable<SyntaxNode> ResolutionCandidates(SyntaxNode node)
|
|
{
|
|
yield return node;
|
|
if (node is IdentifierNameSyntax
|
|
&& node.Parent is MemberAccessExpressionSyntax ma
|
|
&& ma.Name == node)
|
|
{
|
|
yield return ma;
|
|
if (ma.Parent is InvocationExpressionSyntax inv && inv.Expression == ma)
|
|
yield return inv;
|
|
}
|
|
}
|
|
|
|
// Best-effort: returns the <summary> text if the symbol's XML doc is available, else null.
|
|
private static string? TryGetXmlSummary(ISymbol symbol)
|
|
{
|
|
var xml = symbol.GetDocumentationCommentXml();
|
|
if (string.IsNullOrWhiteSpace(xml)) return null;
|
|
try
|
|
{
|
|
var doc = System.Xml.Linq.XDocument.Parse(xml);
|
|
var summary = doc.Descendants("summary").FirstOrDefault()?.Value.Trim();
|
|
return string.IsNullOrWhiteSpace(summary) ? null : summary;
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
/// <summary>Computes signature help for the method invocation at the caret position.</summary>
|
|
/// <param name="req">The signature-help request carrying the script source and caret position.</param>
|
|
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
|
|
/// <returns>The resolved signature and active-parameter index, or an empty response when none applies.</returns>
|
|
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest req, CancellationToken ct = default)
|
|
{
|
|
var empty = new SignatureHelpResponse(null, null, 0);
|
|
if (string.IsNullOrWhiteSpace(req.CodeText)) return empty;
|
|
try
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var code = Normalize(req.CodeText);
|
|
var (tree, _, model, preambleLength) = Analyze(code, ct);
|
|
var position = OffsetInWrapped(code, req.Line, req.Column, preambleLength);
|
|
var token = tree.GetRoot(ct).FindToken(Math.Max(0, position - 1));
|
|
|
|
InvocationExpressionSyntax? inv = null;
|
|
for (var n = token.Parent; n is not null; n = n.Parent)
|
|
if (n is InvocationExpressionSyntax found) { inv = found; break; }
|
|
if (inv is null) return empty;
|
|
|
|
var info = model.GetSymbolInfo(inv, ct);
|
|
var method = (info.Symbol ?? info.CandidateSymbols.FirstOrDefault()) as IMethodSymbol;
|
|
if (method is null) return empty;
|
|
|
|
var ps = method.Parameters
|
|
.Select(p => new SignatureHelpParameter(
|
|
$"{p.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)} {p.Name}", null))
|
|
.ToList();
|
|
var label = $"{method.Name}(" + string.Join(", ", ps.Select(p => p.Label)) + ")";
|
|
|
|
int active = 0;
|
|
foreach (var a in inv.ArgumentList.Arguments) { if (a.Span.End < position) active++; else break; }
|
|
active = Math.Clamp(active, 0, Math.Max(0, ps.Count - 1));
|
|
|
|
return new SignatureHelpResponse(label, ps, active);
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
|
catch (Exception ex) { _logger?.LogWarning(ex, "Script signature help failed."); return empty; }
|
|
}
|
|
/// <summary>Formats a script's source code using Roslyn's syntax-tree whitespace normalization.</summary>
|
|
/// <param name="req">The format request carrying the script source to format.</param>
|
|
/// <returns>The formatted source, or the original source unchanged if parsing/formatting fails.</returns>
|
|
public FormatResponse Format(FormatRequest req)
|
|
{
|
|
if (string.IsNullOrEmpty(req.Code)) return new FormatResponse(req.Code);
|
|
try
|
|
{
|
|
var code = Normalize(req.Code);
|
|
var tree = CSharpSyntaxTree.ParseText(code,
|
|
new CSharpParseOptions(LanguageVersion.Latest, kind: SourceCodeKind.Script));
|
|
var formatted = tree.GetRoot().NormalizeWhitespace(indentation: " ", eol: "\n").ToFullString();
|
|
return new FormatResponse(formatted);
|
|
}
|
|
catch
|
|
{
|
|
return new FormatResponse(req.Code);
|
|
}
|
|
}
|
|
}
|