Editor/AssetDoctorWindow.cs

Editor UI window for an Asset Doctor tool. It builds a dockable editor panel that snapshots project assets, runs a background heuristic scan for quoted asset-path references, collects findings, renders grouped results in the editor UI, and can export reports to a user-selected folder.

File AccessExternal Download
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace AssetDoctor;

/// <summary>Provides the dockable UI for heuristic current-project asset diagnostics.</summary>
[Dock("Editor", "Asset Doctor", "local_hospital")]
public sealed class AssetDoctorWindow : Widget
{
    /// <summary>Limits total scan findings retained in memory and exported.</summary>
    private const int MaxScanFindings = 50_000;
    /// <summary>Limits rendered occurrences under one rule group.</summary>
    private const int MaxRenderedFindingsPerGroup = 250;
    /// <summary>Limits rendered rule groups to protect editor responsiveness.</summary>
    private const int MaxRenderedGroups = 200;
    /// <summary>Bounds memory used by dependency-graph edges.</summary>
    private const int MaxDependencyGraphEdges = 100_000;
    /// <summary>References the scrollable findings host.</summary>
    private ScrollArea? _scrollArea;
    /// <summary>References the current findings canvas.</summary>
    private Widget? _resultsContainer;
    /// <summary>Displays scan and export status.</summary>
    private Label? _statusLabel;
    /// <summary>References the report export button.</summary>
    private Button? _exportButton;
    /// <summary>Stores the cancellation source for the active scan.</summary>
    private CancellationTokenSource? _scanCts;
    /// <summary>Stores findings from the most recently completed scan attempt.</summary>
    private List<Finding> _lastFindings = new();
    /// <summary>Invalidates stale asynchronous scan completions.</summary>
    private int _scanGeneration;

    /// <summary>Initializes the dock content.</summary>
    public AssetDoctorWindow(Widget parent) : base(parent, false) => BuildUI();

    /// <summary>Builds fresh editor controls after creation or hotload.</summary>
    [EditorEvent.Hotload]
    private void BuildUI()
    {
        CancelCurrentScan();
        _scanGeneration++;
        if(Layout == null)
        {
            Layout = Layout.Column();
            Layout.Margin = 4;
            Layout.Spacing = 4;
        }
        else Layout.Clear(true);
        var scanButton = new Button("Run / Restart Scan", "search", this);
        scanButton.Clicked += OnScanClicked;
        scanButton.MinimumSize = new Vector2(220, 38);
        Layout.Add(scanButton);
        _exportButton = new Button("Export Reports", "download", this);
        _exportButton.Clicked += ExportReports;
        _exportButton.MinimumSize = new Vector2(220, 38);
        _exportButton.Hidden = _lastFindings.Count == 0;
        Layout.Add(_exportButton);
        _statusLabel = new Label(string.Empty, this) { Hidden = true };
        Layout.Add(_statusLabel);
        _scrollArea = new ScrollArea(this) { Hidden = true };
        Layout.Add(_scrollArea);
        RebuildResultsContainer();
        if(_lastFindings.Count > 0) RenderFindings(_lastFindings);
    }

    /// <summary>Starts a new scan and cancels any earlier scan.</summary>
    private async void OnScanClicked()
    {
        CancelCurrentScan();
        var cts = new CancellationTokenSource();
        _scanCts = cts;
        var generation = ++_scanGeneration;
        _lastFindings = new();
        if(_exportButton != null) _exportButton.Hidden = true;
        if(_scrollArea != null) _scrollArea.Hidden = true;
        if(_statusLabel != null) _statusLabel.Hidden = false;
        if(_resultsContainer?.Layout != null) _resultsContainer.Layout.Clear(true);
        SetStatus("Scanning current-project source assets...", "#ffffff");
        try
        {
            var snapshot = CreateSnapshot();
            var findings = await Task.Run(() => ScanFiles(snapshot, cts.Token), cts.Token);
            if(!IsValid || cts.IsCancellationRequested || generation != _scanGeneration) return;
            RenderFindings(findings);
        }
        catch(OperationCanceledException)
        {
            if(generation == _scanGeneration) SetStatus("Scan cancelled.", "#ffe19a");
        }
        catch(Exception exception)
        {
            Log.Error($"Asset Doctor scan failed: {exception}");
            if(generation == _scanGeneration) SetStatus($"Scan failed: {exception.GetType().Name}", "#ffb8c0");
        }
        finally
        {
            if(ReferenceEquals(_scanCts, cts)) _scanCts = null;
            cts.Dispose();
        }
    }

