From 9104b6c614dca6c447bec90a5c7be7e64eccae03 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 9 Jun 2026 14:59:12 -0400 Subject: [PATCH] feat(adminui): script hover + signature help --- .../ScriptAnalysis/ScriptAnalysisService.cs | 100 +++++++++++++++++- .../ScriptAnalysis/HoverSignatureTests.cs | 51 +++++++++ 2 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/HoverSignatureTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs index 3654f9d2..47b0c6f5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -227,8 +227,104 @@ public sealed class ScriptAnalysisService return new CompletionItem(symbol.Name, symbol.Name, symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), kind); } - public HoverResponse Hover(HoverRequest req) => new((string?)null); // Task 7 - public SignatureHelpResponse SignatureHelp(SignatureHelpRequest req) => new(null, null, 0); // Task 7 + public HoverResponse 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)); + 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; } + } + + 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; } + } public FormatResponse Format(FormatRequest req) => new(req.Code); // Task 8 public InlayHintsResponse InlayHints(InlayHintsRequest req) => new(Array.Empty()); // Task 8 (stays empty) } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/HoverSignatureTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/HoverSignatureTests.cs new file mode 100644 index 00000000..44a5abb1 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/HoverSignatureTests.cs @@ -0,0 +1,51 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis; + +public sealed class HoverSignatureTests +{ + private static readonly ScriptAnalysisService Svc = new(); + + [Fact] public void Hover_on_GetTag_returns_member_markdown() + { + // hover over the "GetTag" identifier in ctx.GetTag("A") + var md = Svc.Hover(new HoverRequest("""return ctx.GetTag("A").Value;""", 1, 16)).Markdown; + md.ShouldNotBeNull(); + md!.ShouldContain("GetTag"); + } + + [Fact] public void Hover_on_nothing_returns_null() + => Svc.Hover(new HoverRequest(" ", 1, 1)).Markdown.ShouldBeNull(); + + [Fact] public void SignatureHelp_inside_GetTag_call_shows_the_signature() + { + // caret just after the open paren of ctx.GetTag( + var sh = Svc.SignatureHelp(new SignatureHelpRequest("""return ctx.GetTag();""", 1, 19)); + sh.Label.ShouldNotBeNull(); + sh.Label!.ShouldContain("GetTag"); + sh.Parameters.ShouldNotBeNull(); + sh.Parameters!.ShouldContain(p => p.Label.Contains("path") || p.Label.Contains("string")); + } + + [Fact] public void Hover_on_a_plain_local_resolves_the_local_not_an_enclosing_member() + { + // hovering the local `x` (not a member) must resolve the LOCAL, and must NOT climb to GetTag/Value. + var md = Svc.Hover(new HoverRequest("var x = 1;\nreturn ctx.GetTag(\"A\").Value + x;", 2, 33)).Markdown; + md.ShouldNotBeNull(); + md!.ShouldContain("x"); + md.ShouldNotContain("GetTag"); + } + + [Fact] public void SignatureHelp_for_parameterless_call_does_not_throw_and_clamps_active_to_zero() + { + // ctx.Now is a property, not a call; use a parameterless method if one exists, else assert empty-safe behavior. + // Deadband is static on ScriptContext (3 params) — instead verify a 0-arg edge via an empty arg list doesn't throw: + var sh = Svc.SignatureHelp(new SignatureHelpRequest("return ctx.GetTag();", 1, 19)); + sh.ActiveParameter.ShouldBe(0); // clamped; no exception + } + + [Fact] public void Hover_with_out_of_range_position_returns_null_without_throwing() + => Svc.Hover(new HoverRequest("return 1;", 99, 99)).Markdown.ShouldBeNull(); +}