Editor/Prism/Toolchain/LineDirectiveMap.cs

A utility that parses #line directives in preprocessed HLSL shader text and maps preprocessed line numbers back to authored file names and lines. It builds a directive list and include stacks, can calibrate by matching generated content to authored source to create anchors, and remaps diagnostics and spans to authored locations and graph nodes.

ReflectionFile Access
using Editor.Prism.Core;
using System.Text.RegularExpressions;

namespace Editor.Prism.Toolchain;

/// <summary>One <c>#line N "file"</c> directive found in a preprocessed shader.</summary>
/// <param name="At">1-based line number the directive itself occupies in the preprocessed text.</param>
/// <param name="SourceLine">Line number the directive assigns to the line that follows it.</param>
/// <param name="File">File the following lines belong to.</param>
public readonly record struct LineDirective( int At, int SourceLine, string File )
{
	/// <inheritdoc/>
	public override string ToString() => $"#line {SourceLine} \"{File}\" @ {At}";
}

/// <summary>
/// Maps a line of preprocessed shader text back to the file and line a human wrote — hop one of the
/// two-hop mapping that turns a compiler error into a selected node.
/// <para>
/// The engine's managed preprocessor prepends <c>#line 1 "&lt;path&gt;"</c> and then
/// <c>#include "system.fxc"</c> to every file, which already shifts every subsequent line by one, and
/// it splices project-local includes inline while <em>never restoring the parent file's
/// <c>#line</c></em> afterwards. So a naive reading of the directives is right until the first inlined
/// include and quietly wrong after it — precisely the case where a user has an <c>.hlsl</c> of their
/// own beside the graph.
/// </para>
/// <para>
/// Two mechanisms defend against that. The directive scan reconstructs the include stack itself, so a
/// diagnostic can at least say which file it came from and what included it. And
/// <see cref="Calibrate"/> aligns the preprocessed text against the authored text by content, building
/// exact anchors for the file we generated. After calibration the mapping does not depend on the
/// directives at all, which makes it immune to both the injected include and the missing restores.
/// </para>
/// </summary>
public sealed class LineDirectiveMap
{
	/// <summary>Minimum length of a line before it is trusted as a calibration anchor.</summary>
	const int MinAnchorLength = 6;

	static readonly Regex s_directive = new(
		@"^\s*#\s*line\s+(?<line>\d+)(?:\s+""(?<file>[^""]*)"")?\s*$",
		RegexOptions.Compiled | RegexOptions.CultureInvariant );

	readonly List<LineDirective> _directives = new();
	readonly List<string[]> _stacks = new();
	readonly List<(int Preprocessed, int Authored)> _anchors = new();

	string[] _lines = Array.Empty<string>();
	int _authoredLineCount;

	LineDirectiveMap()
	{
	}

	/// <summary>A map over nothing. Every resolution passes through unchanged.</summary>
	public static readonly LineDirectiveMap Empty = new();

	/// <summary>The file the preprocessed text starts in — the one our source map covers.</summary>
	public string RootFile { get; private set; }

	/// <summary>Number of lines in the preprocessed text.</summary>
	public int LineCount => _lines.Length;

	/// <summary>Every <c>#line</c> directive found, in order.</summary>
	public IReadOnlyList<LineDirective> Directives => _directives;

	/// <summary>How many content anchors <see cref="Calibrate"/> established.</summary>
	public int AnchorCount => _anchors.Count;

	/// <summary>True once the map has been aligned against the authored text.</summary>
	public bool IsCalibrated => _anchors.Count > 0;

