Editor/Prism/Text/Completion/CompletionEngine.cs

Completion engine for the editor. Classifies caret context (include path, member, preprocessor, etc.), gathers completion candidates from document symbols, headers, language DB, intrinsics, s&box symbols and other sources, ranks and de-duplicates results, provides signature help and hover information, and caches lightweight derived data (combos, includes).

File AccessNetworking
using Editor.Prism.Core;
using Editor.Prism.Text.Lexer;
using Editor.Prism.Text.LanguageDb;
using Editor.Prism.Toolchain;
using Editor.Prism.Ui;
using System.Diagnostics;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.RegularExpressions;

namespace Editor.Prism.Text.Completion;

/// <summary>What a completion entry is, which drives its icon and its accent colour.</summary>
public enum CompletionItemKind
{
	/// <summary>A language keyword.</summary>
	Keyword,

	/// <summary>A storage, interpolation or parameter modifier.</summary>
	Modifier,

	/// <summary>A scalar, vector or matrix type.</summary>
	Type,

	/// <summary>A texture, buffer or sampler type.</summary>
	ObjectType,

	/// <summary>A built-in intrinsic function.</summary>
	Intrinsic,

	/// <summary>A function declared in the buffer or in a header.</summary>
	Function,

	/// <summary>A member method of an object type or a struct.</summary>
	Method,

	/// <summary>A struct or constant-buffer member.</summary>
	Field,

	/// <summary>A variable at file scope or in the enclosing function.</summary>
	Variable,

	/// <summary>A parameter of the enclosing function.</summary>
	Parameter,

	/// <summary>A preprocessor macro.</summary>
	Macro,

	/// <summary>An engine-provided uniform.</summary>
	EngineGlobal,

	/// <summary>A named constant.</summary>
	Constant,

	/// <summary>A semantic such as <c>SV_Target0</c>.</summary>
	Semantic,

	/// <summary>A metadata annotation legal inside <c>&lt; … &gt;</c>.</summary>
	Annotation,

	/// <summary>A preprocessor directive.</summary>
	Directive,

	/// <summary>A VFX block keyword.</summary>
	Block,

	/// <summary>A Slang module.</summary>
	Module,

	/// <summary>A feature, static or dynamic combo symbol.</summary>
	Combo,

	/// <summary>An includable file.</summary>
	File,

	/// <summary>A folder on an include search path.</summary>
	Folder,

	/// <summary>A component swizzle.</summary>
	Swizzle,

	/// <summary>An attribute name legal inside <c>[ ]</c>.</summary>
	Attribute,

	/// <summary>A bare token that is only legal as an argument, such as a sampler filter name.</summary>
	Token
}

/// <summary>One entry in the completion list.</summary>
/// <param name="Label">What the list shows and what filtering matches against.</param>
/// <param name="Kind">What the entry is.</param>
public sealed record CompletionItem( string Label, CompletionItemKind Kind )
{
	/// <summary>Text to insert, when it differs from the label.</summary>
	public string Insert { get; init; }

	/// <summary>
	/// How many characters back from the end of <see cref="InsertText"/> the caret should land. Zero —
	/// the default — leaves it after the last character, which is right for a word; a multi-line
	/// skeleton uses it to drop the caret on the first thing worth editing instead of past the closing
	/// brace.
	/// </summary>
	public int CaretBack { get; init; }

	/// <summary>The type or signature shown in the second column.</summary>
	public string Detail { get; init; }

	/// <summary>A single sentence shown in the third column and in the hover.</summary>
	public string Documentation { get; init; }

	/// <summary>Material icon name.</summary>
	public string Icon { get; init; }

	/// <summary>True for something DXC rejects outright, drawn struck through.</summary>
	public bool Deprecated { get; init; }

	/// <summary>Why the entry cannot compile on the s&amp;box target, or null when it can.</summary>
	public string Unavailable { get; init; }

	/// <summary>Where the symbol comes from: a header, a file or a line number.</summary>
	public string Origin { get; init; }

	/// <summary>Sort bucket. Lower sorts first when scores tie; locals beat the language database.</summary>
	public int Group { get; init; } = 50;

	/// <summary>Distance in lines from the caret to the declaration, used as the final tiebreak.</summary>
	public int Proximity { get; init; } = int.MaxValue;

	/// <summary>Match score against the current prefix, assigned while filtering.</summary>
	public int Score { get; set; }

	/// <summary>What actually gets typed into the buffer.</summary>
	public string InsertText => string.IsNullOrEmpty( Insert ) ? Label : Insert;

	/// <summary>The accent colour of the icon chip, matching the syntax palette.</summary>
	public Color Accent => Kind switch
	{
		CompletionItemKind.Keyword or CompletionItemKind.Modifier => PrismTheme.Code.Keyword,
		CompletionItemKind.Type => PrismTheme.Code.BuiltinType,
		CompletionItemKind.ObjectType => PrismTheme.Code.UserType,
		CompletionItemKind.Intrinsic or CompletionItemKind.Function or CompletionItemKind.Method =>
			PrismTheme.Code.Intrinsic,
		CompletionItemKind.Field or CompletionItemKind.Variable or CompletionItemKind.Parameter =>
			PrismTheme.TextPrimary,
		CompletionItemKind.Macro or CompletionItemKind.Directive => PrismTheme.Code.Preprocessor,
		CompletionItemKind.EngineGlobal => PrismTheme.Code.EngineGlobal,
		CompletionItemKind.Constant => PrismTheme.Code.Number,
		CompletionItemKind.Semantic => PrismTheme.Code.Semantic,
		CompletionItemKind.Annotation or CompletionItemKind.Attribute => PrismTheme.Code.Annotation,
		CompletionItemKind.Block => PrismTheme.Code.VfxBlock,
		CompletionItemKind.Module => PrismTheme.Accent2,
		CompletionItemKind.Combo => PrismTheme.Code.ControlKeyword,
		CompletionItemKind.Folder => PrismTheme.TextSecondary,
		CompletionItemKind.File => PrismTheme.Code.String,
		CompletionItemKind.Swizzle => PrismTheme.TypeFloat2,
		_ => PrismTheme.TextSecondary
	};

	/// <summary>A one-word label for the kind, drawn in the list's right-hand gutter.</summary>
	public string KindLabel => Kind switch
	{
		CompletionItemKind.ObjectType => "type",
		CompletionItemKind.EngineGlobal => "global",
		CompletionItemKind.Directive => "pp",
		_ => Kind.ToString().ToLowerInvariant()
	};

	/// <inheritdoc/>
	public override string ToString() => Label;
}

/// <summary>Where the caret is, which decides what is worth offering.</summary>
public enum CompletionContextKind
{
	/// <summary>Nothing should be offered — inside a comment or a string, or nothing to complete.</summary>
	None,

	/// <summary>Ordinary code position.</summary>
	Global,

	/// <summary>After a <c>.</c>: members and swizzles of the expression on the left.</summary>
	Member,

	/// <summary>After a <c>::</c>: members of an engine class such as <c>Material</c>.</summary>
	Scope,

	/// <summary>After a <c>#</c>: preprocessor directives.</summary>
	Preprocessor,

	/// <summary>Inside a <c>&lt; … &gt;</c> metadata block.</summary>
	Annotation,

	/// <summary>After a single <c>:</c> in a declaration: semantics.</summary>
	Semantic,

	/// <summary>Start of a statement, where a type is the most likely thing being typed.</summary>
	TypePosition,

	/// <summary>Inside the quotes of an <c>#include</c>.</summary>
	IncludePath,

	/// <summary>Inside a <c>[ … ]</c> attribute.</summary>
	Attribute
}

/// <summary>Everything the engine worked out about the caret before it built a list.</summary>
/// <param name="Kind">What kind of position the caret is in.</param>
/// <param name="Prefix">The word already typed, which the list filters on.</param>
/// <param name="Replace">The range a committed completion replaces.</param>
/// <param name="Line">Zero-based caret line.</param>
public sealed record CompletionContext(
	CompletionContextKind Kind,
	string Prefix,
	TextRange Replace,
	int Line )
{
	/// <summary>The expression to the left of a <c>.</c> or <c>::</c>, when there is one.</summary>
	public string Target { get; init; }

	/// <summary>The resolved type of <see cref="Target"/>, when it could be worked out.</summary>
	public string TargetType { get; init; }

	/// <summary>Language id the buffer is being edited as.</summary>
	public string Language { get; init; } = "hlsl";

	/// <summary>True when the user asked for the list rather than it opening while typing.</summary>
	public bool IsExplicit { get; init; }

	/// <summary>True when there is anything at all to offer here.</summary>
	public bool IsValid => Kind != CompletionContextKind.None;

	/// <inheritdoc/>
	public override string ToString() =>
		Target is null ? $"{Kind} '{Prefix}'" : $"{Kind} {Target}.'{Prefix}'";
}

/// <summary>One overload shown by signature help.</summary>
/// <param name="Label">The whole signature, as written in the database.</param>
/// <param name="Name">The callee's name.</param>
/// <param name="Parameters">Parameter declarations, in order.</param>
/// <param name="Documentation">A single sentence about the callee.</param>
public sealed record SignatureInfo( string Label, string Name, IReadOnlyList<string> Parameters, string Documentation )
{
	/// <summary>Where the symbol is declared, shown under the signature.</summary>
	public string Origin { get; init; }

	/// <inheritdoc/>
	public override string ToString() => Label;
}

/// <summary>The active call at the caret, with the overload set and which argument is being typed.</summary>
/// <param name="Name">The callee's name.</param>
/// <param name="Signatures">Every overload, in database order.</param>
/// <param name="ActiveParameter">Zero-based index of the argument the caret is inside.</param>
public sealed record SignatureHelp( string Name, IReadOnlyList<SignatureInfo> Signatures, int ActiveParameter )
{
	/// <summary>Which overload to show. Cycled by the popup, so it is settable.</summary>
	public int ActiveSignature { get; set; }

	/// <summary>True when there is something to draw.</summary>
	public bool IsValid => Signatures is { Count: > 0 };

	/// <inheritdoc/>
	public override string ToString() => $"{Name} ({ActiveParameter})";
}