    /// <summary>Creates a UI-thread snapshot of project files and all editor-known resolvable logical paths.</summary>
    private static ScanSnapshot CreateSnapshot()
    {
        var scope = ProjectAssetScope.TryCreate();
        if(scope == null) throw new InvalidOperationException("No active project Assets directory is available.");
        var allEditorAssets = AssetSystem.All.Where(x => x != null && !string.IsNullOrWhiteSpace(x.Path)).ToArray();
        var resolvablePaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach(var asset in allEditorAssets)
        {
            resolvablePaths.Add(AssetPathRules.NormalizeSeparators(asset.Path));
            if(!string.IsNullOrWhiteSpace(asset.RelativePath))
                resolvablePaths.Add(AssetPathRules.NormalizeSeparators(asset.RelativePath));
        }
        var projectAssets = allEditorAssets
            .Where(scope.Contains)
            .Where(x => !string.IsNullOrWhiteSpace(x.AbsolutePath))
            .Select(x => new SourceAsset(AssetPathRules.NormalizeSeparators(x.Path), x.AbsolutePath))
            .ToArray();
        return new ScanSnapshot(projectAssets, resolvablePaths);
    }

    /// <summary>Scans physical source files on a worker thread without s&box interop calls.</summary>
    private static List<Finding> ScanFiles(ScanSnapshot snapshot, CancellationToken token)
    {
        var collector = new FindingCollector(MaxScanFindings);
        var projectPaths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        foreach(var asset in snapshot.Assets)
        {
            token.ThrowIfCancellationRequested();
            if(projectPaths.TryGetValue(asset.Path, out var previous))
            {
                if(!collector.TryAdd(new Finding("AD110", FindingSeverity.Error, "Asset paths collide when compared without letter case.", asset.Path, previous)))
                    return collector.Findings;
            }
            else projectPaths.Add(asset.Path, asset.Path);
        }

        var graph = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
        var graphEdgeCount = 0;
        var graphLimitReported = false;
        foreach(var asset in snapshot.Assets)
        {
            token.ThrowIfCancellationRequested();
            if(!AssetPathRules.HasAnyExtension(asset.Path, AssetPathRules.TextAssetExtensions)) continue;
            try
            {
                var info = new FileInfo(asset.AbsolutePath);
                if(!info.Exists)
                {
                    if(!collector.TryAdd(new Finding("AD105", FindingSeverity.Error, "Could not inspect text asset because its source file no longer exists.", asset.Path)))
                        return collector.Findings;
                    continue;
                }
                if(info.Length == 0) continue;
                if(info.Length > AssetPathRules.MaxTextFileBytes)
                {
                    if(!collector.TryAdd(new Finding("AD112", FindingSeverity.Warning, "Text asset was skipped because it exceeds the physical file-size scan limit.", asset.Path)))
                        return collector.Findings;
                    continue;
                }

                var text = File.ReadAllText(asset.AbsolutePath);
                if(text.Length > AssetPathRules.MaxTextCharacters)
                {
                    if(!collector.TryAdd(new Finding("AD112", FindingSeverity.Warning, "Text asset was skipped because it exceeds the decoded character scan limit.", asset.Path)))
                        return collector.Findings;
                    continue;
                }

                var extraction = ReferenceExtractor.Extract(text, AssetPathRules.UsesJsonEscapes(asset.Path), token);
                if(extraction.IsTruncated && !collector.TryAdd(new Finding("AD113", FindingSeverity.Warning, "Reference extraction stopped after reaching the per-file reference limit.", asset.Path)))
                    return collector.Findings;

                foreach(var reference in extraction.References)
                {
                    token.ThrowIfCancellationRequested();
                    try
                    {
                        var invalid = AssetPathValidator.Validate(asset.Path, reference.Path, reference.Line);
                        if(invalid != null)
                        {
                            if(!collector.TryAdd(invalid)) return collector.Findings;
                            continue;
                        }

                        if(projectPaths.TryGetValue(reference.Path, out var actualProjectPath))
                        {
                            if(!string.Equals(reference.Path, actualProjectPath, StringComparison.Ordinal) &&
                                !collector.TryAdd(new Finding("AD103", FindingSeverity.Error, "Asset reference uses different letter case than the actual asset path.", asset.Path, reference.Path, reference.Line, actualProjectPath)))
                                return collector.Findings;

                            if(!graph.TryGetValue(asset.Path, out var dependencies))
                            {
                                dependencies = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
                                graph.Add(asset.Path, dependencies);
                            }

                            if(!dependencies.Contains(actualProjectPath))
                            {
                                if(graphEdgeCount < MaxDependencyGraphEdges)
                                {
                                    dependencies.Add(actualProjectPath);
                                    graphEdgeCount++;
                                }
                                else if(!graphLimitReported)
                                {
                                    if(!collector.TryAdd(new Finding(
                                        "AD115",
                                        FindingSeverity.Warning,
                                        "Dependency graph limit was reached. Circular-reference results may be incomplete.",
                                        asset.Path)))
                                    {
                                        return collector.Findings;
                                    }

                                    graphLimitReported = true;
                                }
                            }
                        }
                        else if(!snapshot.ResolvablePaths.Contains(reference.Path))
                        {
                            if(!collector.TryAdd(new Finding("AD100", FindingSeverity.Error, "Missing asset reference.", asset.Path, reference.Path, reference.Line)))
                                return collector.Findings;
                        }
                    }
                    catch(Exception exception)
                    {
                        if(!collector.TryAdd(new Finding("AD111", FindingSeverity.Error, $"Could not validate asset reference ({exception.GetType().Name}).", asset.Path, reference.Path, reference.Line)))
                            return collector.Findings;
                    }
                }
            }
            catch(Exception exception)
            {
                if(!collector.TryAdd(new Finding("AD105", FindingSeverity.Error, $"Could not read text asset ({exception.GetType().Name}).", asset.Path)))
                    return collector.Findings;
            }
        }

        foreach(var cycleFinding in AssetReferenceCycleDetector.Find(graph, token))
        {
            if(!collector.TryAdd(cycleFinding)) break;
        }
        return collector.Findings;
    }

