feat(adminui): script hover + signature help

This commit is contained in:
Joseph Doherty
2026-06-09 14:59:12 -04:00
parent 521fb61e44
commit 9104b6c614
2 changed files with 149 additions and 2 deletions
@@ -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<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; }
}
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<InlayHint>()); // Task 8 (stays empty)
}
@@ -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();
}