Parser for compiler output lines. It recognises several textual shapes from Slang, DXC, the native VFX masker and SPIRV-Tools, extracts severity, file/line/column spans, folds continuation lines into diagnostics, and synthesises a few special diagnostics.
using Editor.Prism.Core;
using System.Text;
using System.Text.RegularExpressions;
namespace Editor.Prism.Toolchain;
/// <summary>Which recognised message shape a raw compiler line matched.</summary>
public enum CompilerOutputShape
{
/// <summary>Nothing matched; the text was preserved verbatim as information.</summary>
Unrecognised,
/// <summary><c>file(line): error 30019: message</c> — Slang's legacy format and DXC's.</summary>
FileLine,
/// <summary><c>file(line,col): error X3004: message</c> — DXC's column form.</summary>
FileLineColumn,
/// <summary><c>DXC Err: message</c> — the DXC preprocessor's own channel.</summary>
DxcPreprocessor,
/// <summary>The native VFX block masker, which reports a line in the authored <c>.shader</c>.</summary>
BlockMasker,
/// <summary>Two bindings collided — <c>vfx_vulkan</c>'s descriptor allocator giving up.</summary>
ResourceCollision,
/// <summary>A native validation message that aborts the compile outright.</summary>
NativeFatal,
/// <summary>SPIRV-Tools output from the optimiser or validator.</summary>
SpirvTool,
/// <summary>A continuation of the previous diagnostic — caret art, a note, an indented detail.</summary>
Continuation
}
/// <summary>
/// Turns the raw text of <c>ShaderCompile.Results.Program.Output</c> into structured
/// <see cref="Diagnostic"/> values.
/// <para>
/// The engine hands back an undifferentiated, de-duplicated <c>List<string></c> containing
/// warnings and errors from Slang, DXC, the native VFX masker and SPIRV-Tools all mixed together, with
/// no severity, no line and no column. Everything Prism knows about a compile error it learned here, so
/// this file is deliberately generous: eight shapes are recognised, unrecognised text is never
/// discarded but downgraded to <see cref="DiagnosticSeverity.Info"/> with the original line intact, and
/// indented follow-on lines are folded into the preceding diagnostic's detail rather than becoming
/// eight useless list entries.
/// </para>
/// <para>
/// Line numbers arrive pointing into the <em>preprocessed</em> text. Mapping them back to the authored
/// <c>.shader</c> and then to a node is <see cref="LineDirectiveMap"/>'s job, not this one's.
/// </para>
/// </summary>
public static class CompilerOutputParser
{
const RegexOptions Options = RegexOptions.Compiled | RegexOptions.CultureInvariant;
// file(line): error 30019: message
static readonly Regex s_fileLine = new(
@"^\s*(?<file>.+?)\((?<line>\d+)\)\s*:\s*(?<sev>fatal error|internal error|error|warning|note)\s+(?<code>[A-Za-z]*\d+)\s*:\s*(?<msg>.*)$",
Options );
// file(line,col): error X3004: message file(line,col-col): warning: message
static readonly Regex s_fileLineColumn = new(
@"^\s*(?<file>.+?)\((?<line>\d+)\s*,\s*(?<col>\d+)(?:\s*-\s*(?<endcol>\d+))?\)\s*:\s*(?<sev>fatal error|internal error|error|warning|note)\s*:?\s*(?:(?<code>[A-Za-z]+\d+)\s*:)?\s*(?<msg>.*)$",
Options );
// DXC Err: message
static readonly Regex s_dxc = new( @"^\s*DXC Err\s*:\s*(?<msg>.*)$", Options );
// *** Error masking unused parts! ... (line 42)
static readonly Regex s_masker = new(
@"^\s*\*{0,3}\s*Error masking unused parts!\s*(?<msg>.*?)\s*$", Options );
// Equal sign not found after token "FEATURES"! (line 12)
static readonly Regex s_equalSign = new(
@"^\s*Equal sign not found after token\s+""(?<token>[^""]*)""!\s*\(line\s*(?<line>\d+)\)", Options );
// Resources 'a' and 'b' share the same descriptor and binding number
static readonly Regex s_resources = new(
@"^\s*Resources '(?<a>[^']+)' and '(?<b>[^']+)' share the same descriptor and binding number",
Options );
// SPIRV-Tools: "error: line 118: ..." / "warning: ..."
static readonly Regex s_spirv = new(
@"^\s*(?<sev>error|warning|fatal)\s*:\s*(?:line\s+(?<line>\d+)\s*:\s*)?(?<msg>.*)$", Options );
// Anything with a trailing "(line N)" we can still salvage a line number from.
static readonly Regex s_trailingLine = new( @"\(line\s*(?<line>\d+)\)", Options );
/// <summary>Phrases that mean the native side gave up, regardless of the rest of the line.</summary>
static readonly string[] s_fatalMarkers =
{
"Aborting shader compile",
"Failed to load shader file",
"Unknown text found in FEATURES section",
"Error opening include file",
"Cannot find include file",
"Cannot use absolute paths for shader includes",
"Failed to translate semantic"
};
/// <summary>Lines that carry no information at all and would only clutter the panel.</summary>
static readonly string[] s_noise =
{
"compilation failed",
"compilation succeeded",
"1 error generated.",
"errors generated."
};
/// <summary>
/// Parse a compiler output block. Consecutive continuation lines (caret art, indented notes) are
/// folded into the preceding diagnostic's detail, so one compiler complaint produces one entry.
/// </summary>
public static IReadOnlyList<Diagnostic> Parse( IEnumerable<string> lines, string defaultFile = null )
{
var results = new List<Diagnostic>();
if ( lines is null ) return results;
StringBuilder detail = null;
var index = -1;
void Flush()
{
if ( detail is null || index < 0 ) return;
var text = detail.ToString().TrimEnd();
if ( text.Length > 0 )
{
var existing = results[index];
var combined = string.IsNullOrWhiteSpace( existing.Detail ) ? text : existing.Detail + "\n" + text;
results[index] = existing with { Detail = combined };
}
detail = null;
}
foreach ( var raw in lines )
{
if ( raw is null ) continue;
var line = raw.TrimEnd( '\r', '\n' );
if ( string.IsNullOrWhiteSpace( line ) )
{
Flush();
continue;
}
if ( IsNoise( line ) ) continue;
if ( index >= 0 && IsContinuation( line ) )
{
detail ??= new StringBuilder();
detail.AppendLine( line.TrimEnd() );
continue;
}
Flush();
if ( !TryParse( line, out var diagnostic, defaultFile ) ) continue;
results.Add( diagnostic );
index = results.Count - 1;
}
Flush();
return results;
}
/// <summary>Parse a block of compiler output given as one string.</summary>
public static IReadOnlyList<Diagnostic> Parse( string text, string defaultFile = null ) =>
string.IsNullOrEmpty( text )
? Array.Empty<Diagnostic>()
: Parse( text.Split( '\n' ), defaultFile );
/// <summary>
/// Classify one line. Always succeeds for non-blank input: an unrecognised line becomes an
/// informational diagnostic carrying the original text, because a message we cannot parse is still a
/// message the user needs to see.
/// </summary>
public static bool TryParse( string line, out Diagnostic diagnostic, string defaultFile = null )
{
diagnostic = null;
if ( string.IsNullOrWhiteSpace( line ) ) return false;
diagnostic = ParseCore( line.Trim(), defaultFile, out _ );
return diagnostic is not null;
}
/// <summary>Which rule a line matches. Exposed so the shapes can be exercised directly.</summary>
public static CompilerOutputShape Classify( string line )
{
if ( string.IsNullOrWhiteSpace( line ) ) return CompilerOutputShape.Unrecognised;
if ( IsContinuation( line ) ) return CompilerOutputShape.Continuation;
ParseCore( line.Trim(), null, out var shape );
return shape;
}
static Diagnostic ParseCore( string line, string defaultFile, out CompilerOutputShape shape )
{
// 1. the column form first: it is strictly more specific than the line form.
var match = s_fileLineColumn.Match( line );
if ( match.Success )
{
shape = CompilerOutputShape.FileLineColumn;
var start = ToInt( match.Groups["col"].Value, 1 );
var end = match.Groups["endcol"].Success ? ToInt( match.Groups["endcol"].Value, start ) : start;
var lineNumber = ToInt( match.Groups["line"].Value, 1 );
return Make( SeverityOf( match.Groups["sev"].Value ), match.Groups["msg"].Value,
new SourceSpan( File( match, defaultFile ), lineNumber, start, lineNumber, Math.Max( end, start ) ),
CodeDetail( match.Groups["code"].Value ) );
}
// 2. file(line): severity code: message
match = s_fileLine.Match( line );
if ( match.Success )
{
shape = CompilerOutputShape.FileLine;
return Make( SeverityOf( match.Groups["sev"].Value ), match.Groups["msg"].Value,
SourceSpan.AtLine( File( match, defaultFile ), ToInt( match.Groups["line"].Value, 1 ) ),
CodeDetail( match.Groups["code"].Value ) );
}
// 3. the DXC preprocessor's own channel — no location, ever.
match = s_dxc.Match( line );
if ( match.Success )
{
shape = CompilerOutputShape.DxcPreprocessor;
return Make( DiagnosticSeverity.Error, match.Groups["msg"].Value, null,
"Reported by the DXC preprocessor, which runs before the shader is type-checked. " +
"An unresolved #include is the usual cause." );
}
// 4. the native VFX block masker.
match = s_masker.Match( line );
if ( match.Success )
{
shape = CompilerOutputShape.BlockMasker;
var message = match.Groups["msg"].Value.Trim().TrimEnd( ':' );
var located = s_trailingLine.Match( message );
var span = located.Success
? SourceSpan.AtLine( defaultFile ?? string.Empty, ToInt( located.Groups["line"].Value, 1 ) )
: (SourceSpan?)null;
if ( located.Success ) message = message.Remove( located.Index, located.Length ).Trim();
return Make( DiagnosticSeverity.Error,
message.Length == 0 ? "The shader's block structure could not be parsed" : message, span,
"The VFX block masker is whitespace sensitive: block braces must sit at the start of a " +
"line and every declaration must be inside a block." );
}
match = s_equalSign.Match( line );
if ( match.Success )
{
shape = CompilerOutputShape.BlockMasker;
return Make( DiagnosticSeverity.Error,
$"Expected '=' after '{match.Groups["token"].Value}'",
SourceSpan.AtLine( defaultFile ?? string.Empty, ToInt( match.Groups["line"].Value, 1 ) ),
"Reported by the native VFX block masker while splitting the file into programs." );
}
// 5. binding collision.
match = s_resources.Match( line );
if ( match.Success )
{
shape = CompilerOutputShape.ResourceCollision;
return Make( DiagnosticSeverity.Error,
$"'{match.Groups["a"].Value}' and '{match.Groups["b"].Value}' were assigned the same binding",
null,
"The shader declares more resources than the descriptor layout has room for. Reduce the " +
"number of textures, samplers or buffers the graph uses." );
}
// 6. native validation that aborts the compile.
foreach ( var marker in s_fatalMarkers )
{
if ( line.IndexOf( marker, StringComparison.OrdinalIgnoreCase ) < 0 ) continue;
shape = CompilerOutputShape.NativeFatal;
var located = s_trailingLine.Match( line );
return Make( DiagnosticSeverity.Error, line,
located.Success
? SourceSpan.AtLine( defaultFile ?? string.Empty, ToInt( located.Groups["line"].Value, 1 ) )
: (SourceSpan?)null,
"Reported by the engine's native shader front end before any HLSL was compiled." );
}
// 7. SPIRV-Tools.
match = s_spirv.Match( line );
if ( match.Success )
{
shape = CompilerOutputShape.SpirvTool;
var lineNumber = match.Groups["line"].Success ? ToInt( match.Groups["line"].Value, 0 ) : 0;
return Make( SeverityOf( match.Groups["sev"].Value ), match.Groups["msg"].Value,
lineNumber > 0 ? SourceSpan.AtLine( defaultFile ?? string.Empty, lineNumber ) : (SourceSpan?)null,
"Reported while assembling or validating SPIR-V." );
}
// 8. catch-all. Never drop a message.
shape = CompilerOutputShape.Unrecognised;
var severity = line.Contains( "error", StringComparison.OrdinalIgnoreCase )
? DiagnosticSeverity.Error
: line.Contains( "warn", StringComparison.OrdinalIgnoreCase )
? DiagnosticSeverity.Warning
: DiagnosticSeverity.Info;
var trailing = s_trailingLine.Match( line );
return Make( severity, line,
trailing.Success
? SourceSpan.AtLine( defaultFile ?? string.Empty, ToInt( trailing.Groups["line"].Value, 1 ) )
: (SourceSpan?)null,
null );
}
static Diagnostic Make( DiagnosticSeverity severity, string message, SourceSpan? span, string detail )
{
var text = ( message ?? string.Empty ).Trim();
if ( text.Length == 0 ) text = "The shader compiler reported a problem without a message";
return new Diagnostic( severity, DiagnosticCode.CompilerRaw, text, detail, span, null );
}
static string File( Match match, string fallback )
{
var file = match.Groups["file"].Value.Trim();
if ( file.Length == 0 ) return fallback ?? string.Empty;
return file.Replace( '\\', '/' );
}
static string CodeDetail( string code ) =>
string.IsNullOrWhiteSpace( code ) ? null : $"Compiler diagnostic {code.Trim()}.";
static int ToInt( string text, int fallback ) =>
int.TryParse( text, out var value ) && value > 0 ? value : fallback;
/// <summary>Map a compiler's severity word onto ours. Anything unknown is informational.</summary>
public static DiagnosticSeverity SeverityOf( string token )
{
if ( string.IsNullOrWhiteSpace( token ) ) return DiagnosticSeverity.Info;
var text = token.Trim().ToLowerInvariant();
if ( text is "error" or "fatal" or "fatal error" or "internal error" ) return DiagnosticSeverity.Error;
if ( text is "warning" or "warn" ) return DiagnosticSeverity.Warning;
return DiagnosticSeverity.Info;
}
/// <summary>True when a line belongs to the diagnostic above it rather than starting a new one.</summary>
public static bool IsContinuation( string line )
{
if ( string.IsNullOrEmpty( line ) ) return false;
// Rich-diagnostic caret art and gutter rules.
var trimmed = line.TrimStart();
if ( trimmed.StartsWith( "^", StringComparison.Ordinal ) ) return true;
if ( trimmed.StartsWith( "|", StringComparison.Ordinal ) ) return true;
if ( trimmed.StartsWith( "-->", StringComparison.Ordinal ) ) return true;
if ( trimmed.StartsWith( "--'", StringComparison.Ordinal ) ) return true;
if ( trimmed.StartsWith( "note:", StringComparison.OrdinalIgnoreCase ) ) return true;
// An indented line that is not itself a located diagnostic.
if ( line[0] is not ( ' ' or '\t' ) ) return false;
return !s_fileLine.IsMatch( line ) && !s_fileLineColumn.IsMatch( line );
}
/// <summary>True when a line carries no information worth showing.</summary>
public static bool IsNoise( string line )
{
if ( string.IsNullOrWhiteSpace( line ) ) return true;
var trimmed = line.Trim();
foreach ( var phrase in s_noise )
{
if ( trimmed.Equals( phrase, StringComparison.OrdinalIgnoreCase ) ) return true;
}
return false;
}
// ---- synthesised diagnostics -----------------------------------------
/// <summary>
/// The diagnostic for the engine's worst failure mode: <c>Shader.LoadFromSource</c> returned false,
/// so <c>Results.Success</c> is false with an <em>empty</em> program list and the real message went
/// only to the native log. Detecting <c>Programs.Count == 0 && !Success</c> and saying so
/// out loud is the difference between "compile failed" and silence.
/// </summary>
public static Diagnostic BlockHeaderFailure( string file, string detail = null ) =>
new( DiagnosticSeverity.Error, DiagnosticCode.BlockHeaderParseFailure,
"The shader's block header failed to parse, so nothing was compiled",
detail ?? "The engine rejected the file before reaching any HLSL. Check the MODES, FEATURES " +
"and combo declarations: a feature must be named F_*, a static combo S_*, a dynamic combo " +
"D_*, feature value strings must be ordered 0=\"a\", 1=\"b\", and every block brace must " +
"start its own line. The underlying message is only written to the engine log.",
string.IsNullOrWhiteSpace( file ) ? null : SourceSpan.AtLine( file, 1 ), null );
/// <summary>
/// The diagnostic that explains the combo short-circuit. On the first failing combo the engine skips
/// every remaining combo <em>and every remaining program</em>, so a vertex error hides all the pixel
/// errors. Saying "PS not compiled — fix VS errors first" is honest; implying the pixel shader is
/// clean is not.
/// </summary>
public static Diagnostic ProgramSkipped( string program, string blockedBy )
{
var name = Pretty( program );
var blocker = Pretty( blockedBy );
return new Diagnostic( DiagnosticSeverity.Info, DiagnosticCode.ProgramSkipped,
string.IsNullOrWhiteSpace( blocker )
? $"{name} was not compiled, so it has not been checked yet"
: $"{name} was not compiled — fix the {blocker} errors first",
"The engine stops at the first failing combo and skips every program after it, so this " +
"stage's diagnostics are not available until the earlier one compiles cleanly.",
null, null );
}
/// <summary>Turn <c>VFX_PROGRAM_PS</c> into <c>PS</c> for a message a human reads.</summary>
public static string Pretty( string program )
{
if ( string.IsNullOrWhiteSpace( program ) ) return null;
var name = program.Trim();
const string prefix = "VFX_PROGRAM_";
if ( name.StartsWith( prefix, StringComparison.OrdinalIgnoreCase ) ) name = name[prefix.Length..];
return name.ToUpperInvariant();
}
}