    /// <summary>Renders bounded groups from completed findings.</summary>
    private void RenderFindings(List<Finding> findings)
    {
        if(_resultsContainer?.Layout == null) return;
        _lastFindings = findings;
        _resultsContainer.Layout.Clear(true);
        if(_scrollArea != null)
        {
            _scrollArea.Hidden = false;
            _scrollArea.MinimumSize = new Vector2(0, 280);
        }
        if(_exportButton != null) _exportButton.Hidden = findings.Count == 0;
        var errors = findings.Count(x => x.Severity == FindingSeverity.Error);
        var warnings = findings.Count(x => x.Severity == FindingSeverity.Warning);
        SetStatus($"Found {findings.Count} issues · {errors} errors · {warnings} warnings · heuristic quoted-path scan", errors > 0 ? "#ffb8c0" : "#9ee6a5");
        var groups = findings.GroupBy(x => new { x.RuleId, x.Severity, x.Message }).OrderByDescending(x => Rank(x.Key.Severity)).ThenBy(x => x.Key.RuleId).Take(MaxRenderedGroups).ToArray();
        foreach(var group in groups) AddGroup(group.Key.RuleId, group.Key.Severity, group.Key.Message, group);
        if(findings.Count > 0 && groups.Length == MaxRenderedGroups) _resultsContainer.Layout.Add(new Label("More rule groups were omitted to protect Editor performance. Export reports for the full list.", _resultsContainer));
        _resultsContainer.Layout.AddStretchCell();
    }

    /// <summary>Adds one collapsible rule group to the results canvas.</summary>
    private void AddGroup(string ruleId, FindingSeverity severity, string message, IEnumerable<Finding> findings)
    {
        if(_resultsContainer?.Layout == null) return;
        var all = findings.OrderBy(x => x.SourcePath, StringComparer.OrdinalIgnoreCase).ThenBy(x => x.ReferencedPath, StringComparer.OrdinalIgnoreCase).ToArray();
        var rendered = all.Take(MaxRenderedFindingsPerGroup).ToArray();
        var isError = severity == FindingSeverity.Error;
        var background = isError ? "#4a1f25" : "#4a3a12";
        var border = isError ? "#d94b58" : "#e0ae32";
        var text = isError ? "#ffb8c0" : "#ffe19a";
        var container = new Widget(_resultsContainer) { Layout = Layout.Column() };
        container.Layout.Margin = 0;
        container.Layout.Spacing = 1;
        _resultsContainer.Layout.Add(container);
        var closed = $"▶ {ruleId} · {message} · {all.Length} issue(s)";
        var open = $"▼ {ruleId} · {message} · {all.Length} issue(s)";
        var details = new Widget(container) { Layout = Layout.Column(), Hidden = true };
        details.Layout.Margin = 0;
        details.Layout.Spacing = 1;
        var header = new FindingRow(container, closed, background, border, text);
        header.Clicked += () =>
        {
            if(!details.IsValid || !header.IsValid) return;
            details.Hidden = !details.Hidden;
            header.Text = details.Hidden ? closed : open;
        };
        container.Layout.Add(header);
        foreach(var finding in rendered)
        {
            var source = finding.SourcePath;
            var holder = new Widget(details) { Layout = Layout.Row() };
            holder.Layout.Margin = 0;
            holder.Layout.Spacing = 0;
            holder.Layout.Add(new Widget(holder) { FixedWidth = 18 });
            var detail = finding.Details == null ? string.Empty : $" · {finding.Details}";
            var line = finding.Line.HasValue ? $":{finding.Line.Value}" : string.Empty;
            var row = new FindingRow(holder, $"{finding.RuleId} · {finding.Message} · {finding.SourcePath}{line} → {finding.ReferencedPath}{detail}", background, border, text);
            row.Clicked += () => AssetBrowserNavigator.FocusAsset(source);
            holder.Layout.Add(row, 1);
            details.Layout.Add(holder);
        }
        if(all.Length > rendered.Length) details.Layout.Add(new Label($"… {all.Length - rendered.Length} more issue(s); export reports for the full list.", details));
        container.Layout.Add(details);
    }

