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; /// /// 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 CompiledScript.Run(globals) shape /// that compiles, so diagnostics, /// completions, hover, and signature help all see the exact symbol surface a published /// script gets. Built against the allow-list for the /// context. /// /// /// All capabilities are fully implemented: Diagnose, Complete, Hover, SignatureHelp, and Format /// are wired and backed by the real Roslyn document/analysis pipeline. Only InlayHints is /// intentionally a no-op (empty list) — the endpoint exists for future use but the feature is /// not implemented. /// 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? _logger; /// Creates the script-analysis service. /// The tag catalog used to resolve tag-path completions/hover, or null to disable tag-path features. /// The logger used to record analysis failures, or null to disable logging. public ScriptAnalysisService(IScriptTagCatalog? catalog = null, ILogger? logger = null) { _catalog = catalog; _logger = logger; } // Mirrors ScriptEvaluator's wrapper EXACTLY (usings from the sandbox + the // Run(ScriptGlobals) 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"; 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"; } // userSource is appended after Preamble, then the method/class are closed. private static (SyntaxTree Tree, CSharpCompilation Compilation, SemanticModel Model, int PreambleLength) Analyze(string userSource) { var prefix = Preamble; var full = prefix + (userSource ?? "") + "\n }\n}\n"; var tree = CSharpSyntaxTree.ParseText(full); var compilation = CSharpCompilation.Create("ScriptAnalysis", new[] { tree }, Sandbox.References, CompileOptions); return (tree, compilation, compilation.GetSemanticModel(tree), prefix.Length); } // 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"); /// Compiles the script and returns compile-error diagnostics plus sandbox-policy violations /// (forbidden types, disallowed dynamic tag paths). /// The diagnose request carrying the script source. /// The list of diagnostic markers, empty when the script is clean or analysis fails. public DiagnoseResponse Diagnose(DiagnoseRequest req) { if (string.IsNullOrEmpty(req.Code)) return new DiagnoseResponse(Array.Empty()); try { var code = Normalize(req.Code); var (tree, compilation, _, preambleLength) = Analyze(code); var markers = new List(); // (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()) { 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. foreach (var r in ForbiddenTypeAnalyzer.Analyze(compilation)) markers.Add(ToMarker(tree.GetMappedLineSpan(r.Span), 8, r.Message, "OTSCRIPT_FORBIDDEN")); // (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")); return new DiagnoseResponse(markers.Distinct().ToList()); } catch (Exception ex) { _logger?.LogWarning(ex, "Script diagnostics failed; returning no markers."); return new DiagnoseResponse(Array.Empty()); } } // ctx.GetTag("…") / ctx.SetVirtualTag("…") first-arg literal — three-part capture, mirroring // EquipmentScriptPaths.PathLiteralRegex so the editor's {{equip}}/ 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); /// Diagnostics plus the v3 equipment-relative {{equip}}/<RefName> resolution check. /// Runs the synchronous Roslyn/policy then, when an equipment context + catalog /// are present, appends a marker for each {{equip}}/<RefName> whose <RefName> is /// not one of the equipment's reference effective names — flagging exactly what the deploy gate /// (DraftValidator.ValidateEquipReferenceResolution) rejects, so the editor accepts ⇔ publish /// accepts. Inert (base markers only) on the shared ScriptEdit page where EquipmentId is null. /// The diagnose request (its EquipmentId carries the owning-equipment context). /// The base diagnostics plus any unresolved-reference markers. public async Task DiagnoseAsync(DiagnoseRequest req) { var baseResp = Diagnose(req); 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, CancellationToken.None); var resolvable = new HashSet(names, StringComparer.Ordinal); var markers = new List(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}}/ 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 (Exception ex) { _logger?.LogWarning(ex, "Script equip-ref diagnostics failed; returning base markers."); return baseResp; } } 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); } /// Computes completion items (tag-path, dot-member, or in-scope symbol completions) at the caret position. /// The completions request carrying the script source and caret position. /// The resolved list of completion items, empty when none apply. public async Task CompleteAsync(CompletionsRequest req) { if (string.IsNullOrEmpty(req.CodeText)) return new CompletionsResponse(Array.Empty()); try { var code = Normalize(req.CodeText); var (tree, _, model, preambleLength) = Analyze(code); var position = OffsetInWrapped(code, req.Line, req.Column, preambleLength); var root = await tree.GetRootAsync(); // 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}}/ 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()); var names = await _catalog.GetEquipmentReferenceNamesAsync( req.EquipmentId, pathPrefix.Substring(equipPrefix.Length), CancellationToken.None); return new CompletionsResponse(names .Select(n => new CompletionItem(equipPrefix + n, equipPrefix + n, "tag path", "Field")) .ToList()); } var paths = await _catalog.GetPathsAsync(pathPrefix, CancellationToken.None); return new CompletionsResponse( paths.Select(p => new CompletionItem(p, p, "tag path", "Field")).ToList()); } var dot = TryGetDotMembers(token, model); if (dot != null) return new CompletionsResponse(dot); 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 (Exception ex) { _logger?.LogWarning(ex, "Script completion failed; returning none."); return new CompletionsResponse(Array.Empty()); } } private static List? 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) // 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); } /// Computes hover information (tag-path info or C# symbol info) for the position under the caret. /// The hover request carrying the script source and caret position. /// The resolved hover markdown, or a response with a null value when nothing resolves. public async Task Hover(HoverRequest req) { if (string.IsNullOrWhiteSpace(req.CodeText)) return new HoverResponse(null); try { var code = Normalize(req.CodeText); var (tree, _, model, preambleLength) = Analyze(code); var position = OffsetInWrapped(code, req.Line, req.Column, preambleLength); var token = tree.GetRoot().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}}/` 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, CancellationToken.None); 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; the cascade VirtualTagEngine is dormant). 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); 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 (Exception ex) { _logger?.LogWarning(ex, "Script hover failed."); return new HoverResponse(null); } } private static ISymbol? ResolveSymbol(SemanticModel model, SyntaxNode node) { foreach (var candidate in ResolutionCandidates(node)) { var info = model.GetSymbolInfo(candidate); 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 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 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; } } /// Computes signature help for the method invocation at the caret position. /// The signature-help request carrying the script source and caret position. /// The resolved signature and active-parameter index, or an empty response when none applies. public SignatureHelpResponse SignatureHelp(SignatureHelpRequest req) { var empty = new SignatureHelpResponse(null, null, 0); if (string.IsNullOrWhiteSpace(req.CodeText)) return empty; try { var code = Normalize(req.CodeText); var (tree, _, model, preambleLength) = Analyze(code); var position = OffsetInWrapped(code, req.Line, req.Column, preambleLength); var token = tree.GetRoot().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); 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 (Exception ex) { _logger?.LogWarning(ex, "Script signature help failed."); return empty; } } /// Formats a script's source code using Roslyn's syntax-tree whitespace normalization. /// The format request carrying the script source to format. /// The formatted source, or the original source unchanged if parsing/formatting fails. 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); } } /// Returns editor inlay hints for the script. Currently a no-op (always empty) — the endpoint /// exists for future use but the feature is not implemented. /// The inlay-hints request (unused). /// An always-empty inlay-hints response. public InlayHintsResponse InlayHints(InlayHintsRequest req) => new(Array.Empty()); }