Editor/Prism/Text/Diagnostics/TextDiagnosticService.cs

TextDiagnosticService for the editor. Runs local lexing/preprocessor checks and optionally invokes the engine or Slang toolchain to compile/validate shader buffers, debounces requests, publishes diagnostics to a CodeEditorWidget, and manages a temporary workspace for compiling.

File AccessNetworking
using Editor.Prism.Core;
using Editor.Prism.Text.Completion;
using Editor.Prism.Text.Lexer;
using Editor.Prism.Text.LanguageDb;
using Editor.Prism.Toolchain;
using Sandbox.Engine.Shaders;
using PrismDiagnostic = Editor.Prism.Core.Diagnostic;

namespace Editor.Prism.Text.Diagnostics;

/// <summary>
/// Diagnostic codes produced by the text editor's own analysis, as opposed to the graph pipeline's.
/// <para>
/// <b>These are now forwarders.</b> The <c>PR6xxx</c> range was folded into
/// <see cref="Core.DiagnosticCode"/> so there is one table of codes rather than two lists of identical
/// string literals that could drift apart. Every member below is defined as the corresponding
/// <c>DiagnosticCode</c> constant, so the two agree by construction and not by coincidence. New
/// text-tier codes go in <c>DiagnosticCode</c>; this type stays for the callers that already name it.
/// </para>
/// </summary>
public static class TextDiagnosticCode
{
	/// <summary>A call to something nothing in scope declares.</summary>
	public const string UnknownIdentifier = DiagnosticCode.UnknownIdentifier;

	/// <summary>A Direct3D 9 sampler intrinsic DXC removed.</summary>
	public const string DeprecatedIntrinsic = DiagnosticCode.DeprecatedIntrinsic;

	/// <summary>Braces, parentheses or brackets do not balance.</summary>
	public const string Unbalanced = DiagnosticCode.Unbalanced;

	/// <summary>A string literal or block comment is never closed.</summary>
	public const string Unterminated = DiagnosticCode.Unterminated;

	/// <summary>A <c>#</c> directive the preprocessor does not know.</summary>
	public const string UnknownDirective = DiagnosticCode.UnknownDirective;

	/// <summary>An <c>#include</c> that resolves to no file on any search path.</summary>
	public const string MissingInclude = DiagnosticCode.MissingInclude;

	/// <summary>An <c>#include &lt;…&gt;</c>, which the engine's preprocessor never expands.</summary>
	public const string AngleBracketInclude = DiagnosticCode.AngleBracketInclude;

	/// <summary>An <c>#include</c> whose spacing the engine's regex does not match.</summary>
	public const string IncludeSpacing = DiagnosticCode.IncludeSpacing;

	/// <summary>A VFX block the engine's <c>.shader</c> parser throws on.</summary>
	public const string RejectedBlock = DiagnosticCode.RejectedBlock;

	/// <summary>A declaration that shadows an engine global.</summary>
	public const string ShadowedGlobal = DiagnosticCode.ShadowedGlobal;

	/// <summary>The Slang toolchain is absent, so a <c>.slang</c> buffer only gets local checks.</summary>
	public const string SlangNotValidated = DiagnosticCode.SlangNotValidated;
}

/// <summary>
/// Runs the right validator for a buffer and hands back diagnostics, debounced and cancellable.
/// <para>
/// Three tiers, in the order they arrive. Local checks are instant, in-process and always on: unknown
/// calls, DX9 intrinsics DXC removed, intrinsics above Shader Model 6.0, unbalanced brackets, unknown
/// directives and unresolvable includes. They land on the editor before the user has stopped typing.
/// Then the authoritative tier: a <c>.shader</c> is compiled for real by the engine, and a bare
/// <c>.hlsl</c> is wrapped in the smallest legal shader that will hold it and compiled the same way —
/// which is how this editor gets real compiler errors for an include file, something nothing else in
/// s&amp;box does. A <c>.slang</c> goes to <c>slangc</c> when the user installed one, and quietly does
/// without when they did not.
/// </para>
/// </summary>
public sealed class TextDiagnosticService : IDisposable
{
	/// <summary>
	/// Ceiling on unknown-identifier warnings in one file. Past this the file is not wrong, it is
	/// <i>incomplete</i>, and the whole set is dropped — see <see cref="MaxDistinctUnknownIdentifiers"/>.
	/// </summary>
	const int MaxUnknownIdentifiers = 8;

	/// <summary>
	/// Ceiling on <i>distinct</i> unknown names in one file.
	/// <para>
	/// The check exists to catch an isolated mistake — a misspelled intrinsic, a helper that was renamed.
	/// Once five different names in one buffer are unresolved, the far likelier explanation is that the
	/// buffer is an include fragment whose scope its callers supply. The engine's own headers do exactly
	/// this: <c>ffx_fsr1.h</c> calls <c>ARcpF1</c> fourteen times and deliberately does not include
	/// <c>ffx_a.h</c>, and every <c>ffx_denoiser_reflections_*.h</c> calls twenty callbacks the including
	/// shader is required to define. Reporting those as mistakes is simply wrong, so nothing is reported.
	/// </para>
	/// </summary>
	const int MaxDistinctUnknownIdentifiers = 4;

