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&amp;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();
	}
}