Editor/AssetDoctorCore.cs

Editor utility that extracts quoted asset path references from text files. It defines finding types, asset path rules, and a linear-time ReferenceExtractor that finds quoted strings, decodes JSON escapes optionally, normalizes separators, and returns bounded lists of AssetReference with truncation indication.

File Access
#nullable enable
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;

namespace AssetDoctor;

/// <summary>Describes the severity assigned to a validation result.</summary>
public enum FindingSeverity { Info, Warning, Error }

/// <summary>Represents one diagnostic produced by a scan.</summary>
public sealed record Finding(string RuleId, FindingSeverity Severity, string Message, string SourcePath, string? ReferencedPath = null, int? Line = null, string? Details = null);

/// <summary>Stores shared asset-path constants and normalization helpers.</summary>
public static class AssetPathRules
{
    /// <summary>Limits an individual quoted candidate to prevent pathological scans.</summary>
    public const int MaxReferenceLength = 4096;
    /// <summary>Limits extracted references from one source file to protect scan memory.</summary>
    public const int MaxReferencesPerSourceFile = 10_000;
    /// <summary>Limits physical source-file bytes before allocating text in memory.</summary>
    public const long MaxTextFileBytes = 4_000_000;
    /// <summary>Limits decoded source characters after a successful read.</summary>
    public const int MaxTextCharacters = 4_000_000;
    /// <summary>Lists source formats whose quoted values are currently scanned heuristically.</summary>
    public static readonly string[] TextAssetExtensions = { ".scene", ".prefab", ".vmdl", ".vmat", ".vtex", ".sound", ".surface", ".clothing", ".decal", ".vmap", ".vfx", ".vanmgrph", ".vpost", ".shader", ".shdrgrph", ".vpcf", ".json" };
    /// <summary>Lists referenced asset extensions recognized inside quoted source values.</summary>
    public static readonly string[] ReferenceExtensions = { ".scene", ".prefab", ".vmdl", ".vmat", ".vtex", ".sound", ".surface", ".clothing", ".decal", ".vmap", ".vfx", ".vanmgrph", ".vpost", ".shader", ".shdrgrph", ".vpcf", ".png", ".jpg", ".jpeg", ".tga", ".fbx", ".json" };
    /// <summary>Returns whether a path ends in one of the supplied extensions.</summary>
    public static bool HasAnyExtension(string path, IReadOnlyList<string> extensions)
    {
        if(string.IsNullOrEmpty(path)) return false;
        for(var index = 0; index < extensions.Count; index++) if(path.EndsWith(extensions[index], StringComparison.OrdinalIgnoreCase)) return true;
        return false;
    }
    /// <summary>Converts one or more Windows separators without removing unsafe whitespace.</summary>
    public static string NormalizeSeparators(string path) => (path ?? string.Empty).Replace('\\', '/');
    /// <summary>Returns whether references in this source format should use JSON-style escape decoding.</summary>
    public static bool UsesJsonEscapes(string path) => path.EndsWith(".json", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".scene", StringComparison.OrdinalIgnoreCase);
}

/// <summary>Represents one extracted asset path and its one-based source line.</summary>
public sealed record AssetReference(string Path, int Line);

/// <summary>Contains bounded reference extraction output and indicates whether the per-file limit was reached.</summary>
public sealed record ReferenceExtractionResult(IReadOnlyList<AssetReference> References, bool IsTruncated);

/// <summary>Extracts quoted asset paths with bounded linear-time scanning.</summary>
public static class ReferenceExtractor
{
    /// <summary>Extracts recognized references from text; output is heuristic because only quoted values are examined.</summary>
    public static ReferenceExtractionResult Extract(string? text, bool decodeJsonEscapes, CancellationToken token = default)
    {
        var results = new List<AssetReference>();
        if(string.IsNullOrEmpty(text)) return new ReferenceExtractionResult(results, false);
        var line = 1;
        for(var index = 0; index < text.Length; index++)
        {
            if((index & 0xFFF) == 0) token.ThrowIfCancellationRequested();
            if(text[index] == '\n') { line++; continue; }
            var quote = text[index];
            if(quote != '\'' && quote != '"') continue;
            var startLine = line;
            var start = ++index;
            var escaped = false;
            var tooLong = false;
            var closed = false;
            while(index < text.Length)
            {
                if((index & 0xFFF) == 0) token.ThrowIfCancellationRequested();
                var character = text[index];
                if(character == '\n') line++;
                if(character == quote && !escaped) { closed = true; break; }
                if(index - start >= AssetPathRules.MaxReferenceLength) tooLong = true;
                escaped = character == '\\' ? !escaped : false;
                index++;
            }
            if(!closed || tooLong) continue;
            var raw = text.Substring(start, index - start);
            if(!TryDecodeEscapes(raw, decodeJsonEscapes, out var decoded)) continue;
            var normalized = AssetPathRules.NormalizeSeparators(decoded);
            if(normalized.Length == 0 || !AssetPathRules.HasAnyExtension(normalized, AssetPathRules.ReferenceExtensions)) continue;
            results.Add(new AssetReference(normalized, startLine));
            if(results.Count >= AssetPathRules.MaxReferencesPerSourceFile)
                return new ReferenceExtractionResult(results, true);
        }
        return new ReferenceExtractionResult(results, false);
    }

    /// <summary>Decodes JSON escapes only for JSON-like source formats and preserves ordinary backslash paths otherwise.</summary>
    private static bool TryDecodeEscapes(string raw, bool decodeJsonEscapes, out string value)
    {
        if(!decodeJsonEscapes) { value = raw; return true; }
        var builder = new StringBuilder(raw.Length);
        for(var index = 0; index < raw.Length; index++)
        {
            var character = raw[index];
            if(character != '\\') { builder.Append(character); continue; }
            if(++index >= raw.Length) { value = string.Empty; return false; }
            switch(raw[index])
            {
                case '"': builder.Append('"'); break;
                case '\\': builder.Append('\\'); break;
                case '/': builder.Append('/'); break;
                case 'b': builder.Append('\b'); break;
                case 'f': builder.Append('\f'); break;
                case 'n': builder.Append('\n'); break;
                case 'r': builder.Append('\r'); break;
                case 't': builder.Append('\t'); break;
                case 'u' when index + 4 < raw.Length:
                    var hex = raw.Substring(index + 1, 4);
                    if(!ushort.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var code)) { value = string.Empty; return false; }
                    builder.Append((char)code); index += 4; break;
                default: value = string.Empty; return false;
            }
        }
        value = builder.ToString();
        return true;
    }
}