	/// <summary>
	/// Scan a preprocessed shader for its <c>#line</c> directives. Never throws and never returns null;
	/// text with no directives at all produces an identity map.
	/// </summary>
	public static LineDirectiveMap Build( string preprocessedSource, string rootFileHint = null )
	{
		var map = new LineDirectiveMap { RootFile = Normalize( rootFileHint ) };

		if ( string.IsNullOrEmpty( preprocessedSource ) ) return map;

		return PrismLog.Guard( "Scanning #line directives", () =>
		{
			map._lines = preprocessedSource.Replace( "\r\n", "\n" ).Split( '\n' );

			var stack = new List<string>();

			for ( int i = 0; i < map._lines.Length; i++ )
			{
				var match = s_directive.Match( map._lines[i] );

				if ( !match.Success ) continue;

				var file = match.Groups["file"].Success ? Normalize( match.Groups["file"].Value ) : null;
				var sourceLine = int.TryParse( match.Groups["line"].Value, out var parsed ) ? parsed : 1;

				file ??= stack.Count > 0 ? stack[^1] : map.RootFile;

				map.RootFile ??= file;

				// The engine only ever emits a directive on entering a file, never on leaving one, so a
				// directive naming a file already on the stack means we have come back up to it.
				var existing = stack.LastIndexOf( file );

				if ( existing >= 0 )
				{
					stack.RemoveRange( existing + 1, stack.Count - existing - 1 );
				}
				else
				{
					stack.Add( file );
				}

				map._directives.Add( new LineDirective( i + 1, sourceLine, file ) );
				map._stacks.Add( stack.ToArray() );
			}

			return map;
		}, map );
	}

	/// <summary>
	/// Align this map against the text we actually generated, by matching line content.
	/// <para>
	/// Only lines that are long enough to be distinctive and that occur exactly once in the authored
	/// text become anchors, and anchors must advance monotonically, so a repeated block cannot drag the
	/// mapping backwards. Between two anchors the mapping is a straight offset. The result is exact for
	/// the generated file regardless of what the preprocessor did to the line numbering.
	/// </para>
	/// <para>Returns this instance so it can be chained onto <see cref="Build"/>.</para>
	/// </summary>
	public LineDirectiveMap Calibrate( string authoredSource )
	{
		if ( this == Empty || string.IsNullOrEmpty( authoredSource ) || _lines.Length == 0 ) return this;

		PrismLog.Guard( "Calibrating the line map", () =>
		{
			_anchors.Clear();

			var authored = authoredSource.Replace( "\r\n", "\n" ).Split( '\n' );

			_authoredLineCount = authored.Length;

			var index = new Dictionary<string, int>( authored.Length, StringComparer.Ordinal );

			for ( int i = 0; i < authored.Length; i++ )
			{
				var key = authored[i].Trim();

				if ( !IsDistinctive( key ) ) continue;

				// A duplicate makes the content useless as an anchor: mark it and never trust it again.
				index[key] = index.ContainsKey( key ) ? -1 : i + 1;
			}

			var lastAuthored = 0;

			for ( int i = 0; i < _lines.Length; i++ )
			{
				var key = _lines[i].Trim();

				if ( !IsDistinctive( key ) ) continue;
				if ( !index.TryGetValue( key, out var authoredLine ) || authoredLine <= lastAuthored ) continue;

				_anchors.Add( (i + 1, authoredLine) );
				lastAuthored = authoredLine;
			}
		} );

		return this;
	}

	/// <summary>
	/// Resolve a preprocessed line to the file and line it came from. Returns false when there is
	/// nothing to resolve against, in which case the caller should keep the line it already had.
	/// </summary>
	public bool TryResolve( int preprocessedLine, out string file, out int line )
	{
		file = RootFile;
		line = preprocessedLine;

		if ( this == Empty || preprocessedLine <= 0 ) return false;

		var directive = FindDirective( preprocessedLine );

		// Anchors beat directives wherever they reach, because they were established by matching the
		// text we generated against the text the compiler saw. That is the whole point: the directives
		// are the thing that is wrong.
		if ( TryFromAnchors( preprocessedLine, directive < 0, out var authored ) )
		{
			file = RootFile;
			line = authored;

			return true;
		}

		if ( directive >= 0 )
		{
			var entry = _directives[directive];

			file = entry.File;
			line = entry.SourceLine + ( preprocessedLine - entry.At - 1 );
		}

		if ( line < 1 ) line = 1;

		return true;
	}

