// MIT License - Copyright (c) 2025 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Editor.Tools.UnityMethodAnalyzer { #if UNITY_EDITOR using System; using System.Collections.Generic; using UnityEditor; using WallstopStudios.UnityHelpers.Core.Extension; using UnityEditor.IMGUI.Controls; using UnityEngine; /// /// TreeView item for displaying analyzer issues. /// internal sealed class IssueTreeViewItem : TreeViewItem { public AnalyzerIssue Issue { get; } public string FilePath { get; } public int LineNumber { get; } public bool IsFile { get; } public bool IsCategory { get; } public IssueSeverity? Severity { get; } public int IssueCount { get; set; } public int CriticalCount { get; set; } public int HighCount { get; set; } public IssueTreeViewItem( int id, int depth, string displayName, AnalyzerIssue issue = null, string filePath = null, int lineNumber = 0, bool isFile = false, bool isCategory = false, IssueSeverity? severity = null ) : base(id, depth, displayName) { Issue = issue; FilePath = filePath; LineNumber = lineNumber; IsFile = isFile; IsCategory = isCategory; Severity = severity; } } /// /// TreeView for displaying analyzer issues in a hierarchical format. /// internal sealed class IssueTreeView : TreeView { private IReadOnlyList _issues; private string _rootPath; private bool _groupByFile = true; private bool _groupBySeverity; private bool _groupByCategory; private IssueSeverity? _severityFilter; private IssueCategory? _categoryFilter; private string _searchFilter; public event Action OnIssueSelected; public event Action OnOpenFile; public event Action OnRevealInExplorer; public event Action OnCopyIssueAsJson; public event Action OnCopyIssueAsMarkdown; public event Action OnCopyAllAsJson; public event Action OnCopyAllAsMarkdown; public IssueTreeView(TreeViewState state) : base(state) { _issues = Array.Empty(); showAlternatingRowBackgrounds = true; showBorder = true; Reload(); } public void SetIssues(IReadOnlyList issues, string rootPath) { _issues = issues ?? Array.Empty(); _rootPath = rootPath; Reload(); } public void SetGrouping(bool byFile, bool bySeverity, bool byCategory) { _groupByFile = byFile; _groupBySeverity = bySeverity; _groupByCategory = byCategory; Reload(); } public void SetFilters(IssueSeverity? severity, IssueCategory? category, string search) { _severityFilter = severity; _categoryFilter = category; _searchFilter = search; Reload(); } protected override TreeViewItem BuildRoot() { TreeViewItem root = new(-1, -1, "Root"); if (_issues == null || _issues.Count == 0) { root.AddChild(new TreeViewItem(0, 0, "No issues found")); return root; } // Build filtered list in a single pass to reduce allocations List issueList = BuildFilteredIssueList(); if (issueList.Count == 0) { root.AddChild(new TreeViewItem(0, 0, "No issues match the current filters")); return root; } int id = 1; if (_groupBySeverity) { BuildTreeBySeverity(root, issueList, ref id); } else if (_groupByCategory) { BuildTreeByCategory(root, issueList, ref id); } else if (_groupByFile) { BuildTreeByFile(root, issueList, ref id); } else { BuildFlatTree(root, issueList, ref id); } SetupDepthsFromParentsAndChildren(root); return root; } /// /// Builds a filtered issue list in a single pass to reduce allocations. /// Uses case-insensitive comparison instead of ToLowerInvariant() to avoid string allocations. /// private List BuildFilteredIssueList() { bool hasSeverityFilter = _severityFilter.HasValue; bool hasCategoryFilter = _categoryFilter.HasValue; bool hasSearchFilter = !string.IsNullOrWhiteSpace(_searchFilter); // If no filters, return a copy of the list if (!hasSeverityFilter && !hasCategoryFilter && !hasSearchFilter) { return new List(_issues); } // Pre-allocate with estimated capacity List filtered = new(_issues.Count); foreach (AnalyzerIssue issue in _issues) { // Apply severity filter if (hasSeverityFilter && issue.Severity != _severityFilter.Value) { continue; } // Apply category filter if (hasCategoryFilter && issue.Category != _categoryFilter.Value) { continue; } // Apply search filter using IndexOf with OrdinalIgnoreCase to avoid string allocations if (hasSearchFilter) { bool matchesSearch = issue.ClassName.IndexOf(_searchFilter, StringComparison.OrdinalIgnoreCase) >= 0 || issue.MethodName.IndexOf( _searchFilter, StringComparison.OrdinalIgnoreCase ) >= 0 || issue.IssueType.IndexOf( _searchFilter, StringComparison.OrdinalIgnoreCase ) >= 0 || issue.FilePath.IndexOf(_searchFilter, StringComparison.OrdinalIgnoreCase) >= 0 || issue.Description.IndexOf( _searchFilter, StringComparison.OrdinalIgnoreCase ) >= 0; if (!matchesSearch) { continue; } } filtered.Add(issue); } return filtered; } private void BuildTreeBySeverity(TreeViewItem root, List issues, ref int id) { // Build grouped structure in a single pass using dictionary Dictionary>> severityGroups = new(); foreach (AnalyzerIssue issue in issues) { if ( !severityGroups.TryGetValue( issue.Severity, out Dictionary> fileGroups ) ) { fileGroups = new Dictionary>(); severityGroups[issue.Severity] = fileGroups; } fileGroups.GetOrAdd(issue.FilePath).Add(issue); } // Build tree from grouped data foreach ( IssueSeverity severity in new[] { IssueSeverity.Critical, IssueSeverity.High, IssueSeverity.Medium, IssueSeverity.Low, IssueSeverity.Info, } ) { if ( !severityGroups.TryGetValue( severity, out Dictionary> fileGroups ) ) { continue; } int totalCount = 0; foreach (List fileIssues in fileGroups.Values) { totalCount += fileIssues.Count; } string severityName = GetSeverityDisplayName(severity); IssueTreeViewItem severityItem = new( id++, 0, $"{severityName} ({totalCount})", isCategory: true, severity: severity ) { IssueCount = totalCount, }; List sortedFilePaths = new(fileGroups.Keys); sortedFilePaths.Sort(StringComparer.Ordinal); foreach (string filePath in sortedFilePaths) { List fileIssues = fileGroups[filePath]; int criticalCount = 0; int highCount = 0; foreach (AnalyzerIssue issue in fileIssues) { if (issue.Severity == IssueSeverity.Critical) { criticalCount++; } else if (issue.Severity == IssueSeverity.High) { highCount++; } } IssueTreeViewItem fileItem = new( id++, 1, $"{filePath} ({fileIssues.Count})", filePath: filePath, isFile: true ) { IssueCount = fileIssues.Count, CriticalCount = criticalCount, HighCount = highCount, }; // Sort by line number fileIssues.Sort((a, b) => a.LineNumber.CompareTo(b.LineNumber)); foreach (AnalyzerIssue issue in fileIssues) { string display = FormatIssueDisplay(issue); IssueTreeViewItem issueItem = new( id++, 2, display, issue, issue.FilePath, issue.LineNumber, severity: issue.Severity ); fileItem.AddChild(issueItem); } severityItem.AddChild(fileItem); } root.AddChild(severityItem); } } private void BuildTreeByCategory(TreeViewItem root, List issues, ref int id) { // Build grouped structure in a single pass using dictionary Dictionary>> categoryGroups = new(); foreach (AnalyzerIssue issue in issues) { if ( !categoryGroups.TryGetValue( issue.Category, out Dictionary> fileGroups ) ) { fileGroups = new Dictionary>(); categoryGroups[issue.Category] = fileGroups; } fileGroups.GetOrAdd(issue.FilePath).Add(issue); } // Build tree from grouped data foreach ( IssueCategory category in new[] { IssueCategory.UnityLifecycle, IssueCategory.UnityInheritance, IssueCategory.GeneralInheritance, } ) { if ( !categoryGroups.TryGetValue( category, out Dictionary> fileGroups ) ) { continue; } int totalCount = 0; int totalCritical = 0; int totalHigh = 0; foreach (List fileIssues in fileGroups.Values) { foreach (AnalyzerIssue issue in fileIssues) { totalCount++; if (issue.Severity == IssueSeverity.Critical) { totalCritical++; } else if (issue.Severity == IssueSeverity.High) { totalHigh++; } } } string categoryName = GetCategoryDisplayName(category); IssueTreeViewItem categoryItem = new( id++, 0, $"{categoryName} ({totalCount})", isCategory: true ) { IssueCount = totalCount, CriticalCount = totalCritical, HighCount = totalHigh, }; List sortedFilePaths = new(fileGroups.Keys); sortedFilePaths.Sort(StringComparer.Ordinal); foreach (string filePath in sortedFilePaths) { List fileIssues = fileGroups[filePath]; int criticalCount = 0; int highCount = 0; foreach (AnalyzerIssue issue in fileIssues) { if (issue.Severity == IssueSeverity.Critical) { criticalCount++; } else if (issue.Severity == IssueSeverity.High) { highCount++; } } IssueTreeViewItem fileItem = new( id++, 1, $"{filePath} ({fileIssues.Count})", filePath: filePath, isFile: true ) { IssueCount = fileIssues.Count, CriticalCount = criticalCount, HighCount = highCount, }; // Sort by line number fileIssues.Sort((a, b) => a.LineNumber.CompareTo(b.LineNumber)); foreach (AnalyzerIssue issue in fileIssues) { string display = FormatIssueDisplay(issue); IssueTreeViewItem issueItem = new( id++, 2, display, issue, issue.FilePath, issue.LineNumber, severity: issue.Severity ); fileItem.AddChild(issueItem); } categoryItem.AddChild(fileItem); } root.AddChild(categoryItem); } } private void BuildTreeByFile(TreeViewItem root, List issues, ref int id) { // Build grouped structure with counts in a single pass Dictionary issues, int critical, int high)> fileGroups = new(); foreach (AnalyzerIssue issue in issues) { if ( !fileGroups.TryGetValue( issue.FilePath, out (List issues, int critical, int high) group ) ) { group = (new List(), 0, 0); } group.issues.Add(issue); if (issue.Severity == IssueSeverity.Critical) { group.critical++; } else if (issue.Severity == IssueSeverity.High) { group.high++; } fileGroups[issue.FilePath] = group; } // Sort file paths by critical count desc, high count desc, then alphabetically List sortedFilePaths = new(fileGroups.Keys); sortedFilePaths.Sort( (a, b) => { (List _, int critical, int high) groupA = fileGroups[a]; (List _, int critical, int high) groupB = fileGroups[b]; int criticalCompare = groupB.critical.CompareTo(groupA.critical); if (criticalCompare != 0) { return criticalCompare; } int highCompare = groupB.high.CompareTo(groupA.high); if (highCompare != 0) { return highCompare; } return string.Compare(a, b, StringComparison.Ordinal); } ); foreach (string filePath in sortedFilePaths) { (List fileIssues, int criticalCount, int highCount) = fileGroups[ filePath ]; string prefix = criticalCount > 0 ? "🔴 " : highCount > 0 ? "🟠 " : "🟡 "; IssueTreeViewItem fileItem = new( id++, 0, $"{prefix}{filePath} ({fileIssues.Count})", filePath: filePath, isFile: true ) { IssueCount = fileIssues.Count, CriticalCount = criticalCount, HighCount = highCount, }; // Sort by line number fileIssues.Sort((a, b) => a.LineNumber.CompareTo(b.LineNumber)); foreach (AnalyzerIssue issue in fileIssues) { string display = FormatIssueDisplay(issue); IssueTreeViewItem issueItem = new( id++, 1, display, issue, issue.FilePath, issue.LineNumber, severity: issue.Severity ); fileItem.AddChild(issueItem); } root.AddChild(fileItem); } } private void BuildFlatTree(TreeViewItem root, List issues, ref int id) { // Sort in-place to avoid creating new collection issues.Sort( (a, b) => { int severityCompare = ((int)a.Severity).CompareTo((int)b.Severity); if (severityCompare != 0) { return severityCompare; } int fileCompare = string.Compare( a.FilePath, b.FilePath, StringComparison.Ordinal ); if (fileCompare != 0) { return fileCompare; } return a.LineNumber.CompareTo(b.LineNumber); } ); foreach (AnalyzerIssue issue in issues) { string display = $"{FormatIssueDisplay(issue)} - {issue.FilePath}:{issue.LineNumber}"; IssueTreeViewItem issueItem = new( id++, 0, display, issue, issue.FilePath, issue.LineNumber, severity: issue.Severity ); root.AddChild(issueItem); } } private static string FormatIssueDisplay(AnalyzerIssue issue) { string severityEmoji = issue.Severity switch { IssueSeverity.Critical => "🔴", IssueSeverity.High => "🟠", IssueSeverity.Medium => "🟡", IssueSeverity.Low => "🟢", IssueSeverity.Info => "🔵", _ => "⚪", }; return $"{severityEmoji} Line {issue.LineNumber}: {issue.ClassName}.{issue.MethodName} - {issue.IssueType}"; } private static string GetSeverityDisplayName(IssueSeverity severity) { return severity switch { IssueSeverity.Critical => "🔴 Critical", IssueSeverity.High => "🟠 High", IssueSeverity.Medium => "🟡 Medium", IssueSeverity.Low => "🟢 Low", IssueSeverity.Info => "🔵 Info", _ => "Unknown", }; } private static string GetCategoryDisplayName(IssueCategory category) { return category switch { IssueCategory.UnityLifecycle => "🎮 Unity Lifecycle", IssueCategory.UnityInheritance => "🔷 Unity Inheritance", IssueCategory.GeneralInheritance => "📦 General Inheritance", _ => "Unknown", }; } protected override void RowGUI(RowGUIArgs args) { IssueTreeViewItem item = args.item as IssueTreeViewItem; Rect contentRect = args.rowRect; contentRect.xMin += GetContentIndent(args.item); if (item?.Issue != null) { GUIStyle style = new(EditorStyles.label); Color textColor = item.Severity switch { IssueSeverity.Critical => new Color(1f, 0.3f, 0.3f), IssueSeverity.High => new Color(1f, 0.6f, 0.2f), IssueSeverity.Medium => new Color(1f, 0.9f, 0.2f), IssueSeverity.Low => new Color(0.5f, 0.9f, 0.5f), _ => Color.white, }; style.normal.textColor = textColor; GUI.Label(contentRect, args.item.displayName, style); } else if (item?.IsFile == true) { GUIStyle style = new(EditorStyles.boldLabel); if (item.CriticalCount > 0) { style.normal.textColor = new Color(1f, 0.5f, 0.5f); } else if (item.HighCount > 0) { style.normal.textColor = new Color(1f, 0.7f, 0.4f); } GUI.Label(contentRect, args.item.displayName, style); } else { base.RowGUI(args); } } protected override void SingleClickedItem(int id) { TreeViewItem item = FindItem(id, rootItem); if (item is IssueTreeViewItem issueItem) { if (issueItem.Issue != null) { OnIssueSelected?.Invoke(issueItem.Issue); } } } protected override void DoubleClickedItem(int id) { TreeViewItem item = FindItem(id, rootItem); if (item is IssueTreeViewItem issueItem) { string filePath = issueItem.FilePath; int lineNumber = issueItem.LineNumber; if (issueItem.Issue != null) { filePath = issueItem.Issue.FilePath; lineNumber = issueItem.Issue.LineNumber; } if (!string.IsNullOrEmpty(filePath)) { OnOpenFile?.Invoke(filePath, lineNumber); } } } protected override void ContextClickedItem(int id) { TreeViewItem item = FindItem(id, rootItem); if (item is IssueTreeViewItem issueItem) { string filePath = issueItem.FilePath; AnalyzerIssue issue = issueItem.Issue; if (issue != null) { filePath = issue.FilePath; } GenericMenu menu = new(); if (!string.IsNullOrEmpty(filePath)) { menu.AddItem( new GUIContent("Open File"), false, () => { int lineNumber = issueItem.LineNumber; if (issue != null) { lineNumber = issue.LineNumber; } OnOpenFile?.Invoke(filePath, lineNumber); } ); menu.AddItem( new GUIContent("Reveal in File Browser"), false, () => OnRevealInExplorer?.Invoke(filePath) ); } if (issue != null) { if (!string.IsNullOrEmpty(filePath)) { menu.AddSeparator(""); } menu.AddItem( new GUIContent("Copy Issue as JSON"), false, () => OnCopyIssueAsJson?.Invoke(issue) ); menu.AddItem( new GUIContent("Copy Issue as Markdown"), false, () => OnCopyIssueAsMarkdown?.Invoke(issue) ); } menu.AddSeparator(""); menu.AddItem( new GUIContent("Copy All Issues as JSON"), false, () => OnCopyAllAsJson?.Invoke() ); menu.AddItem( new GUIContent("Copy All Issues as Markdown"), false, () => OnCopyAllAsMarkdown?.Invoke() ); menu.ShowAsContext(); } } } #endif }