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.
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 "<path>"</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";
}