	/// <summary>
	/// The inverse: given a file and line as the <em>compiler</em> reported them, find the line of
	/// preprocessed text that produced it.
	/// <para>
	/// This hop is necessary because the compiler has already applied the <c>#line</c> directives by the
	/// time it prints a diagnostic, so what comes back is the engine preprocessor's idea of the
	/// location, missing restores and all. Undoing that is the only way to get somewhere the content
	/// anchors can correct.
	/// </para>
	/// <para>
	/// Returns false for a file that never appears in a directive — a core engine header, which is left
	/// unexpanded and whose diagnostics are already correct and none of our business.
	/// </para>
	/// </summary>
	public bool TryFindPreprocessed( string file, int reportedLine, out int preprocessedLine )
	{
		preprocessedLine = reportedLine;

		if ( this == Empty || reportedLine <= 0 ) return false;

		if ( _directives.Count == 0 ) return IsRootFile( file );

		var extrapolated = -1;

		for ( int i = 0; i < _directives.Count; i++ )
		{
			var entry = _directives[i];

			if ( !SameFile( entry.File, file ) && !( string.IsNullOrWhiteSpace( file ) && IsRootFile( entry.File ) ) )
			{
				continue;
			}

			var candidate = entry.At + ( reportedLine - entry.SourceLine ) + 1;

			if ( candidate <= entry.At ) continue;

			var end = i + 1 < _directives.Count ? _directives[i + 1].At - 1 : Math.Max( _lines.Length, entry.At );

			if ( candidate <= end )
			{
				preprocessedLine = candidate;

				return true;
			}

			// Past the end of this segment. The engine never emits a restoring directive, so a line
			// number that overruns its segment is exactly the misattribution we are here to undo.
			if ( candidate <= _lines.Length ) extrapolated = candidate;
		}

		if ( extrapolated <= 0 ) return false;

		preprocessedLine = extrapolated;

		return true;
	}

	/// <summary>
	/// The chain of files enclosing a preprocessed line, outermost first. Empty when the line is in the
	/// root file. This is what a diagnostic's detail says when the error is inside an include.
	/// </summary>
	public IReadOnlyList<string> IncludeStack( int preprocessedLine )
	{
		var directive = FindDirective( preprocessedLine );

		if ( directive < 0 || directive >= _stacks.Count ) return Array.Empty<string>();

		var stack = _stacks[directive];

		return stack.Length <= 1 ? Array.Empty<string>() : stack;
	}

	/// <summary>True when a file name refers to the text we generated.</summary>
	public bool IsRootFile( string file )
	{
		if ( string.IsNullOrWhiteSpace( RootFile ) ) return string.IsNullOrWhiteSpace( file );
		if ( string.IsNullOrWhiteSpace( file ) ) return true;

		var a = Normalize( RootFile );
		var b = Normalize( file );

		if ( string.Equals( a, b, StringComparison.OrdinalIgnoreCase ) ) return true;

		// Diagnostics vary between absolute, project-relative and bare names for the same file.
		return string.Equals( System.IO.Path.GetFileName( a ), System.IO.Path.GetFileName( b ),
			StringComparison.OrdinalIgnoreCase );
	}

	/// <summary>The raw preprocessed text of a line, or null when out of range.</summary>
	public string PreprocessedLine( int line ) =>
		line >= 1 && line <= _lines.Length ? _lines[line - 1] : null;

	// ---- diagnostics -----------------------------------------------------

