Editor-side diagnostics definitions and a thread-safe diagnostic sink. Declares severity enum, SourceSpan and GraphRef value types, Diagnostic record with helpers, a registry of stable diagnostic codes, and DiagnosticSink which collects and scopes diagnostics.
namespace Editor.Prism.Core;
/// <summary>How loud a <see cref="Diagnostic"/> is.</summary>
public enum DiagnosticSeverity
{
/// <summary>Informational. Never gates anything.</summary>
Info,
/// <summary>Something is suspicious or lossy but the graph still compiles.</summary>
Warning,
/// <summary>The graph cannot produce a correct shader.</summary>
Error
}
/// <summary>
/// A location inside a text artifact — a generated <c>.shader</c>, a <c>.slang</c> module or a
/// hand-authored file open in the code editor. Lines and columns are 1-based, matching every
/// shader compiler in existence.
/// </summary>
public readonly record struct SourceSpan( string File, int Line, int Column, int EndLine, int EndColumn )
{
/// <summary>A whole line.</summary>
public static SourceSpan AtLine( string file, int line ) => new( file, line, 1, line, 1 );
/// <summary>A single point.</summary>
public static SourceSpan At( string file, int line, int column ) => new( file, line, column, line, column );
/// <summary>True when this span points somewhere.</summary>
public bool IsValid => Line > 0;
/// <summary>The same span shifted by <paramref name="delta"/> lines. Used when text is prepended.</summary>
public SourceSpan Shift( int delta ) => this with { Line = Line + delta, EndLine = EndLine + delta };
/// <inheritdoc/>
public override string ToString() => Column > 0 ? $"{File}({Line},{Column})" : $"{File}({Line})";
}
/// <summary>
/// A location inside the graph. Any combination may be set: a node-level problem sets only
/// <see cref="Node"/>, a bad connection sets <see cref="Edge"/>, a port-level problem sets both
/// <see cref="Node"/> and <see cref="Port"/>.
/// </summary>
public readonly record struct GraphRef( NodeId Node, PortId? Port, EdgeId? Edge )
{
/// <summary>Reference a whole node.</summary>
public static GraphRef ForNode( NodeId node ) => new( node, null, null );
/// <summary>Reference one port of a node.</summary>
public static GraphRef ForPort( NodeId node, PortId port ) => new( node, port, null );
/// <summary>Reference a connection.</summary>
public static GraphRef ForEdge( EdgeId edge ) => new( NodeId.None, null, edge );
/// <summary>True when this reference points at something.</summary>
public bool IsValid => Node.IsValid || Edge.HasValue;
/// <inheritdoc/>
public override string ToString()
{
if ( Edge.HasValue ) return $"edge {Edge.Value}";
if ( Port.HasValue ) return $"{Node}.{Port.Value}";
return Node.ToString();
}
}
/// <summary>
/// One problem, from any tier: our own static validation, the real shader compiler, or slangc.
/// <para>
/// <see cref="Span"/> points into generated or authored text; <see cref="Graph"/> points at the node
/// that produced it. A compiler error arrives with only a span and is upgraded to a graph reference
/// by inverting the <see cref="SourceMap"/> — that two-hop mapping is what makes "click the error,
/// select the node" work.
/// </para>
/// </summary>
public sealed record Diagnostic(
DiagnosticSeverity Severity,
string Code,
string Message,
string Detail,
SourceSpan? Span,
GraphRef? Graph )
{
/// <summary>Build an error.</summary>
public static Diagnostic Error( string code, string message, GraphRef? graph = null, string detail = null ) =>
new( DiagnosticSeverity.Error, code, message, detail, null, graph );
/// <summary>Build a warning.</summary>
public static Diagnostic Warning( string code, string message, GraphRef? graph = null, string detail = null ) =>
new( DiagnosticSeverity.Warning, code, message, detail, null, graph );
/// <summary>Build an informational note.</summary>
public static Diagnostic Info( string code, string message, GraphRef? graph = null, string detail = null ) =>
new( DiagnosticSeverity.Info, code, message, detail, null, graph );
/// <summary>Build a diagnostic that points at a location in text.</summary>
public static Diagnostic AtSpan( DiagnosticSeverity severity, string code, string message, SourceSpan span,
string detail = null ) => new( severity, code, message, detail, span, null );
/// <summary>The same diagnostic with a graph reference attached.</summary>
public Diagnostic WithGraph( GraphRef graph ) => this with { Graph = graph };
/// <summary>The same diagnostic with a source span attached.</summary>
public Diagnostic WithSpan( SourceSpan span ) => this with { Span = span };
/// <inheritdoc/>
public override string ToString()
{
var where = Span.HasValue ? $" {Span.Value}" : Graph.HasValue ? $" [{Graph.Value}]" : string.Empty;
return $"{Severity} {Code}:{where} {Message}";
}
}
/// <summary>
/// Stable diagnostic codes. These appear in the UI and in bug reports, so they never change meaning.
/// <c>PR0xxx</c> graph/model, <c>PR1xxx</c> types, <c>PR2xxx</c> stages and capabilities,
/// <c>PR3xxx</c> emission, <c>PR4xxx</c> external compilers, <c>PR5xxx</c> serialization,
/// <c>PR6xxx</c> the text editor's own analysis.
/// <para>
/// New codes are appended and never renumbered. <see cref="All"/> is the complete set, and the
/// serializer self-test asserts that it holds no duplicate and no malformed code — which is what
/// makes "a code never changes meaning" a checked property rather than a promise.
/// </para>
/// </summary>
public static class DiagnosticCode
{
// -- graph / model
/// <summary>The graph contains a cycle.</summary>
public const string Cycle = "PR0001";
/// <summary>A required input has nothing connected and no inline value.</summary>
public const string MissingInput = "PR0002";
/// <summary>An edge references a node or port that does not exist.</summary>
public const string DanglingEdge = "PR0003";
/// <summary>The graph has no active output node.</summary>
public const string NoOutput = "PR0004";
/// <summary>A node type could not be resolved and was preserved verbatim.</summary>
public const string UnknownNodeType = "PR0005";
/// <summary>A parameter or keyword reference could not be resolved.</summary>
public const string UnresolvedParameter = "PR0006";
/// <summary>Two node types declare the same stable id.</summary>
public const string DuplicateNodeId = "PR0007";
/// <summary>A subgraph instance could not be loaded or expanded.</summary>
public const string SubgraphUnavailable = "PR0008";
// -- types
/// <summary>Two ports cannot be connected at all.</summary>
public const string IllegalConversion = "PR1001";
/// <summary>A connection loses components.</summary>
public const string LossyConversion = "PR1002";
/// <summary>A connection invents components.</summary>
public const string PaddedConversion = "PR1003";
/// <summary>Type inference could not assign a concrete type to a port.</summary>
public const string UnresolvedType = "PR1004";
/// <summary>Unification failed between two ports that must share a type.</summary>
public const string UnificationFailure = "PR1005";
// -- stages / capabilities
/// <summary>An operation was requested in a stage that cannot perform it.</summary>
public const string StageViolation = "PR2001";
/// <summary>An implicit-LOD sample outside the pixel stage was lowered to SampleLevel.</summary>
public const string SampleLowered = "PR2002";
/// <summary>The requested feature needs a higher shader model than the target.</summary>
public const string ShaderModelTooHigh = "PR2003";
/// <summary>The selected backend cannot express something the graph uses.</summary>
public const string BackendUnsupported = "PR2004";
/// <summary>Interpolator, sampler or combo budget exceeded.</summary>
public const string BudgetExceeded = "PR2005";
// -- emission
/// <summary>A node threw while emitting; it was quarantined and the compile continued.</summary>
public const string NodeEmitFailed = "PR3001";
/// <summary>Two helper functions share a name but not a body.</summary>
public const string HelperCollision = "PR3002";
/// <summary>A global declaration collided with a different declaration of the same name.</summary>
public const string GlobalCollision = "PR3003";
/// <summary>Generated code failed our own pre-flight VFX block validation.</summary>
public const string InvalidBlock = "PR3004";
// -- external compilers
/// <summary>Raw output from the s&box shader compiler that we could not classify.</summary>
public const string CompilerRaw = "PR4001";
/// <summary>The shader block header failed to parse; no per-program diagnostics exist.</summary>
public const string BlockHeaderParseFailure = "PR4002";
/// <summary>A later program was skipped because an earlier one failed.</summary>
public const string ProgramSkipped = "PR4003";
/// <summary>slangc reported a problem.</summary>
public const string SlangDiagnostic = "PR4004";
/// <summary>The Slang toolchain is absent, so Slang output was not validated.</summary>
public const string SlangUnavailable = "PR4005";
// -- serialization
/// <summary>A node's properties failed to deserialize; it was preserved as an unknown node.</summary>
public const string NodeReadFailed = "PR5001";
/// <summary>An edge failed to deserialize and became a broken edge.</summary>
public const string EdgeReadFailed = "PR5002";
/// <summary>A document section failed to deserialize and fell back to defaults.</summary>
public const string SectionReadFailed = "PR5003";
/// <summary>A document or node migration ran.</summary>
public const string Migrated = "PR5004";
/// <summary>The document round-trip integrity check failed; the original file was not replaced.</summary>
public const string RoundTripFailed = "PR5005";
// -- text editor analysis
/// <summary>A call to something nothing in scope declares.</summary>
public const string UnknownIdentifier = "PR6001";
/// <summary>A Direct3D 9 sampler intrinsic DXC removed.</summary>
public const string DeprecatedIntrinsic = "PR6002";
/// <summary>Braces, parentheses or brackets do not balance.</summary>
public const string Unbalanced = "PR6003";
/// <summary>A string literal or block comment is never closed.</summary>
public const string Unterminated = "PR6004";
/// <summary>A <c>#</c> directive the preprocessor does not know.</summary>
public const string UnknownDirective = "PR6005";
/// <summary>An <c>#include</c> that resolves to no file on any search path.</summary>
public const string MissingInclude = "PR6006";
/// <summary>An <c>#include <…></c>, which the engine's preprocessor never expands.</summary>
public const string AngleBracketInclude = "PR6007";
/// <summary>An <c>#include</c> whose spacing the engine's regex does not match.</summary>
public const string IncludeSpacing = "PR6008";
/// <summary>A VFX block the engine's <c>.shader</c> parser throws on.</summary>
public const string RejectedBlock = "PR6009";
/// <summary>A declaration that shadows an engine global.</summary>
public const string ShadowedGlobal = "PR6010";
/// <summary>The Slang toolchain is absent, so a <c>.slang</c> buffer only gets local checks.</summary>
public const string SlangNotValidated = "PR6011";
/// <summary>Every code this build declares, in declaration order.</summary>
public static IReadOnlyList<string> All => s_all ??= Collect();
/// <summary>True when a code is one this build declares.</summary>
public static bool IsKnown( string code ) =>
!string.IsNullOrEmpty( code ) && All.Contains( code, StringComparer.Ordinal );
/// <summary>
/// The tier a code belongs to, derived from its number: graph, type, stage, emission, compiler,
/// serialization or text. Used to group the diagnostics list.
/// </summary>
public static string TierOf( string code )
{
if ( string.IsNullOrEmpty( code ) || code.Length < 3 ) return "Other";
return code[2] switch
{
'0' => "Graph",
'1' => "Types",
'2' => "Stages",
'3' => "Emission",
'4' => "Compiler",
'5' => "Document",
'6' => "Text",
_ => "Other"
};
}
static IReadOnlyList<string> s_all;
static IReadOnlyList<string> Collect() =>
typeof( DiagnosticCode )
.GetFields( System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static )
.Where( x => x.IsLiteral && x.FieldType == typeof( string ) )
.Select( x => (string)x.GetRawConstantValue() )
.ToArray();
}
/// <summary>
/// Collects diagnostics. Every pipeline stage takes one of these; nothing in Prism throws to report
/// a user-facing problem. Thread-safe, because backends and validators may run off the main thread.
/// </summary>
public class DiagnosticSink
{
readonly List<Diagnostic> _items = new();
readonly object _lock = new();
/// <summary>Everything reported so far, in report order.</summary>
public virtual IReadOnlyList<Diagnostic> All
{
get
{
lock ( _lock ) return _items.ToArray();
}
}
/// <summary>Number of diagnostics reported.</summary>
public virtual int Count
{
get
{
lock ( _lock ) return _items.Count;
}
}
/// <summary>True when at least one error was reported.</summary>
public virtual bool HasErrors
{
get
{
lock ( _lock ) return _items.Any( x => x.Severity == DiagnosticSeverity.Error );
}
}
/// <summary>Report a diagnostic. Null is ignored.</summary>
public virtual void Report( Diagnostic diagnostic )
{
if ( diagnostic is null ) return;
lock ( _lock ) _items.Add( diagnostic );
}
/// <summary>Report a batch of diagnostics.</summary>
public void ReportRange( IEnumerable<Diagnostic> diagnostics )
{
if ( diagnostics is null ) return;
foreach ( var d in diagnostics ) Report( d );
}
/// <summary>Report an error.</summary>
public void Error( string code, string message, GraphRef? graph = null, string detail = null ) =>
Report( Diagnostic.Error( code, message, graph, detail ) );
/// <summary>Report a warning.</summary>
public void Warn( string code, string message, GraphRef? graph = null, string detail = null ) =>
Report( Diagnostic.Warning( code, message, graph, detail ) );
/// <summary>Report an informational note.</summary>
public void Info( string code, string message, GraphRef? graph = null, string detail = null ) =>
Report( Diagnostic.Info( code, message, graph, detail ) );
/// <summary>Drop everything reported so far.</summary>
public virtual void Clear()
{
lock ( _lock ) _items.Clear();
}
/// <summary>
/// A view over this sink that stamps <paramref name="graph"/> onto every diagnostic that does not
/// already carry one. Node emission uses this so nodes never have to name themselves.
/// <para>
/// The view is a pass-through, not a buffer: reads (<see cref="All"/>, <see cref="Count"/>,
/// <see cref="HasErrors"/>) and <see cref="Clear"/> all go to the underlying sink, so a node asking
/// <c>ctx.Diagnostics.HasErrors</c> sees the whole compile's state.
/// </para>
/// </summary>
public DiagnosticSink Scoped( GraphRef graph ) => new ScopedSink( this, graph );
sealed class ScopedSink( DiagnosticSink inner, GraphRef graph ) : DiagnosticSink
{
public override IReadOnlyList<Diagnostic> All => inner.All;
public override int Count => inner.Count;
public override bool HasErrors => inner.HasErrors;
public override void Report( Diagnostic diagnostic )
{
if ( diagnostic is null ) return;
inner.Report( diagnostic.Graph.HasValue ? diagnostic : diagnostic.WithGraph( graph ) );
}
public override void Clear() => inner.Clear();
}
}