	/// <summary>
	/// Ceiling on how often one unknown name may appear before the file is treated as incomplete. Nobody
	/// misspells the same identifier three times; a missing header goes wrong on every use.
	/// </summary>
	const int MaxUsesOfOneUnknown = 2;

	/// <summary>
	/// How deep the include graph is walked when harvesting the names a buffer can see. Deeper than
	/// <see cref="CompletenessDepth"/> on purpose: every extra name found can only remove a false
	/// positive, never create one.
	/// </summary>
	const int IncludeDepth = 4;

	/// <summary>
	/// How deep the "did every include resolve?" test looks. Kept shallow deliberately: engine header
	/// trees fan out into the fourteen compiler-embedded includes within three or four hops, and a
	/// stricter test would simply stop checking two thirds of the shipped shaders.
	/// </summary>
	const int CompletenessDepth = 2;

	/// <summary>Ceiling on files walked while harvesting, so a pathological graph cannot stall a check.</summary>
	const int IncludeFiles = 96;

	static bool s_collected;

	readonly object _lock = new();

	CancellationTokenSource _inFlight;
	TempWorkspace _workspace;
	int _generation;
	bool _disposed;

	CodeEditorWidget _editor;
	Action<CodeEditorWidget> _settled;

	/// <summary>
	/// Creates a service with its own scratch folder. The folder is per instance rather than per
	/// session because two tabs editing files with the same name would otherwise compile over each
	/// other, and the output path the engine picks is derived from the file name.
	/// </summary>
	public TextDiagnosticService( string sessionId = null )
	{
		SessionId = string.IsNullOrWhiteSpace( sessionId )
			? $"text-{Ids.NewShortId()}"
			: sessionId;

		// A crashed editor leaves its scratch folders behind and nobody comes back for them. Once per
		// process is enough; every tab does not need to rescan the directory.
		if ( !s_collected )
		{
			s_collected = true;

			PrismLog.Guard( "Prism.Text: collect stale scratch sessions",
				() => TempWorkspace.CollectGarbage( PrismConstants.TempSessionLifetimeHours, SessionId ) );
		}
	}

	/// <summary>
	/// Wires a service to an editor: validates when typing settles, pushes the result onto the editor's
	/// squiggles, and validates once immediately so a freshly opened file is not silently unchecked.
	/// </summary>
	public static TextDiagnosticService Attach( CodeEditorWidget editor, string filePath = null )
	{
		if ( editor is not { IsValid: true } )
			return null;

		var service = new TextDiagnosticService
		{
			FilePath = filePath ?? editor.Document?.FilePath,
			_editor = editor
		};

		service._settled = _ => service.RequestFor( editor );
		editor.TextSettled += service._settled;

		service.Completed += diagnostics =>
		{
			if ( editor is { IsValid: true } )
				editor.SetDiagnostics( diagnostics );
		};

		service.RequestFor( editor );

		return service;
	}

	/// <summary>The scratch session this service compiles through.</summary>
	public string SessionId { get; }

	/// <summary>Path of the buffer being validated. Keep it in step with Save As.</summary>
	public string FilePath { get; set; }

	/// <summary>Whether the authoritative compiler tier runs at all. Local checks always do.</summary>
	public bool UseCompiler { get; set; } = true;

	/// <summary>Whether unknown calls are reported. On by default; off for buffers full of generated macros.</summary>
	public bool ReportUnknownIdentifiers { get; set; } = true;

	/// <summary>How long after the last keystroke a validation starts.</summary>
	public int DebounceMs { get; set; } = PrismConstants.TextDebounceMs;

	/// <summary>True while a validation is running.</summary>
	public bool IsRunning { get; private set; }

	/// <summary>The most recent result. Never null.</summary>
	public IReadOnlyList<PrismDiagnostic> Last { get; private set; } = Array.Empty<PrismDiagnostic>();

	/// <summary>Raised on the main thread when a validation starts.</summary>
	public event Action Started;

	/// <summary>Raised on the main thread with every completed result, in order.</summary>
	public event Action<IReadOnlyList<PrismDiagnostic>> Completed;

	// ---- driving ----------------------------------------------------------

	/// <summary>Validates an editor's buffer. Safe to call on every keystroke.</summary>
	public void RequestFor( CodeEditorWidget editor )
	{
		if ( editor is not { IsValid: true } || editor.Document is null )
			return;

		Request( editor.Document.Text, FilePath ?? editor.Document.FilePath, editor.Language );
	}