	/// <summary>
	/// Rewrite a span the compiler reported so it points at the text a human wrote: undo the
	/// preprocessor's line attribution, then correct it with the content anchors. A span in a file we
	/// never expanded — an engine header — is already correct and comes back untouched.
	/// </summary>
	public SourceSpan Remap( SourceSpan span )
	{
		if ( !TryFindPreprocessed( span.File, span.Line, out var preprocessed ) ) return span;
		if ( !TryResolve( preprocessed, out var file, out var line ) ) return span;

		var endLine = line;

		if ( span.EndLine > span.Line && TryFindPreprocessed( span.File, span.EndLine, out var preprocessedEnd ) &&
			TryResolve( preprocessedEnd, out _, out var mappedEnd ) )
		{
			endLine = mappedEnd;
		}

		return new SourceSpan( file, line, span.Column, Math.Max( endLine, line ), span.EndColumn );
	}

	/// <summary>Rewrite a diagnostic's span, and note the include chain in its detail when there is one.</summary>
	public Diagnostic Remap( Diagnostic diagnostic )
	{
		if ( diagnostic?.Span is not { } span ) return diagnostic;

		var mapped = Remap( span );

		if ( mapped == span ) return diagnostic;

		var result = diagnostic.WithSpan( mapped );

		// An error genuinely inside an inlined include should say so; one the preprocessor merely
		// mislabelled has already been corrected back to the root file and needs no note.
		if ( !IsRootFile( mapped.File ) && TryFindPreprocessed( span.File, span.Line, out var preprocessed ) )
		{
			var stack = IncludeStack( preprocessed );

			if ( stack.Count > 1 )
			{
				var chain = "In " + string.Join( ", included from ", stack.Reverse() ) + ".";

				result = result with
				{
					Detail = string.IsNullOrWhiteSpace( result.Detail ) ? chain : result.Detail + "\n" + chain
				};
			}
		}

		return result;
	}

	/// <summary>
	/// The full two-hop mapping: preprocessed line to authored line, then authored line to the node that
	/// produced it. A diagnostic that lands in an engine header keeps its span and gains no graph
	/// reference, which is correct — that error is not any node's fault.
	/// </summary>
	public Diagnostic Remap( Diagnostic diagnostic, SourceMap sourceMap, string generatedFile = null,
		int nearestDistance = 8 )
	{
		if ( diagnostic is null ) return null;

		var mapped = Remap( diagnostic );

		if ( sourceMap is null || mapped.Graph is not null || mapped.Span is not { } span ) return mapped;

		var belongs = IsRootFile( span.File ) ||
			( !string.IsNullOrWhiteSpace( generatedFile ) && SameFile( span.File, generatedFile ) );

		if ( !belongs ) return mapped;

		return Attach( mapped, sourceMap, nearestDistance );
	}

	/// <summary>Map every diagnostic in a batch. Null entries are dropped.</summary>
	public IReadOnlyList<Diagnostic> RemapAll( IEnumerable<Diagnostic> diagnostics, SourceMap sourceMap,
		string generatedFile = null, int nearestDistance = 8 )
	{
		var results = new List<Diagnostic>();

		if ( diagnostics is null ) return results;

		foreach ( var diagnostic in diagnostics )
		{
			if ( diagnostic is null ) continue;

			results.Add( Remap( diagnostic, sourceMap, generatedFile, nearestDistance ) );
		}

		return results;
	}

	/// <summary>
	/// Hop two on its own: given a diagnostic whose span already points at generated text, look the line
	/// up in the source map and stamp the owning node and port onto it.
	/// </summary>
	public static Diagnostic Attach( Diagnostic diagnostic, SourceMap sourceMap, int nearestDistance = 8 )
	{
		if ( diagnostic is null || sourceMap is null ) return diagnostic;
		if ( diagnostic.Graph is not null ) return diagnostic;
		if ( diagnostic.Span is not { } span || span.Line <= 0 ) return diagnostic;

		if ( sourceMap.TryGetEntry( span.Line, out var entry ) )
		{
			return diagnostic.WithGraph( entry.Port is { } port
				? GraphRef.ForPort( entry.Node, port )
				: GraphRef.ForNode( entry.Node ) );
		}

		if ( nearestDistance > 0 && sourceMap.TryGetNearestNode( span.Line, out var node, nearestDistance ) )
		{
			return diagnostic.WithGraph( GraphRef.ForNode( node ) );
		}

		return diagnostic;
	}

