Editor/HotCodeEditor/Navigation/CompletionProvider.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
public enum CompletionKind
{
Keyword,
Local,
Field,
Property,
Method,
Event,
Class,
Struct,
Interface,
Enum,
EnumMember,
Delegate,
Namespace,
TypeParameter
}
/// <summary>
/// One suggestion. Lower <see cref="Priority"/> sorts first among equally good matches.
/// </summary>
public record CompletionItem( string Name, CompletionKind Kind, string Detail, int Priority );
/// <summary>
/// Suggestions for a position, and where the word being completed starts (it gets replaced on commit).
/// </summary>
public record CompletionResult( int Line, int StartColumn, IReadOnlyList<CompletionItem> Items, string Error )
{
public static CompletionResult Empty( int line, int column, string error = null ) => new( line, column, Array.Empty<CompletionItem>(), error );
}
/// <summary>
/// Autocomplete from Roslyn's symbol lookup (Roslyn's own completion service lives in an assembly
/// s&box doesn't ship). Handles:
/// <list type="bullet">
/// <item><c>value.</c> - instance members, including extension methods</item>
/// <item><c>Type.</c> - static members and nested types</item>
/// <item><c>Namespace.</c> - types and namespaces inside it</item>
/// <item>anything else - what's in scope (locals, members, types, namespaces) plus keywords</item>
/// </list>
/// </summary>
public static class CompletionProvider
{
static readonly string[] Keywords =
{
"abstract", "as", "async", "await", "base", "bool", "break", "byte", "case", "catch", "char", "class", "const",
"continue", "decimal", "default", "delegate", "do", "double", "else", "enum", "event", "false", "finally", "float",
"for", "foreach", "get", "if", "in", "init", "int", "interface", "internal", "is", "long", "namespace", "new", "null",
"object", "out", "override", "params", "partial", "private", "protected", "public", "readonly", "record", "ref",
"return", "sealed", "set", "short", "static", "string", "struct", "switch", "this", "throw", "true", "try", "typeof",
"uint", "ulong", "using", "var", "virtual", "void", "when", "where", "while", "yield", "nameof", "required"
};
public static CompletionResult Get( RoslynProject.Snapshot snapshot, int line, int column )
{
try
{
return GetInternal( snapshot, line, column );
}
catch ( Exception e )
{
return CompletionResult.Empty( line, column, e.Message );
}
}
private static CompletionResult GetInternal( RoslynProject.Snapshot snapshot, int line, int column )
{
var (compilation, tree) = RoslynProject.GetCompilation( snapshot );
if ( tree is null ) return CompletionResult.Empty( line, column );
var text = tree.GetText();
if ( line < 0 || line >= text.Lines.Count ) return CompletionResult.Empty( line, column );
var lineInfo = text.Lines[line];
var position = Math.Min( lineInfo.Start + column, lineInfo.End );
// The word being typed, which the chosen item replaces
var wordStart = position;
while ( wordStart > lineInfo.Start && IsIdentChar( text[wordStart - 1] ) ) wordStart--;
var startColumn = wordStart - lineInfo.Start;
var root = tree.GetRoot();
if ( IsInCommentOrString( root, position ) ) return CompletionResult.Empty( line, startColumn );
var model = compilation.GetSemanticModel( tree );
var items = new List<CompletionItem>();
// Is there a "something." right before the word?
var dotToken = wordStart > 0 ? root.FindToken( wordStart - 1 ) : default;
if ( dotToken.IsKind( SyntaxKind.DotToken ) && dotToken.Span.End <= wordStart )
{
AddMemberAccess( model, dotToken, wordStart, items );
}
else
{
// Typing a declaration's own name (`int fo|`): nothing useful to suggest
if ( IsDeclarationName( root, wordStart ) ) return CompletionResult.Empty( line, startColumn );
AddInScope( model, wordStart, items );
items.AddRange( Keywords.Select( k => new CompletionItem( k, CompletionKind.Keyword, "keyword", 4 ) ) );
}
return new CompletionResult( line, startColumn, Dedupe( items ), null );
}
private static bool IsIdentChar( char c ) => char.IsLetterOrDigit( c ) || c == '_';
private static bool IsInCommentOrString( SyntaxNode root, int position )
{
var trivia = root.FindTrivia( position > 0 ? position - 1 : 0 );
if ( trivia.Span.Contains( position - 1 ) && (trivia.IsKind( SyntaxKind.SingleLineCommentTrivia ) || trivia.IsKind( SyntaxKind.MultiLineCommentTrivia )
|| trivia.IsKind( SyntaxKind.SingleLineDocumentationCommentTrivia ) || trivia.IsKind( SyntaxKind.MultiLineDocumentationCommentTrivia )) )
return true;
var token = root.FindToken( position > 0 ? position - 1 : 0 );
var inside = token.Span.Start < position && position <= token.Span.End;
return inside && (token.IsKind( SyntaxKind.StringLiteralToken ) || token.IsKind( SyntaxKind.CharacterLiteralToken )
|| token.IsKind( SyntaxKind.InterpolatedStringTextToken ) || token.IsKind( SyntaxKind.MultiLineRawStringLiteralToken )
|| token.IsKind( SyntaxKind.SingleLineRawStringLiteralToken ));
}
private static bool IsDeclarationName( SyntaxNode root, int wordStart )
{
var token = root.FindToken( wordStart );
if ( !token.IsKind( SyntaxKind.IdentifierToken ) || token.SpanStart != wordStart ) return false;
return token.Parent switch
{
VariableDeclaratorSyntax v => v.Identifier == token,
ParameterSyntax p => p.Identifier == token && p.Type is not null,
BaseTypeDeclarationSyntax t => t.Identifier == token,
MethodDeclarationSyntax m => m.Identifier == token,
PropertyDeclarationSyntax pr => pr.Identifier == token,
_ => false
};
}
private static void AddMemberAccess( SemanticModel model, SyntaxToken dot, int position, List<CompletionItem> items )
{
ExpressionSyntax left = dot.Parent switch
{
MemberAccessExpressionSyntax ma => ma.Expression,
QualifiedNameSyntax q => q.Left,
_ => null
};
// `a?.b`: the left side is on the conditional access
if ( left is null && dot.Parent is MemberBindingExpressionSyntax && dot.Parent.Parent?.Parent is ConditionalAccessExpressionSyntax conditional )
left = conditional.Expression;
if ( left is null ) return;
var symbol = model.GetSymbolInfo( left ).Symbol;
var type = model.GetTypeInfo( left ).Type;
if ( symbol is INamespaceSymbol ns )
{
foreach ( var s in model.LookupNamespacesAndTypes( position, ns ) )
Add( items, s, priority: 2 );
return;
}
if ( symbol is ITypeSymbol staticType )
{
// `Color.Red` where a property is also called Color: offer both static and instance members
var alsoInstance = type is not null && !SymbolEqualityComparer.Default.Equals( type, staticType );
foreach ( var s in model.LookupStaticMembers( position, staticType ) )
Add( items, s, priority: 1 );
if ( alsoInstance )
AddInstanceMembers( model, position, type, items );
return;
}
if ( type is not null && type.TypeKind != TypeKind.Error )
AddInstanceMembers( model, position, type, items );
}
private static void AddInstanceMembers( SemanticModel model, int position, ITypeSymbol type, List<CompletionItem> items )
{
foreach ( var s in model.LookupSymbols( position, type, includeReducedExtensionMethods: true ) )
{
if ( s.IsStatic && s is not IMethodSymbol { MethodKind: MethodKind.ReducedExtension } ) continue;
if ( s is ITypeSymbol ) continue;
Add( items, s, priority: s.ContainingType?.SpecialType == SpecialType.System_Object ? 3 : 1 );
}
}
private static void AddInScope( SemanticModel model, int position, List<CompletionItem> items )
{
var enclosingType = model.GetEnclosingSymbol( position )?.ContainingType;
foreach ( var s in model.LookupSymbols( position ) )
{
var priority = s switch
{
ILocalSymbol or IParameterSymbol or IRangeVariableSymbol => 0,
_ when enclosingType is not null && s.ContainingType is not null && InheritsFrom( enclosingType, s.ContainingType ) => 1,
INamespaceSymbol => 3,
_ => 2
};
Add( items, s, priority );
}
}
private static bool InheritsFrom( INamedTypeSymbol type, INamedTypeSymbol ancestor )
{
for ( var t = type; t is not null; t = t.BaseType )
if ( SymbolEqualityComparer.Default.Equals( t.OriginalDefinition, ancestor.OriginalDefinition ) ) return true;
return false;
}
private static void Add( List<CompletionItem> items, ISymbol symbol, int priority )
{
if ( !symbol.CanBeReferencedByName || symbol.IsImplicitlyDeclared && symbol is not IParameterSymbol ) return;
if ( symbol is IMethodSymbol method && method.MethodKind is not (MethodKind.Ordinary or MethodKind.ReducedExtension or MethodKind.LocalFunction) )
return;
var kind = KindOf( symbol );
if ( kind is null ) return;
items.Add( new CompletionItem( symbol.Name, kind.Value, DetailOf( symbol ), priority ) );
}
private static CompletionKind? KindOf( ISymbol symbol ) => symbol switch
{
ILocalSymbol or IParameterSymbol or IRangeVariableSymbol => CompletionKind.Local,
IFieldSymbol { ContainingType.TypeKind: TypeKind.Enum } => CompletionKind.EnumMember,
IFieldSymbol => CompletionKind.Field,
IPropertySymbol => CompletionKind.Property,
IMethodSymbol => CompletionKind.Method,
IEventSymbol => CompletionKind.Event,
INamespaceSymbol => CompletionKind.Namespace,
ITypeParameterSymbol => CompletionKind.TypeParameter,
INamedTypeSymbol t => t.TypeKind switch
{
TypeKind.Struct => CompletionKind.Struct,
TypeKind.Interface => CompletionKind.Interface,
TypeKind.Enum => CompletionKind.Enum,
TypeKind.Delegate => CompletionKind.Delegate,
_ => CompletionKind.Class
},
_ => null
};
static readonly SymbolDisplayFormat TypeFormat = SymbolDisplayFormat.MinimallyQualifiedFormat;
private static string DetailOf( ISymbol symbol ) => symbol switch
{
ILocalSymbol l => l.Type.ToDisplayString( TypeFormat ),
IParameterSymbol p => p.Type.ToDisplayString( TypeFormat ),
IFieldSymbol f => f.Type.ToDisplayString( TypeFormat ),
IPropertySymbol p => p.Type.ToDisplayString( TypeFormat ),
IEventSymbol e => e.Type.ToDisplayString( TypeFormat ),
IMethodSymbol m => $"{m.ReturnType.ToDisplayString( TypeFormat )} {m.Name}({string.Join( ", ", m.Parameters.Select( p => p.Type.ToDisplayString( TypeFormat ) ) )})",
INamespaceSymbol => "namespace",
INamedTypeSymbol t => t.ContainingNamespace is { IsGlobalNamespace: false } ns ? ns.ToDisplayString() : t.TypeKind.ToString().ToLowerInvariant(),
_ => ""
};
/// <summary>
/// One entry per name: overloads become "(+N overloads)", and a name found several ways keeps its best priority.
/// </summary>
private static List<CompletionItem> Dedupe( List<CompletionItem> items )
{
return items
.GroupBy( x => x.Name )
.Select( g =>
{
var best = g.OrderBy( x => x.Priority ).First();
var overloads = g.Count( x => x.Kind == CompletionKind.Method );
return overloads > 1 ? best with { Detail = $"{best.Detail} (+{overloads - 1} overloads)" } : best;
} )
.ToList();
}
}
/// <summary>
/// Ranks completion items against what's been typed: exact-case prefix, then any-case prefix,
/// then camel humps ("GO" -> GameObject), then substring.
/// </summary>
public static class CompletionMatcher
{
/// <summary>
/// Higher is better; -1 means it doesn't match.
/// </summary>
public static int Score( string candidate, string typed )
{
if ( string.IsNullOrEmpty( typed ) ) return 1;
if ( candidate.StartsWith( typed, StringComparison.Ordinal ) ) return 400;
if ( candidate.StartsWith( typed, StringComparison.OrdinalIgnoreCase ) ) return 300;
if ( MatchesHumps( candidate, typed ) ) return 200;
if ( candidate.Contains( typed, StringComparison.OrdinalIgnoreCase ) ) return 100;
return -1;
}
/// <summary>
/// Each typed character either continues the current match or starts at a new "hump"
/// (an uppercase letter, or a letter after '_'). The first character must match the start.
/// </summary>
public static bool MatchesHumps( string candidate, string typed )
{
if ( typed.Length == 0 ) return true;
if ( candidate.Length == 0 || char.ToLowerInvariant( candidate[0] ) != char.ToLowerInvariant( typed[0] ) ) return false;
int c = 1;
for ( int t = 1; t < typed.Length; t++ )
{
var want = char.ToLowerInvariant( typed[t] );
// Continue the current hump
if ( c < candidate.Length && char.ToLowerInvariant( candidate[c] ) == want )
{
c++;
continue;
}
// Jump to the next hump that starts with this character
var found = false;
for ( int k = c; k < candidate.Length; k++ )
{
var isHump = char.IsUpper( candidate[k] ) || (k > 0 && candidate[k - 1] == '_');
if ( isHump && char.ToLowerInvariant( candidate[k] ) == want )
{
c = k + 1;
found = true;
break;
}
}
if ( !found ) return false;
}
return true;
}
/// <summary>
/// Items that match, best first.
/// </summary>
public static List<CompletionItem> Filter( IEnumerable<CompletionItem> items, string typed )
{
return items
.Select( item => (item, score: Score( item.Name, typed )) )
.Where( x => x.score >= 0 )
.OrderByDescending( x => x.score )
.ThenBy( x => x.item.Priority )
.ThenBy( x => x.item.Name.Length )
.ThenBy( x => x.item.Name, StringComparer.Ordinal )
.Select( x => x.item )
.ToList();
}
}