/// <summary>What the hover popup draws for a symbol.</summary>
/// <param name="Title">The symbol's name.</param>
/// <param name="Signature">The declaration, drawn in the code font.</param>
/// <param name="Description">A sentence about what it does.</param>
public sealed record HoverInfo( string Title, string Signature, string Description )
{
	/// <summary>Where it is declared: a header, a file, or a line in this buffer.</summary>
	public string Origin { get; init; }

	/// <summary>A shader-model or stage restriction worth warning about, or null.</summary>
	public string Note { get; init; }

	/// <summary>True when <see cref="Note"/> is a hard error rather than advice.</summary>
	public bool NoteIsError { get; init; }

	/// <summary>The kind, for the icon chip.</summary>
	public CompletionItemKind Kind { get; init; } = CompletionItemKind.Variable;

	/// <summary>The diagnostic under the cursor, when there was one.</summary>
	public CodeDiagnostic Diagnostic { get; init; }

	/// <summary>True when there is anything to draw.</summary>
	public bool IsEmpty => string.IsNullOrEmpty( Title ) && Diagnostic is null;

	/// <inheritdoc/>
	public override string ToString() => Title;
}

/// <summary>
/// An optional external source of completions, so a language server can add to the built-in list
/// without any call site knowing it exists. Everything is best-effort: a source that is unavailable,
/// slow or broken simply contributes nothing.
/// </summary>
public interface ISlangCompletionSource
{
	/// <summary>True when the source can currently answer.</summary>
	bool Available { get; }

	/// <summary>
	/// Ask for completions. Must never throw and must respect the token; returning an empty list is
	/// always an acceptable answer.
	/// </summary>
	Task<IReadOnlyList<CompletionItem>> Complete( string text, string filePath, TextPosition caret,
		CancellationToken ct );
}

/// <summary>
/// Turns a caret position into a ranked completion list, a signature-help set or a hover card.
/// <para>
/// The engine is pure: it reads a document and the language databases and returns data. Every pixel
/// lives in <see cref="CompletionPopup"/>, <see cref="SignatureHelpPopup"/> and the hover popup, and
/// every keystroke lives in <see cref="CompletionHost"/>. That split is what lets the ranking be
/// reasoned about — and, more practically, what keeps a bug in the list from being a bug in typing.
/// </para>
/// </summary>
public sealed class CompletionEngine
{
	/// <summary>Hard ceiling on the list, because a popup nobody can read is not a feature.</summary>
	public const int DefaultMaxResults = 60;

	static readonly Regex s_combo = new( @"\b([FSD]_[A-Z][A-Z0-9_]*)\b",
		RegexOptions.Compiled | RegexOptions.CultureInvariant );

	static readonly ConditionalWeakTable<TextDocument, ComboCache> s_combos = new();
	static readonly ConditionalWeakTable<TextDocument, IncludeCache> s_includes = new();

	static readonly string[] s_swizzleSets = { "xyzw", "rgba" };

	static readonly char[] s_pathSeparators = { '/', '\\' };

	/// <summary>Marks where the caret goes inside a skeleton. Stripped before the text is inserted.</summary>
	const char CaretMark = '\u0001';

	sealed class ComboCache
	{
		public int Version = -1;
		public string[] Names = Array.Empty<string>();
	}

	sealed class IncludeCache
	{
		public int Version = -1;
		public string[] Paths = Array.Empty<string>();
	}

	/// <summary>The language database in force. Set from the editor's language id.</summary>
	public LanguageDefinition Language { get; set; } = LanguageDefinition.Hlsl;

	/// <summary>Path of the buffer, used to resolve includes. May be null for an unsaved buffer.</summary>
	public string FilePath { get; set; }

	/// <summary>Maximum entries returned.</summary>
	public int MaxResults { get; set; } = DefaultMaxResults;

	/// <summary>Whether declarations from included headers are offered. On by default.</summary>
	public bool IncludeHeaderSymbols { get; set; } = true;

	/// <summary>An optional language-server source, merged in asynchronously when it answers.</summary>
	public ISlangCompletionSource External { get; set; }

	/// <summary>Points the engine at a language by id, extension or path.</summary>
	public void SetLanguage( string languageOrPath ) => Language = LanguageDefinition.For( languageOrPath );

	// ---- context ----------------------------------------------------------

	/// <summary>Works out what the caret is sitting in. Never throws; returns a None context on doubt.</summary>
	public CompletionContext Classify( CodeEditorWidget editor, bool explicitRequest = false )
	{
		if ( editor is not { IsValid: true } || editor.Document is null )
			return None();

		return PrismLog.Guard( "Prism.Text: classify completion",
			() => ClassifyCore( editor.Document, editor.CaretPosition, editor.Language, editor.IsCaretInert,
				editor.CaretTokenKind, explicitRequest ),
			None() );
	}

	/// <summary>
	/// The widget-free form, for callers that already have the document and the lexer's verdict on the
	/// caret. Everything the widget overload does goes through here.
	/// </summary>
	public CompletionContext Classify( TextDocument document, TextPosition caret, string language,
		bool inert = false, TokenKind caretTokenKind = TokenKind.None, bool explicitRequest = false )
	{
		if ( document is null )
			return None();

		return PrismLog.Guard( "Prism.Text: classify completion",
			() => ClassifyCore( document, caret, language, inert, caretTokenKind, explicitRequest ), None() );
	}

	CompletionContext ClassifyCore( TextDocument document, TextPosition caret, string language, bool inert,
		TokenKind caretTokenKind, bool explicitRequest )
	{
		var line = document.GetLine( caret.Line );
		var column = Math.Clamp( caret.Column, 0, line.Length );

		// An include path is lexed as a string, so it has to be recognised before the inert check.
		if ( IncludeResolver.TryParse( line, caret.Line, out var include ) && !include.AngleBracket &&
			 column >= include.Start && column <= include.End )
		{
			var typed = line.Substring( include.Start, column - include.Start );

			// Only the final segment is being completed. Ranking `shared.hlsl` against the whole typed
			// `common/` scores every candidate below zero and the popup comes up empty, and replacing the
			// whole path would delete the directory the user just typed. The full path still goes to the
			// resolver, through Target, because that is what selects the folder to list.
			var slash = typed.LastIndexOfAny( s_pathSeparators );
			var segment = slash < 0 ? typed : typed.Substring( slash + 1 );

			return new CompletionContext( CompletionContextKind.IncludePath, segment,
				new TextRange( new TextPosition( caret.Line, include.Start + slash + 1 ), caret ), caret.Line )
			{
				Target = typed,
				Language = language,
				IsExplicit = explicitRequest
			};
		}

		if ( inert )
			return None();

		var start = column;

		while ( start > 0 && TextEditController.IsWordChar( line[start - 1] ) )
			start--;

		var prefix = line.Substring( start, column - start );
		var replace = new TextRange( new TextPosition( caret.Line, start ), caret );

		// The interesting character is the last significant one before what has been typed, so
		// `i.` and `i . ` classify the same way.
		var back = start - 1;

		while ( back >= 0 && ( line[back] == ' ' || line[back] == '\t' ) )
			back--;

		var previous = back >= 0 ? line[back] : '\0';
		var previous2 = back >= 1 ? line[back - 1] : '\0';

		CompletionContext Make( CompletionContextKind kind, string target = null ) =>
			new( kind, prefix, replace, caret.Line )
			{
				Target = target,
				Language = language,
				IsExplicit = explicitRequest
			};

		if ( previous == '#' )
			return Make( CompletionContextKind.Preprocessor );

		if ( previous == ':' && previous2 == ':' )
			return Make( CompletionContextKind.Scope, ReadExpression( line, back - 1 ) );

		if ( previous == '.' && !( back >= 1 && char.IsDigit( line[back - 1] ) ) )
		{
			var target = ReadExpression( line, back );

			return new CompletionContext( CompletionContextKind.Member, prefix, replace, caret.Line )
			{
				Target = target,
				Language = language,
				IsExplicit = explicitRequest
			};
		}

		if ( caretTokenKind == TokenKind.Annotation ||
			 ( Language.SupportsAnnotations && InAnnotation( line, start ) ) )
		{
			return Make( CompletionContextKind.Annotation );
		}

		// `: SV_Target0` is a semantic; `a ? b : c` is not, and neither is `case 1:`.
		if ( previous == ':' && previous2 != ':' && line.IndexOf( '?' ) < 0 &&
			 !line.TrimStart().StartsWith( "case", StringComparison.Ordinal ) )
		{
			return Make( CompletionContextKind.Semantic );
		}

		if ( InAttribute( line, start ) )
			return Make( CompletionContextKind.Attribute );

		if ( prefix.Length == 0 && !explicitRequest )
			return None();

		return Make( AtStatementStart( document, caret.Line, start )
			? CompletionContextKind.TypePosition
			: CompletionContextKind.Global );
	}

	static CompletionContext None() =>
		new( CompletionContextKind.None, string.Empty, TextRange.Empty, 0 );

	/// <summary>Reads the dotted expression that ends at <paramref name="end"/>, exclusive.</summary>
	static string ReadExpression( string line, int end )
	{
		var start = end;

		while ( start > 0 )
		{
			var c = line[start - 1];

			if ( TextEditController.IsWordChar( c ) || c == '.' || c == ']' || c == '[' )
			{
				start--;
				continue;
			}

			if ( c == ':' && start > 1 && line[start - 2] == ':' )
			{
				start -= 2;
				continue;
			}

			break;
		}

		return start >= end ? string.Empty : line.Substring( start, end - start );
	}