	/// <summary>
	/// Validates a buffer after the debounce, cancelling whatever was already running. The local checks
	/// are published as soon as they are done so the editor never waits on the compiler to show an
	/// obviously broken line.
	/// </summary>
	public void Request( string text, string filePath, string language )
	{
		if ( _disposed )
			return;

		var generation = Interlocked.Increment( ref _generation );

		// Resolving includes enumerates mounted projects, which is editor state. Do it here, on the
		// thread the caller is on, rather than from the worker below.
		PrismLog.Guard( "Prism.Text: warm include roots", IncludeResolver.Warm );

		CancellationTokenSource cancellation;

		lock ( _lock )
		{
			_inFlight?.Cancel();
			_inFlight?.Dispose();
			_inFlight = new CancellationTokenSource();
			cancellation = _inFlight;
		}

		var token = cancellation.Token;

		_ = Task.Run( async () =>
		{
			try
			{
				await Task.Delay( Math.Max( 0, DebounceMs ), token ).ConfigureAwait( false );

				if ( generation != Volatile.Read( ref _generation ) )
					return;

				MainThread.Queue( () =>
				{
					if ( generation != Volatile.Read( ref _generation ) )
						return;

					IsRunning = true;
					PrismLog.Guard( "Prism.Text: diagnostics started", () => Started?.Invoke() );
				} );

				var definition = LanguageDefinition.For( language );
				var local = LocalChecks( text, filePath, definition, ReportUnknownIdentifiers );

				Publish( generation, local );

				if ( !UseCompiler )
				{
					Finish( generation );
					return;
				}

				var deep = await Compile( text, filePath, definition, token ).ConfigureAwait( false );

				if ( token.IsCancellationRequested )
					return;

				var all = new List<PrismDiagnostic>( local );

				all.AddRange( deep );

				Publish( generation, all );
				Finish( generation );
			}
			catch ( OperationCanceledException )
			{
				// Superseded by a newer request; the newer one publishes.
			}
			catch ( Exception e )
			{
				PrismLog.Error( e, "Prism.Text: validation failed" );
				Finish( generation );
			}
		} );
	}

	/// <summary>Runs a validation right now, with no debounce, and hands back the result.</summary>
	public async Task<IReadOnlyList<PrismDiagnostic>> Validate( string text, string filePath, string language,
		CancellationToken ct )
	{
		var definition = LanguageDefinition.For( language );
		var results = new List<PrismDiagnostic>( LocalChecks( text, filePath, definition, ReportUnknownIdentifiers ) );

		if ( UseCompiler )
			results.AddRange( await Compile( text, filePath, definition, ct ).ConfigureAwait( false ) );

		return results;
	}

	/// <summary>Cancels whatever is running and leaves the last published result in place.</summary>
	public void Cancel()
	{
		Interlocked.Increment( ref _generation );

		lock ( _lock )
		{
			_inFlight?.Cancel();
		}
	}

	void Publish( int generation, IReadOnlyList<PrismDiagnostic> diagnostics )
	{
		MainThread.Queue( () =>
		{
			if ( _disposed || generation != Volatile.Read( ref _generation ) )
				return;

			Last = diagnostics;

			PrismLog.Guard( "Prism.Text: diagnostics published", () => Completed?.Invoke( diagnostics ) );
		} );
	}

	void Finish( int generation )
	{
		MainThread.Queue( () =>
		{
			if ( generation != Volatile.Read( ref _generation ) )
				return;

			IsRunning = false;
		} );
	}

	// ---- local checks -----------------------------------------------------

	/// <summary>
	/// Everything that can be decided without a compiler, in a single lexer pass. Fast enough to run on
	/// a keystroke and precise enough that the squiggle lands on the right token.
	/// </summary>
	public static IReadOnlyList<PrismDiagnostic> LocalChecks( string text, string filePath,
		LanguageDefinition language, bool reportUnknownIdentifiers = true )
	{
		var results = new List<PrismDiagnostic>();

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

		language ??= LanguageDefinition.Hlsl;

		PrismLog.Guard( "Prism.Text: local checks",
			() => RunLocalChecks( text, filePath, language, reportUnknownIdentifiers, results ) );

		return results;
	}

