Editor/Prism/Text/IncludeResolver.cs

Editor utility that parses, resolves and validates #include directives in shader source text. It scans documents for includes, suggests completions from search roots, resolves include paths against engine and project shader folders, caches include file contents, and produces diagnostics for missing or malformed includes.

File Access
using Editor.Prism.Core;
using Editor.Prism.Text.Diagnostics;
using Editor.Prism.Text.LanguageDb;
using System.IO;
using System.Text.RegularExpressions;
using PrismDiagnostic = Editor.Prism.Core.Diagnostic;

namespace Editor.Prism.Text;

/// <summary>What happened when an <c>#include</c> was resolved against the engine's search paths.</summary>
public enum IncludeStatus
{
	/// <summary>A real file was found on disk.</summary>
	Resolved,

	/// <summary>One of the fourteen includes that live inside the compiler DLLs. Known-good, unresolvable.</summary>
	Virtual,

	/// <summary>Nothing matched in any search root.</summary>
	Missing,

	/// <summary>Written with angle brackets, which the engine's preprocessor never matches.</summary>
	AngleBracket,

	/// <summary>The directive was there but the path could not be read out of it.</summary>
	Malformed
}

/// <summary>
/// One <c>#include</c> directive found in a buffer, with the exact character range of its path so the
/// editor can underline it, complete inside it and jump from it.
/// </summary>
/// <param name="Line">Zero-based line the directive is on.</param>
/// <param name="Start">Zero-based column of the first character of the path, inside the quotes.</param>
/// <param name="End">Zero-based column one past the last character of the path.</param>
/// <param name="Path">The path exactly as written.</param>
/// <param name="AngleBracket">True when the user wrote <c>&lt;…&gt;</c> instead of <c>"…"</c>.</param>
public sealed record IncludeReference( int Line, int Start, int End, string Path, bool AngleBracket )
{
	/// <summary>
	/// True when the separator between <c>#include</c> and the path is not exactly one space. The
	/// engine's preprocessor matches <c>#include "…"</c> with a single space and silently ignores
	/// anything else, so this is a real failure dressed as a formatting nit.
	/// </summary>
	public bool BadSpacing { get; init; }

	/// <summary>The range covering the quotes as well as the path, for a squiggle.</summary>
	public TextRange QuotedRange => new(
		new TextPosition( Line, Math.Max( 0, Start - 1 ) ),
		new TextPosition( Line, End + 1 ) );

	/// <summary>The range covering just the path, which is what completion replaces.</summary>
	public TextRange PathRange => new( new TextPosition( Line, Start ), new TextPosition( Line, End ) );

	/// <summary>True when <paramref name="column"/> falls inside the path or its quotes.</summary>
	public bool Covers( int column ) => column >= Start - 1 && column <= End + 1;

	/// <inheritdoc/>
	public override string ToString() => AngleBracket ? $"#include <{Path}>" : $"#include \"{Path}\"";
}

/// <summary>An <see cref="IncludeReference"/> plus the verdict of resolving it.</summary>
/// <param name="Reference">The directive that was resolved.</param>
/// <param name="Status">What happened.</param>
/// <param name="AbsolutePath">The file that was found, or null.</param>
public sealed record ResolvedInclude( IncludeReference Reference, IncludeStatus Status, string AbsolutePath )
{
	/// <summary>True when a file was found and can be opened.</summary>
	public bool Exists => Status == IncludeStatus.Resolved && !string.IsNullOrEmpty( AbsolutePath );

	/// <summary>True when nothing is wrong, whether or not a file exists.</summary>
	public bool IsOk => Status is IncludeStatus.Resolved or IncludeStatus.Virtual;

	/// <summary>A sentence for a tooltip or a diagnostic detail.</summary>
	public string Describe() => Status switch
	{
		IncludeStatus.Resolved => AbsolutePath,
		IncludeStatus.Virtual =>
			$"'{Reference.Path}' is built into the shader compiler and has no file on disk. It resolves at compile time.",
		IncludeStatus.AngleBracket =>
			$"The s&box preprocessor only matches the quoted form. Write #include \"{Reference.Path}\".",
		IncludeStatus.Missing =>
			$"'{Reference.Path}' was not found in any shader search path.",
		_ => "The include directive could not be read."
	};