	/// <summary>
	/// True when the caret is inside a VFX metadata block. The opening angle bracket must be preceded
	/// by whitespace, which is what separates <c>float4 g_vTint &lt; UiGroup(…); &gt;</c> from
	/// <c>Texture2D&lt;float4&gt;</c> and from an ordinary less-than.
	/// </summary>
	static bool InAnnotation( string line, int column )
	{
		var depth = 0;
		var parens = 0;
		var assigned = false;

		for ( var i = 0; i < column && i < line.Length; i++ )
		{
			var c = line[i];

			if ( c == '(' )
				parens++;
			else if ( c == ')' )
				parens = Math.Max( 0, parens - 1 );
			else if ( c == '=' && depth == 0 )
				assigned = true;
			else if ( c == ';' )
			{
				depth = 0;
				assigned = false;
			}
			else if ( c == '<' && depth == 0 )
			{
				// An annotation opens after a declaration: whitespace before the bracket rules out
				// `Texture2D<float4>`, no assignment rules out `a < b`, and no open parenthesis rules
				// out `if ( a < b )`.
				if ( i > 0 && char.IsWhiteSpace( line[i - 1] ) && !assigned && parens == 0 )
					depth++;
			}
			else if ( c == '<' )
				depth++;
			else if ( c == '>' && depth > 0 )
				depth--;
		}

		return depth > 0;
	}

	/// <summary>
	/// True when the caret is inside a <c>[ … ]</c> attribute. An attribute opens the line; a bracket
	/// anywhere else is an array subscript, and offering <c>unroll</c> inside <c>a[i]</c> would be
	/// worse than offering nothing.
	/// </summary>
	static bool InAttribute( string line, int column )
	{
		var depth = 0;
		var first = true;

		for ( var i = 0; i < column && i < line.Length; i++ )
		{
			var c = line[i];

			if ( char.IsWhiteSpace( c ) )
				continue;

			if ( c == '[' && ( first || depth > 0 ) )
				depth++;
			else if ( c == ']' && depth > 0 )
				depth--;

			first = false;
		}

		return depth > 0;
	}

	static bool AtStatementStart( TextDocument document, int line, int column )
	{
		for ( var l = line; l >= 0 && l > line - 4; l-- )
		{
			var text = document.GetLine( l );
			var from = l == line ? Math.Min( column, text.Length ) : text.Length;

			for ( var i = from - 1; i >= 0; i-- )
			{
				var c = text[i];

				if ( char.IsWhiteSpace( c ) )
					continue;

				return c is ';' or '{' or '}' or ')';
			}
		}

		return true;
	}

	// ---- completion -------------------------------------------------------

	/// <summary>Builds the ranked list for an editor's caret.</summary>
	public IReadOnlyList<CompletionItem> Complete( CodeEditorWidget editor, bool explicitRequest = false )
	{
		var context = Classify( editor, explicitRequest );

		return Complete( editor?.Document, context );
	}

	/// <summary>Builds the ranked list for a context that was already classified.</summary>
	public IReadOnlyList<CompletionItem> Complete( TextDocument document, CompletionContext context )
	{
		if ( document is null || context is null || !context.IsValid )
			return Array.Empty<CompletionItem>();

		return PrismLog.Guard<IReadOnlyList<CompletionItem>>( "Prism.Text: build completions",
			() => Rank( Gather( document, context ), context.Prefix ),
			Array.Empty<CompletionItem>() );
	}

	List<CompletionItem> Gather( TextDocument document, CompletionContext context )
	{
		var results = new List<CompletionItem>( 256 );
		var symbols = DocumentSymbols.For( document, context.Language );

		switch ( context.Kind )
		{
			case CompletionContextKind.IncludePath:
				AddIncludePaths( results, context );
				return results;

			case CompletionContextKind.Preprocessor:
				AddDirectives( results );
				return results;

			case CompletionContextKind.Semantic:
				AddSemantics( results );
				return results;

			case CompletionContextKind.Annotation:
				AddAnnotations( results );
				return results;

			case CompletionContextKind.Attribute:
				AddAttributes( results );
				return results;

			case CompletionContextKind.Member:
				AddMembers( results, symbols, context );
				return results;

			case CompletionContextKind.Scope:
				AddScopeMembers( results, symbols, context );
				return results;
		}

		var typeFirst = context.Kind == CompletionContextKind.TypePosition;

		AddDocumentSymbols( results, symbols, context );
		AddHeaderSymbols( results, document, context );
		AddCombos( results, document );
		AddLanguageWords( results, typeFirst );
		AddIntrinsics( results );
		AddSboxSymbols( results );

		if ( Language.HasVfxBlocks )
			AddBlocks( results, document, context );

		return results;
	}

	// ---- sources ----------------------------------------------------------

	void AddIncludePaths( List<CompletionItem> results, CompletionContext context )
	{
		// Target carries the whole path typed so far; Prefix is only its last segment.
		foreach ( var suggestion in IncludeResolver.Suggest( context.Target ?? context.Prefix, FilePath ) )
		{
			results.Add( new CompletionItem( suggestion.Name,
				suggestion.IsDirectory ? CompletionItemKind.Folder : CompletionItemKind.File )
			{
				Detail = suggestion.Root,
				Icon = suggestion.IsDirectory ? "folder" : "description",
				Origin = suggestion.AbsolutePath,
				Group = suggestion.IsDirectory ? 10 : 20
			} );
		}
	}

	void AddDirectives( List<CompletionItem> results )
	{
		foreach ( var directive in Language.PreprocessorDirectives )
		{
			results.Add( new CompletionItem( directive, CompletionItemKind.Directive )
			{
				Icon = "tag",
				Documentation = DirectiveDoc( directive ),
				Group = 10
			} );
		}
	}

	static string DirectiveDoc( string directive ) => directive switch
	{
		"include" => "Inline another file. s&box only matches the double-quoted form, with one space.",
		"define" => "Define a macro.",
		"undef" => "Undefine a macro.",
		"if" or "elif" => "Conditional compilation on a constant expression.",
		"ifdef" => "Compile the block when a macro is defined.",
		"ifndef" => "Compile the block when a macro is not defined.",
		"else" => "The other branch of a conditional block.",
		"endif" => "Close a conditional block.",
		"pragma" => "Compiler directive.",
		"error" => "Fail the compile with a message.",
		"line" => "Override the reported line number and file.",
		_ => null
	};

	void AddSemantics( List<CompletionItem> results )
	{
		foreach ( var semantic in HlslLanguage.Semantics )
		{
			var unavailable = semantic.MinShaderModel > ShaderModel.Target
				? $"'{semantic.Name}' requires Shader Model {semantic.MinShaderModel}; s&box compiles at SM {ShaderModel.Target}."
				: null;

			var group = semantic.IsSystemValue ? 10 : 20;

			results.Add( new CompletionItem( semantic.Name, CompletionItemKind.Semantic )
			{
				Detail = semantic.Type,
				Documentation = semantic.Description,
				Icon = "sell",
				Unavailable = unavailable,
				Group = group
			} );

			// The indexed spelling is what actually gets written — nobody binds a pixel output to bare
			// `SV_Target`, they write `SV_Target0` — so offer the forms that exist rather than making the
			// user finish the word by hand. They sit one group behind their base name, so an empty prefix
			// still lists the bare semantics first and the indexed ones only surface once it is typed.
			var indexed = IndexedFormCount( semantic );

			for ( var index = 0; index < indexed; index++ )
			{
				results.Add( new CompletionItem( semantic.Name + index, CompletionItemKind.Semantic )
				{
					Detail = semantic.Type,
					Documentation = semantic.Description,
					Icon = "sell",
					Unavailable = unavailable,
					Group = group + 1
				} );
			}
		}
	}

	/// <summary>
	/// How many indexed spellings of a semantic are worth offering, which is a question about what people
	/// write rather than about what the grammar allows — every indexed semantic accepts any integer.
	/// </summary>
	static int IndexedFormCount( SemanticDoc semantic )
	{
		if ( !semantic.Indexed )
			return 0;

		return semantic.Name switch
		{
			// One per render target a pixel shader can bind.
			"SV_Target" => 8,

			// s&box takes TEXCOORD0-7 and 11; 13 and up are free for custom interpolators, and
			// PrismConstants.FirstFreeTexcoord is where the varying allocator starts handing them out.
			"TEXCOORD" => 16,

			"COLOR" => 2,
			"SV_ClipDistance" => 2,
			"SV_CullDistance" => 2,

			// Everything else — POSITION, NORMAL, TANGENT, BLENDWEIGHT — is written bare or with a
			// single zero, and a longer ladder would only push the bare form down the list.
			_ => 1
		};
	}

	void AddAnnotations( List<CompletionItem> results )
	{
		foreach ( var symbol in SboxSymbols.OfKind( SboxSymbolKind.Annotation ) )
		{
			results.Add( new CompletionItem( symbol.Name, CompletionItemKind.Annotation )
			{
				Detail = symbol.Signature,
				Documentation = symbol.Description,
				Origin = symbol.Header,
				Icon = "data_object",
				Group = 10
			} );
		}

		void Tokens( IReadOnlyList<string> names, string detail )
		{
			foreach ( var name in names )
			{
				results.Add( new CompletionItem( name, CompletionItemKind.Token )
				{
					Detail = detail,
					Icon = "label",
					Group = 30
				} );
			}
		}

		Tokens( SboxSymbols.SamplerFilters, "sampler filter" );
		Tokens( SboxSymbols.SamplerAddressModes, "address mode" );
		Tokens( SboxSymbols.ImageAlgorithms, "image processor" );
		Tokens( SboxSymbols.ChannelAlgorithms, "channel packer" );
		Tokens( SboxSymbols.OutputFormats, "output format" );
		Tokens( SboxSymbols.ColorSpaces, "colour space" );
		Tokens( SboxSymbols.UiTypes, "UiType value" );
		Tokens( SboxSymbols.SourceTokens, "Source value" );
	}

	void AddAttributes( List<CompletionItem> results )
	{
		foreach ( var attribute in Language.Attributes )
		{
			results.Add( new CompletionItem( attribute, CompletionItemKind.Attribute )
			{
				Icon = "bolt",
				Group = 10
			} );
		}
	}

