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)
}