	// ---- internals -------------------------------------------------------

	/// <summary>Index of the last directive at or before a line, or -1 when there is none.</summary>
	int FindDirective( int preprocessedLine )
	{
		if ( _directives.Count == 0 ) return -1;

		var low = 0;
		var high = _directives.Count - 1;
		var found = -1;

		while ( low <= high )
		{
			var mid = ( low + high ) / 2;

			if ( _directives[mid].At < preprocessedLine )
			{
				found = mid;
				low = mid + 1;
			}
			else
			{
				high = mid - 1;
			}
		}

		return found;
	}

	/// <summary>
	/// Resolve an authored line from the calibration anchors, but only where they can be trusted.
	/// <para>
	/// An anchor that matched the line itself is exact. Between two anchors the mapping is only used
	/// when the gap is the same length on both sides — an unequal gap means text was spliced in
	/// between, so the lines inside it are not ours to claim and the directives get the last word.
	/// Extrapolating past the outermost anchors is allowed only when no directive covers the line, so
	/// the anchors never quietly overrule something the compiler was explicit about.
	/// </para>
	/// </summary>
	bool TryFromAnchors( int preprocessedLine, bool allowExtrapolation, out int authored )
	{
		authored = 0;

		if ( _anchors.Count == 0 ) return false;

		var low = 0;
		var high = _anchors.Count - 1;
		var found = -1;

		while ( low <= high )
		{
			var mid = ( low + high ) / 2;

			if ( _anchors[mid].Preprocessed <= preprocessedLine )
			{
				found = mid;
				low = mid + 1;
			}
			else
			{
				high = mid - 1;
			}
		}

		if ( found >= 0 && _anchors[found].Preprocessed == preprocessedLine )
		{
			authored = _anchors[found].Authored;

			return true;
		}

		if ( found >= 0 && found + 1 < _anchors.Count )
		{
			var before = _anchors[found];
			var after = _anchors[found + 1];

			if ( after.Preprocessed - before.Preprocessed == after.Authored - before.Authored )
			{
				authored = Clamp( before.Authored + ( preprocessedLine - before.Preprocessed ) );

				return true;
			}

			return false;
		}

		if ( !allowExtrapolation ) return false;

		var anchor = found >= 0 ? _anchors[found] : _anchors[0];

		authored = Clamp( anchor.Authored + ( preprocessedLine - anchor.Preprocessed ) );

		return true;
	}

	int Clamp( int line )
	{
		if ( line < 1 ) return 1;

		return _authoredLineCount > 0 && line > _authoredLineCount ? _authoredLineCount : line;
	}

	/// <summary>A line worth trusting as an anchor: long enough, and not just structure.</summary>
	static bool IsDistinctive( string trimmed )
	{
		if ( trimmed.Length < MinAnchorLength ) return false;

		foreach ( var c in trimmed )
		{
			if ( char.IsLetterOrDigit( c ) ) return true;
		}

		return false;
	}

	static bool SameFile( string a, string b )
	{
		if ( string.IsNullOrWhiteSpace( a ) || string.IsNullOrWhiteSpace( b ) ) return false;

		var left = Normalize( a );
		var right = Normalize( b );

		return string.Equals( left, right, StringComparison.OrdinalIgnoreCase ) ||
			string.Equals( System.IO.Path.GetFileName( left ), System.IO.Path.GetFileName( right ),
				StringComparison.OrdinalIgnoreCase );
	}

	static string Normalize( string path ) =>
		string.IsNullOrWhiteSpace( path ) ? null : path.Trim().Replace( '\\', '/' );

	/// <inheritdoc/>
	public override string ToString() =>
		$"{RootFile ?? "<unknown>"}: {_directives.Count} directives, {_anchors.Count} anchors";
}