feat(adminui): script hover + signature help
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user