	void AddBlocks( List<CompletionItem> results, TextDocument document, CompletionContext context )
	{
		// A block name only ever opens a line at file scope, so that is the only place the skeleton is
		// offered; anywhere else the bare word is what was meant.
		var skeletons = AloneOnLine( document, context );
		var indent = skeletons ? LeadingWhitespace( document.GetLine( context.Line ) ) : string.Empty;

		foreach ( var block in SboxSymbols.BlockNames )
		{
			var rejected = SboxSymbols.RejectedBlockNames.Contains( block );
			var insert = rejected || !skeletons ? null : BlockSkeleton( block, indent );
			var caretBack = 0;

			if ( insert is not null )
			{
				var mark = insert.IndexOf( CaretMark );

				if ( mark >= 0 )
				{
					insert = insert.Remove( mark, 1 );
					caretBack = insert.Length - mark;
				}
			}

			results.Add( new CompletionItem( block, CompletionItemKind.Block )
			{
				Insert = insert,
				CaretBack = caretBack,
				Icon = "widgets",
				Detail = insert is null ? null : "block skeleton",
				Documentation = rejected
					? "The engine's .shader parser rejects this block outright."
					: "A top-level VFX block.",
				Unavailable = rejected ? $"s&box refuses to compile a shader containing a {block} block." : null,
				Deprecated = rejected,

				// The VFX word tables carry the same names as plain keywords at group 25, and the ranker
				// keeps whichever of two identical labels sorts better. A skeleton has to outrank its own
				// keyword or it would never be the entry the user sees.
				Group = insert is null ? 25 : 5
			} );
		}
	}

	/// <summary>True when nothing but whitespace precedes the prefix and nothing follows it.</summary>
	static bool AloneOnLine( TextDocument document, CompletionContext context )
	{
		if ( document is null || context is null )
			return false;

		var line = document.GetLine( context.Line );

		for ( var i = 0; i < context.Replace.Start.Column && i < line.Length; i++ )
		{
			if ( !char.IsWhiteSpace( line[i] ) )
				return false;
		}

		for ( var i = context.Replace.End.Column; i < line.Length; i++ )
		{
			if ( !char.IsWhiteSpace( line[i] ) )
				return false;
		}

		return true;
	}

	static string LeadingWhitespace( string line )
	{
		var i = 0;

		while ( i < line.Length && ( line[i] == ' ' || line[i] == '\t' ) )
			i++;

		return i == 0 ? string.Empty : line.Substring( 0, i );
	}

	/// <summary>
	/// The body a VFX block is nearly always written with, taken from the shipped engine shaders rather
	/// than invented. <see cref="CaretMark"/> marks where the caret lands; every skeleton has exactly one.
	/// </summary>
	static string BlockSkeleton( string block, string indent )
	{
		var body = block switch
		{
			"HEADER" => "\tDescription = \"\u0001\";",
			"MODES" => "\tForward();\n\tDepth();\u0001",
			"FEATURES" => "\t#include \"common/features.hlsl\"\u0001",
			"COMMON" => "\t#include \"common/shared.hlsl\"\u0001",
			"VS" =>
				"\t#include \"common/vertex.hlsl\"\n\n" +
				"\tPixelInput MainVs( VertexInput i )\n" +
				"\t{\n" +
				"\t\tPixelInput o = ProcessVertex( i );\u0001\n" +
				"\t\treturn FinalizeVertex( o );\n" +
				"\t}",
			"PS" =>
				"\t#include \"common/pixel.hlsl\"\n\n" +
				"\tfloat4 MainPs( PixelInput i ) : SV_Target0\n" +
				"\t{\n" +
				"\t\tMaterial m = Material::From( i );\u0001\n" +
				"\t\treturn ShadingModelStandard::Shade( i, m );\n" +
				"\t}",
			"CS" =>
				"\t[numthreads( 8, 8, 1 )]\n" +
				"\tvoid MainCs( uint3 vThreadId : SV_DispatchThreadID )\n" +
				"\t{\n" +
				"\t\t\u0001\n" +
				"\t}",
			_ => "\t\u0001"
		};

		var text = $"{block}\n{{\n{body}\n}}";

		return indent.Length == 0 ? text : text.Replace( "\n", "\n" + indent );
	}

	void AddMembers( List<CompletionItem> results, DocumentSymbols symbols, CompletionContext context )
	{
		var type = context.TargetType ?? ResolveType( symbols, context.Target, context.Line );

		if ( !string.IsNullOrEmpty( type ) )
		{
			var bare = BareType( type );

			foreach ( var member in symbols.MembersOf( bare ) )
			{
				results.Add( new CompletionItem( member.Name,
					member.Kind == DocumentSymbolKind.Method ? CompletionItemKind.Method : CompletionItemKind.Field )
				{
					Detail = member.Kind == DocumentSymbolKind.Method ? member.Signature : member.Type,
					Documentation = member.Semantic is null ? null : $"Semantic {member.Semantic}.",
					Icon = member.Icon,
					Group = 0,
					Proximity = Math.Abs( member.Line - context.Line )
				} );
			}

			foreach ( var method in HlslLanguage.MembersOf( bare ) )
				results.Add( MemberItem( method, 5 ) );

			if ( ShaderType.TryParse( bare, out var shaderType ) && shaderType.IsScalarOrVector )
				AddSwizzles( results, shaderType.Components );

			if ( results.Count > 0 )
				return;
		}

		// The type could not be worked out. Offering every member method beats offering nothing, but it
		// sorts below anything the buffer itself declares.
		foreach ( var method in HlslLanguage.MemberMethods )
			results.Add( MemberItem( method, 40 ) );

		AddSwizzles( results, 4 );
	}

	static CompletionItem MemberItem( TypeMemberDoc method, int group ) =>
		new( method.Name, CompletionItemKind.Method )
		{
			Detail = method.Signature,
			Documentation = method.Description,
			Icon = "functions",
			Unavailable = method.MinShaderModel > ShaderModel.Target
				? $"'{method.Name}' requires Shader Model {method.MinShaderModel}; s&box compiles at SM {ShaderModel.Target} (Vulkan)."
				: null,
			Group = group
		};

	static void AddSwizzles( List<CompletionItem> results, int components )
	{
		components = Math.Clamp( components, 1, 4 );

		foreach ( var set in s_swizzleSets )
		{
			for ( var width = 1; width <= components; width++ )
			{
				var label = set.Substring( 0, width );

				results.Add( new CompletionItem( label, CompletionItemKind.Swizzle )
				{
					Detail = width == 1 ? "component" : $"float{width}",
					Icon = "tune",
					Group = 15 + width
				} );
			}

			for ( var i = 0; i < components; i++ )
			{
				var label = set[i].ToString();

				if ( results.Any( x => x.Kind == CompletionItemKind.Swizzle && x.Label == label ) )
					continue;

				results.Add( new CompletionItem( label, CompletionItemKind.Swizzle )
				{
					Detail = "component",
					Icon = "tune",
					Group = 16
				} );
			}
		}
	}

	void AddScopeMembers( List<CompletionItem> results, DocumentSymbols symbols, CompletionContext context )
	{
		var target = context.Target;

		if ( string.IsNullOrEmpty( target ) )
			return;

		var qualified = target + "::";

		foreach ( var symbol in SboxSymbols.All )
		{
			if ( !symbol.Name.StartsWith( qualified, StringComparison.Ordinal ) )
				continue;

			results.Add( new CompletionItem( symbol.Name.Substring( qualified.Length ), CompletionItemKind.Method )
			{
				Detail = symbol.Signature,
				Documentation = symbol.Description,
				Origin = symbol.Header,
				Icon = "functions",
				Group = 0
			} );
		}

		foreach ( var member in symbols.MembersOf( target ) )
		{
			results.Add( new CompletionItem( member.Name,
				member.Kind == DocumentSymbolKind.Method ? CompletionItemKind.Method : CompletionItemKind.Field )
			{
				Detail = member.Kind == DocumentSymbolKind.Method ? member.Signature : member.Type,
				Icon = member.Icon,
				Group = 5,
				Proximity = Math.Abs( member.Line - context.Line )
			} );
		}
	}

	void AddDocumentSymbols( List<CompletionItem> results, DocumentSymbols symbols, CompletionContext context )
	{
		foreach ( var symbol in symbols.VisibleAt( context.Line ) )
		{
			if ( symbol.Kind is DocumentSymbolKind.Block or DocumentSymbolKind.Import )
				continue;

			results.Add( new CompletionItem( symbol.Name, KindOf( symbol ) )
			{
				Detail = symbol.IsCallable ? symbol.Signature : symbol.Type,
				Documentation = symbol.Semantic is null ? null : $"Semantic {symbol.Semantic}.",
				Icon = symbol.Icon,
				Origin = $"line {symbol.Line + 1}",
				Group = symbol.Kind is DocumentSymbolKind.Variable or DocumentSymbolKind.Parameter ? 0 : 5,
				Proximity = Math.Abs( symbol.Line - context.Line )
			} );
		}
	}

	static CompletionItemKind KindOf( DocumentSymbol symbol ) => symbol.Kind switch
	{
		DocumentSymbolKind.Function => CompletionItemKind.Function,
		DocumentSymbolKind.Method => CompletionItemKind.Method,
		DocumentSymbolKind.Struct or DocumentSymbolKind.Interface or DocumentSymbolKind.CBuffer
			or DocumentSymbolKind.Enum or DocumentSymbolKind.TypeAlias => CompletionItemKind.Type,
		DocumentSymbolKind.Field => CompletionItemKind.Field,
		DocumentSymbolKind.Parameter => CompletionItemKind.Parameter,
		DocumentSymbolKind.Macro => CompletionItemKind.Macro,
		DocumentSymbolKind.Module => CompletionItemKind.Module,
		_ => CompletionItemKind.Variable
	};

	/// <summary>
	/// The files a buffer includes, resolved once per document version. Walking the include graph is
	/// cheap but not free, and completion runs on every keystroke.
	/// </summary>
	IReadOnlyList<string> IncludedFiles( TextDocument document )
	{
		var cache = s_includes.GetValue( document, static _ => new IncludeCache() );

		if ( cache.Version == document.Version )
			return cache.Paths;

		cache.Paths = IncludeResolver.Transitive( document.Text, FilePath ).ToArray();
		cache.Version = document.Version;

		return cache.Paths;
	}

