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).
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>< … ></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&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>< … ></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 < UiGroup(…); ></c> from
/// <c>Texture2D<float4></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&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();
}
}