	/// <inheritdoc/>
	public override string ToString() => $"{Reference.Path} -> {Status}";
}

/// <summary>One entry offered while completing inside <c>#include "…"</c>.</summary>
/// <param name="Name">The name to insert, ending in <c>/</c> for a directory.</param>
/// <param name="IsDirectory">True for a folder.</param>
/// <param name="AbsolutePath">Where it was found.</param>
/// <param name="Root">The search root it came from, for the completion detail column.</param>
public sealed record IncludeSuggestion( string Name, bool IsDirectory, string AbsolutePath, string Root )
{
	/// <inheritdoc/>
	public override string ToString() => Name;
}

/// <summary>
/// Reproduces the include search order the engine's shader preprocessor actually uses, which is the
/// same list it writes into <c>.vscode/settings.json</c>: the including file's own directory, then
/// <c>&lt;sbox&gt;/core/shaders</c>, then <c>Assets/shaders</c> of every mounted project.
/// <para>
/// Two things stop this from being a naive file-exists check. Fourteen includes
/// (<c>math_general.fxc</c>, <c>instancing.fxc</c>, …) live inside the compiler DLLs and have no file
/// anywhere in the install, so reporting them as missing would flood every real shader with red.
/// And <c>#include &lt;angle&gt;</c> is an <em>error</em> rather than an alternative spelling, because
/// the engine's regex only matches the double-quoted form — an angle-bracket include compiles to
/// nothing at all and fails much later with a confusing "undeclared identifier".
/// </para>
/// </summary>
public static class IncludeResolver
{
	/// <summary>How long a cached search-root list is trusted before it is rebuilt.</summary>
	const double RootCacheSeconds = 30.0;

	/// <summary>How long a cached include file's text is trusted before its timestamp is checked again.</summary>
	const double TextCacheSeconds = 5.0;

	/// <summary>Ceiling on suggestions, so completing on an empty path in a big tree stays instant.</summary>
	const int MaxSuggestions = 400;

	/// <summary>File extensions that are worth offering as an include.</summary>
	static readonly string[] s_extensions = { ".hlsl", ".fxc", ".hlsli", ".h", ".inc", ".shader", ".slang" };

	static readonly Regex s_include = new(
		@"^(?<lead>\s*#\s*include(?<gap>\s*))(?:""(?<q>[^""]*)""?|<(?<a>[^>]*)>?)",
		RegexOptions.Compiled | RegexOptions.CultureInvariant );

	static readonly object s_lock = new();
	static readonly Dictionary<string, CachedText> s_text = new( StringComparer.OrdinalIgnoreCase );

	static string[] s_roots = Array.Empty<string>();
	static DateTime s_rootsStamp = DateTime.MinValue;
	static IReadOnlyList<string> s_extraRoots = Array.Empty<string>();

	sealed class CachedText
	{
		public string Text;
		public DateTime Written;
		public DateTime Checked;
	}

	/// <summary>
	/// The absolute search roots, in the order the preprocessor tries them. Rebuilt automatically every
	/// half minute and on demand through <see cref="Refresh"/>.
	/// </summary>
	public static IReadOnlyList<string> SearchRoots
	{
		get
		{
			EnsureRoots();
			return s_roots;
		}
	}

	/// <summary>The s&amp;box install root, or null when it cannot be resolved.</summary>
	public static string GameRoot => PrismLog.Guard( "Prism.Text: game root",
		() => Editor.FileSystem.Root?.GetFullPath( "/" ), null );

	/// <summary>
	/// Extra roots searched ahead of the mounted projects, for a tree whose shaders do not live where
	/// the engine expects. Setting this rebuilds the cache immediately.
	/// </summary>
	public static IReadOnlyList<string> ExtraRoots
	{
		get => s_extraRoots;
		set
		{
			s_extraRoots = value ?? Array.Empty<string>();
			Refresh();
		}
	}

