da2578833d
R04:P1 - the editor no longer ships its full text JS->.NET on every keystroke. It reports at blur, and ScriptEdit / the VirtualTag modal read the authoritative value with MonacoEditor.GetValueAsync at Save (validation runs after the pull). The Blazor hub's MaximumReceiveMessageSize is raised from 32 KB to 1 MB. The JS refuses a value that would not fit instead of sending an oversize frame, and Save surfaces that refusal rather than persisting stale source. R04:P9 - diagnostics carry a generation counter and an AbortController. Every edit aborts the in-flight request, and only the response for the current text is applied. R04:S2 - the always-empty inlay-hints provider, endpoint, service method and contracts are removed. R04:S3 - MarkersChanged is typed (DiagnosticMarker) and feeds a new ScriptProblemsList. Clicking a problem calls RevealLineAsync. Markers cross to .NET only when a list subscribes. Refs #745 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
555 lines
32 KiB
C#
555 lines
32 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;
|
|
}
|
|
|
|
// This scope's current compilation (one per scoped service). Each analysis swaps its tree in with
|
|
// ReplaceSyntaxTree; a lost race between two concurrent calls on one scope is harmless because every
|
|
// compilation in the lineage is a complete, valid single-tree compilation.
|
|
private CSharpCompilation _compilation = BaseCompilation;
|
|
|
|
// userSource is appended after Preamble, then the method/class are closed.
|
|
private (SyntaxTree Tree, CSharpCompilation Compilation, SemanticModel Model, int PreambleLength) Analyze(
|
|
string userSource, CancellationToken ct)
|
|
{
|
|
var tree = WrapAndParse(userSource, ct);
|
|
var current = _compilation;
|
|
var compilation = current.ReplaceSyntaxTree(current.SyntaxTrees.Single(), tree);
|
|
_compilation = compilation;
|
|
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(markers.Distinct().ToList());
|
|
}
|
|
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(markers.Distinct().ToList());
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogWarning(ex, "Script equip-ref diagnostics failed; returning base markers.");
|
|
return baseResp;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|