	void AddHeaderSymbols( List<CompletionItem> results, TextDocument document, CompletionContext context )
	{
		if ( !IncludeHeaderSymbols )
			return;

		foreach ( var path in IncludedFiles( document ) )
		{
			var header = DocumentSymbols.ForFile( path, context.Language );
			var name = System.IO.Path.GetFileName( path );

			foreach ( var symbol in header.TopLevel )
			{
				if ( symbol.Kind is DocumentSymbolKind.Block or DocumentSymbolKind.Import
					or DocumentSymbolKind.Parameter )
				{
					continue;
				}

				results.Add( new CompletionItem( symbol.Name, KindOf( symbol ) )
				{
					Detail = symbol.IsCallable ? symbol.Signature : symbol.Type,
					Icon = symbol.Icon,
					Origin = name,
					Group = 20
				} );
			}
		}
	}

	void AddCombos( List<CompletionItem> results, TextDocument document )
	{
		foreach ( var combo in Combos( document ) )
		{
			results.Add( new CompletionItem( combo, CompletionItemKind.Combo )
			{
				Detail = combo[0] switch
				{
					'F' => "feature combo",
					'S' => "static combo",
					_ => "dynamic combo"
				},
				Icon = "call_split",
				Group = 15
			} );
		}
	}

	static IReadOnlyList<string> Combos( TextDocument document )
	{
		var cache = s_combos.GetValue( document, static _ => new ComboCache() );

		if ( cache.Version == document.Version )
			return cache.Names;

		var names = new List<string>();
		var seen = new HashSet<string>( StringComparer.Ordinal );

		PrismLog.Guard( "Prism.Text: scan combos", () =>
		{
			foreach ( Match match in s_combo.Matches( document.Text ) )
			{
				var name = match.Groups[1].Value;

				if ( seen.Add( name ) )
					names.Add( name );

				if ( names.Count >= 128 )
					return;
			}
		} );

		cache.Version = document.Version;
		cache.Names = names.ToArray();

		return cache.Names;
	}

	void AddLanguageWords( List<CompletionItem> results, bool typeFirst )
	{
		void Words( IReadOnlySet<string> words, CompletionItemKind kind, string icon, int group )
		{
			foreach ( var word in words )
			{
				results.Add( new CompletionItem( word, kind )
				{
					Icon = icon,
					Group = group
				} );
			}
		}

		Words( Language.BuiltinTypes, CompletionItemKind.Type, "category", typeFirst ? 8 : 30 );
		Words( Language.ObjectTypes, CompletionItemKind.ObjectType, "image", typeFirst ? 9 : 30 );
		Words( Language.ControlKeywords, CompletionItemKind.Keyword, "alt_route", typeFirst ? 35 : 25 );
		Words( Language.Keywords, CompletionItemKind.Keyword, "key", 25 );
		Words( Language.Modifiers, CompletionItemKind.Modifier, "tune", 28 );
		Words( Language.Literals, CompletionItemKind.Constant, "toggle_on", 28 );
		Words( Language.PredefinedMacros, CompletionItemKind.Macro, "tag", 32 );
		Words( Language.ExtraIntrinsics, CompletionItemKind.Intrinsic, "functions", 22 );
	}

	void AddIntrinsics( List<CompletionItem> results )
	{
		foreach ( var doc in IntrinsicDb.All )
		{
			// A DX9 sampler intrinsic is an error the moment it is typed; never offer one.
			if ( doc.Deprecated )
				continue;

			results.Add( new CompletionItem( doc.Name, CompletionItemKind.Intrinsic )
			{
				Detail = doc.Signature,
				Documentation = doc.Description,
				Icon = "functions",
				Unavailable = doc.UnavailableReason,
				Group = doc.IsAvailableOnTarget ? 20 : 60
			} );
		}
	}

	void AddSboxSymbols( List<CompletionItem> results )
	{
		if ( !Language.HasSboxSymbols )
			return;

		foreach ( var symbol in SboxSymbols.All )
		{
			// Members are reached through `::`, not by typing their qualified name.
			if ( symbol.Kind == SboxSymbolKind.Method || symbol.Name.Contains( "::", StringComparison.Ordinal ) )
				continue;

			if ( symbol.Kind is SboxSymbolKind.Annotation or SboxSymbolKind.BlockKeyword or SboxSymbolKind.Token )
				continue;

			results.Add( new CompletionItem( symbol.Name, SboxKind( symbol.Kind ) )
			{
				Detail = symbol.Signature,
				Documentation = symbol.Description,
				Origin = symbol.Header,
				Icon = SboxIcon( symbol.Kind ),
				Group = symbol.Kind == SboxSymbolKind.Global ? 18 : 22
			} );
		}
	}

	static CompletionItemKind SboxKind( SboxSymbolKind kind ) => kind switch
	{
		SboxSymbolKind.Macro => CompletionItemKind.Macro,
		SboxSymbolKind.Function => CompletionItemKind.Function,
		SboxSymbolKind.Global => CompletionItemKind.EngineGlobal,
		SboxSymbolKind.Constant => CompletionItemKind.Constant,
		SboxSymbolKind.Struct => CompletionItemKind.ObjectType,
		SboxSymbolKind.Enum => CompletionItemKind.Type,
		SboxSymbolKind.ComboDeclaration => CompletionItemKind.Combo,
		SboxSymbolKind.RenderState => CompletionItemKind.Token,
		SboxSymbolKind.HeaderKey => CompletionItemKind.Token,
		SboxSymbolKind.ModeFunction => CompletionItemKind.Function,
		_ => CompletionItemKind.Variable
	};

	static string SboxIcon( SboxSymbolKind kind ) => kind switch
	{
		SboxSymbolKind.Macro => "tag",
		SboxSymbolKind.Function or SboxSymbolKind.ModeFunction => "functions",
		SboxSymbolKind.Global => "public",
		SboxSymbolKind.Constant => "pin",
		SboxSymbolKind.Struct => "data_object",
		SboxSymbolKind.Enum => "list",
		SboxSymbolKind.ComboDeclaration => "call_split",
		_ => "code"
	};

	// ---- ranking ----------------------------------------------------------

	/// <summary>
	/// Folds an extra source's entries into a list that is already on screen and re-ranks the two
	/// together, so a late answer from a language server slots into the right place rather than being
	/// appended at the bottom.
	/// </summary>
	public IReadOnlyList<CompletionItem> Merge( IReadOnlyList<CompletionItem> existing,
		IReadOnlyList<CompletionItem> extra, string prefix )
	{
		var combined = new List<CompletionItem>( ( existing?.Count ?? 0 ) + ( extra?.Count ?? 0 ) );

		if ( existing is not null )
			combined.AddRange( existing );

		if ( extra is not null )
			combined.AddRange( extra );

		return Rank( combined, prefix );
	}

	List<CompletionItem> Rank( List<CompletionItem> items, string prefix )
	{
		var seen = new Dictionary<string, CompletionItem>( StringComparer.Ordinal );

		foreach ( var item in items )
		{
			var score = Score( item.Label, prefix );

			if ( score < 0 )
				continue;

			item.Score = score;

			// The same name can arrive from the buffer, a header and the language database. Keep the
			// one that sorts best, which is always the most specific.
			if ( seen.TryGetValue( item.Label, out var existing ) && Compare( existing, item ) <= 0 )
				continue;

			seen[item.Label] = item;
		}

		var matched = new List<CompletionItem>( seen.Values );

		matched.Sort( Compare );

		if ( matched.Count > MaxResults )
			matched.RemoveRange( MaxResults, matched.Count - MaxResults );

		return matched;
	}

	static int Compare( CompletionItem a, CompletionItem b )
	{
		if ( a.Score != b.Score )
			return b.Score - a.Score;

		if ( a.Group != b.Group )
			return a.Group - b.Group;

		if ( a.Proximity != b.Proximity )
			return a.Proximity - b.Proximity;

		if ( a.Label.Length != b.Label.Length )
			return a.Label.Length - b.Label.Length;

		return string.CompareOrdinal( a.Label, b.Label );
	}

	/// <summary>
	/// How well a candidate matches what has been typed. Higher is better and -1 means "do not show".
	/// The ladder is exact, then case-sensitive prefix, then case-insensitive prefix, then camel-hump,
	/// then substring, then fuzzy subsequence — so <c>nrm</c> finds <c>normalize</c> without
	/// <c>normalize</c> ever outranking a local called <c>nrm</c>.
	/// </summary>
	public static int Score( string candidate, string prefix )
	{
		if ( string.IsNullOrEmpty( candidate ) )
			return -1;

		if ( string.IsNullOrEmpty( prefix ) )
			return 500;

		if ( prefix.Length > candidate.Length )
			return -1;

		var penalty = Math.Min( 40, candidate.Length - prefix.Length );

		if ( string.Equals( candidate, prefix, StringComparison.Ordinal ) )
			return 1000;

		if ( string.Equals( candidate, prefix, StringComparison.OrdinalIgnoreCase ) )
			return 970;

		if ( candidate.StartsWith( prefix, StringComparison.Ordinal ) )
			return 900 - penalty;

		if ( candidate.StartsWith( prefix, StringComparison.OrdinalIgnoreCase ) )
			return 840 - penalty;

		if ( CamelHumpMatch( candidate, prefix ) )
			return 700 - penalty;

		var index = candidate.IndexOf( prefix, StringComparison.OrdinalIgnoreCase );

		if ( index > 0 )
			return 600 - Math.Min( 40, index ) - penalty / 2;

		var gaps = SubsequenceGaps( candidate, prefix );

		return gaps < 0 ? -1 : 450 - Math.Min( 120, gaps );
	}

	/// <summary>True when the prefix matches the candidate's humps, so <c>gvcp</c> finds <c>g_vCameraPos</c>.</summary>
	static bool CamelHumpMatch( string candidate, string prefix )
	{
		var humps = new StringBuilder( 8 );
		var boundary = true;

		for ( var i = 0; i < candidate.Length; i++ )
		{
			var c = candidate[i];

			if ( c == '_' )
			{
				boundary = true;
				continue;
			}

			if ( boundary || char.IsUpper( c ) || char.IsDigit( c ) )
				humps.Append( char.ToLowerInvariant( c ) );

			boundary = false;
		}

		return humps.Length >= prefix.Length &&
			humps.ToString().StartsWith( prefix.ToLowerInvariant(), StringComparison.Ordinal );
	}