	/// <summary>
	/// Builds the search roots now, on whatever thread the caller is on. Enumerating mounted projects
	/// touches editor state, so anything that will resolve includes on a worker thread should warm the
	/// cache from the main thread first.
	/// </summary>
	public static void Warm() => EnsureRoots();

	/// <summary>Drops the cached search roots and include texts. Call after a project mounts or on hotload.</summary>
	public static void Refresh()
	{
		lock ( s_lock )
		{
			s_rootsStamp = DateTime.MinValue;
			s_text.Clear();
		}
	}

	/// <summary>
	/// Drops every cache this resolver holds — the search roots, the include texts and any extra roots a
	/// window installed. The umbrella <see cref="TextCaches.Flush"/> calls this; <see cref="Refresh"/> is
	/// the gentler form that keeps the extra roots, for when a project mounts.
	/// </summary>
	public static void Flush()
	{
		s_extraRoots = Array.Empty<string>();
		Refresh();
	}

	// ---- parsing ----------------------------------------------------------

	/// <summary>
	/// Reads an <c>#include</c> off one line. Returns false for any line that is not one, including a
	/// commented-out directive, which the caller has already filtered through the lexer.
	/// </summary>
	public static bool TryParse( string lineText, int lineIndex, out IncludeReference reference )
	{
		reference = null;

		if ( string.IsNullOrEmpty( lineText ) || lineText.IndexOf( '#' ) < 0 )
			return false;

		var match = s_include.Match( lineText );

		if ( !match.Success )
			return false;

		var quoted = match.Groups["q"];
		var angled = match.Groups["a"];
		var group = quoted.Success ? quoted : angled;

		if ( !group.Success )
			return false;

		reference = new IncludeReference( lineIndex, group.Index, group.Index + group.Length,
			group.Value.Trim(), angled.Success )
		{
			BadSpacing = match.Groups["gap"].Value != " "
		};

		return true;
	}

	/// <summary>Every <c>#include</c> in a document, in file order.</summary>
	public static IReadOnlyList<IncludeReference> Scan( TextDocument document )
	{
		var results = new List<IncludeReference>();

		if ( document is null )
			return results;

		for ( var line = 0; line < document.LineCount; line++ )
		{
			if ( TryParse( document.GetLine( line ), line, out var reference ) )
				results.Add( reference );
		}

		return results;
	}

	/// <summary>Every <c>#include</c> in a block of text, in file order.</summary>
	public static IReadOnlyList<IncludeReference> Scan( string text )
	{
		var results = new List<IncludeReference>();

		if ( string.IsNullOrEmpty( text ) )
			return results;

		var lines = text.Replace( "\r\n", "\n" ).Split( '\n' );

		for ( var line = 0; line < lines.Length; line++ )
		{
			if ( TryParse( lines[line], line, out var reference ) )
				results.Add( reference );
		}

		return results;
	}

	// ---- resolution -------------------------------------------------------

	/// <summary>Resolves one parsed directive against the search paths.</summary>
	public static ResolvedInclude Resolve( IncludeReference reference, string fromFile )
	{
		if ( reference is null )
			return null;

		if ( string.IsNullOrWhiteSpace( reference.Path ) )
			return new ResolvedInclude( reference, IncludeStatus.Malformed, null );

		if ( reference.AngleBracket )
			return new ResolvedInclude( reference, IncludeStatus.AngleBracket, Find( reference.Path, fromFile ) );

		if ( SboxSymbols.IsVirtualInclude( reference.Path ) )
			return new ResolvedInclude( reference, IncludeStatus.Virtual, null );

		var found = Find( reference.Path, fromFile );

		return new ResolvedInclude( reference,
			found is null ? IncludeStatus.Missing : IncludeStatus.Resolved, found );
	}

	/// <summary>Resolves a bare include path, as if it had been written in the quoted form.</summary>
	public static ResolvedInclude Resolve( string includePath, string fromFile ) =>
		Resolve( new IncludeReference( 0, 0, includePath?.Length ?? 0, includePath, false ), fromFile );

