Editor/Prism/Core/Diagnostics.cs

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.

ReflectionFile Access
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&amp;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 &lt;…&gt;</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();
	}
}