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.
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 <…></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&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;
}
}
}