	/// <summary>Resolves every directive in a document.</summary>
	public static IReadOnlyList<ResolvedInclude> ResolveAll( TextDocument document, string fromFile = null )
	{
		var results = new List<ResolvedInclude>();
		var path = fromFile ?? document?.FilePath;

		foreach ( var reference in Scan( document ) )
			results.Add( Resolve( reference, path ) );

		return results;
	}

	/// <summary>
	/// Every absolute path that would be tried for an include, in order. Exposed so a "could not find
	/// it" message can show the user exactly where we looked.
	/// </summary>
	public static IReadOnlyList<string> Candidates( string includePath, string fromFile )
	{
		var results = new List<string>();

		if ( string.IsNullOrWhiteSpace( includePath ) )
			return results;

		var relative = Normalize( includePath );

		if ( Path.IsPathRooted( relative ) )
		{
			results.Add( relative );
			return results;
		}

		var own = DirectoryOf( fromFile );

		if ( !string.IsNullOrEmpty( own ) )
			results.Add( Combine( own, relative ) );

		foreach ( var root in SearchRoots )
		{
			var candidate = Combine( root, relative );

			if ( candidate is not null && !results.Contains( candidate, StringComparer.OrdinalIgnoreCase ) )
				results.Add( candidate );
		}

		return results;
	}

	/// <summary>The first candidate that exists on disk, or null.</summary>
	public static string Find( string includePath, string fromFile )
	{
		foreach ( var candidate in Candidates( includePath, fromFile ) )
		{
			if ( PrismLog.Guard( "Prism.Text: probe include", () => File.Exists( candidate ), false ) )
				return candidate;
		}

		return null;
	}

	/// <summary>
	/// Goto-definition on an include line: returns the file the path under the caret points at. False
	/// when the caret is not on an include, or the include does not resolve to a real file.
	/// </summary>
	public static bool TryGetDefinition( string lineText, int lineIndex, int column, string fromFile,
		out string absolutePath )
	{
		absolutePath = null;

		if ( !TryParse( lineText, lineIndex, out var reference ) )
			return false;

		if ( column >= 0 && !reference.Covers( column ) )
			return false;

		var resolved = Resolve( reference, fromFile );

		if ( !resolved.Exists )
			return false;

		absolutePath = resolved.AbsolutePath;
		return true;
	}

	// ---- completion -------------------------------------------------------

	/// <summary>
	/// Files and folders that could complete a partly typed include path. The partial is everything
	/// already inside the quotes; its directory part selects the folder to list and its final segment
	/// filters the result.
	/// </summary>
	public static IReadOnlyList<IncludeSuggestion> Suggest( string partial, string fromFile )
	{
		var results = new List<IncludeSuggestion>();
		var seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );

		var text = Normalize( partial ?? string.Empty );
		var slash = text.LastIndexOf( '/' );
		var folder = slash >= 0 ? text.Substring( 0, slash ) : string.Empty;
		var prefix = slash >= 0 ? text.Substring( slash + 1 ) : text;

		var roots = new List<string>();
		var own = DirectoryOf( fromFile );

		if ( !string.IsNullOrEmpty( own ) )
			roots.Add( own );

		roots.AddRange( SearchRoots );

		foreach ( var root in roots )
		{
			if ( results.Count >= MaxSuggestions )
				break;

			var directory = string.IsNullOrEmpty( folder ) ? root : Combine( root, folder );

			if ( string.IsNullOrEmpty( directory ) )
				continue;

			PrismLog.Guard( "Prism.Text: list include directory", () =>
			{
				if ( !Directory.Exists( directory ) )
					return;

				foreach ( var entry in Directory.EnumerateDirectories( directory ) )
				{
					var name = Path.GetFileName( entry );

					if ( !Matches( name, prefix ) || !seen.Add( name + "/" ) )
						continue;

					results.Add( new IncludeSuggestion( name + "/", true, entry, Short( root ) ) );
				}

				foreach ( var entry in Directory.EnumerateFiles( directory ) )
				{
					var name = Path.GetFileName( entry );
					var extension = Path.GetExtension( name );

					if ( Array.IndexOf( s_extensions, extension.ToLowerInvariant() ) < 0 )
						continue;

					if ( !Matches( name, prefix ) || !seen.Add( name ) )
						continue;

					results.Add( new IncludeSuggestion( name, false, entry, Short( root ) ) );
				}
			} );
		}