	/// <summary>Total skipped characters when the prefix appears in order, or -1 when it does not.</summary>
	static int SubsequenceGaps( string candidate, string prefix )
	{
		var gaps = 0;
		var at = 0;

		for ( var i = 0; i < prefix.Length; i++ )
		{
			var found = -1;

			for ( var j = at; j < candidate.Length; j++ )
			{
				if ( char.ToLowerInvariant( candidate[j] ) != char.ToLowerInvariant( prefix[i] ) )
					continue;

				found = j;
				break;
			}

			if ( found < 0 )
				return -1;

			gaps += found - at;
			at = found + 1;
		}

		return gaps;
	}

	// ---- type resolution --------------------------------------------------

	/// <summary>
	/// Works out the type of a dotted expression: a local or parameter first, then a file-scope
	/// declaration, then an engine global. Gives up quietly rather than guessing.
	/// </summary>
	public string ResolveType( DocumentSymbols symbols, string expression, int line )
	{
		if ( string.IsNullOrWhiteSpace( expression ) )
			return null;

		var segments = expression.Split( '.', StringSplitOptions.RemoveEmptyEntries );

		if ( segments.Length == 0 )
			return null;

		var type = RootType( symbols, Strip( segments[0] ), line );

		for ( var i = 1; i < segments.Length && type is not null; i++ )
		{
			var member = Strip( segments[i] );
			var bare = BareType( type );
			string next = null;

			foreach ( var candidate in symbols.MembersOf( bare ) )
			{
				if ( !string.Equals( candidate.Name, member, StringComparison.Ordinal ) )
					continue;

				next = candidate.Type;
				break;
			}

			if ( next is null && ShaderType.TryParse( bare, out var shaderType ) && shaderType.IsScalarOrVector )
				next = shaderType.WithComponents( member.Length ).ToString();

			type = next;
		}

		return type;
	}

	string RootType( DocumentSymbols symbols, string name, int line )
	{
		if ( string.IsNullOrEmpty( name ) )
			return null;

		if ( symbols.TryGetTypeOf( name, line, out var declared ) && !string.IsNullOrEmpty( declared ) )
			return declared;

		if ( SboxSymbols.TryGet( name, out var symbol ) && !string.IsNullOrEmpty( symbol.Signature ) )
			return TypeFromSignature( symbol.Signature, name );

		return null;
	}

	static string TypeFromSignature( string signature, string name )
	{
		var at = signature.IndexOf( name, StringComparison.Ordinal );

		if ( at <= 0 )
			return null;

		var words = signature.Substring( 0, at ).Split( ' ', StringSplitOptions.RemoveEmptyEntries );

		return words.Length == 0 ? null : words[^1];
	}

	static string Strip( string segment )
	{
		var cut = segment.IndexOfAny( new[] { '[', '(' } );

		return cut < 0 ? segment : segment.Substring( 0, cut );
	}

	static string BareType( string type )
	{
		if ( string.IsNullOrEmpty( type ) )
			return type;

		var cut = type.IndexOfAny( new[] { '<', '[' } );
		var bare = cut < 0 ? type : type.Substring( 0, cut );

		return bare.Trim();
	}

	// ---- signature help ---------------------------------------------------

	/// <summary>
	/// The call the caret is inside, if any. Walks back over the buffer with comments and strings
	/// blanked out, so a bracket in a comment cannot confuse the argument count.
	/// </summary>
	public SignatureHelp SignatureAt( TextDocument document, TextPosition caret, string language = null )
	{
		if ( document is null )
			return null;

		return PrismLog.Guard( "Prism.Text: signature help",
			() => SignatureCore( document, caret, language ?? Language.Id ), null );
	}

	SignatureHelp SignatureCore( TextDocument document, TextPosition caret, string language )
	{
		var window = ScanWindow.Around( document, caret, language, 120 );

		if ( window is null )
			return null;

		var depth = 0;
		var commas = 0;
		var open = -1;

		for ( var i = window.CaretOffset - 1; i >= 0; i-- )
		{
			var c = window.Text[i];

			if ( c == ')' )
				depth++;
			else if ( c == '(' )
			{
				if ( depth == 0 )
				{
					open = i;
					break;
				}

				depth--;
			}
			else if ( c == ',' && depth == 0 )
				commas++;
			else if ( c == ';' || c == '{' || c == '}' )
				return null;
		}

		if ( open < 0 )
			return null;

		var name = window.IdentifierBefore( open );

		if ( string.IsNullOrEmpty( name ) )
			return null;

		var signatures = Overloads( name, document, caret );

		if ( signatures.Count == 0 )
			return null;

		return new SignatureHelp( name, signatures, commas );
	}

	List<SignatureInfo> Overloads( string name, TextDocument document, TextPosition caret )
	{
		var results = new List<SignatureInfo>();

		if ( IntrinsicDb.TryGet( name, out var intrinsic ) )
		{
			foreach ( var signature in intrinsic.Signatures )
			{
				results.Add( new SignatureInfo( signature, name, SplitSignature( signature ),
					intrinsic.Description )
				{
					Origin = intrinsic.UnavailableReason ?? "HLSL intrinsic"
				} );
			}
		}

		if ( HlslLanguage.TryGetMember( name, out var member ) )
		{
			foreach ( var signature in member.Signatures )
			{
				results.Add( new SignatureInfo( signature, name, SplitSignature( signature ),
					member.Description )
				{
					Origin = "object member"
				} );
			}
		}

		if ( SboxSymbols.TryGet( name, out var symbol ) && !string.IsNullOrEmpty( symbol.Signature ) )
		{
			results.Add( new SignatureInfo( symbol.Signature, name, SplitSignature( symbol.Signature ),
				symbol.Description )
			{
				Origin = symbol.Header
			} );
		}

		var symbols = DocumentSymbols.For( document, Language.Id );

		if ( symbols.TryGet( name, out var declared ) && declared.IsCallable )
		{
			results.Insert( 0, new SignatureInfo( declared.Signature, name, declared.Parameters, null )
			{
				Origin = $"line {declared.Line + 1}"
			} );
		}
		else if ( results.Count == 0 )
		{
			foreach ( var path in IncludedFiles( document ) )
			{
				var header = DocumentSymbols.ForFile( path, Language.Id );

				if ( !header.TryGet( name, out var external ) || !external.IsCallable )
					continue;

				results.Add( new SignatureInfo( external.Signature, name, external.Parameters, null )
				{
					Origin = System.IO.Path.GetFileName( path )
				} );

				break;
			}
		}

		return results;
	}

	/// <summary>Splits a signature's parameter list on top-level commas.</summary>
	public static IReadOnlyList<string> SplitSignature( string signature )
	{
		var results = new List<string>();

		if ( string.IsNullOrEmpty( signature ) )
			return results;

		var open = signature.IndexOf( '(' );
		var close = signature.LastIndexOf( ')' );

		if ( open < 0 || close <= open )
			return results;

		var depth = 0;
		var start = open + 1;

		for ( var i = open + 1; i < close; i++ )
		{
			var c = signature[i];

			if ( c is '(' or '<' or '[' )
				depth++;
			else if ( c is ')' or '>' or ']' )
				depth--;
			else if ( c == ',' && depth == 0 )
			{
				results.Add( signature.Substring( start, i - start ).Trim() );
				start = i + 1;
			}
		}

		var last = signature.Substring( start, close - start ).Trim();

		if ( last.Length > 0 )
			results.Add( last );

		return results;
	}

	// ---- hover ------------------------------------------------------------

	/// <summary>
	/// Everything known about the symbol at a position: its declaration, a sentence about it, where it
	/// came from and — the part that matters for s&amp;box — why it will not compile, if it will not.
	/// </summary>
	public HoverInfo HoverAt( TextDocument document, TextPosition position, CodeDiagnostic diagnostic = null )
	{
		if ( document is null )
			return null;

		return PrismLog.Guard( "Prism.Text: hover", () => HoverCore( document, position, diagnostic ), null );
	}

	HoverInfo HoverCore( TextDocument document, TextPosition position, CodeDiagnostic diagnostic )
	{
		var line = document.GetLine( position.Line );
		var column = Math.Clamp( position.Column, 0, Math.Max( 0, line.Length ) );

		// An include path is worth a card of its own: it says where the file resolved to.
		if ( IncludeResolver.TryParse( line, position.Line, out var include ) && include.Covers( column ) )
		{
			var resolved = IncludeResolver.Resolve( include, FilePath );

			return new HoverInfo( include.Path, include.ToString(), resolved.Describe() )
			{
				Kind = CompletionItemKind.File,
				Origin = resolved.Exists ? resolved.AbsolutePath : null,
				Note = resolved.IsOk ? null : resolved.Describe(),
				NoteIsError = !resolved.IsOk,
				Diagnostic = diagnostic
			};
		}

		var word = WordAt( line, column );

		if ( string.IsNullOrEmpty( word ) )
			return diagnostic is null ? null : new HoverInfo( null, null, null ) { Diagnostic = diagnostic };

		var qualified = QualifiedAt( line, column, word );
		var info = Lookup( qualified ) ?? Lookup( word ) ?? FromDocument( document, word, position.Line );

		if ( info is null )
			return diagnostic is null ? null : new HoverInfo( null, null, null ) { Diagnostic = diagnostic };

		return info with { Diagnostic = diagnostic };
	}

