Editor/Prism/Text/TextSelfTest.cs

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.

File Access
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>&lt;</c> in each of these opens an s&amp;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 &lt;root&gt; [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 ) );
	}
}