		foreach ( var virtualInclude in SboxSymbols.VirtualIncludes )
		{
			if ( results.Count >= MaxSuggestions )
				break;

			if ( !string.IsNullOrEmpty( folder ) || !Matches( virtualInclude, prefix ) )
				continue;

			if ( seen.Add( virtualInclude ) )
				results.Add( new IncludeSuggestion( virtualInclude, false, null, "compiler built-in" ) );
		}

		results.Sort( static ( a, b ) =>
		{
			if ( a.IsDirectory != b.IsDirectory )
				return a.IsDirectory ? -1 : 1;

			return string.Compare( a.Name, b.Name, StringComparison.OrdinalIgnoreCase );
		} );

		return results;
	}

	// ---- reading ----------------------------------------------------------

	/// <summary>
	/// The text of an included file, cached by write time. Used to harvest symbols out of headers the
	/// buffer includes without re-reading them on every keystroke.
	/// </summary>
	public static string ReadText( string absolutePath )
	{
		if ( string.IsNullOrWhiteSpace( absolutePath ) )
			return null;

		var now = DateTime.UtcNow;

		lock ( s_lock )
		{
			if ( s_text.TryGetValue( absolutePath, out var cached ) &&
				( now - cached.Checked ).TotalSeconds < TextCacheSeconds )
			{
				return cached.Text;
			}
		}

		var written = PrismLog.Guard( "Prism.Text: include timestamp",
			() => File.Exists( absolutePath ) ? File.GetLastWriteTimeUtc( absolutePath ) : DateTime.MinValue,
			DateTime.MinValue );

		lock ( s_lock )
		{
			if ( s_text.TryGetValue( absolutePath, out var cached ) && cached.Written == written )
			{
				cached.Checked = now;
				return cached.Text;
			}
		}

		if ( written == DateTime.MinValue )
			return null;

		var text = PrismLog.Guard( "Prism.Text: read include",
			() => File.ReadAllText( absolutePath ), null );

		lock ( s_lock )
		{
			s_text[absolutePath] = new CachedText { Text = text, Written = written, Checked = now };

			// A shader tree is not big, but a runaway cache in a long editor session is still a leak.
			if ( s_text.Count > 256 )
			{
				var stale = s_text.Where( x => ( now - x.Value.Checked ).TotalMinutes > 10 )
					.Select( x => x.Key ).ToList();

				foreach ( var key in stale )
					s_text.Remove( key );
			}
		}

		return text;
	}

	/// <summary>
	/// Every file reachable from a buffer through its includes, breadth first and de-duplicated. Depth
	/// is capped because the engine headers are deep and completion only needs the near neighbourhood.
	/// </summary>
	public static IReadOnlyList<string> Transitive( string text, string fromFile, int maxDepth = 2,
		int maxFiles = 48 )
	{
		var results = new List<string>();
		var seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		var frontier = new List<(string Text, string File)> { (text, fromFile) };

		for ( var depth = 0; depth <= maxDepth && frontier.Count > 0; depth++ )
		{
			var next = new List<(string Text, string File)>();

			foreach ( var (body, file) in frontier )
			{
				foreach ( var reference in Scan( body ) )
				{
					if ( results.Count >= maxFiles )
						return results;

					var resolved = Resolve( reference, file );

					if ( !resolved.Exists || !seen.Add( resolved.AbsolutePath ) )
						continue;

					results.Add( resolved.AbsolutePath );

					if ( depth < maxDepth )
					{
						var included = ReadText( resolved.AbsolutePath );

						if ( !string.IsNullOrEmpty( included ) )
							next.Add( (included, resolved.AbsolutePath) );
					}
				}
			}

			frontier = next;
		}

		return results;
	}

	/// <summary>
	/// True when every <c>#include</c> reachable from a buffer resolved to a file we could actually
	/// read. False as soon as one is missing or is one of the fourteen that live inside the compiler —
	/// which is the signal that we do not know the full set of symbols in scope and must not claim an
	/// identifier is undeclared.
	/// </summary>
	public static bool IsGraphComplete( string text, string fromFile, int maxDepth = 2, int maxFiles = 64 )
	{
		var seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		var frontier = new List<(string Text, string File)> { (text, fromFile) };
		var visited = 0;

		for ( var depth = 0; depth <= maxDepth && frontier.Count > 0; depth++ )
		{
			var next = new List<(string Text, string File)>();

			foreach ( var (body, file) in frontier )
			{
				foreach ( var reference in Scan( body ) )
				{
					var resolved = Resolve( reference, file );

					if ( resolved.Status != IncludeStatus.Resolved )
						return false;

					if ( ++visited > maxFiles || depth >= maxDepth || !seen.Add( resolved.AbsolutePath ) )
						continue;

					var included = ReadText( resolved.AbsolutePath );

					if ( !string.IsNullOrEmpty( included ) )
						next.Add( (included, resolved.AbsolutePath) );
				}
			}

			frontier = next;
		}

		return true;
	}

	// ---- validation -------------------------------------------------------

	/// <summary>
	/// Diagnostics for every include in a document: missing files, angle brackets, and the spacing the
	/// engine's regex silently rejects. Never reports one of the fourteen compiler-embedded includes.
	/// </summary>
	public static IReadOnlyList<PrismDiagnostic> Validate( TextDocument document, string filePath,
		LanguageDefinition language = null ) =>
		Validate( Scan( document ), filePath ?? document?.FilePath, language );

	/// <summary>Diagnostics for every include in a block of text.</summary>
	public static IReadOnlyList<PrismDiagnostic> Validate( string text, string filePath,
		LanguageDefinition language = null ) =>
		Validate( Scan( text ), filePath, language );

	static IReadOnlyList<PrismDiagnostic> Validate( IReadOnlyList<IncludeReference> references,
		string filePath, LanguageDefinition language )
	{
		var results = new List<PrismDiagnostic>();

		if ( references is null )
			return results;

		var angleAllowed = language is { SupportsAngleBracketIncludes: true };
		var file = filePath ?? string.Empty;

		foreach ( var reference in references )
		{
			var resolved = Resolve( reference, file );
			var span = SpanOf( reference, file );

			switch ( resolved.Status )
			{
				case IncludeStatus.AngleBracket when !angleAllowed:
					results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error,
						TextDiagnosticCode.AngleBracketInclude,
						$"#include <{reference.Path}> is never expanded",
						span,
						"The s&box preprocessor only matches the double-quoted form, so this line is silently " +
						$"dropped and everything it declares goes missing. Write #include \"{reference.Path}\"." ) );
					break;

				case IncludeStatus.Missing:
					results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error,
						TextDiagnosticCode.MissingInclude,
						$"Cannot find include '{reference.Path}'",
						span,
						DescribeSearch( reference.Path, file ) ) );
					break;

				case IncludeStatus.Malformed:
					results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Error,
						TextDiagnosticCode.MissingInclude,
						"This #include has no path",
						span ) );
					break;
			}

			if ( reference.BadSpacing && !reference.AngleBracket && resolved.Status != IncludeStatus.Malformed )
			{
				results.Add( PrismDiagnostic.AtSpan( DiagnosticSeverity.Warning,
					TextDiagnosticCode.IncludeSpacing,
					"Use exactly one space between #include and the path",
					span,
					"The engine's preprocessor matches #include \"…\" with a single space. Any other spacing " +
					"is ignored and the file is never inlined." ) );
			}
		}

		return results;
	}

	/// <summary>The multi-line "we looked here" body of a missing-include diagnostic.</summary>
	public static string DescribeSearch( string includePath, string fromFile )
	{
		var candidates = Candidates( includePath, fromFile );
		var shown = candidates.Take( 8 ).Select( x => "  " + x );
		var more = candidates.Count > 8 ? $"\n  … and {candidates.Count - 8} more" : string.Empty;

		return "Searched:\n" + string.Join( "\n", shown ) + more;
	}

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

	static SourceSpan SpanOf( IncludeReference reference, string file )
	{
		var range = reference.QuotedRange;

		return new SourceSpan( file, range.Start.Line + 1, range.Start.Column + 1,
			range.End.Line + 1, range.End.Column + 1 );
	}

	static bool Matches( string name, string prefix ) =>
		string.IsNullOrEmpty( prefix ) || name.StartsWith( prefix, StringComparison.OrdinalIgnoreCase );

	static string Short( string root )
	{
		if ( string.IsNullOrEmpty( root ) )
			return string.Empty;

		var trimmed = root.TrimEnd( '/', '\\' );
		var parent = Path.GetFileName( Path.GetDirectoryName( trimmed ) ?? string.Empty );
		var leaf = Path.GetFileName( trimmed );

		return string.IsNullOrEmpty( parent ) ? leaf : $"{parent}/{leaf}";
	}

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

	static string DirectoryOf( string file )
	{
		if ( string.IsNullOrWhiteSpace( file ) )
			return null;

		return PrismLog.Guard( "Prism.Text: include directory",
			() => Path.GetDirectoryName( Path.GetFullPath( file ) ), null );
	}

	static string Combine( string root, string relative )
	{
		if ( string.IsNullOrEmpty( root ) || string.IsNullOrEmpty( relative ) )
			return null;

		return PrismLog.Guard( "Prism.Text: combine include path",
			() => Path.GetFullPath( Path.Combine( root, relative.Replace( '/', Path.DirectorySeparatorChar ) ) ),
			null );
	}

	static void EnsureRoots()
	{
		lock ( s_lock )
		{
			if ( ( DateTime.UtcNow - s_rootsStamp ).TotalSeconds < RootCacheSeconds )
				return;
		}

		var roots = BuildRoots();

		lock ( s_lock )
		{
			s_roots = roots;
			s_rootsStamp = DateTime.UtcNow;
		}
	}

	static string[] BuildRoots()
	{
		var results = new List<string>();
		var seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );

		void Add( string path )
		{
			if ( string.IsNullOrWhiteSpace( path ) )
				return;

			var full = PrismLog.Guard( "Prism.Text: include root", () => Path.GetFullPath( path ), null );

			if ( string.IsNullOrEmpty( full ) || !seen.Add( full ) )
				return;

			if ( PrismLog.Guard( "Prism.Text: include root exists", () => Directory.Exists( full ), false ) )
				results.Add( full );
		}

		foreach ( var extra in s_extraRoots )
			Add( extra );

		// The engine's own headers come first: this is what system.fxc and common/* live under.
		var game = GameRoot;

		if ( !string.IsNullOrEmpty( game ) )
		{
			Add( Path.Combine( game, "core", "shaders" ) );
			Add( Path.Combine( game, "addons", "base", "Assets", "shaders" ) );
		}

		// Then every mounted project, which is the same list the engine writes into .vscode/settings.json.
		PrismLog.Guard( "Prism.Text: enumerate projects", () =>
		{
			foreach ( var project in EditorUtility.Projects.GetAll() )
			{
				if ( project is null )
					continue;

				var assets = project.GetAssetsPath();

				if ( string.IsNullOrWhiteSpace( assets ) )
					continue;

				Add( Path.Combine( assets, "shaders" ) );
				Add( assets );
			}
		} );

		if ( !string.IsNullOrEmpty( game ) )
		{
			Add( Path.Combine( game, "addons", "menu", "Assets", "shaders" ) );
			Add( Path.Combine( game, "addons", "tools", "Assets", "shaders" ) );
			Add( Path.Combine( game, "core" ) );
		}

		return results.ToArray();
	}
}