    /// <summary>Exports reports through a user-selected directory.</summary>
    private void ExportReports()
    {
        if(_lastFindings.Count == 0) return;
        var dialog = new FileDialog(this) { Title = "Select Report Folder" };
        dialog.SetFindDirectory();
        if(!dialog.Execute() || string.IsNullOrWhiteSpace(dialog.Directory)) return;
        try
        {
            var name = Path.GetFileName(Project.Current?.GetAssetsPath()?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)) ?? "project";
            AssetDoctorReportExporter.Export(dialog.Directory, name, _lastFindings);
            SetStatus($"Reports exported to {dialog.Directory}", "#9ee6a5");
        }
        catch(Exception exception)
        {
            Log.Error($"Asset Doctor export failed: {exception}");
            SetStatus($"Export failed: {exception.GetType().Name}", "#ffb8c0");
        }
    }

    /// <summary>Creates the scroll canvas used for results.</summary>
    private void RebuildResultsContainer()
    {
        if(_scrollArea == null) return;
        _resultsContainer = new Widget(null) { Layout = Layout.Column() };
        _resultsContainer.Layout.Spacing = 2;
        _scrollArea.Canvas = _resultsContainer;
    }

    /// <summary>Cancels and detaches the active scan; its owner disposes the source after completion.</summary>
    private void CancelCurrentScan()
    {
        var current = Interlocked.Exchange(ref _scanCts, null);
        current?.Cancel();
    }

    /// <summary>Updates status text and its severity color.</summary>
    private void SetStatus(string text, string color)
    {
        if(_statusLabel == null) return;
        _statusLabel.Hidden = false;
        _statusLabel.Text = text;
        _statusLabel.SetStyles($"padding: 4px 2px; color: {color};");
    }

    /// <summary>Returns sort precedence for severities.</summary>
    private static int Rank(FindingSeverity severity) => severity == FindingSeverity.Error ? 2 : severity == FindingSeverity.Warning ? 1 : 0;
    /// <summary>Bounds retained findings and adds one explicit truncation diagnostic when the scan limit is reached.</summary>
    private sealed class FindingCollector
    {
        /// <summary>Stores retained findings.</summary>
        private readonly List<Finding> _findings = new();
        /// <summary>Stores the maximum retained finding count.</summary>
        private readonly int _limit;
        /// <summary>Tracks whether the terminal limit finding was added.</summary>
        private bool _truncated;

        /// <summary>Initializes a bounded finding collector.</summary>
        public FindingCollector(int limit) => _limit = limit;
        /// <summary>Gets retained findings.</summary>
        public List<Finding> Findings => _findings;
        /// <summary>Adds a finding or a final truncation diagnostic, returning false when scanning must stop.</summary>
        public bool TryAdd(Finding finding)
        {
            if(_truncated) return false;
            if(_findings.Count < _limit - 1)
            {
                _findings.Add(finding);
                return true;
            }

            _findings.Add(new Finding("AD114", FindingSeverity.Warning, "Scan stopped after reaching the global finding limit.", finding.SourcePath, finding.ReferencedPath, finding.Line));
            _truncated = true;
            return false;
        }
    }

    /// <summary>Represents a physical source file captured on the UI thread.</summary>
    private sealed record SourceAsset(string Path, string AbsolutePath);
    /// <summary>Represents a scan-input snapshot that worker code treats as read-only.</summary>
    private sealed record ScanSnapshot(SourceAsset[] Assets, HashSet<string> ResolvablePaths);
}