Editor developer tool class that runs self-tests for the text lexers, incremental lexer driver, completion and diagnostics. It contains built-in test corpus, sweeps real shader files, verifies lexer invariants (exact cover, determinism, resumability, no exceptions), and exercises completion, conditionals and cache flush behavior, and exposes menu/console commands to run tests.
using Editor.Prism.Core;
using Editor.Prism.Text.Completion;
using Editor.Prism.Text.Diagnostics;
using Editor.Prism.Text.LanguageDb;
using Editor.Prism.Text.Lexer;
using System.IO;
using System.Text;
namespace Editor.Prism.Text;
/// <summary>
/// The headless proof that the lexers, the incremental driver and the language intelligence do what
/// they claim. Nothing here needs Qt, a window, a document on disk or the engine compiler, so it runs
/// from any harness and from the console.
/// <para>
/// <see cref="Run"/> checks the invariants against a corpus written into this file, so it is
/// self-contained and always available. <see cref="Sweep"/> runs the same invariants over a real tree
/// of shaders — point it at <c>game/addons/base/Assets/shaders</c> and <c>game/core/shaders</c> and it
/// covers every shader and header the engine ships.
/// </para>
/// <para>
/// The four invariants are what make the editor's incremental highlighting correct, and each one is a
/// silent corruption rather than a crash when it breaks:
/// </para>
/// <list type="number">
/// <item><description><b>Exact cover.</b> A line's tokens start at column zero, are contiguous, never
/// overlap and end exactly at its length. A gap paints unstyled text; an overlap paints twice.</description></item>
/// <item><description><b>Determinism.</b> The same line lexed twice from the same entry state gives the
/// same tokens and the same exit state. Without it nothing below can hold.</description></item>
/// <item><description><b>Resumability.</b> Restarting from any line with that line's entry state
/// reproduces every later line exactly. This is what lets one keystroke re-lex three lines instead of
/// ten thousand.</description></item>
/// <item><description><b>No exceptions.</b> A lexer fault must never reach the paint path.</description></item>
/// </list>
/// </summary>
public static class TextSelfTest
{
/// <summary>Restart the resumability check on every n-th line of a swept file.</summary>
const int ResumeStride = 7;
/// <summary>Text file extensions <see cref="Sweep"/> treats as shader source.</summary>
static readonly string[] s_extensions = { ".shader", ".hlsl", ".fxc", ".hlsli", ".h", ".inc", ".slang" };
sealed class Runner
{
public readonly StringBuilder Report = new();
public int Passed;
public int Failed;
public void Check( string what, bool ok, string detail = null )
{
if ( ok )
{
Passed++;
Report.AppendLine( $" pass {what}" );
return;
}
Failed++;
Report.AppendLine( $" FAIL {what}" );
if ( !string.IsNullOrWhiteSpace( detail ) )
Report.AppendLine( $" {detail}" );
}
public void Section( string title )
{
Report.AppendLine();
Report.AppendLine( title );
}
public string Finish( string title )
{
var head = new StringBuilder();
head.AppendLine( title );
head.AppendLine( new string( '=', title.Length ) );
Report.AppendLine();
Report.AppendLine( new string( '-', 40 ) );
Report.AppendLine( $"{Passed} passed, {Failed} failed" );
return head.Append( Report ).ToString();
}
}
// ---- the built-in corpus ----------------------------------------------
/// <summary>
/// Shapes the shipped engine shaders do not contain, so the invariants are exercised on the awkward
/// input as well as the ordinary kind: unterminated constructs, multi-line attributes and macros,
/// non-ASCII identifiers, junk bytes, and Slang, which no shipped file is written in.
/// </summary>
static readonly (string Language, string Source, string Why)[] s_corpus =
{
( "hlsl", "[numthreads(\n\t8,\n\t8,\n\t1\n)]\nvoid MainCs( uint3 t : SV_DispatchThreadID ) { }",
"an attribute split over five lines" ),
( "hlsl", "[[vk::binding( 0, 0 )]]\nTexture2D g_t;", "a double-bracket attribute" ),
( "hlsl", "/* a block comment\n that never ends", "an unterminated block comment" ),
( "hlsl", "float4 x = \"a string that never ends;", "an unterminated string" ),
( "hlsl", "#define WRAP( a, b ) \\\n\t( (a) < (b) \\\n\t? (a) : (b) )\nfloat x = 1;",
"a macro continued with backslashes" ),
( "hlsl", "float a = 1.0e+5f; int b = 0xFF; float c = .5; half d = 2.0h;", "every number shape" ),
( "hlsl", "float éè = 1; float 中文 = 2; float µs = 3;", "non-ASCII identifiers" ),
( "hlsl", "@#$%^&`~", "bytes no shader grammar allows" ),
( "hlsl", "", "an empty line" ),
( "hlsl", "\t\t\t", "nothing but tabs" ),
( "hlsl", "float x = 1 /* a */ + /* b */ 2; // and a tail comment", "block comments inside a line" ),
( "hlsl", "#include \"common/shared.hlsl\" // and a comment", "an include with a trailing comment" ),
( "hlsl", "struct S { float a; }; struct T { float b; };", "two declarations on one line" ),
( "hlsl", "cbuffer Frame : register( b0 )\n{\n\tfloat4x4 g_mView;\n};", "a constant buffer" ),
( "hlsl", "#if 0\nfloat unbalanced(\n#endif\nfloat ok;", "an unbalanced dead branch" ),
( "vfx", "HEADER\n{\n\tDescription = \"x\";\n}\nMODES\n{\n\tForward();\n}\nPS\n{\n\tfloat4 MainPs() : SV_Target0 { return 1; }\n}",
"a whole block file" ),
( "vfx", "Texture2D g_tRMA <\n\tChannel( R, Box( Roughness ), Linear );\n\tSrgbRead( false );\n>;",
"an annotation wrapped over three lines" ),
( "slang", "module scene;\nimport mathlib;\n__include helpers;\nimplementing scene;", "Slang module directives" ),
( "slang", "struct Foo<T : IBar> { T value; };\ninterface IBar { };", "Slang generics" ),
};
/// <summary>
/// Whether a <c><</c> in each of these opens an s&box metadata annotation. The whole point of
/// the annotation heuristic is that these two families cannot be told apart by the bracket alone.
/// </summary>
static readonly (string Language, string Source, bool IsAnnotation, string Why)[] s_annotations =
{
( "vfx", "Texture2D g_tColor < Channel( RGB, Box( Color ), Srgb ); >;", true, "the canonical form" ),
( "vfx", "float g_flX < UiGroup( \"A\" ); Default( 1 ); Range( 0, 1 ); >;", true, "a scalar annotation" ),
( "vfx", "CreateTexture2D( g_tFoo ) < Channel( RGB, Box( C ), Srgb ); >;", true, "after a call" ),
( "vfx", "Texture2D g_t : register( t0 ) < Channel( RGB, Box( C ), Srgb ); >;", true, "after a register binding" ),
( "vfx", "Texture2D g_tRMA <", true, "opening at the end of a line" ),
( "hlsl", "if ( a < b ) return;", false, "a comparison" ),
( "hlsl", "for ( int i = 0; i < count; i++ ) { }", false, "a loop bound" ),
( "hlsl", "bool ok = Foo( a ) < Bar( b );", false, "a call compared to a call" ),
( "hlsl", "bool ok = Foo( a ) < Bar( b ) && c > d;", false, "a comparison chain that does contain a >" ),
( "hlsl", "if ( Length( a ) < Length( b ) || x > y ) return;", false, "the same chain inside a condition" ),
( "hlsl", "Buffer<float4> g_buf;", false, "a generic object type" ),
( "hlsl", "StructuredBuffer<MyStruct> g_b;", false, "a generic over a user type" ),
( "hlsl", "matrix < float, 4, 4 > m;", false, "the matrix template" ),
( "hlsl", "float3 a; MyTemplate<int> x;", false, "a generic after a statement" ),
( "hlsl", "return a < b( c );", false, "a comparison against a call" ),
( "hlsl", "arr[i] < f( x );", false, "a comparison after an index" ),
( "hlsl", "#define LESS( a, b ) a < b( c )", false, "inside a directive" ),
( "hlsl", "// Texture2D g_t < Channel( RGB ); >;", false, "inside a comment" ),
( "slang", "struct Foo<T : IBar> { };", false, "a generic struct" ),
( "slang", "interface IThing<T> { };", false, "a generic interface" ),
( "slang", "MyType myFunc<T>( T x ) { return x; }", false, "a generic function" ),
( "slang", "let v = obj.method<int>( 3 );", false, "a generic member call" ),
};
// ---- entry points -----------------------------------------------------
/// <summary>
/// Runs every check against the built-in corpus and hands back a report. Never throws: a fault is
/// reported as a failed check like any other.
/// </summary>
public static string Run()
{
var runner = new Runner();
PrismLog.Guard( "Prism.Text: self-test", () =>
{
runner.Section( "[1] lexer invariants over the built-in corpus" );
foreach ( var (language, source, why) in s_corpus )
{
var lexer = Lexers.For( language );
var lines = TextDocument.SplitLines( source );
runner.Check( $"{language}: {why}", CheckFile( lexer, lines, out var detail ), detail );
}
runner.Section( "[2] the annotation heuristic" );
foreach ( var (language, source, isAnnotation, why) in s_annotations )
{
var got = OpensAnnotation( Lexers.For( language ), TextDocument.SplitLines( source ) );
runner.Check( $"{language}: {why} is {( isAnnotation ? "an annotation" : "not an annotation" )}",
got == isAnnotation, got ? "read as an annotation" : "read as a comparison" );
}
runner.Section( "[3] the incremental driver agrees with a straight lex" );
CheckIncremental( runner );
runner.Section( "[4] editing re-lexes only what changed" );
CheckEditing( runner );
runner.Section( "[5] preprocessor regions gate the unknown-identifier check" );
CheckConditionals( runner );
runner.Section( "[6] completion context and content" );
CheckCompletion( runner );
runner.Section( "[7] every cache can be dropped" );
CheckFlush( runner );
} );
return runner.Finish( "Prism text and language self-test" );
}
/// <summary>
/// Runs the four lexer invariants over every shader and header under <paramref name="roots"/>,
/// recursively. Reports one line per failure and a summary; a clean tree reports the file and line
/// count only. Never throws.
/// </summary>
public static string Sweep( params string[] roots )
{
var report = new StringBuilder();
var files = 0;
var lines = 0;
var failures = 0;
report.AppendLine( "Prism lexer corpus sweep" );
report.AppendLine( "========================" );
report.AppendLine();
PrismLog.Guard( "Prism.Text: corpus sweep", () =>
{
foreach ( var path in Enumerate( roots ) )
{
var text = PrismLog.Guard( "Prism.Text: read swept file", () => File.ReadAllText( path ), null );
if ( text is null )
continue;
files++;
var content = TextDocument.SplitLines( text );
lines += content.Count;
if ( CheckFile( Lexers.For( path ), content, out var detail ) )
continue;
failures++;
report.AppendLine( $" FAIL {Path.GetFileName( path )}" );
report.AppendLine( $" {detail}" );
}
} );
report.AppendLine();
report.AppendLine( new string( '-', 40 ) );
report.AppendLine( $"{files} files, {lines} lines, {failures} failed" );
return report.ToString();
}
/// <summary>Every shader-ish file under a set of roots, recursively and in a stable order.</summary>
static IEnumerable<string> Enumerate( string[] roots )
{
var results = new List<string>();
if ( roots is null )
return results;
foreach ( var root in roots )
{
if ( string.IsNullOrWhiteSpace( root ) )
continue;
PrismLog.Guard( "Prism.Text: enumerate swept root", () =>
{
if ( !Directory.Exists( root ) )
return;
foreach ( var file in Directory.EnumerateFiles( root, "*", SearchOption.AllDirectories ) )
{
if ( Array.IndexOf( s_extensions, Path.GetExtension( file ).ToLowerInvariant() ) >= 0 )
results.Add( file );
}
} );
}
results.Sort( StringComparer.OrdinalIgnoreCase );
return results;
}
// ---- the invariants ---------------------------------------------------
/// <summary>
/// Exact cover, determinism, resumability and no exceptions, over one file's lines. Returns false and
/// fills <paramref name="detail"/> with the first failure found.
/// </summary>
static bool CheckFile( ILexer lexer, IReadOnlyList<string> lines, out string detail )
{
detail = null;
var entry = new LexState[lines.Count + 1];
var tokens = new List<Token>[lines.Count];
var scratch = new List<Token>();
for ( var i = 0; i < lines.Count; i++ )
{
var line = lines[i];
var output = new List<Token>();
try
{
entry[i + 1] = lexer.Lex( line, entry[i], output );
}
catch ( Exception e )
{
detail = $"line {i + 1}: the lexer threw {e.GetType().Name}: {e.Message}";
return false;
}
tokens[i] = output;
// ---- 1. exact cover ----
var covered = 0;
for ( var t = 0; t < output.Count; t++ )
{
var token = output[t];
if ( token.Length <= 0 )
{
detail = $"line {i + 1}: token {t} has length {token.Length}";
return false;
}
if ( token.Start != covered )
{
detail = $"line {i + 1}: token {t} starts at {token.Start}, expected {covered} " +
$"({( token.Start < covered ? "overlap" : "gap" )})";
return false;
}
covered = token.Start + token.Length;
}
if ( covered != line.Length )
{
detail = $"line {i + 1}: tokens cover {covered} of {line.Length} characters";
return false;
}
// ---- 2. determinism ----
scratch.Clear();
var again = lexer.Lex( line, entry[i], scratch );
if ( again != entry[i + 1] || !Same( scratch, output ) )
{
detail = $"line {i + 1}: lexing the same line twice from the same state gave a different result";
return false;
}
}
// ---- 3. resumability ----
for ( var start = 0; start < lines.Count; start += ResumeStride )
{
var state = entry[start];
for ( var i = start; i < lines.Count; i++ )
{
scratch.Clear();
state = lexer.Lex( lines[i], state, scratch );
if ( state != entry[i + 1] )
{
detail = $"restarting at line {start + 1} diverged by line {i + 1}";
return false;
}
if ( !Same( scratch, tokens[i] ) )
{
detail = $"restarting at line {start + 1} gave different tokens on line {i + 1}";
return false;
}
}
}
return true;
}
static bool Same( List<Token> a, List<Token> b )
{
if ( a.Count != b.Count )
return false;
for ( var i = 0; i < a.Count; i++ )
{
if ( a[i] != b[i] )
return false;
}
return true;
}
/// <summary>Whether anything in a snippet was read as the start of a metadata annotation.</summary>
static bool OpensAnnotation( ILexer lexer, IReadOnlyList<string> lines )
{
var state = LexState.Default;
var output = new List<Token>();
for ( var i = 0; i < lines.Count; i++ )
{
var before = state;
output.Clear();
state = lexer.Lex( lines[i], before, output );
if ( ( state.Flags & LexFlags.Annotation ) != 0 && ( before.Flags & LexFlags.Annotation ) == 0 )
return true;
for ( var t = 0; t < output.Count; t++ )
{
if ( output[t].Kind == TokenKind.Annotation )
return true;
}
}
return false;
}
// ---- the driver -------------------------------------------------------
static void CheckIncremental( Runner runner )
{
var text = string.Join( "\r\n", new[]
{
"HEADER",
"{",
"\tDescription = \"probe\";",
"}",
"",
"PS",
"{",
"\t#include \"common/pixel.hlsl\"",
"",
"\tTexture2D g_tColor < Channel( RGB, Box( Color ), Srgb ); >;",
"",
"\tfloat4 MainPs( PixelInput i ) : SV_Target0",
"\t{",
"\t\t/* a block",
"\t\t comment */",
"\t\treturn Tex2D( g_tColor, i.vTextureCoords.xy );",
"\t}",
"}"
} );
var lexer = Lexers.Vfx;
var lines = TextDocument.SplitLines( text );
var state = LexState.Default;
var straight = new List<Token>[lines.Count];
var exits = new LexState[lines.Count];
for ( var i = 0; i < lines.Count; i++ )
{
straight[i] = new List<Token>();
state = lexer.Lex( lines[i], state, straight[i] );
exits[i] = state;
}
var document = new TextDocument( text );
var driver = new IncrementalLexer( document, lexer );
driver.Sync( int.MaxValue );
var agrees = document.LineCount == lines.Count;
var where = agrees ? null : $"the document has {document.LineCount} lines, the split has {lines.Count}";
for ( var i = 0; agrees && i < lines.Count; i++ )
{
var got = driver.GetTokens( i );
if ( got.Count != straight[i].Count || driver.GetExitState( i ) != exits[i] )
{
agrees = false;
where = $"line {i + 1}";
}
}
runner.Check( "the incremental driver reproduces a straight lex", agrees, where );
runner.Check( "the driver settles", !driver.IsDirty, $"first dirty line {driver.FirstDirtyLine}" );
// A block comment is the strongest cross-line state there is: a caret inside one is inert, so
// completion, bracket matching and occurrence highlighting all have to stand down.
runner.Check( "a caret inside a block comment is inert",
driver.IsInert( new TextPosition( 14, 5 ) ), null );
runner.Check( "a caret in live code is not inert",
!driver.IsInert( new TextPosition( 15, 10 ) ), null );
driver.Detach();
}
static void CheckEditing( Runner runner )
{
var builder = new StringBuilder();
for ( var i = 0; i < 4000; i++ )
builder.Append( "\tfloat value" ).Append( i ).Append( " = Length( float3( 1, 2, 3 ) );\r\n" );
var document = new TextDocument( builder.ToString() );
var driver = new IncrementalLexer( document, Lexers.Hlsl );
driver.Sync( int.MaxValue );
var middle = document.LineCount / 2;
// Identity, not equality: the document replaces an edited line's string wholesale and the driver
// replaces a re-lexed line's token array wholesale, so counting how many of each a keystroke
// swaps out is exactly the measure of how much work one keystroke causes.
var texts = new object[document.LineCount];
var tokens = new object[document.LineCount];
for ( var i = 0; i < document.LineCount; i++ )
{
texts[i] = document.GetLine( i );
tokens[i] = driver.GetTokens( i );
}
var worstText = 0;
var worstTokens = 0;
for ( var k = 0; k < 16; k++ )
{
document.Insert( new TextPosition( middle + k, 1 ), "x" );
driver.Sync( int.MaxValue );
var changedText = 0;
var changedTokens = 0;
for ( var i = 0; i < document.LineCount; i++ )
{
var line = document.GetLine( i );
var lineTokens = driver.GetTokens( i );
if ( !ReferenceEquals( line, texts[i] ) )
{
changedText++;
texts[i] = line;
}
if ( !ReferenceEquals( lineTokens, tokens[i] ) )
{
changedTokens++;
tokens[i] = lineTokens;
}
}
worstText = Math.Max( worstText, changedText );
worstTokens = Math.Max( worstTokens, changedTokens );
}
runner.Check( "one keystroke rewrites one line", worstText == 1, $"worst case {worstText} lines" );
runner.Check( "one keystroke re-lexes one line", worstTokens == 1, $"worst case {worstTokens} lines" );
// Reading tokens back for a viewport must be a lookup, not a lex.
var before = GC.GetAllocatedBytesForCurrentThread();
for ( var frame = 0; frame < 64; frame++ )
{
for ( var i = middle - 30; i < middle + 30; i++ )
driver.GetTokens( i );
}
var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
runner.Check( "reading a viewport's tokens allocates nothing", allocated == 0, $"{allocated} bytes over 64 frames" );
driver.Detach();
}
static void CheckConditionals( Runner runner )
{
var source = string.Join( "\n", new[]
{
"#if 0",
"float a() { return NeverCompiled( 1 ); }",
"#endif",
"#if 1",
"float b() { return AlwaysCompiled( 1 ); }",
"#endif",
"#define HAVE_LOCAL 1",
"#ifdef HAVE_LOCAL",
"float c() { return GuardedByLocal( 1 ); }",
"#endif",
"#ifdef DEFINED_BY_WHOEVER_INCLUDES_US",
"float d() { return GuardedByCaller( 1 ); }",
"#endif",
"float e() { return Unconditional( 1 ); }",
""
} );
var reported = new HashSet<string>( StringComparer.Ordinal );
foreach ( var diagnostic in TextDiagnosticService.LocalChecks( source, null, LanguageDefinition.Hlsl ) )
{
if ( diagnostic.Code == TextDiagnosticCode.UnknownIdentifier )
reported.Add( diagnostic.Message );
}
bool Named( string name )
{
foreach ( var message in reported )
{
if ( message.Contains( name, StringComparison.Ordinal ) )
return true;
}
return false;
}
var all = string.Join( "; ", reported );
runner.Check( "a #if 0 branch is never judged", !Named( "NeverCompiled" ), all );
runner.Check( "a #if 1 branch is judged", Named( "AlwaysCompiled" ), all );
runner.Check( "a #ifdef on a macro this file defines is judged", Named( "GuardedByLocal" ), all );
runner.Check( "a #ifdef the caller decides is never judged", !Named( "GuardedByCaller" ), all );
runner.Check( "unconditional code is judged", Named( "Unconditional" ), all );
// Order of declaration is not scope: a call above the definition is ordinary in shader code.
var forward = string.Join( "\n", new[]
{
"float3 Below( float3 v );",
"float3 Above( float3 v ) { return Below( v ) + AlsoBelow( v ); }",
"float3 AlsoBelow( float3 v ) { return v; }",
""
} );
var unresolved = 0;
foreach ( var diagnostic in TextDiagnosticService.LocalChecks( forward, null, LanguageDefinition.Hlsl ) )
{
if ( diagnostic.Code == TextDiagnosticCode.UnknownIdentifier )
unresolved++;
}
runner.Check( "a name declared anywhere in the file counts as declared", unresolved == 0,
$"{unresolved} reported" );
// And the check still earns its place: a genuine typo is still caught.
var typo = "float4 f( float2 uv ) { return Saturat( uv.x ); }\n";
var caught = false;
foreach ( var diagnostic in TextDiagnosticService.LocalChecks( typo, null, LanguageDefinition.Hlsl ) )
{
if ( diagnostic.Code == TextDiagnosticCode.UnknownIdentifier )
caught = true;
}
runner.Check( "a misspelled intrinsic is still reported", caught, null );
}
static void CheckCompletion( Runner runner )
{
var source = string.Join( "\n", new[]
{
"struct Surface",
"{",
"\tfloat3 Albedo;",
"\tfloat Roughness;",
"};",
"",
"float4 MainPs() : SV_Target0",
"{",
"\tSurface s;",
"\tfloat3 v = 1;",
"\treturn 1;",
"}"
} );
var document = new TextDocument( source );
var engine = new CompletionEngine();
engine.SetLanguage( "hlsl" );
IReadOnlyList<CompletionItem> Complete( TextPosition caret )
{
var context = engine.Classify( document, caret, "hlsl", false, TokenKind.None, true );
return engine.Complete( document, context );
}
// `\ts.` — column 3 is just past the dot.
document.Insert( new TextPosition( 10, 0 ), "\ts." );
var members = Complete( new TextPosition( 10, 3 ) );
runner.Check( "a struct instance completes its members",
Contains( members, "Albedo" ) && Contains( members, "Roughness" ), Labels( members ) );
document.Replace( new TextRange( new TextPosition( 10, 0 ), new TextPosition( 10, 3 ) ), "\tv." );
var swizzles = Complete( new TextPosition( 10, 3 ) );
runner.Check( "a float3 completes its swizzles",
Contains( swizzles, "xyz" ) && Contains( swizzles, "rgb" ) && !Contains( swizzles, "w" ),
Labels( swizzles ) );
document.Replace( new TextRange( new TextPosition( 10, 0 ), new TextPosition( 10, 3 ) ), "\t" );
var semantics = new TextDocument( "float4 MainPs() : SV_Target" );
var context = engine.Classify( semantics, new TextPosition( 0, 27 ), "hlsl", false, TokenKind.None, true );
var offered = engine.Complete( semantics, context );
runner.Check( "a semantic position completes semantics", context.Kind == CompletionContextKind.Semantic,
context.Kind.ToString() );
runner.Check( "the bare semantic outranks its indexed forms",
offered.Count > 1 && offered[0].Label == "SV_Target" && offered[1].Label == "SV_Target0",
Labels( offered ) );
var directive = new TextDocument( "#in" );
runner.Check( "a directive position completes directives",
KindOf( engine, directive, new TextPosition( 0, 3 ), "hlsl" ) == CompletionContextKind.Preprocessor, null );
var include = new TextDocument( "#include \"common/" );
runner.Check( "an include path position completes paths",
KindOf( engine, include, new TextPosition( 0, 17 ), "hlsl" ) == CompletionContextKind.IncludePath, null );
var annotation = new TextDocument( "float g_flX < " );
var vfx = new CompletionEngine();
vfx.SetLanguage( "vfx" );
var annotationContext = vfx.Classify( annotation, new TextPosition( 0, 14 ), "vfx", false, TokenKind.None, true );
var annotationItems = vfx.Complete( annotation, annotationContext );
runner.Check( "an annotation body completes annotations",
annotationContext.Kind == CompletionContextKind.Annotation, annotationContext.Kind.ToString() );
runner.Check( "the annotation list has the material-UI entries",
Contains( annotationItems, "UiGroup" ) && Contains( annotationItems, "Default" ) &&
Contains( annotationItems, "Range" ), Labels( annotationItems ) );
var blocks = new TextDocument( string.Empty );
var blockItems = vfx.Complete( blocks,
vfx.Classify( blocks, TextPosition.Zero, "vfx", false, TokenKind.None, true ) );
foreach ( var name in new[] { "HEADER", "MODES", "FEATURES", "COMMON", "VS", "PS", "CS" } )
{
var skeleton = InsertTextOf( blockItems, name );
runner.Check( $"the {name} block completes to a skeleton",
skeleton is not null && skeleton.Contains( '{' ) && skeleton.Contains( '}' ),
skeleton is null ? "not offered" : skeleton.Replace( "\n", "\\n" ) );
}
// A caret in a comment or a string must offer nothing at all.
var inert = new TextDocument( "// a comment mentioning Textur" );
var inertLexer = new IncrementalLexer( inert, Lexers.Hlsl );
inertLexer.Sync( int.MaxValue );
var caretInComment = new TextPosition( 0, 30 );
runner.Check( "a caret in a comment offers nothing",
engine.Classify( inert, caretInComment, "hlsl", inertLexer.IsInert( caretInComment ),
inertLexer.KindAt( caretInComment ), true ).Kind == CompletionContextKind.None, null );
inertLexer.Detach();
}
static CompletionContextKind KindOf( CompletionEngine engine, TextDocument document, TextPosition caret,
string language ) =>
engine.Classify( document, caret, language, false, TokenKind.None, true ).Kind;
static bool Contains( IReadOnlyList<CompletionItem> items, string label )
{
for ( var i = 0; i < items.Count; i++ )
{
if ( string.Equals( items[i].Label, label, StringComparison.Ordinal ) )
return true;
}
return false;
}
static string InsertTextOf( IReadOnlyList<CompletionItem> items, string label )
{
for ( var i = 0; i < items.Count; i++ )
{
if ( string.Equals( items[i].Label, label, StringComparison.Ordinal ) )
return items[i].InsertText;
}
return null;
}
static string Labels( IReadOnlyList<CompletionItem> items )
{
var shown = new StringBuilder();
for ( var i = 0; i < items.Count && i < 12; i++ )
{
if ( shown.Length > 0 )
shown.Append( ", " );
shown.Append( items[i].Label );
}
return shown.ToString();
}
static void CheckFlush( Runner runner )
{
// Every one of these is a lazy that would otherwise outlive a hotload holding word tables, file
// text or delegates belonging to the assembly that just went away.
var hlsl = Lexers.Hlsl;
var vfx = LanguageDefinition.Vfx;
var intrinsics = IntrinsicDb.Count;
var symbols = SboxSymbols.Count;
TextCaches.Flush();
runner.Check( "the lexers are rebuilt after a flush", !ReferenceEquals( hlsl, Lexers.Hlsl ), null );
runner.Check( "the VFX definition is rebuilt after a flush",
!ReferenceEquals( vfx, LanguageDefinition.Vfx ), null );
// The tables themselves are static readonly and go with the assembly, so a flush must leave them
// intact and usable rather than emptying them.
runner.Check( "the intrinsic table survives a flush", IntrinsicDb.Count == intrinsics, null );
runner.Check( "the s&box symbol table survives a flush", SboxSymbols.Count == symbols, null );
runner.Check( "lookups still work after a flush",
IntrinsicDb.Contains( "saturate" ) && SboxSymbols.IsBlockKeyword( "HEADER" ) &&
SboxSymbols.IsModeFunction( "Forward" ), null );
runner.Check( "a second flush is harmless", PrismLog.Guard( "Prism.Text: double flush", TextCaches.Flush ), null );
}
// ---- entry points from the editor -------------------------------------
/// <summary>Run the text self-test from the editor's developer menu.</summary>
[Menu( "Editor", "Prism/Developer/Run Text Self Test", "spellcheck", Priority = 103 )]
public static void RunFromMenu()
{
var report = Run();
PrismLog.Info( report );
PrismSelfTest.CopyReport( report, "text self-test" );
}
/// <summary>Run the text self-test from the developer console: <c>prism_textselftest</c>.</summary>
[ConCmd( "prism_textselftest" )]
public static void RunFromConsole() => PrismLog.Info( Run() );
/// <summary>
/// Run the lexer invariants over a real shader tree from the developer console:
/// <c>prism_textsweep <root> [root…]</c>. The roots are arguments rather than baked in
/// because the interesting ones are inside somebody's engine install.
/// </summary>
[ConCmd( "prism_textsweep" )]
public static void SweepFromConsole( string roots )
{
var paths = ( roots ?? string.Empty )
.Split( ';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries );
if ( paths.Length == 0 )
{
PrismLog.Info( "prism_textsweep <root>[;<root>…] — sweeps every .shader/.hlsl/.slang under " +
"each root through the four lexer invariants." );
return;
}
PrismLog.Info( Sweep( paths ) );
}
}