	HoverInfo Lookup( string word )
	{
		if ( string.IsNullOrEmpty( word ) )
			return null;

		if ( IntrinsicDb.TryGet( word, out var intrinsic ) )
		{
			return new HoverInfo( word, string.Join( "\n", intrinsic.Signatures ), intrinsic.Description )
			{
				Kind = CompletionItemKind.Intrinsic,
				Origin = $"HLSL intrinsic · {intrinsic.Category}",
				Note = intrinsic.UnavailableReason ??
					( intrinsic.IsPixelOnly ? "Pixel stage only." : null ),
				NoteIsError = intrinsic.UnavailableReason is not null
			};
		}

		if ( SboxSymbols.TryGet( word, out var symbol ) )
		{
			return new HoverInfo( word, symbol.Signature, symbol.Description )
			{
				Kind = SboxKind( symbol.Kind ),
				Origin = symbol.Header
			};
		}

		if ( HlslLanguage.TryGetMember( word, out var member ) )
		{
			return new HoverInfo( word, string.Join( "\n", member.Signatures ), member.Description )
			{
				Kind = CompletionItemKind.Method,
				Origin = "object member",
				Note = member.MinShaderModel > ShaderModel.Target
					? $"'{word}' requires Shader Model {member.MinShaderModel}; s&box compiles at SM {ShaderModel.Target} (Vulkan)."
					: null,
				NoteIsError = member.MinShaderModel > ShaderModel.Target
			};
		}

		if ( HlslLanguage.TryGetObjectType( word, out var objectType ) )
		{
			return new HoverInfo( word, objectType.ToString(), objectType.Description )
			{
				Kind = CompletionItemKind.ObjectType,
				Origin = "HLSL object type",
				Note = objectType.Deprecated
					? $"'{word}' is a Direct3D 9 type; DXC and Shader Model 6 removed it."
					: null,
				NoteIsError = objectType.Deprecated
			};
		}

		if ( HlslLanguage.TryGetSemantic( word, out var semantic ) )
		{
			return new HoverInfo( word, $"{semantic.Type} : {word}", semantic.Description )
			{
				Kind = CompletionItemKind.Semantic,
				Origin = semantic.IsSystemValue ? "system value" : "semantic"
			};
		}

		if ( Language.IsKeyword( word ) || Language.IsControlKeyword( word ) || Language.IsModifier( word ) )
		{
			return new HoverInfo( word, word, null )
			{
				Kind = CompletionItemKind.Keyword,
				Origin = Language.DisplayName + " keyword"
			};
		}

		if ( Language.IsType( word ) )
		{
			ShaderType.TryParse( word, out var type );

			return new HoverInfo( word, word, type.IsVoid ? null : $"{type.Components} component(s)." )
			{
				Kind = CompletionItemKind.Type,
				Origin = Language.DisplayName + " built-in type"
			};
		}

		if ( SboxSymbols.IsComboSymbol( word ) )
		{
			return new HoverInfo( word, word, word[0] switch
			{
				'F' => "A feature combo. Declared in FEATURES and switched on per material.",
				'S' => "A static combo. Every value is a separately compiled permutation.",
				_ => "A dynamic combo. Branches at runtime with no extra permutations."
			} )
			{
				Kind = CompletionItemKind.Combo,
				Origin = "shader combo"
			};
		}

		return null;
	}

	HoverInfo FromDocument( TextDocument document, string word, int line )
	{
		var symbols = DocumentSymbols.For( document, Language.Id );

		if ( symbols.TryGet( word, out var symbol ) )
		{
			return new HoverInfo( word, symbol.Signature, null )
			{
				Kind = KindOf( symbol ),
				Origin = $"declared on line {symbol.Line + 1}"
			};
		}

		foreach ( var path in IncludedFiles( document ) )
		{
			var header = DocumentSymbols.ForFile( path, Language.Id );

			if ( !header.TryGet( word, out var external ) )
				continue;

			return new HoverInfo( word, external.Signature, null )
			{
				Kind = KindOf( external ),
				Origin = System.IO.Path.GetFileName( path )
			};
		}

		return null;
	}

	/// <summary>The identifier covering a column, or empty.</summary>
	public static string WordAt( string line, int column )
	{
		if ( string.IsNullOrEmpty( line ) )
			return string.Empty;

		column = Math.Clamp( column, 0, line.Length );

		var start = column;
		var end = column;

		while ( start > 0 && TextEditController.IsWordChar( line[start - 1] ) )
			start--;

		while ( end < line.Length && TextEditController.IsWordChar( line[end] ) )
			end++;

		return end > start ? line.Substring( start, end - start ) : string.Empty;
	}

	/// <summary>The <c>Type::Member</c> spelling around a column, when the word is part of one.</summary>
	static string QualifiedAt( string line, int column, string word )
	{
		var start = column;

		while ( start > 0 && TextEditController.IsWordChar( line[start - 1] ) )
			start--;

		if ( start >= 2 && line[start - 1] == ':' && line[start - 2] == ':' )
		{
			var scopeEnd = start - 2;
			var scopeStart = scopeEnd;

			while ( scopeStart > 0 && TextEditController.IsWordChar( line[scopeStart - 1] ) )
				scopeStart--;

			if ( scopeEnd > scopeStart )
				return line.Substring( scopeStart, scopeEnd - scopeStart ) + "::" + word;
		}

		var end = start + word.Length;

		if ( end + 1 < line.Length && line[end] == ':' && line[end + 1] == ':' )
		{
			var memberStart = end + 2;
			var memberEnd = memberStart;

			while ( memberEnd < line.Length && TextEditController.IsWordChar( line[memberEnd] ) )
				memberEnd++;

			if ( memberEnd > memberStart )
				return word + "::" + line.Substring( memberStart, memberEnd - memberStart );
		}

		return null;
	}

	// ---- scanning ---------------------------------------------------------

	/// <summary>
	/// A window of the buffer with every comment and string blanked to spaces, so bracket and comma
	/// counting cannot be fooled by punctuation inside a comment.
	/// </summary>
	sealed class ScanWindow
	{
		ScanWindow( string text, int caretOffset )
		{
			Text = text;
			CaretOffset = caretOffset;
		}

		public string Text { get; }

		public int CaretOffset { get; }

		public static ScanWindow Around( TextDocument document, TextPosition caret, string language, int lines )
		{
			var first = Math.Max( 0, caret.Line - lines );
			var last = Math.Min( document.LineCount - 1, caret.Line );

			if ( last < first )
				return null;

			var lexer = Lexers.For( language );
			var state = LexState.Default;
			var tokens = new List<Token>( 64 );
			var builder = new StringBuilder();
			var caretOffset = 0;

			// The entry state of the first line in the window is unknown, so re-lex from the top of the
			// file. That is a single cheap pass, but it is capped anyway: past a few thousand lines the
			// risk of misreading an unterminated comment is worth less than the latency.
			var from = last > 4000 ? first : 0;

			for ( var line = from; line <= last; line++ )
			{
				var text = document.GetLine( line );

				tokens.Clear();
				state = lexer.Lex( text, state, tokens );

				if ( line < first )
					continue;

				var blanked = new StringBuilder( text );

				for ( var i = 0; i < tokens.Count; i++ )
				{
					var token = tokens[i];

					if ( token.Kind is not ( TokenKind.Comment or TokenKind.DocComment or TokenKind.String ) )
						continue;

					for ( var c = token.Start; c < token.Start + token.Length && c < blanked.Length; c++ )
						blanked[c] = ' ';
				}

				if ( line == caret.Line )
					caretOffset = builder.Length + Math.Clamp( caret.Column, 0, text.Length );

				builder.Append( blanked );
				builder.Append( '\n' );
			}

			return new ScanWindow( builder.ToString(), caretOffset );
		}

		/// <summary>The identifier immediately before an offset, skipping whitespace.</summary>
		public string IdentifierBefore( int offset )
		{
			var i = offset - 1;

			while ( i >= 0 && char.IsWhiteSpace( Text[i] ) )
				i--;

			var end = i + 1;

			while ( i >= 0 && TextEditController.IsWordChar( Text[i] ) )
				i--;

			return end > i + 1 ? Text.Substring( i + 1, end - i - 1 ) : null;
		}
	}
}

/// <summary>
/// Completions from <c>slangd</c>, the Slang language server, spoken over stdio JSON-RPC.
/// <para>
/// This is strictly a bonus tier. It only ever runs for <c>.slang</c> buffers, only when the Slang
/// toolchain the user installed happens to include <c>slangd.exe</c>, and only until the first thing
/// goes wrong — one timeout or one malformed reply retires the source for the rest of the session
/// with a single log line. The built-in list is complete without it; this adds the compiler's own
/// opinion on top when it is cheap to have.
/// </para>
/// </summary>
public sealed class SlangdCompletionSource : ISlangCompletionSource, IDisposable
{
	/// <summary>How long one request may take before the server is assumed to be wedged.</summary>
	const int RequestTimeoutMs = 2000;

	/// <summary>How long the handshake may take.</summary>
	const int StartTimeoutMs = 6000;

	readonly object _lock = new();
	readonly Dictionary<int, TaskCompletionSource<JsonNode>> _pending = new();
	readonly SemaphoreSlim _gate = new( 1, 1 );

	Process _process;
	int _nextId = 1;
	int _version;
	string _openUri;
	bool _failed;
	bool _started;

	/// <summary>The server executable, next to the <c>slangc</c> the toolchain located, or null.</summary>
	public static string ExecutablePath
	{
		get
		{
			var directory = SlangToolchain.Current?.Directory;

			if ( string.IsNullOrWhiteSpace( directory ) )
				return null;

			var name = OperatingSystem.IsWindows() ? "slangd.exe" : "slangd";
			var path = PrismLog.Guard( "Prism.Text: locate slangd",
				() => Path.Combine( directory, name ), null );

			return PrismLog.Guard( "Prism.Text: probe slangd", () => File.Exists( path ), false ) ? path : null;
		}
	}

	/// <summary>The source, or null when no server is installed. Callers never branch on more than this.</summary>
	public static SlangdCompletionSource CreateIfAvailable() =>
		string.IsNullOrEmpty( ExecutablePath ) ? null : new SlangdCompletionSource();

	/// <inheritdoc/>
	public bool Available => !_failed && !string.IsNullOrEmpty( ExecutablePath );