	static void RunLocalChecks( string text, string filePath, LanguageDefinition language,
		bool reportUnknownIdentifiers, List<PrismDiagnostic> results )
	{
		var file = filePath ?? string.Empty;
		var lines = text.Replace( "\r\n", "\n" ).Replace( '\r', '\n' ).Split( '\n' );
		var lexer = Lexers.For( language.Id );
		var state = LexState.Default;
		var tokens = new List<Token>( 64 );

		var symbols = DocumentSymbols.Parse( text, language.Id, filePath );
		var declared = new HashSet<string>( StringComparer.Ordinal );

		// A name declared anywhere in the buffer counts, wherever the caret is and whichever branch of
		// the preprocessor it sits in: this pass answers "does anything declare it", not "is it in scope
		// on line N", and a forward reference to a function defined lower down is perfectly normal.
		foreach ( var symbol in symbols.All )
			declared.Add( symbol.Name );

		var includedMacros = new HashSet<string>( StringComparer.Ordinal );

		// The same walk the completeness test does, so a name declared in a header we did read can never
		// be reported as unknown just because the harvest stopped one level shallower than the test.
		foreach ( var path in IncludeResolver.Transitive( text, filePath, IncludeDepth, IncludeFiles ) )
		{
			foreach ( var symbol in DocumentSymbols.ForFile( path, language.Id ).All )
			{
				declared.Add( symbol.Name );

				if ( symbol.Kind == DocumentSymbolKind.Macro )
					includedMacros.Add( symbol.Name );
			}
		}

		// If any include could not be read — because it is missing, or because it is one of the
		// fourteen that live inside the compiler and have no file at all — then we genuinely do not
		// know what is in scope, and every "undeclared" warning would be a guess. Almost every real
		// s&box shader reaches a compiler-embedded header eventually, so this is the common case, and
		// staying quiet is the only honest thing to do. The real compile still catches everything.
		if ( reportUnknownIdentifiers && !IncludeResolver.IsGraphComplete( text, filePath, CompletenessDepth, IncludeFiles ) )
			reportUnknownIdentifiers = false;

		var conditionals = reportUnknownIdentifiers ? new PreprocessorRegions( symbols, includedMacros ) : null;
		var braces = new Stack<(char Kind, int Line, int Column)>();
		var unknown = reportUnknownIdentifiers ? new List<PrismDiagnostic>() : null;
		var unknownNames = reportUnknownIdentifiers ? new Dictionary<string, int>( StringComparer.Ordinal ) : null;

		for ( var line = 0; line < lines.Length; line++ )
		{
			var content = lines[line];
			var continued = ( state.Flags & LexFlags.PreprocessorContinuation ) != 0;

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

			if ( conditionals is not null && !continued )
				conditionals.Feed( content, line );

			// A directive is its own little language: `defined(X)` is not a call, and a macro body's
			// braces do not have to balance on the line they are written on.
			var directive = continued || FirstKind( tokens ) == TokenKind.Preprocessor;

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

				if ( token.Start < 0 || token.Length <= 0 || token.Start + token.Length > content.Length )
					continue;

				if ( directive && token.Kind != TokenKind.Preprocessor )
					continue;

				var word = content.Substring( token.Start, token.Length );

				switch ( token.Kind )
				{
					case TokenKind.String:
						// A literal that runs to the end of the line without a closing quote never ends.
						if ( token.Start + token.Length == content.Length && token.Length >= 1 &&
							 ( token.Length == 1 || content[^1] != word[0] ) )
						{
							results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error,
								TextDiagnosticCode.Unterminated, "Unterminated string literal",
								Span( file, line, token ) ) );
						}

						continue;

					case TokenKind.Comment:
					case TokenKind.DocComment:
					case TokenKind.Whitespace:
						continue;

					case TokenKind.Punctuation:
						Balance( results, braces, word, file, line, token );
						continue;

					case TokenKind.Preprocessor:
						// Only at the head of a line: `##` inside a macro body lexes the same way.
						if ( IsFirstOnLine( tokens, i ) )
							CheckDirective( results, language, content, line, token, tokens, i, file );

						continue;

					case TokenKind.IncludePath:
						continue;

					case TokenKind.BlockKeyword:
						if ( SboxSymbols.RejectedBlockNames.Contains( word ) )
						{
							results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error,
								TextDiagnosticCode.RejectedBlock,
								$"s&box cannot compile a {word} block",
								Span( file, line, token ),
								$"The engine's .shader parser throws \"{word} does nothing!\" and the whole file " +
								"fails to load, with no diagnostic of its own." ) );
						}

						continue;
				}

				if ( token.Kind is not ( TokenKind.Identifier or TokenKind.Intrinsic or TokenKind.FunctionName ) )
					continue;

				// A member access is resolved by the compiler, not by us.
				if ( PrecededByAccess( content, token.Start ) )
					continue;

				if ( IntrinsicDb.TryGet( word, out var intrinsic ) )
				{
					if ( intrinsic.Deprecated )
					{
						results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error,
							TextDiagnosticCode.DeprecatedIntrinsic,
							$"'{word}' was removed by Shader Model 6",
							Span( file, line, token ), intrinsic.UnavailableReason ) );

						continue;
					}

					if ( intrinsic.NeedsHigherShaderModel )
					{
						results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error,
							DiagnosticCode.ShaderModelTooHigh, intrinsic.UnavailableReason,
							Span( file, line, token ) ) );

						continue;
					}

					continue;
				}

				if ( !reportUnknownIdentifiers )
					continue;

				// Code the preprocessor may never reach cannot be judged: `ffx_a.h` calls `fract` and
				// `mix` inside `#ifdef A_GLSL`, and whether that branch exists is decided by whoever
				// includes it. Only unconditional code, and code a condition we could actually evaluate
				// selected, is checked.
				if ( !conditionals.IsLive )
					continue;

				// Only calls are reported. An unknown bare identifier is far more often a macro, a
				// combo or something a header we could not resolve declares than a real mistake.
				if ( !FollowedByCall( content, token.Start + token.Length ) )
					continue;

				if ( declared.Contains( word ) || language.IsKnownIdentifier( word ) ||
					 SboxSymbols.IsComboSymbol( word ) || SboxSymbols.IsEngineGlobal( word ) ||
					 SboxSymbols.IsModeFunction( word ) )
				{
					continue;
				}

				unknownNames.TryGetValue( word, out var uses );
				unknownNames[word] = uses + 1;

				unknown.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Warning,
					TextDiagnosticCode.UnknownIdentifier,
					$"Nothing in scope declares '{word}'",
					Span( file, line, token ),
					"It is not an intrinsic, an s&box symbol, or declared in this file or any include " +
					"Prism could resolve. If it comes from a header, check the #include path." ) );

				// Past either ceiling the verdict is already "incomplete file", so stop collecting: a
				// generated header can otherwise pile up thousands of diagnostics nobody will ever see.
				if ( unknown.Count > MaxUnknownIdentifiers || unknownNames.Count > MaxDistinctUnknownIdentifiers )
				{
					reportUnknownIdentifiers = false;
					unknown.Clear();
				}
			}
		}

		if ( unknown is { Count: > 0 } && !LooksLikeFragment( unknown.Count, unknownNames ) )
			results.AddRange( unknown );

		if ( ( state.Flags & LexFlags.BlockComment ) != 0 )
		{
			results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error, TextDiagnosticCode.Unterminated,
				"Unterminated block comment",
				SourceSpan.AtLine( file, Math.Max( 1, lines.Length ) ),
				"Everything after the last /* is being treated as a comment." ) );
		}

		while ( braces.Count > 0 )
		{
			var (kind, line, column) = braces.Pop();

			results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error, TextDiagnosticCode.Unbalanced,
				$"'{kind}' is never closed",
				SourceSpan.At( file, line + 1, column + 1 ) ) );
		}

		results.AddRange( IncludeResolver.Validate( text, filePath, language ) );
	}

	/// <summary>
	/// Whether the unknown names found in a buffer say "this file has a mistake in it" or "this file is
	/// half of a translation unit". See <see cref="MaxDistinctUnknownIdentifiers"/> for the reasoning.
	/// </summary>
	static bool LooksLikeFragment( int total, Dictionary<string, int> names )
	{
		if ( total > MaxUnknownIdentifiers || names.Count > MaxDistinctUnknownIdentifiers )
			return true;

		foreach ( var uses in names.Values )
		{
			if ( uses > MaxUsesOfOneUnknown )
				return true;
		}

		return false;
	}

	/// <summary>
	/// A three-valued <c>#if</c> tracker: a region is <b>live</b>, <b>dead</b>, or — the case that
	/// matters — <b>undecidable</b>.
	/// <para>
	/// Only the conditions that can be settled from the buffer alone are evaluated: <c>#if 0</c>,
	/// <c>#if 1</c>, and <c>#ifdef</c> / <c>#ifndef</c> / <c>defined(X)</c> where <c>X</c> is
	/// <c>#define</c>d earlier in this file or in an include we read. The "earlier" matters: an include
	/// guard defines its own symbol <i>inside</i> the <c>#ifndef</c> it opens, and treating that as
	/// already-defined would mark every file dead. Everything else — <c>#ifdef A_GLSL</c>,
	/// <c>#if ( S_MODE == 2 )</c>, any arithmetic — stays undecidable, because the symbol may be defined
	/// by the shader that includes this one or by the engine's own preprocessor.
	/// </para>
	/// </summary>
	sealed class PreprocessorRegions
	{
		enum Branch { Live, Dead, Unknown }

		readonly Dictionary<string, int> _defined = new( StringComparer.Ordinal );
		readonly List<Branch> _stack = new();

		string _guard;
		int _guardDepth;

		public PreprocessorRegions( DocumentSymbols symbols, HashSet<string> fromIncludes )
		{
			if ( symbols is not null )
			{
				foreach ( var symbol in symbols.All )
				{
					if ( symbol.Kind != DocumentSymbolKind.Macro )
						continue;

					if ( !_defined.TryGetValue( symbol.Name, out var first ) || symbol.Line < first )
						_defined[symbol.Name] = symbol.Line;
				}
			}

			// A macro from an include is in scope from the first line, so it gets a line number no
			// directive in this buffer can precede.
			if ( fromIncludes is null )
				return;

			foreach ( var name in fromIncludes )
				_defined.TryAdd( name, int.MinValue );
		}

		/// <summary>True when nothing on the conditional stack is dead or undecidable.</summary>
		public bool IsLive
		{
			get
			{
				for ( var i = 0; i < _stack.Count; i++ )
				{
					if ( _stack[i] != Branch.Live )
						return false;
				}

				return true;
			}
		}

		/// <summary>Feeds one physical line, which is a no-op unless it opens or closes a region.</summary>
		public void Feed( string line, int lineIndex )
		{
			var i = 0;

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

			if ( i >= line.Length || line[i] != '#' )
				return;

			i++;

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

			var nameStart = i;

			while ( i < line.Length && ( char.IsLetterOrDigit( line[i] ) || line[i] == '_' ) )
				i++;

			var directive = line.Substring( nameStart, i - nameStart );
			var rest = i < line.Length ? line.Substring( i ) : string.Empty;
			var guard = _guard;

			_guard = null;

			switch ( directive )
			{
				case "if":
					_stack.Add( Evaluate( rest, lineIndex ) );
					return;

				case "ifdef":
					_stack.Add( DefinedBefore( FirstWord( rest ), lineIndex ) ? Branch.Live : Branch.Unknown );
					return;

				case "ifndef":
					var undefined = FirstWord( rest );

					_stack.Add( DefinedBefore( undefined, lineIndex ) ? Branch.Dead : Branch.Unknown );

					// Remember it in case the next directive turns out to be its include guard.
					_guard = undefined;
					_guardDepth = _stack.Count;
					return;

				case "define":
					// `#ifndef FOO_H` / `#define FOO_H` is an include guard, and the first inclusion always
					// takes it. Without this, every guarded header would be one big undecidable region and
					// nothing in it would ever be checked.
					if ( guard is not null && guard.Length > 0 && _stack.Count == _guardDepth &&
						 _stack[^1] == Branch.Unknown && FirstWord( rest ) == guard )
					{
						_stack[^1] = Branch.Live;
					}

					return;

				case "elif":
					// The branch before this one was live, so this one cannot be; otherwise re-evaluate.
					if ( _stack.Count > 0 )
						_stack[^1] = _stack[^1] == Branch.Live ? Branch.Dead : Evaluate( rest, lineIndex );

					return;

				case "else":
					if ( _stack.Count > 0 )
					{
						_stack[^1] = _stack[^1] switch
						{
							Branch.Live => Branch.Dead,
							Branch.Dead => Branch.Live,
							_ => Branch.Unknown
						};
					}

					return;

				case "endif":
					if ( _stack.Count > 0 )
						_stack.RemoveAt( _stack.Count - 1 );

					return;
			}
		}

		Branch Evaluate( string expression, int lineIndex )
		{
			var text = expression.Trim();

			// Strip one layer of wrapping parentheses: `#if ( 0 )` is written as often as `#if 0`.
			while ( text.Length > 2 && text[0] == '(' && text[^1] == ')' )
				text = text.Substring( 1, text.Length - 2 ).Trim();

			if ( text == "0" )
				return Branch.Dead;

			if ( text == "1" )
				return Branch.Live;

			var negated = text.StartsWith( "!", StringComparison.Ordinal );

			if ( negated )
				text = text.Substring( 1 ).TrimStart();

			if ( !text.StartsWith( "defined", StringComparison.Ordinal ) )
				return Branch.Unknown;

			var argument = text.Substring( "defined".Length ).Trim();

			while ( argument.Length > 2 && argument[0] == '(' && argument[^1] == ')' )
				argument = argument.Substring( 1, argument.Length - 2 ).Trim();

			var name = FirstWord( argument );

			// `defined(A) && defined(B)` leaves a tail behind; anything left over is not decidable.
			if ( name.Length == 0 || name.Length != argument.Length )
				return Branch.Unknown;

			if ( !DefinedBefore( name, lineIndex ) )
				return Branch.Unknown;

			return negated ? Branch.Dead : Branch.Live;
		}

		bool DefinedBefore( string name, int lineIndex ) =>
			name.Length > 0 && _defined.TryGetValue( name, out var line ) && line < lineIndex;

		static string FirstWord( string text )
		{
			var i = 0;

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

			var start = i;

			while ( i < text.Length && ( char.IsLetterOrDigit( text[i] ) || text[i] == '_' ) )
				i++;

			return text.Substring( start, i - start );
		}
	}

	static void Balance( List<PrismDiagnostic> results, Stack<(char, int, int)> braces, string word,
		string file, int line, Token token )
	{
		if ( word.Length != 1 )
			return;

		var c = word[0];

		if ( c is '{' or '(' or '[' )
		{
			braces.Push( (c, line, token.Start) );
			return;
		}

		if ( c is not ( '}' or ')' or ']' ) )
			return;

		var expected = c switch { '}' => '{', ')' => '(', _ => '[' };

		if ( braces.Count == 0 )
		{
			results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error, TextDiagnosticCode.Unbalanced,
				$"'{c}' has no matching '{expected}'", Span( file, line, token ) ) );

			return;
		}

		var top = braces.Peek();

		if ( top.Item1 != expected )
		{
			results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error, TextDiagnosticCode.Unbalanced,
				$"'{c}' closes a '{top.Item1}' opened on line {top.Item2 + 1}", Span( file, line, token ) ) );
		}

		braces.Pop();
	}

	static TokenKind FirstKind( List<Token> tokens )
	{
		for ( var i = 0; i < tokens.Count; i++ )
		{
			if ( tokens[i].Kind != TokenKind.Whitespace )
				return tokens[i].Kind;
		}

		return TokenKind.None;
	}

	static bool IsFirstOnLine( List<Token> tokens, int index )
	{
		for ( var i = 0; i < index; i++ )
		{
			if ( tokens[i].Kind != TokenKind.Whitespace )
				return false;
		}

		return true;
	}

	static void CheckDirective( List<PrismDiagnostic> results, LanguageDefinition language, string content,
		int line, Token hash, List<Token> tokens, int index, string file )
	{
		// The lexer emits `#include` as one token; a lexer that splits the hash off has to work too.
		var name = content.Substring( hash.Start, hash.Length ).TrimStart( '#' ).Trim();
		var end = hash.Start + hash.Length;

		if ( name.Length == 0 )
		{
			if ( index + 1 >= tokens.Count )
				return;

			var next = tokens[index + 1];

			if ( next.Start < 0 || next.Start + next.Length > content.Length )
				return;

			name = content.Substring( next.Start, next.Length );
			end = next.Start + next.Length;
		}

		if ( language.IsDirective( name ) )
			return;

		// `# 42 "file"` is a line marker the preprocessor emits; never a mistake in authored code.
		if ( name.Length == 0 || char.IsDigit( name[0] ) )
			return;

		results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error, TextDiagnosticCode.UnknownDirective,
			$"Unknown preprocessor directive '#{name}'",
			new SourceSpan( file, line + 1, hash.Start + 1, line + 1, end + 1 ) ) );
	}

	static bool PrecededByAccess( string content, int start )
	{
		var i = start - 1;

		while ( i >= 0 && content[i] == ' ' )
			i--;

		if ( i < 0 )
			return false;

		if ( content[i] == '.' )
			return true;

		return i >= 1 && content[i] == ':' && content[i - 1] == ':';
	}

	static bool FollowedByCall( string content, int end )
	{
		for ( var i = end; i < content.Length; i++ )
		{
			if ( content[i] == ' ' || content[i] == '\t' )
				continue;

			return content[i] == '(';
		}

		return false;
	}

	static SourceSpan Span( string file, int line, Token token ) =>
		new( file, line + 1, token.Start + 1, line + 1, token.Start + token.Length + 1 );

	// ---- compiler tier ----------------------------------------------------

	async Task<IReadOnlyList<PrismDiagnostic>> Compile( string text, string filePath,
		LanguageDefinition definition, CancellationToken ct )
	{
		if ( string.IsNullOrWhiteSpace( text ) )
			return Array.Empty<PrismDiagnostic>();

		var kind = definition?.Id ?? "hlsl";

		if ( string.Equals( kind, "slang", StringComparison.OrdinalIgnoreCase ) )
			return await ValidateSlang( text, filePath, ct ).ConfigureAwait( false );

		return await ValidateShader( text, filePath, kind, ct ).ConfigureAwait( false );
	}

	TempWorkspace Workspace
	{
		get
		{
			lock ( _lock )
			{
				_workspace ??= new TempWorkspace( SessionId );
				return _workspace;
			}
		}
	}

	async Task<IReadOnlyList<PrismDiagnostic>> ValidateShader( string text, string filePath, string kind,
		CancellationToken ct )
	{
		var results = new List<PrismDiagnostic>();
		var stem = Stem( filePath );

		// A .shader is already a block file. A bare .hlsl is an include, and the engine refuses to
		// compile one, so it gets wrapped in the smallest legal shader that will hold it.
		var probe = string.Equals( kind, "vfx", StringComparison.OrdinalIgnoreCase )
			? ShaderProbeBuilder.ForShaderFile( text, stem )
			: ShaderProbeBuilder.ForHlsl( text, new ShaderProbeOptions { Name = stem } );

		var fileName = $"{stem}.{PrismConstants.ShaderExtension}";
		var workspace = Workspace;

		if ( !workspace.IsValid || !workspace.Write( fileName, probe.Text ) )
		{
			results.Add( PrismDiagnostic.Info( DiagnosticCode.CompilerRaw,
				"Prism could not write its scratch shader, so only local checks ran" ) );

			return results;
		}

		var relative = workspace.Relative( fileName );

		var options = new ShaderCompileOptions
		{
			ForceRecompile = false,
			ConsoleOutput = false,
			SingleThreaded = false
		};

		ShaderCompile.Results compiled = null;

		try
		{
			// Back to the main thread before touching the engine compiler.
			//
			// EditorUtility.CompileShader reaches straight into native code: Shader.LoadFromSource, the
			// vfx_vulkan.dll interface (lazily loaded by ShaderCompile's static constructor, on whatever
			// thread happens to touch it first), FinalizeCompile, InitializeWrite and the native resource
			// compiler. None of that is thread-affine by contract, and none of it is documented as safe to
			// call from anywhere.
			//
			// Every call site in the engine invokes it from the main thread and simply awaits — see
			// ShaderGraph's MainWindow, ShaderHooks and StartupLoadProject. The engine offloads the part
			// that is actually parallel itself: ProgramSource.CompileCore wraps the combo loop in its own
			// Task.Run/Parallel.ForEach. Wrapping the whole call in Task.Run, as this used to, put the
			// serial native prologue and epilogue on a pool thread instead, which no engine code ever
			// does. Awaiting from the main thread does not block the editor — the await yields, and the
			// expensive combo loop still runs on the pool where the engine put it.
			await MainThread.Wait();

			PrismLog.Info( $"Prism.Text: compiling '{relative}' through the engine shader compiler" );

			compiled = await EditorUtility.CompileShader( Editor.FileSystem.Root, relative, options, ct );

			PrismLog.Info( $"Prism.Text: engine compile of '{relative}' returned " +
				$"success={compiled?.Success}, programs={compiled?.Programs?.Count ?? 0}" );
		}
		catch ( OperationCanceledException )
		{
			throw;
		}
		catch ( Exception e )
		{
			PrismLog.Error( e, "Prism.Text: the engine shader compiler threw" );

			results.Add( PrismDiagnostic.Error( DiagnosticCode.CompilerRaw,
				"The engine shader compiler failed", null, e.Message ) );

			return results;
		}

		if ( compiled is null )
			return results;

		var programs = compiled.Programs ?? new List<ShaderCompile.Results.Program>();

		if ( !compiled.Success && programs.Count == 0 )
		{
			results.Add( probe.MapBack( CompilerOutputParser.BlockHeaderFailure( fileName ), filePath ) );
			return results;
		}

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

		foreach ( var program in programs )
		{
			if ( program?.Output is not { Count: > 0 } )
				continue;

			var map = LineDirectiveMap.Build( program.Source, fileName ).Calibrate( probe.Text );
			var parsed = CompilerOutputParser.Parse( program.Output, fileName );
			var stage = CompilerOutputParser.Pretty( program.Name );

			foreach ( var diagnostic in map.RemapAll( parsed, null, fileName ) )
			{
				var mapped = probe.MapBack( diagnostic, filePath );

				if ( mapped is null )
					continue;

				// The same COMMON-block error is reported once per program; show it once.
				if ( !seen.Add( $"{mapped.Severity}|{mapped.Code}|{mapped.Span}|{mapped.Message}" ) )
					continue;

				results.Add( string.IsNullOrEmpty( stage )
					? mapped
					: mapped with
					{
						Detail = string.IsNullOrWhiteSpace( mapped.Detail )
							? $"Reported while compiling {stage}."
							: $"{mapped.Detail}\nReported while compiling {stage}."
					} );
			}
		}

		return results;
	}

	async Task<IReadOnlyList<PrismDiagnostic>> ValidateSlang( string text, string filePath, CancellationToken ct )
	{
		var validator = SlangToolchain.CreateValidator();

		if ( validator is null || !validator.Available )
		{
			return new[]
			{
				PrismDiagnostic.Info( TextDiagnosticCode.SlangNotValidated,
					"Slang is not validated: no slangc was found",
					null,
					"Install the Slang toolchain from Preferences to have slangc check this file. Local " +
					"checks still run, and nothing else in Prism depends on it." )
			};
		}

		var probe = ShaderProbeBuilder.ForSlang( text, new ShaderProbeOptions { Name = Stem( filePath ) } );
		var entries = ShaderProbeBuilder.DiscoverSlangEntryPoints( probe.Text );

		var request = new SlangValidationRequest
		{
			Source = probe.Text,
			EntryPoints = entries,
			DisplayName = probe.FileName,
			IncludePaths = IncludeResolver.SearchRoots.ToArray()
		};

		var diagnostics = await validator.Validate( request, ct ).ConfigureAwait( false );

		return probe.MapBack( diagnostics, filePath );
	}

	static string Stem( string filePath )
	{
		var name = string.IsNullOrWhiteSpace( filePath )
			? "buffer"
			: System.IO.Path.GetFileNameWithoutExtension( filePath );

		if ( string.IsNullOrWhiteSpace( name ) )
			name = "buffer";

		var clean = new System.Text.StringBuilder( name.Length );

		foreach ( var c in name )
			clean.Append( char.IsLetterOrDigit( c ) || c == '_' ? c : '_' );

		return "prism_text_" + clean;
	}

	/// <summary>Stops any work, drops the scratch folder and detaches from the editor.</summary>
	public void Dispose()
	{
		if ( _disposed )
			return;

		_disposed = true;

		Cancel();

		if ( _editor is { IsValid: true } && _settled is not null )
			_editor.TextSettled -= _settled;

		_editor = null;
		_settled = null;

		lock ( _lock )
		{
			PrismLog.Guard( "Prism.Text: drop scratch workspace", () => _workspace?.Dispose() );

			_workspace = null;

			_inFlight?.Dispose();
			_inFlight = null;
		}
	}
}