Editor/HotCodeEditor/Navigation/SignatureHelpProvider.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
public record SignatureParameter( string Display, string Name, string Documentation );
/// <summary>
/// One overload, shown as Prefix + parameters joined with ", " + Suffix.
/// </summary>
public record SignatureInfo( string Prefix, IReadOnlyList<SignatureParameter> Parameters, string Suffix, string Documentation );
/// <summary>
/// Parameter hints for the call around the caret. <see cref="ActiveParameter"/> is -1 when the caret
/// is past the last parameter. Open paren position identifies the call, so the editor can tell
/// "same call, new argument" from "different call".
/// </summary>
public record SignatureHelpResult( IReadOnlyList<SignatureInfo> Signatures, int ActiveSignature, int ActiveParameter, int OpenParenLine, int OpenParenColumn );
/// <summary>
/// Parameter hints from Roslyn: finds the innermost argument list around the caret
/// (method call, <c>new Foo(</c>, or <c>base(</c>/<c>this(</c>) and lists its overloads.
/// </summary>
public static class SignatureHelpProvider
{
static readonly SymbolDisplayFormat TypeFormat = SymbolDisplayFormat.MinimallyQualifiedFormat;
/// <summary>
/// Hints for (line, column), or null when the caret isn't inside a call's brackets.
/// </summary>
public static SignatureHelpResult Get( RoslynProject.Snapshot snapshot, int line, int column )
{
try
{
return GetInternal( snapshot, line, column );
}
catch ( Exception )
{
return null;
}
}
private static SignatureHelpResult GetInternal( RoslynProject.Snapshot snapshot, int line, int column )
{
var (compilation, tree) = RoslynProject.GetCompilation( snapshot );
if ( tree is null ) return null;
var text = tree.GetText();
if ( line < 0 || line >= text.Lines.Count ) return null;
var position = Math.Min( text.Lines[line].Start + column, text.Lines[line].End );
var root = tree.GetRoot();
var argumentList = FindArgumentList( root, position );
if ( argumentList is null ) return null;
var model = compilation.GetSemanticModel( tree );
var (methods, chosen) = Candidates( model, argumentList, position );
if ( methods.Count == 0 ) return null;
// Which argument is the caret in? Count the commas before it; a named argument picks its parameter by name.
var argumentIndex = argumentList.Arguments.GetSeparators().Count( s => s.SpanStart < position );
var currentArgument = argumentIndex < argumentList.Arguments.Count ? argumentList.Arguments[argumentIndex] : null;
var namedParameter = currentArgument?.NameColon?.Name.Identifier.ValueText;
var activeSignature = chosen is not null ? methods.FindIndex( m => SymbolEqualityComparer.Default.Equals( m, chosen ) ) : -1;
if ( activeSignature < 0 )
activeSignature = Math.Max( 0, methods.FindIndex( m => m.Parameters.Length > argumentIndex || m.Parameters.LastOrDefault()?.IsParams == true ) );
var active = methods[activeSignature];
var activeParameter = ActiveParameterIndex( active, argumentIndex, namedParameter );
var open = text.Lines.GetLinePosition( argumentList.OpenParenToken.SpanStart );
return new SignatureHelpResult( methods.Select( Describe ).ToList(), activeSignature, activeParameter, open.Line, open.Character );
}
/// <summary>
/// Index of the parameter for an argument: by name for named arguments, otherwise by position,
/// staying on a trailing "params" array; -1 when there are more arguments than parameters.
/// </summary>
public static int ActiveParameterIndex( IMethodSymbol method, int argumentIndex, string namedParameter )
{
if ( namedParameter is not null )
return method.Parameters.IndexOf( method.Parameters.FirstOrDefault( p => p.Name == namedParameter ) );
if ( argumentIndex < method.Parameters.Length ) return argumentIndex;
return method.Parameters.LastOrDefault()?.IsParams == true ? method.Parameters.Length - 1 : -1;
}
/// <summary>
/// The innermost argument list whose brackets contain the position. Also handles calls that
/// haven't been closed yet ("Foo( a, |").
/// </summary>
private static ArgumentListSyntax FindArgumentList( SyntaxNode root, int position )
{
ArgumentListSyntax best = null;
foreach ( var at in new[] { position - 1, position } )
{
if ( at < 0 || at >= root.FullSpan.End ) continue;
foreach ( var list in root.FindToken( at ).Parent?.AncestorsAndSelf().OfType<ArgumentListSyntax>() ?? Enumerable.Empty<ArgumentListSyntax>() )
{
var afterOpen = position > list.OpenParenToken.SpanStart;
var beforeClose = list.CloseParenToken.IsMissing || position <= list.CloseParenToken.SpanStart;
if ( !afterOpen || !beforeClose ) continue;
// Innermost wins: a nested call's list starts later
if ( best is null || list.SpanStart > best.SpanStart ) best = list;
break;
}
}
return best;
}
private static (List<IMethodSymbol> Methods, IMethodSymbol Chosen) Candidates( SemanticModel model, ArgumentListSyntax list, int position )
{
var methods = new List<IMethodSymbol>();
IMethodSymbol chosen = null;
switch ( list.Parent )
{
case InvocationExpressionSyntax invocation:
methods.AddRange( model.GetMemberGroup( invocation.Expression ).OfType<IMethodSymbol>() );
chosen = Chosen( model.GetSymbolInfo( invocation ) );
break;
case BaseObjectCreationExpressionSyntax creation:
if ( model.GetTypeInfo( creation ).Type is INamedTypeSymbol type )
methods.AddRange( type.InstanceConstructors.Where( c => model.IsAccessible( position, c ) ) );
chosen = Chosen( model.GetSymbolInfo( creation ) );
break;
case ConstructorInitializerSyntax initializer:
var info = model.GetSymbolInfo( initializer );
var target = (info.Symbol ?? info.CandidateSymbols.FirstOrDefault())?.ContainingType;
if ( target is not null )
methods.AddRange( target.InstanceConstructors.Where( c => model.IsAccessible( position, c ) ) );
chosen = Chosen( info );
break;
}
methods = methods
.Where( m => m.MethodKind is not (MethodKind.PropertyGet or MethodKind.PropertySet) )
.Distinct( SymbolEqualityComparer.Default ).Cast<IMethodSymbol>()
.OrderBy( m => m.Parameters.Length )
.ToList();
return (methods, chosen);
}
private static IMethodSymbol Chosen( SymbolInfo info ) =>
info.Symbol as IMethodSymbol ?? (info.CandidateSymbols.Length == 1 ? info.CandidateSymbols[0] as IMethodSymbol : null);
private static SignatureInfo Describe( IMethodSymbol method )
{
var docs = DocComment.Parse( (method.ReducedFrom ?? method).GetDocumentationCommentXml( expandIncludes: true ) );
string prefix;
if ( method.MethodKind == MethodKind.Constructor )
prefix = $"new {method.ContainingType.ToDisplayString( TypeFormat )}(";
else
{
var typeArgs = method.IsGenericMethod ? $"<{string.Join( ", ", method.TypeArguments.Select( t => t.ToDisplayString( TypeFormat ) ) )}>" : "";
prefix = $"{method.ReturnType.ToDisplayString( TypeFormat )} {method.ContainingType?.Name}.{method.Name}{typeArgs}(";
}
var parameters = method.Parameters.Select( p => new SignatureParameter(
DescribeParameter( p ),
p.Name,
docs.Parameters.TryGetValue( p.Name, out var doc ) ? doc : "" ) ).ToList();
return new SignatureInfo( prefix, parameters, ")", docs.Summary );
}
private static string DescribeParameter( IParameterSymbol p )
{
var modifier = p.RefKind switch
{
RefKind.Ref => "ref ",
RefKind.Out => "out ",
RefKind.In => "in ",
_ => ""
};
var text = $"{(p.IsParams ? "params " : "")}{modifier}{p.Type.ToDisplayString( TypeFormat )} {p.Name}";
if ( p.HasExplicitDefaultValue )
{
var value = p.ExplicitDefaultValue switch
{
null => p.Type.IsValueType ? "default" : "null",
string s => $"\"{s}\"",
bool b => b ? "true" : "false",
float f => $"{f}f",
_ => p.ExplicitDefaultValue.ToString()
};
text += $" = {value}";
}
return text;
}
}