	/// <inheritdoc/>
	public async Task<IReadOnlyList<CompletionItem>> Complete( string text, string filePath,
		TextPosition caret, CancellationToken ct )
	{
		if ( !Available || string.IsNullOrEmpty( text ) )
			return Array.Empty<CompletionItem>();

		try
		{
			await _gate.WaitAsync( ct ).ConfigureAwait( false );
		}
		catch ( OperationCanceledException )
		{
			return Array.Empty<CompletionItem>();
		}

		try
		{
			using var timeout = CancellationTokenSource.CreateLinkedTokenSource( ct );

			timeout.CancelAfter( RequestTimeoutMs + StartTimeoutMs );

			if ( !await Start( timeout.Token ).ConfigureAwait( false ) )
				return Array.Empty<CompletionItem>();

			var uri = UriFor( filePath );

			await Sync( uri, text, timeout.Token ).ConfigureAwait( false );

			var reply = await Request( "textDocument/completion", new JsonObject
			{
				["textDocument"] = new JsonObject { ["uri"] = uri },
				["position"] = new JsonObject
				{
					["line"] = caret.Line,
					["character"] = caret.Column
				},
				["context"] = new JsonObject { ["triggerKind"] = 1 }
			}, timeout.Token ).ConfigureAwait( false );

			return Translate( reply );
		}
		catch ( OperationCanceledException ) when ( ct.IsCancellationRequested )
		{
			return Array.Empty<CompletionItem>();
		}
		catch ( Exception e )
		{
			Retire( e.Message );
			return Array.Empty<CompletionItem>();
		}
		finally
		{
			_gate.Release();
		}
	}

	void Retire( string why )
	{
		if ( _failed )
			return;

		_failed = true;

		PrismLog.Warn( $"Prism.Text: slangd completions disabled for this session ({why})." );

		Shutdown();
	}

	async Task<bool> Start( CancellationToken ct )
	{
		if ( _started && _process is { HasExited: false } )
			return true;

		Shutdown();

		var executable = ExecutablePath;

		if ( string.IsNullOrEmpty( executable ) )
			return false;

		var info = new ProcessStartInfo( executable )
		{
			RedirectStandardInput = true,
			RedirectStandardOutput = true,
			RedirectStandardError = true,
			UseShellExecute = false,
			CreateNoWindow = true,
			StandardOutputEncoding = Encoding.UTF8
		};

		_process = Process.Start( info );

		if ( _process is null )
			return false;

		_ = Task.Run( ReadLoop );

		// The error stream is drained so a chatty server cannot fill its pipe and deadlock.
		_ = Task.Run( async () =>
		{
			try
			{
				await _process.StandardError.ReadToEndAsync().ConfigureAwait( false );
			}
			catch ( Exception )
			{
				// The process went away; nothing to report.
			}
		} );

		await Request( "initialize", new JsonObject
		{
			["processId"] = null,
			["rootUri"] = null,
			["capabilities"] = new JsonObject
			{
				["textDocument"] = new JsonObject
				{
					["completion"] = new JsonObject
					{
						["completionItem"] = new JsonObject { ["snippetSupport"] = false }
					}
				}
			}
		}, ct ).ConfigureAwait( false );

		Notify( "initialized", new JsonObject() );

		_started = true;
		return true;
	}

	async Task Sync( string uri, string text, CancellationToken ct )
	{
		await Task.Yield();

		if ( _openUri is not null )
		{
			Notify( "textDocument/didClose", new JsonObject
			{
				["textDocument"] = new JsonObject { ["uri"] = _openUri }
			} );
		}

		_version++;

		Notify( "textDocument/didOpen", new JsonObject
		{
			["textDocument"] = new JsonObject
			{
				["uri"] = uri,
				["languageId"] = "slang",
				["version"] = _version,
				["text"] = text
			}
		} );

		_openUri = uri;

		ct.ThrowIfCancellationRequested();
	}

	static string UriFor( string filePath )
	{
		var path = string.IsNullOrWhiteSpace( filePath )
			? Path.Combine( Path.GetTempPath(), "prism_buffer.slang" )
			: filePath;

		return new Uri( Path.GetFullPath( path ) ).AbsoluteUri;
	}

	Task<JsonNode> Request( string method, JsonObject parameters, CancellationToken ct )
	{
		int id;
		var completion = new TaskCompletionSource<JsonNode>( TaskCreationOptions.RunContinuationsAsynchronously );

		lock ( _lock )
		{
			id = _nextId++;
			_pending[id] = completion;
		}

		Send( new JsonObject
		{
			["jsonrpc"] = "2.0",
			["id"] = id,
			["method"] = method,
			["params"] = parameters
		} );

		return WithTimeout( completion, id, ct );
	}

	async Task<JsonNode> WithTimeout( TaskCompletionSource<JsonNode> completion, int id, CancellationToken ct )
	{
		using var timeout = CancellationTokenSource.CreateLinkedTokenSource( ct );

		timeout.CancelAfter( RequestTimeoutMs );

		var delay = Task.Delay( Timeout.Infinite, timeout.Token );
		var finished = await Task.WhenAny( completion.Task, delay ).ConfigureAwait( false );

		if ( finished != completion.Task )
		{
			lock ( _lock )
			{
				_pending.Remove( id );
			}

			throw new TimeoutException( "slangd did not answer in time" );
		}

		return await completion.Task.ConfigureAwait( false );
	}

	void Notify( string method, JsonObject parameters ) => Send( new JsonObject
	{
		["jsonrpc"] = "2.0",
		["method"] = method,
		["params"] = parameters
	} );

	void Send( JsonObject message )
	{
		var process = _process;

		if ( process is null || process.HasExited )
			throw new InvalidOperationException( "slangd is not running" );

		var body = Encoding.UTF8.GetBytes( message.ToJsonString() );
		var header = Encoding.ASCII.GetBytes( $"Content-Length: {body.Length}\r\n\r\n" );

		var stream = process.StandardInput.BaseStream;

		stream.Write( header, 0, header.Length );
		stream.Write( body, 0, body.Length );
		stream.Flush();
	}

	void ReadLoop()
	{
		var process = _process;

		try
		{
			var stream = process.StandardOutput.BaseStream;

			while ( true )
			{
				var length = ReadHeader( stream );

				if ( length <= 0 )
					return;

				var body = new byte[length];
				var read = 0;

				while ( read < length )
				{
					var got = stream.Read( body, read, length - read );

					if ( got <= 0 )
						return;

					read += got;
				}

				Dispatch( Encoding.UTF8.GetString( body ) );
			}
		}
		catch ( Exception )
		{
			// The pipe closed or the process died. Every pending request fails, which retires the source.
		}
		finally
		{
			FailPending();
		}
	}

	static int ReadHeader( Stream stream )
	{
		var line = new StringBuilder( 48 );
		var length = -1;

		while ( true )
		{
			var b = stream.ReadByte();

			if ( b < 0 )
				return -1;

			if ( b != '\n' )
			{
				if ( b != '\r' )
					line.Append( (char)b );

				continue;
			}

			if ( line.Length == 0 )
				return length;

			var text = line.ToString();

			if ( text.StartsWith( "Content-Length:", StringComparison.OrdinalIgnoreCase ) &&
				 int.TryParse( text.AsSpan( 15 ).Trim(), out var parsed ) )
			{
				length = parsed;
			}

			line.Clear();
		}
	}

	void Dispatch( string json )
	{
		var node = PrismLog.Guard( "Prism.Text: slangd reply", () => JsonNode.Parse( json ), null );

		if ( node is not JsonObject message )
			return;

		if ( message["id"] is null )
			return;

		if ( !int.TryParse( message["id"].ToString(), out var id ) )
			return;

		TaskCompletionSource<JsonNode> completion;

		lock ( _lock )
		{
			if ( !_pending.TryGetValue( id, out completion ) )
				return;

			_pending.Remove( id );
		}

		completion.TrySetResult( message["result"] );
	}

	void FailPending()
	{
		List<TaskCompletionSource<JsonNode>> pending;

		lock ( _lock )
		{
			pending = _pending.Values.ToList();
			_pending.Clear();
		}

		foreach ( var completion in pending )
			completion.TrySetException( new IOException( "slangd closed the connection" ) );
	}

	static IReadOnlyList<CompletionItem> Translate( JsonNode reply )
	{
		var results = new List<CompletionItem>();

		var items = reply switch
		{
			JsonArray array => array,
			JsonObject obj => obj["items"] as JsonArray,
			_ => null
		};

		if ( items is null )
			return results;

		foreach ( var node in items )
		{
			if ( node is not JsonObject item )
				continue;

			var label = item["label"]?.ToString();

			if ( string.IsNullOrWhiteSpace( label ) )
				continue;

			var kind = int.TryParse( item["kind"]?.ToString(), out var raw ) ? raw : 0;

			results.Add( new CompletionItem( label.Trim(), KindOf( kind ) )
			{
				Detail = item["detail"]?.ToString(),
				Documentation = Documentation( item["documentation"] ),
				Origin = "slangd",
				Icon = "auto_awesome",
				Group = 12
			} );
		}

		return results;
	}

	static string Documentation( JsonNode node ) => node switch
	{
		JsonObject obj => obj["value"]?.ToString(),
		null => null,
		_ => node.ToString()
	};

	static CompletionItemKind KindOf( int lspKind ) => lspKind switch
	{
		2 or 3 => CompletionItemKind.Function,
		4 => CompletionItemKind.Type,
		5 or 10 => CompletionItemKind.Field,
		6 => CompletionItemKind.Variable,
		7 or 22 => CompletionItemKind.ObjectType,
		8 => CompletionItemKind.ObjectType,
		9 => CompletionItemKind.Module,
		13 => CompletionItemKind.Type,
		14 => CompletionItemKind.Keyword,
		17 => CompletionItemKind.File,
		19 => CompletionItemKind.Folder,
		20 or 21 => CompletionItemKind.Constant,
		25 => CompletionItemKind.Type,
		_ => CompletionItemKind.Variable
	};

	void Shutdown()
	{
		var process = _process;

		_process = null;
		_started = false;
		_openUri = null;

		FailPending();

		if ( process is null )
			return;

		PrismLog.Guard( "Prism.Text: stop slangd", () =>
		{
			if ( !process.HasExited )
				process.Kill( true );

			process.Dispose();
		} );
	}

	/// <summary>Stops the server. Safe to call more than once.</summary>
	public void Dispose()
	{
		Shutdown();
		_gate.Dispose();
	}
}