Editor-side parser for source buffers that extracts document symbols (functions, methods, structs, variables, macros, etc.) for outline, completion and signature help. It tokenizes lines via the project's lexer, walks tokens to detect declarations, tracks nesting depth, builds DocumentSymbol records, caches results per document and per-file, and exposes queries like VisibleAt, MembersOf and TryGetTypeOf.
using Editor.Prism.Core;
using Editor.Prism.Text.Lexer;
using Editor.Prism.Text.LanguageDb;
using System.Runtime.CompilerServices;
using System.Text;
namespace Editor.Prism.Text.Completion;
/// <summary>What a parsed declaration is.</summary>
public enum DocumentSymbolKind
{
/// <summary>A free function.</summary>
Function,
/// <summary>A function declared inside a struct or class.</summary>
Method,
/// <summary>A <c>struct</c> or <c>class</c>.</summary>
Struct,
/// <summary>A Slang <c>interface</c>.</summary>
Interface,
/// <summary>A member of a struct, class or constant buffer.</summary>
Field,
/// <summary>A variable at file scope or inside a function body.</summary>
Variable,
/// <summary>A function parameter.</summary>
Parameter,
/// <summary>A <c>#define</c>.</summary>
Macro,
/// <summary>A <c>cbuffer</c>.</summary>
CBuffer,
/// <summary>An <c>enum</c>.</summary>
Enum,
/// <summary>A member of an enum.</summary>
EnumMember,
/// <summary>A <c>typedef</c> or Slang <c>typealias</c>.</summary>
TypeAlias,
/// <summary>A VFX block such as <c>COMMON</c> or <c>PS</c>.</summary>
Block,
/// <summary>A Slang <c>module</c> declaration.</summary>
Module,
/// <summary>A Slang <c>import</c> or <c>__include</c>.</summary>
Import
}
/// <summary>
/// One declaration found in a buffer. Positions are zero-based editor coordinates so the outline and
/// the completion popup can jump straight to them.
/// </summary>
/// <param name="Name">The declared name.</param>
/// <param name="Kind">What kind of declaration it is.</param>
/// <param name="Type">The declared type, or the return type for a callable. May be empty.</param>
/// <param name="Signature">The declaration rendered back out for signature help and tooltips.</param>
/// <param name="Line">Zero-based line the declaration starts on.</param>
/// <param name="EndLine">Zero-based line its body ends on, equal to <paramref name="Line"/> when there is none.</param>
/// <param name="Container">The struct, buffer or function that encloses it, or null at file scope.</param>
public sealed record DocumentSymbol(
string Name,
DocumentSymbolKind Kind,
string Type,
string Signature,
int Line,
int EndLine,
string Container )
{
/// <summary>Parameter declarations, for a callable.</summary>
public IReadOnlyList<string> Parameters { get; init; } = Array.Empty<string>();
/// <summary>The semantic bound to a field, such as <c>SV_Target0</c>.</summary>
public string Semantic { get; init; }
/// <summary>Brace depth the declaration sits at. Zero is file scope.</summary>
public int Depth { get; init; }
/// <summary>True when the symbol can be called and therefore has signature help.</summary>
public bool IsCallable => Kind is DocumentSymbolKind.Function or DocumentSymbolKind.Method or DocumentSymbolKind.Macro;
/// <summary>True when the symbol names a type that other declarations can be members of.</summary>
public bool IsType => Kind is DocumentSymbolKind.Struct or DocumentSymbolKind.Interface
or DocumentSymbolKind.Enum or DocumentSymbolKind.CBuffer;
/// <summary>The Material-icon name the outline and the completion list draw for this kind.</summary>
public string Icon => Kind switch
{
DocumentSymbolKind.Function or DocumentSymbolKind.Method => "functions",
DocumentSymbolKind.Struct => "data_object",
DocumentSymbolKind.Interface => "hub",
DocumentSymbolKind.Field => "label",
DocumentSymbolKind.Variable => "code",
DocumentSymbolKind.Parameter => "input",
DocumentSymbolKind.Macro => "tag",
DocumentSymbolKind.CBuffer => "view_list",
DocumentSymbolKind.Enum => "list",
DocumentSymbolKind.EnumMember => "radio_button_checked",
DocumentSymbolKind.TypeAlias => "swap_horiz",
DocumentSymbolKind.Block => "widgets",
DocumentSymbolKind.Module => "inventory_2",
DocumentSymbolKind.Import => "download",
_ => "code"
};
/// <inheritdoc/>
public override string ToString() => string.IsNullOrEmpty( Signature ) ? Name : Signature;
}
/// <summary>
/// A deliberately small parser over the token stream the incremental lexer already produces: enough
/// structure to power the outline, member completion, signature help and in-scope variable
/// completion, and nothing more.
/// <para>
/// It is not a compiler front end and never pretends to be one. It tracks brace depth, recognises
/// declarations by shape and gives up quietly on anything it does not understand — an unparsed line
/// costs one missing completion, never a wrong one. Because it runs off the lexer it inherits correct
/// comment, string and preprocessor handling for free.
/// </para>
/// </summary>
public sealed class DocumentSymbols
{
/// <summary>The empty result, used wherever a buffer could not be parsed.</summary>
public static readonly DocumentSymbols Empty = new();
static readonly ConditionalWeakTable<TextDocument, Cached> s_documents = new();
static readonly object s_fileLock = new();
static readonly Dictionary<string, DocumentSymbols> s_files = new( StringComparer.OrdinalIgnoreCase );
readonly List<DocumentSymbol> _all = new();
readonly List<DocumentSymbol> _topLevel = new();
readonly List<string> _includes = new();
readonly Dictionary<string, DocumentSymbol> _byName = new( StringComparer.Ordinal );
readonly Dictionary<string, List<DocumentSymbol>> _members = new( StringComparer.Ordinal );
sealed class Cached
{
public int Version = -1;
public string Language;
public DocumentSymbols Symbols = Empty;
}
private DocumentSymbols()
{
}
/// <summary>The language id the buffer was parsed as.</summary>
public string Language { get; private set; } = "hlsl";
/// <summary>Where the buffer came from, when it came from a file.</summary>
public string FilePath { get; private set; }
/// <summary>The document version this snapshot was parsed from, or -1 for a detached parse.</summary>
public int Version { get; private set; } = -1;
/// <summary>Every declaration, in file order.</summary>
public IReadOnlyList<DocumentSymbol> All => _all;
/// <summary>Declarations at file scope, which is what the outline lists.</summary>
public IReadOnlyList<DocumentSymbol> TopLevel => _topLevel;
/// <summary>The raw paths of every <c>#include</c>, in file order.</summary>
public IReadOnlyList<string> Includes => _includes;
/// <summary>Number of declarations found.</summary>
public int Count => _all.Count;
// ---- entry points -----------------------------------------------------
/// <summary>
/// Parses a buffer. Never throws: a parser fault yields whatever was recognised before it and a log
/// line, because a broken outline must not break typing.
/// </summary>
public static DocumentSymbols Parse( string text, string language, string filePath = null )
{
var symbols = new DocumentSymbols
{
Language = string.IsNullOrWhiteSpace( language ) ? "hlsl" : language,
FilePath = filePath
};
if ( string.IsNullOrEmpty( text ) )
return symbols;
PrismLog.Guard( "Prism.Text: parse document symbols", () => symbols.Run( SplitLines( text ) ) );
return symbols;
}
/// <summary>
/// The parsed symbols of a live document, re-parsed only when its version changed. This is the call
/// completion makes on every keystroke, so it has to be cheap when nothing moved.
/// </summary>
public static DocumentSymbols For( TextDocument document, string language = null )
{
if ( document is null )
return Empty;
var cached = s_documents.GetValue( document, static _ => new Cached() );
var resolved = string.IsNullOrWhiteSpace( language ) ? "hlsl" : language;
if ( cached.Version == document.Version && string.Equals( cached.Language, resolved, StringComparison.Ordinal ) )
return cached.Symbols;
var symbols = new DocumentSymbols
{
Language = resolved,
FilePath = document.FilePath,
Version = document.Version
};
PrismLog.Guard( "Prism.Text: parse document symbols", () => symbols.Run( document.Lines ) );
cached.Version = document.Version;
cached.Language = resolved;
cached.Symbols = symbols;
return symbols;
}
/// <summary>
/// The parsed symbols of a file on disk, cached by content. Used to pull declarations out of the
/// headers a buffer includes.
/// </summary>
public static DocumentSymbols ForFile( string absolutePath, string language = null )
{
if ( string.IsNullOrWhiteSpace( absolutePath ) )
return Empty;
var text = IncludeResolver.ReadText( absolutePath );
if ( string.IsNullOrEmpty( text ) )
return Empty;
var stamp = text.Length;
lock ( s_fileLock )
{
if ( s_files.TryGetValue( absolutePath, out var hit ) && hit.Version == stamp )
return hit;
}
var resolved = string.IsNullOrWhiteSpace( language )
? LanguageDefinition.For( absolutePath ).Id
: language;
var symbols = Parse( text, resolved, absolutePath );
symbols.Version = stamp;
lock ( s_fileLock )
{
if ( s_files.Count > 128 )
s_files.Clear();
s_files[absolutePath] = symbols;
}
return symbols;
}
/// <summary>Drops every cached file parse. Call on hotload or when a header is saved.</summary>
public static void FlushFileCache()
{
lock ( s_fileLock )
{
s_files.Clear();
}
}
// ---- queries ----------------------------------------------------------
/// <summary>Looks a declaration up by name. Later declarations win, matching C's scoping closely enough.</summary>
public bool TryGet( string name, out DocumentSymbol symbol )
{
symbol = null;
return !string.IsNullOrEmpty( name ) && _byName.TryGetValue( name, out symbol );
}
/// <summary>The members of a struct, class or constant buffer. Empty for anything else.</summary>
public IReadOnlyList<DocumentSymbol> MembersOf( string typeName )
{
if ( !string.IsNullOrEmpty( typeName ) && _members.TryGetValue( typeName, out var list ) )
return list;
return Array.Empty<DocumentSymbol>();
}
/// <summary>The function whose body contains a line, or null.</summary>
public DocumentSymbol EnclosingFunction( int line )
{
DocumentSymbol best = null;
for ( var i = 0; i < _all.Count; i++ )
{
var symbol = _all[i];
if ( !symbol.IsCallable || symbol.Kind == DocumentSymbolKind.Macro )
continue;
if ( line < symbol.Line || line > symbol.EndLine )
continue;
if ( best is null || symbol.Line > best.Line )
best = symbol;
}
return best;
}
/// <summary>
/// Everything nameable at a line: file-scope declarations, plus the parameters and locals of the
/// enclosing function that were declared above it. This is what makes local variables complete.
/// </summary>
public IEnumerable<DocumentSymbol> VisibleAt( int line )
{
var function = EnclosingFunction( line );
for ( var i = 0; i < _all.Count; i++ )
{
var symbol = _all[i];
if ( symbol.Depth == 0 )
{
yield return symbol;
continue;
}
if ( function is null || !string.Equals( symbol.Container, function.Name, StringComparison.Ordinal ) )
continue;
if ( symbol.Kind == DocumentSymbolKind.Parameter || symbol.Line <= line )
yield return symbol;
}
}
/// <summary>
/// The declared type of an identifier as seen from a line: a local or parameter of the enclosing
/// function first, then a file-scope declaration.
/// </summary>
public bool TryGetTypeOf( string identifier, int line, out string type )
{
type = null;
if ( string.IsNullOrEmpty( identifier ) )
return false;
DocumentSymbol best = null;
var function = EnclosingFunction( line );
for ( var i = 0; i < _all.Count; i++ )
{
var symbol = _all[i];
if ( !string.Equals( symbol.Name, identifier, StringComparison.Ordinal ) )
continue;
if ( symbol.Kind is not ( DocumentSymbolKind.Variable or DocumentSymbolKind.Parameter or DocumentSymbolKind.Field ) )
continue;
var local = symbol.Depth > 0 && function is not null &&
string.Equals( symbol.Container, function.Name, StringComparison.Ordinal );
if ( symbol.Depth > 0 && !local )
continue;
if ( local && symbol.Kind != DocumentSymbolKind.Parameter && symbol.Line > line )
continue;
// A local shadows a global, and the nearest declaration above the caret shadows an earlier one.
if ( best is null || ( local && best.Depth == 0 ) || ( local == ( best.Depth > 0 ) && symbol.Line > best.Line ) )
best = symbol;
}
if ( best is null || string.IsNullOrEmpty( best.Type ) )
return false;
type = best.Type;
return true;
}
/// <summary>Projects the symbols onto the outline model the code window's dock renders.</summary>
public List<CodeSymbol> ToOutline()
{
var results = new List<CodeSymbol>();
for ( var i = 0; i < _all.Count; i++ )
{
var symbol = _all[i];
if ( symbol.Kind is DocumentSymbolKind.Variable or DocumentSymbolKind.Parameter && symbol.Depth > 0 )
continue;
var detail = symbol.Kind switch
{
DocumentSymbolKind.Function or DocumentSymbolKind.Method => Parenthesised( symbol ),
DocumentSymbolKind.Field or DocumentSymbolKind.Variable => symbol.Type,
_ => symbol.Kind.ToString().ToLowerInvariant()
};
results.Add( new CodeSymbol( symbol.Name, detail, symbol.Line,
symbol.Icon, symbol.Container is null ? 0 : 1 ) );
}
return results;
}
static string Parenthesised( DocumentSymbol symbol ) =>
symbol.Parameters is { Count: > 0 } ? "(" + string.Join( ", ", symbol.Parameters ) + ")" : "()";
// ---- parsing ----------------------------------------------------------
readonly struct Tok
{
public Tok( int line, int start, int length, TokenKind kind, string text )
{
Line = line;
Start = start;
Length = length;
Kind = kind;
Text = text;
}
public readonly int Line;
public readonly int Start;
public readonly int Length;
public readonly TokenKind Kind;
public readonly string Text;
public bool Is( string value ) => string.Equals( Text, value, StringComparison.Ordinal );
public bool IsWord => Kind is TokenKind.Identifier or TokenKind.BuiltinType or TokenKind.UserType
or TokenKind.Intrinsic or TokenKind.FunctionName or TokenKind.EngineGlobal or TokenKind.Keyword
or TokenKind.Modifier;
}
List<Tok> _tokens;
string[] _lines;
void Run( IReadOnlyList<string> lines )
{
_lines = new string[lines.Count];
for ( var i = 0; i < lines.Count; i++ )
_lines[i] = lines[i] ?? string.Empty;
_tokens = Tokenize( _lines, Language );
Walk();
_tokens = null;
_lines = null;
}
static List<Tok> Tokenize( string[] lines, string language )
{
var lexer = Lexers.For( language );
var state = LexState.Default;
var buffer = new List<Token>( 64 );
var results = new List<Tok>( lines.Length * 6 );
for ( var line = 0; line < lines.Length; line++ )
{
buffer.Clear();
state = lexer.Lex( lines[line], state, buffer );
var text = lines[line];
for ( var i = 0; i < buffer.Count; i++ )
{
var token = buffer[i];
if ( token.Kind is TokenKind.Whitespace or TokenKind.Comment or TokenKind.DocComment
or TokenKind.String or TokenKind.None )
{
continue;
}
if ( token.Start < 0 || token.Length <= 0 || token.Start + token.Length > text.Length )
continue;
results.Add( new Tok( line, token.Start, token.Length, token.Kind,
text.Substring( token.Start, token.Length ) ) );
}
}
return results;
}
void Walk()
{
var depth = 0;
var i = 0;
var containers = new List<string>();
var containerDepths = new List<int>();
string function = null;
var functionDepth = -1;
var pendingBlock = false;
// A .shader wraps everything in HEADER/COMMON/VS/PS braces. Those braces are file scope as far
// as declarations are concerned, so they are subtracted out of the depth everything else sees.
var blockBase = 0;
while ( i < _tokens.Count )
{
var tok = _tokens[i];
var scope = depth - blockBase;
// ---- preprocessor ------------------------------------------------
// Only at the head of a line: `##` inside a macro body also lexes as a preprocessor token.
if ( tok.Kind == TokenKind.Preprocessor && ( i == 0 || _tokens[i - 1].Line != tok.Line ) )
{
i = Directive( i );
continue;
}
if ( tok.Kind == TokenKind.IncludePath )
{
i++;
continue;
}
// ---- VFX block headers -------------------------------------------
if ( tok.Kind == TokenKind.BlockKeyword )
{
Add( new DocumentSymbol( tok.Text, DocumentSymbolKind.Block, null, tok.Text,
tok.Line, tok.Line, null ) );
pendingBlock = depth == 0;
i++;
continue;
}
// ---- braces ------------------------------------------------------
if ( tok.Is( "{" ) )
{
if ( pendingBlock && depth == 0 )
blockBase = 1;
pendingBlock = false;
depth++;
i++;
continue;
}
if ( tok.Is( "}" ) )
{
depth = Math.Max( 0, depth - 1 );
if ( depth < blockBase )
blockBase = 0;
var closed = depth - blockBase;
while ( containerDepths.Count > 0 && closed <= containerDepths[^1] )
{
containers.RemoveAt( containers.Count - 1 );
containerDepths.RemoveAt( containerDepths.Count - 1 );
}
if ( function is not null && closed <= functionDepth )
{
CloseFunction( function, tok.Line );
function = null;
functionDepth = -1;
}
i++;
continue;
}
// ---- leading attributes -------------------------------------------
if ( tok.Is( "[" ) )
{
var closeBracket = MatchBracket( i );
if ( closeBracket > i )
{
i = closeBracket + 1;
continue;
}
}
// ---- type declarations -------------------------------------------
if ( tok.Kind is TokenKind.Keyword or TokenKind.Modifier or TokenKind.Identifier &&
tok.Text is "struct" or "class" or "interface" or "cbuffer" or "ConstantBuffer" or "enum" )
{
var consumed = TypeDeclaration( i, scope, containers, containerDepths );
if ( consumed > i )
{
// The declaration's own brace is opened by the generic brace handling above.
i = consumed;
continue;
}
}
if ( tok.Kind is TokenKind.Keyword or TokenKind.Identifier &&
tok.Text is "module" or "import" or "__include" or "implementing" && scope == 0 )
{
i = ModuleDeclaration( i );
continue;
}
if ( tok.Kind is TokenKind.Keyword or TokenKind.Identifier && tok.Text is "typedef" or "typealias" )
{
i = AliasDeclaration( i );
continue;
}
// ---- everything else is a declaration or a statement --------------
var end = StatementEnd( i, out var terminator );
if ( end <= i )
{
i++;
continue;
}
var container = containers.Count > 0 ? containers[^1] : null;
Declaration( i, end, terminator, scope, container, function, out var declared );
if ( declared is { IsCallable: true } && terminator == '{' && function is null )
{
function = declared.Name;
functionDepth = scope;
}
i = end;
}
if ( function is not null )
CloseFunction( function, Math.Max( 0, _lines.Length - 1 ) );
}
void CloseFunction( string name, int endLine )
{
for ( var i = _all.Count - 1; i >= 0; i-- )
{
if ( !_all[i].IsCallable || !string.Equals( _all[i].Name, name, StringComparison.Ordinal ) )
continue;
var updated = _all[i] with { EndLine = endLine };
var original = _all[i];
_all[i] = updated;
_byName[name] = updated;
for ( var j = _topLevel.Count - 1; j >= 0; j-- )
{
if ( !ReferenceEquals( _topLevel[j], original ) )
continue;
_topLevel[j] = updated;
break;
}
return;
}
}
int Directive( int i )
{
var line = _tokens[i].Line;
var next = i + 1;
// The HLSL lexer emits `#include` as one token, but a lexer that splits the hash from the name
// has to work too, so both shapes are accepted.
var directive = _tokens[i].Text.TrimStart( '#' ).Trim();
if ( directive.Length == 0 )
{
if ( next >= _tokens.Count || _tokens[next].Line != line )
return next;
directive = _tokens[next].Text;
next++;
}
if ( directive is "define" && next < _tokens.Count && _tokens[next].Line == line )
{
var name = _tokens[next];
var parameters = new List<string>();
var cursor = next + 1;
// `#define FOO(a,b)` is a function macro only when the paren touches the name.
if ( cursor < _tokens.Count && _tokens[cursor].Line == line && _tokens[cursor].Is( "(" ) &&
_tokens[cursor].Start == name.Start + name.Length )
{
cursor++;
while ( cursor < _tokens.Count && _tokens[cursor].Line == line && !_tokens[cursor].Is( ")" ) )
{
if ( _tokens[cursor].IsWord )
parameters.Add( _tokens[cursor].Text );
cursor++;
}
if ( cursor < _tokens.Count )
cursor++;
}
var signature = parameters.Count > 0
? $"#define {name.Text}( {string.Join( ", ", parameters )} )"
: $"#define {name.Text}";
Add( new DocumentSymbol( name.Text, DocumentSymbolKind.Macro, null, signature,
line, line, null )
{
Parameters = parameters
} );
return SkipLine( cursor, line );
}
if ( directive is "include" )
{
if ( IncludeResolver.TryParse( _lines[line], line, out var reference ) )
_includes.Add( reference.Path );
return SkipLine( next, line );
}
return SkipLine( next, line );
}
/// <summary>
/// Skips the rest of a directive's <b>logical</b> line, following every backslash continuation.
/// <para>
/// A multi-line <c>#define</c> is one of the most common shapes in a real shader, and stopping at the
/// first newline leaves its body in the token stream. The walker then reads that body as ordinary
/// code, and because a macro body rarely ends in a semicolon the statement it thinks it is reading
/// runs on and swallows the next real declaration — which is how <c>blendable.shader</c> lost
/// <c>ComputeBlendWeight</c>, declared immediately after a six-line <c>#define</c>.
/// </para>
/// </summary>
int SkipLine( int i, int line )
{
while ( true )
{
while ( i < _tokens.Count && _tokens[i].Line == line )
i++;
if ( line < 0 || line >= _lines.Length || !EndsWithBackslash( _lines[line] ) )
return i;
line++;
if ( line >= _lines.Length )
return i;
}
}
static bool EndsWithBackslash( string line )
{
for ( var i = line.Length - 1; i >= 0; i-- )
{
if ( char.IsWhiteSpace( line[i] ) )
continue;
return line[i] == '\\';
}
return false;
}
int ModuleDeclaration( int i )
{
var keyword = _tokens[i];
var builder = new StringBuilder();
var cursor = i + 1;
while ( cursor < _tokens.Count && !_tokens[cursor].Is( ";" ) && _tokens[cursor].Line == keyword.Line )
{
builder.Append( _tokens[cursor].Text );
cursor++;
}
var name = builder.ToString();
if ( !string.IsNullOrEmpty( name ) )
{
var kind = keyword.Is( "module" ) ? DocumentSymbolKind.Module : DocumentSymbolKind.Import;
Add( new DocumentSymbol( name, kind, null, $"{keyword.Text} {name}",
keyword.Line, keyword.Line, null ) );
}
return cursor < _tokens.Count ? cursor + 1 : cursor;
}
int AliasDeclaration( int i )
{
var start = _tokens[i];
var end = StatementEnd( i, out _ );
var name = LastWordBefore( i, end );
if ( name is not null )
{
Add( new DocumentSymbol( name.Value.Text, DocumentSymbolKind.TypeAlias, null,
Render( i, end ), start.Line, start.Line, null ) );
}
return end;
}
/// <summary>
/// Parses <c>struct Name : Base { … }</c> and its cousins. Returns the index just past the name so
/// the caller's brace handling opens the body, or the input index when this was not a declaration.
/// </summary>
int TypeDeclaration( int i, int depth, List<string> containers, List<int> containerDepths )
{
var keyword = _tokens[i];
var cursor = i + 1;
if ( cursor >= _tokens.Count )
return i;
// `struct { … } name;` is anonymous; nothing useful to record.
if ( !_tokens[cursor].IsWord )
return i;
var name = _tokens[cursor];
var kind = keyword.Text switch
{
"cbuffer" or "ConstantBuffer" => DocumentSymbolKind.CBuffer,
"interface" => DocumentSymbolKind.Interface,
"enum" => DocumentSymbolKind.Enum,
_ => DocumentSymbolKind.Struct
};
// A forward declaration or a variable of an existing type is not a definition.
var scan = cursor + 1;
while ( scan < _tokens.Count && !_tokens[scan].Is( "{" ) && !_tokens[scan].Is( ";" ) )
scan++;
if ( scan >= _tokens.Count || _tokens[scan].Is( ";" ) )
return i;
Add( new DocumentSymbol( name.Text, kind, null, Render( i, scan ),
keyword.Line, keyword.Line, containers.Count > 0 ? containers[^1] : null )
{
Depth = depth
} );
containers.Add( name.Text );
containerDepths.Add( depth );
if ( !_members.ContainsKey( name.Text ) )
_members[name.Text] = new List<DocumentSymbol>();
return scan;
}
/// <summary>
/// Reads one declaration or statement. Returns true when it recorded a symbol; the out parameter is
/// the symbol, or null when the statement was recognised but produced nothing worth listing.
/// </summary>
bool Declaration( int start, int end, char terminator, int depth, string container, string function,
out DocumentSymbol declared )
{
declared = null;
if ( start >= end || start >= _tokens.Count )
return false;
var open = FirstParen( start, end );
if ( open > start + 1 && ( terminator == '{' || terminator == ';' ) )
{
// A call statement has no type in front of the name; a declaration does.
var name = _tokens[open - 1];
var close = MatchParen( open, end );
var looksLikeFunction = name.IsWord && close > open &&
IndexOf( start, open, "=" ) < 0 && IndexOf( start, open, "." ) < 0 &&
LooksLikeType( start, open - 1 );
if ( looksLikeFunction )
{
var parameters = SplitParameters( open + 1, close );
var returnType = CleanType( Render( start, open - 1 ) );
var kind = container is not null ? DocumentSymbolKind.Method : DocumentSymbolKind.Function;
var symbol = new DocumentSymbol( name.Text, kind, returnType,
$"{returnType} {name.Text}( {string.Join( ", ", parameters )} )".Trim(),
_tokens[start].Line, _tokens[start].Line, container )
{
Parameters = parameters,
Depth = depth
};
Add( symbol );
if ( terminator == '{' )
AddParameters( open + 1, close, name.Text );
declared = symbol;
return true;
}
}
if ( terminator != ';' )
return false;
// `type name;`, `type name : SEMANTIC;`, `type name = value;`, `type name[4];`
var stop = end;
var assign = IndexOf( start, end, "=" );
var semanticAt = IndexOf( start, end, ":" );
if ( assign >= 0 )
stop = Math.Min( stop, assign );
if ( semanticAt >= 0 )
stop = Math.Min( stop, semanticAt );
var bracket = IndexOf( start, stop, "[" );
if ( bracket >= 0 )
stop = bracket;
if ( stop <= start )
return false;
var declaredName = LastWordBefore( start, stop );
if ( declaredName is null )
return false;
var nameIndex = IndexOfToken( start, stop, declaredName.Value );
if ( nameIndex <= start )
return false;
var type = Render( start, nameIndex );
if ( string.IsNullOrEmpty( type ) )
return false;
if ( !LooksLikeType( start, nameIndex ) )
return false;
string semantic = null;
if ( semanticAt >= 0 && semanticAt + 1 < end && _tokens[semanticAt + 1].IsWord )
semantic = _tokens[semanticAt + 1].Text;
var variableKind = container is not null && depth > 0 && function is null
? DocumentSymbolKind.Field
: DocumentSymbolKind.Variable;
var owner = function ?? container;
var variable = new DocumentSymbol( declaredName.Value.Text, variableKind, CleanType( type ),
Render( start, Math.Max( start, end - 1 ) ), _tokens[start].Line, _tokens[start].Line, owner )
{
Semantic = semantic,
Depth = depth
};
Add( variable );
declared = variable;
return true;
}
void AddParameters( int open, int close, string function )
{
var cursor = open;
while ( cursor < close )
{
var comma = IndexOf( cursor, close, "," );
var end = comma < 0 ? close : comma;
var stop = end;
var assign = IndexOf( cursor, end, "=" );
if ( assign >= 0 )
stop = assign;
var name = LastWordBefore( cursor, stop );
if ( name is not null )
{
var index = IndexOfToken( cursor, stop, name.Value );
if ( index > cursor )
{
Add( new DocumentSymbol( name.Value.Text, DocumentSymbolKind.Parameter,
CleanType( Render( cursor, index ) ), Render( cursor, stop ),
_tokens[cursor].Line, _tokens[cursor].Line, function )
{
Depth = 1
} );
}
}
cursor = end + 1;
}
}
List<string> SplitParameters( int open, int close )
{
var results = new List<string>();
var cursor = open;
var depth = 0;
for ( var i = open; i < close; i++ )
{
if ( _tokens[i].Is( "(" ) || _tokens[i].Is( "<" ) )
depth++;
else if ( _tokens[i].Is( ")" ) || _tokens[i].Is( ">" ) )
depth--;
else if ( _tokens[i].Is( "," ) && depth == 0 )
{
var text = Render( cursor, i );
if ( !string.IsNullOrWhiteSpace( text ) )
results.Add( text );
cursor = i + 1;
}
}
var last = Render( cursor, close );
if ( !string.IsNullOrWhiteSpace( last ) )
results.Add( last );
return results;
}
// ---- token helpers ----------------------------------------------------
int StatementEnd( int start, out char terminator )
{
var depth = 0;
terminator = '\0';
for ( var i = start; i < _tokens.Count; i++ )
{
var tok = _tokens[i];
if ( tok.Is( "(" ) || tok.Is( "[" ) )
{
depth++;
continue;
}
if ( tok.Is( ")" ) || tok.Is( "]" ) )
{
depth--;
continue;
}
if ( depth > 0 )
continue;
if ( tok.Is( ";" ) )
{
terminator = ';';
return i + 1;
}
if ( tok.Is( "{" ) )
{
terminator = '{';
return i;
}
if ( tok.Is( "}" ) )
{
terminator = '}';
return i;
}
// A directive ends whatever was being read: the two never nest in authored code, and
// running past one is how a whole COMMON block gets swallowed as a single statement.
if ( tok.Kind == TokenKind.Preprocessor && i > start && _tokens[i - 1].Line != tok.Line )
{
terminator = '#';
return i;
}
}
return _tokens.Count;
}
int IndexOf( int start, int end, string text )
{
var depth = 0;
for ( var i = start; i < end && i < _tokens.Count; i++ )
{
if ( _tokens[i].Is( "(" ) )
depth++;
else if ( _tokens[i].Is( ")" ) )
depth--;
else if ( depth == 0 && _tokens[i].Is( text ) )
return i;
}
return -1;
}
int FirstParen( int start, int end )
{
for ( var i = start; i < end && i < _tokens.Count; i++ )
{
if ( _tokens[i].Is( "(" ) )
return i;
}
return -1;
}
int MatchBracket( int open )
{
var depth = 0;
for ( var i = open; i < _tokens.Count; i++ )
{
if ( _tokens[i].Is( "[" ) )
depth++;
else if ( _tokens[i].Is( "]" ) )
{
depth--;
if ( depth == 0 )
return i;
}
else if ( _tokens[i].Is( ";" ) || _tokens[i].Is( "{" ) )
{
return -1;
}
}
return -1;
}
int IndexOfToken( int start, int end, Tok token )
{
for ( var i = end - 1; i >= start && i >= 0; i-- )
{
if ( _tokens[i].Line == token.Line && _tokens[i].Start == token.Start )
return i;
}
return -1;
}
int MatchParen( int open, int limit )
{
var depth = 0;
for ( var i = open; i < _tokens.Count && i < limit + 1; i++ )
{
if ( _tokens[i].Is( "(" ) )
depth++;
else if ( _tokens[i].Is( ")" ) )
{
depth--;
if ( depth == 0 )
return i;
}
}
return -1;
}
Tok? LastWordBefore( int start, int end )
{
for ( var i = Math.Min( end, _tokens.Count ) - 1; i >= start && i >= 0; i-- )
{
if ( _tokens[i].IsWord )
return _tokens[i];
}
return null;
}
/// <summary>
/// True when the tokens ahead of a name read like a type rather than like the tail of an
/// expression. Without this every <c>a + b;</c> would be recorded as a variable called <c>b</c>.
/// </summary>
bool LooksLikeType( int start, int nameIndex )
{
for ( var i = start; i < nameIndex; i++ )
{
var tok = _tokens[i];
if ( tok.Kind is TokenKind.Operator && !tok.Is( "*" ) && !tok.Is( "&" ) )
return false;
if ( tok.Kind is TokenKind.Number or TokenKind.Punctuation && !tok.Is( "<" ) && !tok.Is( ">" ) &&
!tok.Is( "," ) && !tok.Is( ":" ) )
{
return false;
}
if ( tok.Kind is TokenKind.ControlKeyword )
return false;
}
var first = _tokens[start];
return first.Kind is TokenKind.BuiltinType or TokenKind.UserType or TokenKind.Identifier
or TokenKind.Modifier or TokenKind.Keyword;
}
static string CleanType( string type )
{
if ( string.IsNullOrEmpty( type ) )
return type;
string[] noise = { "static", "const", "uniform", "in", "out", "inout", "groupshared", "nointerpolation",
"centroid", "linear", "noperspective", "sample", "row_major", "column_major", "precise", "globallycoherent" };
var words = type.Split( ' ', StringSplitOptions.RemoveEmptyEntries )
.Where( x => Array.IndexOf( noise, x ) < 0 )
.ToArray();
return words.Length == 0 ? type : string.Join( " ", words );
}
string Render( int start, int end )
{
var builder = new StringBuilder();
for ( var i = start; i < end && i < _tokens.Count; i++ )
{
var text = _tokens[i].Text;
if ( builder.Length > 0 && NeedsSpace( builder[^1], text[0] ) )
builder.Append( ' ' );
builder.Append( text );
}
return builder.ToString().Trim();
}
static bool NeedsSpace( char previous, char next )
{
if ( next is ',' or ')' or ']' or ';' or '>' or '.' or '<' )
return false;
if ( previous is '(' or '[' or '<' or '.' )
return false;
return true;
}
void Add( DocumentSymbol symbol )
{
if ( symbol is null || string.IsNullOrEmpty( symbol.Name ) )
return;
_all.Add( symbol );
_byName[symbol.Name] = symbol;
if ( symbol.Depth == 0 || symbol.Kind is DocumentSymbolKind.Macro or DocumentSymbolKind.Block )
_topLevel.Add( symbol );
if ( symbol.Container is null )
return;
if ( symbol.Kind is not ( DocumentSymbolKind.Field or DocumentSymbolKind.Method or DocumentSymbolKind.EnumMember ) )
return;
if ( !_members.TryGetValue( symbol.Container, out var list ) )
{
list = new List<DocumentSymbol>();
_members[symbol.Container] = list;
}
list.Add( symbol );
}
static string[] SplitLines( string text )
{
var normalised = text.Replace( "\r\n", "\n" ).Replace( '\r', '\n' );
return normalised.Split( '\n' );
}
}