// 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 System.IO;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using UnityEditor;
using UnityEditor.IMGUI.Controls;
using UnityEngine;
using WallstopStudios.UnityHelpers.Core.Extension;
using WallstopStudios.UnityHelpers.Core.Serialization;
using WallstopStudios.UnityHelpers.Editor.Utils;
using WallstopStudios.UnityHelpers.Editor.Utils.WButton;
using Object = UnityEngine.Object;
///
/// Unity Editor window for analyzing C# code for inheritance and Unity method issues.
///
public sealed class UnityMethodAnalyzerWindow : EditorWindow
{
private static bool SuppressUserPrompts { get; set; }
static UnityMethodAnalyzerWindow()
{
try
{
if (Application.isBatchMode || IsInvokedByTestRunner())
{
SuppressUserPrompts = true;
}
}
catch
{
// Swallow
}
}
private static bool IsInvokedByTestRunner()
{
string[] args = Environment.GetCommandLineArgs();
for (int i = 0; i < args.Length; ++i)
{
string a = args[i];
if (
a.IndexOf("runTests", StringComparison.OrdinalIgnoreCase) >= 0
|| a.IndexOf("testResults", StringComparison.OrdinalIgnoreCase) >= 0
|| a.IndexOf("testPlatform", StringComparison.OrdinalIgnoreCase) >= 0
)
{
return true;
}
}
return false;
}
[SerializeField]
internal List _sourcePaths = new();
[SerializeField]
private TreeViewState _treeViewState;
[SerializeField]
private bool _sourcePathsFoldout = true;
private Vector2 _sourcePathsScrollPosition;
internal MethodAnalyzer _analyzer;
private IssueTreeView _treeView;
private Vector2 _detailScrollPosition;
private AnalyzerIssue _selectedIssue;
internal bool _isAnalyzing;
internal float _analysisProgress;
internal string _statusMessage = "Ready to analyze";
internal CancellationTokenSource _cancellationTokenSource;
///
/// Internal TaskCompletionSource for tests to await analysis completion.
/// Set before StartAnalysis() to enable awaiting completion.
///
#pragma warning disable CS0649 // Field is never assigned to, and will always have its default value
internal TaskCompletionSource _analysisCompletionSource;
#pragma warning restore CS0649 // Field is never assigned to, and will always have its default value
///
/// Internal reference to the current analysis task for test synchronization.
/// Tests can await this task to know when the async analysis work is complete,
/// then call FlushMainThreadQueue() to process the completion callback.
///
internal Task _analysisTask;
internal bool _groupByFile = true;
internal bool _groupBySeverity;
internal bool _groupByCategory;
private IssueSeverity? _severityFilter;
private IssueCategory? _categoryFilter;
private string _searchFilter = string.Empty;
internal int _criticalCount;
internal int _highCount;
internal int _mediumCount;
internal int _lowCount;
internal int _infoCount;
internal int _totalCount;
private const float ToolbarHeight = 40f;
private const float FilterHeight = 50f;
private const float SummaryHeight = 30f;
private const float DetailPanelMinHeight = 150f;
private const float SplitterHeight = 4f;
private const float MinSourcePathsSectionHeight = 80f;
private const float MaxSourcePathsSectionHeight = 200f;
private const float NarrowLayoutThreshold = 700f;
private const float VeryNarrowLayoutThreshold = 500f;
private float _detailPanelHeight = 200f;
private bool _isResizingDetailPanel;
[MenuItem("Tools/Wallstop Studios/Unity Helpers/Unity Method Analyzer")]
public static void ShowWindow()
{
UnityMethodAnalyzerWindow window = GetWindow(
"Unity Method Analyzer"
);
window.minSize = new Vector2(450, 400);
window.Show();
}
private void OnEnable()
{
Initialize();
}
///
/// Initializes the window's analyzer and tree view. Called from OnEnable().
/// Also accessible for testing purposes.
///
internal void Initialize()
{
_analyzer = new MethodAnalyzer();
_treeViewState ??= new TreeViewState();
_treeView = new IssueTreeView(_treeViewState);
_treeView.OnIssueSelected += OnIssueSelected;
_treeView.OnOpenFile += OpenFileAtLine;
_treeView.OnRevealInExplorer += RevealFileInExplorer;
_treeView.OnCopyIssueAsJson += CopyIssueToClipboardAsJson;
_treeView.OnCopyIssueAsMarkdown += CopyIssueToClipboardAsMarkdown;
_treeView.OnCopyAllAsJson += CopyAllIssuesToClipboardAsJson;
_treeView.OnCopyAllAsMarkdown += CopyAllIssuesToClipboardAsMarkdown;
if (_sourcePaths == null || _sourcePaths.Count == 0)
{
_sourcePaths = new List { GetProjectRoot() };
}
}
internal void OnDisable()
{
CancellationTokenSource cts = _cancellationTokenSource;
if (cts != null)
{
try
{
// Both IsCancellationRequested and Cancel() can throw if disposed
if (!cts.IsCancellationRequested)
{
cts.Cancel();
}
}
catch (ObjectDisposedException)
{
// CTS was already disposed, safe to ignore
}
try
{
cts.Dispose();
}
catch (ObjectDisposedException)
{
// Already disposed, safe to ignore
}
_cancellationTokenSource = null;
}
if (_treeView != null)
{
_treeView.OnIssueSelected -= OnIssueSelected;
_treeView.OnOpenFile -= OpenFileAtLine;
_treeView.OnRevealInExplorer -= RevealFileInExplorer;
_treeView.OnCopyIssueAsJson -= CopyIssueToClipboardAsJson;
_treeView.OnCopyIssueAsMarkdown -= CopyIssueToClipboardAsMarkdown;
_treeView.OnCopyAllAsJson -= CopyAllIssuesToClipboardAsJson;
_treeView.OnCopyAllAsMarkdown -= CopyAllIssuesToClipboardAsMarkdown;
}
}
private static string GetProjectRoot()
{
return Path.GetDirectoryName(Application.dataPath) ?? Application.dataPath;
}
private void OnGUI()
{
float windowWidth = position.width;
bool isNarrowLayout = windowWidth < NarrowLayoutThreshold;
bool isVeryNarrowLayout = windowWidth < VeryNarrowLayoutThreshold;
float sourcePathsHeight = DrawSourcePathsSection(isNarrowLayout);
DrawToolbar(isNarrowLayout);
DrawFilters(isNarrowLayout, isVeryNarrowLayout);
DrawSummary(isNarrowLayout);
float fixedHeight = ToolbarHeight + FilterHeight + SummaryHeight + sourcePathsHeight;
float remainingHeight = position.height - fixedHeight - _detailPanelHeight;
DrawTreeView(Mathf.Max(remainingHeight, 100f));
DrawSplitter();
DrawDetailPanel();
if (_isAnalyzing)
{
Repaint();
}
}
private float DrawSourcePathsSection(bool isNarrowLayout)
{
GUILayout.BeginVertical(EditorStyles.helpBox);
GUILayout.BeginHorizontal();
_sourcePathsFoldout = EditorGUILayout.Foldout(
_sourcePathsFoldout,
$"Source Directories ({_sourcePaths?.Count ?? 0})",
true
);
GUILayout.FlexibleSpace();
if (GUILayout.Button("+", GUILayout.Width(25)))
{
string browsePath =
_sourcePaths?.Count > 0 && !string.IsNullOrEmpty(_sourcePaths[^1])
? _sourcePaths[^1]
: GetProjectRoot();
string selectedPath = EditorUi.OpenFolderPanel(
"Select Source Directory",
browsePath,
""
);
if (!string.IsNullOrEmpty(selectedPath))
{
_sourcePaths ??= new List();
if (!_sourcePaths.Contains(selectedPath))
{
_sourcePaths.Add(selectedPath);
}
}
}
GUILayout.EndHorizontal();
float sectionHeight = EditorGUIUtility.singleLineHeight + 6f;
if (_sourcePathsFoldout && _sourcePaths is { Count: > 0 })
{
float pathsListHeight = Mathf.Min(
_sourcePaths.Count * (EditorGUIUtility.singleLineHeight + 4f),
MaxSourcePathsSectionHeight - sectionHeight
);
pathsListHeight = Mathf.Max(
pathsListHeight,
MinSourcePathsSectionHeight - sectionHeight
);
_sourcePathsScrollPosition = GUILayout.BeginScrollView(
_sourcePathsScrollPosition,
GUILayout.Height(pathsListHeight)
);
int removeIndex = -1;
for (int i = 0; i < _sourcePaths.Count; i++)
{
GUILayout.BeginHorizontal();
string displayPath = _sourcePaths[i];
string projectRoot = GetProjectRoot();
if (
!string.IsNullOrEmpty(displayPath)
&& displayPath.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase)
)
{
displayPath =
displayPath.Length > projectRoot.Length
? "." + displayPath.Substring(projectRoot.Length)
: ".";
}
bool pathExists =
!string.IsNullOrEmpty(_sourcePaths[i]) && Directory.Exists(_sourcePaths[i]);
GUIStyle pathStyle = pathExists
? EditorStyles.label
: new GUIStyle(EditorStyles.label)
{
normal = { textColor = new Color(1f, 0.4f, 0.4f) },
};
float labelWidth = isNarrowLayout
? position.width - 85f
: position.width - 105f;
GUILayout.Label(
new GUIContent(displayPath, _sourcePaths[i]),
pathStyle,
GUILayout.Width(labelWidth)
);
if (GUILayout.Button("...", GUILayout.Width(30)))
{
string browsePath =
!string.IsNullOrEmpty(_sourcePaths[i])
&& Directory.Exists(_sourcePaths[i])
? _sourcePaths[i]
: GetProjectRoot();
string selectedPath = EditorUi.OpenFolderPanel(
"Select Source Directory",
browsePath,
""
);
if (!string.IsNullOrEmpty(selectedPath))
{
_sourcePaths[i] = selectedPath;
}
}
if (GUILayout.Button("-", GUILayout.Width(25)))
{
removeIndex = i;
}
GUILayout.EndHorizontal();
}
if (removeIndex >= 0)
{
_sourcePaths.RemoveAt(removeIndex);
}
GUILayout.EndScrollView();
sectionHeight += pathsListHeight;
}
GUILayout.EndVertical();
return sectionHeight + 4f;
}
private static readonly Color AnalyzeButtonColor = new(0.25f, 0.68f, 0.38f, 1f);
private static readonly Color CancelButtonColor = new(0.92f, 0.29f, 0.33f, 1f);
private static readonly Color DisabledButtonColor = new(0.5f, 0.5f, 0.5f, 1f);
private void DrawToolbar(bool isNarrowLayout)
{
GUILayout.BeginHorizontal(EditorStyles.helpBox, GUILayout.Height(ToolbarHeight));
bool hasValidPaths =
_sourcePaths != null
&& _sourcePaths.Any(p => !string.IsNullOrEmpty(p) && Directory.Exists(p));
bool analyzeEnabled = !_isAnalyzing && hasValidPaths;
Color analyzeColor = analyzeEnabled ? AnalyzeButtonColor : DisabledButtonColor;
Color analyzeTextColor = WButtonColorUtility.GetReadableTextColor(analyzeColor);
GUIStyle analyzeButtonStyle = WButtonStyles.GetColoredButtonStyle(
analyzeColor,
analyzeTextColor
);
analyzeButtonStyle = new GUIStyle(analyzeButtonStyle)
{
fontStyle = FontStyle.Bold,
fontSize = 13,
fixedHeight = ToolbarHeight - 8,
padding = new RectOffset(12, 12, 4, 4),
};
GUI.enabled = analyzeEnabled;
string buttonText = isNarrowLayout ? "▶ Analyze" : "▶ Analyze Code";
float buttonWidth = isNarrowLayout ? 95f : 130f;
if (GUILayout.Button(buttonText, analyzeButtonStyle, GUILayout.Width(buttonWidth)))
{
StartAnalysis();
}
GUI.enabled = true;
if (_isAnalyzing)
{
GUILayout.Space(8);
Color cancelTextColor = WButtonColorUtility.GetReadableTextColor(CancelButtonColor);
GUIStyle cancelButtonStyle = WButtonStyles.GetColoredButtonStyle(
CancelButtonColor,
cancelTextColor
);
cancelButtonStyle = new GUIStyle(cancelButtonStyle)
{
fixedHeight = ToolbarHeight - 8,
};
if (GUILayout.Button("Cancel", cancelButtonStyle, GUILayout.Width(60)))
{
CancelAnalysis();
}
GUILayout.Space(8);
Rect progressRect = GUILayoutUtility.GetRect(
isNarrowLayout ? 80f : 120f,
ToolbarHeight - 12,
GUILayout.ExpandWidth(false)
);
progressRect.y += 2;
EditorGUI.ProgressBar(
progressRect,
_analysisProgress,
$"{_analysisProgress * 100:F0}%"
);
}
GUILayout.FlexibleSpace();
GUIStyle statusStyle = new(EditorStyles.label)
{
alignment = TextAnchor.MiddleRight,
wordWrap = true,
};
GUILayout.Label(
_statusMessage,
statusStyle,
GUILayout.MaxWidth(isNarrowLayout ? 150f : 300f)
);
GUILayout.EndHorizontal();
}
private void DrawFilters(bool isNarrowLayout, bool isVeryNarrowLayout)
{
GUILayout.BeginVertical(EditorStyles.helpBox);
if (isVeryNarrowLayout)
{
DrawFiltersVerticalLayout();
}
else if (isNarrowLayout)
{
DrawFiltersTwoRowLayout();
}
else
{
DrawFiltersSingleRowLayout();
}
GUILayout.EndVertical();
}
private void DrawFiltersSingleRowLayout()
{
GUILayout.BeginHorizontal();
DrawGroupBySection();
GUILayout.Space(15);
DrawSeverityFilter();
GUILayout.Space(10);
DrawCategoryFilter();
GUILayout.Space(10);
DrawSearchFilter();
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
}
private void DrawFiltersTwoRowLayout()
{
GUILayout.BeginHorizontal();
DrawGroupBySection();
GUILayout.FlexibleSpace();
DrawSearchFilter();
GUILayout.EndHorizontal();
GUILayout.BeginHorizontal();
DrawSeverityFilter();
GUILayout.Space(10);
DrawCategoryFilter();
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
}
private void DrawFiltersVerticalLayout()
{
DrawGroupBySection();
GUILayout.Space(4);
GUILayout.BeginHorizontal();
DrawSeverityFilter();
GUILayout.Space(10);
DrawCategoryFilter();
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
DrawSearchFilter();
}
private void DrawGroupBySection()
{
GUILayout.BeginHorizontal();
GUILayout.Label("Group By:", GUILayout.Width(60));
bool newGroupByFile = GUILayout.Toggle(
_groupByFile,
"File",
EditorStyles.miniButtonLeft,
GUILayout.Width(50)
);
bool newGroupBySeverity = GUILayout.Toggle(
_groupBySeverity,
"Severity",
EditorStyles.miniButtonMid,
GUILayout.Width(60)
);
bool newGroupByCategory = GUILayout.Toggle(
_groupByCategory,
"Category",
EditorStyles.miniButtonRight,
GUILayout.Width(65)
);
GUILayout.EndHorizontal();
// These toggles act as radio buttons - exactly one must be selected.
// Detect which button was clicked by checking for a transition from false to true.
// If a button transitions from true to false (user clicked currently selected), ignore it.
// Use else-if to ensure only one transition is processed per frame.
bool fileClicked = newGroupByFile && !_groupByFile;
bool severityClicked = newGroupBySeverity && !_groupBySeverity;
bool categoryClicked = newGroupByCategory && !_groupByCategory;
if (fileClicked)
{
_groupByFile = true;
_groupBySeverity = false;
_groupByCategory = false;
UpdateTreeViewGrouping();
}
else if (severityClicked)
{
_groupByFile = false;
_groupBySeverity = true;
_groupByCategory = false;
UpdateTreeViewGrouping();
}
else if (categoryClicked)
{
_groupByFile = false;
_groupBySeverity = false;
_groupByCategory = true;
UpdateTreeViewGrouping();
}
}
private void DrawSeverityFilter()
{
GUILayout.BeginHorizontal();
GUILayout.Label("Severity:", GUILayout.Width(55));
int severityIndex = _severityFilter.HasValue ? (int)_severityFilter.Value : 0;
string[] severityOptions = { "All", "Critical", "High", "Medium", "Low", "Info" };
int newSeverityIndex = EditorGUILayout.Popup(
severityIndex,
severityOptions,
GUILayout.Width(80)
);
GUILayout.EndHorizontal();
if (newSeverityIndex != severityIndex)
{
_severityFilter = newSeverityIndex == 0 ? null : (IssueSeverity)newSeverityIndex;
UpdateTreeViewFilters();
}
}
private void DrawCategoryFilter()
{
GUILayout.BeginHorizontal();
GUILayout.Label("Category:", GUILayout.Width(60));
int categoryIndex = _categoryFilter.HasValue ? (int)_categoryFilter.Value + 1 : 0;
string[] categoryOptions =
{
"All",
"Unity Lifecycle",
"Unity Inheritance",
"General Inheritance",
};
int newCategoryIndex = EditorGUILayout.Popup(
categoryIndex,
categoryOptions,
GUILayout.Width(120)
);
GUILayout.EndHorizontal();
if (newCategoryIndex != categoryIndex)
{
_categoryFilter =
newCategoryIndex == 0 ? null : (IssueCategory)(newCategoryIndex - 1);
UpdateTreeViewFilters();
}
}
private void DrawSearchFilter()
{
GUILayout.BeginHorizontal();
GUILayout.Label("Search:", GUILayout.Width(50));
string newSearch = EditorGUILayout.TextField(_searchFilter, GUILayout.MinWidth(100));
GUILayout.EndHorizontal();
if (newSearch != _searchFilter)
{
_searchFilter = newSearch;
UpdateTreeViewFilters();
}
}
private void DrawSummary(bool isNarrowLayout)
{
GUILayout.BeginHorizontal(EditorStyles.helpBox);
GUIStyle totalStyle = new(EditorStyles.boldLabel) { alignment = TextAnchor.MiddleLeft };
GUILayout.Label(
$"Total: {_totalCount}",
totalStyle,
GUILayout.Width(isNarrowLayout ? 60f : 80f)
);
GUIStyle criticalStyle = new(EditorStyles.label)
{
normal = { textColor = new Color(1f, 0.3f, 0.3f) },
};
GUIStyle highStyle = new(EditorStyles.label)
{
normal = { textColor = new Color(1f, 0.6f, 0.2f) },
};
GUIStyle mediumStyle = new(EditorStyles.label)
{
normal = { textColor = new Color(1f, 0.9f, 0.2f) },
};
GUIStyle lowStyle = new(EditorStyles.label)
{
normal = { textColor = new Color(0.5f, 0.9f, 0.5f) },
};
GUIStyle infoStyle = new(EditorStyles.label)
{
normal = { textColor = new Color(0.5f, 0.7f, 1f) },
};
if (isNarrowLayout)
{
GUILayout.Label($"🔴{_criticalCount}", criticalStyle);
GUILayout.Label($"🟠{_highCount}", highStyle);
GUILayout.Label($"🟡{_mediumCount}", mediumStyle);
GUILayout.Label($"🟢{_lowCount}", lowStyle);
GUILayout.Label($"🔵{_infoCount}", infoStyle);
}
else
{
GUILayout.Label(
$"🔴 Critical: {_criticalCount}",
criticalStyle,
GUILayout.Width(100)
);
GUILayout.Label($"🟠 High: {_highCount}", highStyle, GUILayout.Width(80));
GUILayout.Label($"🟡 Medium: {_mediumCount}", mediumStyle, GUILayout.Width(100));
GUILayout.Label($"🟢 Low: {_lowCount}", lowStyle, GUILayout.Width(70));
GUILayout.Label($"🔵 Info: {_infoCount}", infoStyle, GUILayout.Width(70));
}
GUILayout.FlexibleSpace();
if (_totalCount > 0)
{
if (GUILayout.Button("Export ▾", GUILayout.Width(isNarrowLayout ? 70f : 100f)))
{
ShowExportMenu();
}
}
GUILayout.EndHorizontal();
}
private void DrawTreeView(float height)
{
GUILayout.BeginVertical(EditorStyles.helpBox, GUILayout.Height(height));
GUILayout.Label("Issues", EditorStyles.boldLabel);
float innerHeight = height - EditorGUIUtility.singleLineHeight - 8f;
Rect treeViewRect = GUILayoutUtility.GetRect(
0,
innerHeight,
GUILayout.ExpandWidth(true)
);
_treeView?.OnGUI(treeViewRect);
GUILayout.EndVertical();
}
private void DrawSplitter()
{
Rect splitterRect = GUILayoutUtility.GetRect(
GUIContent.none,
GUIStyle.none,
GUILayout.Height(SplitterHeight),
GUILayout.ExpandWidth(true)
);
EditorGUI.DrawRect(splitterRect, new Color(0.2f, 0.2f, 0.2f));
EditorGUIUtility.AddCursorRect(splitterRect, MouseCursor.ResizeVertical);
if (
Event.current.type == EventType.MouseDown
&& splitterRect.Contains(Event.current.mousePosition)
)
{
_isResizingDetailPanel = true;
Event.current.Use();
}
if (_isResizingDetailPanel)
{
if (Event.current.type == EventType.MouseDrag)
{
_detailPanelHeight -= Event.current.delta.y;
_detailPanelHeight = Mathf.Clamp(
_detailPanelHeight,
DetailPanelMinHeight,
position.height - 200
);
Repaint();
Event.current.Use();
}
if (Event.current.type == EventType.MouseUp)
{
_isResizingDetailPanel = false;
Event.current.Use();
}
}
}
private void DrawDetailPanel()
{
GUILayout.BeginVertical(EditorStyles.helpBox, GUILayout.Height(_detailPanelHeight));
GUILayout.Label("Issue Details", EditorStyles.boldLabel);
if (_selectedIssue == null)
{
GUILayout.Label(
"Select an issue to view details. Double-click to open the file.",
EditorStyles.centeredGreyMiniLabel
);
}
else
{
_detailScrollPosition = GUILayout.BeginScrollView(_detailScrollPosition);
GUILayout.BeginHorizontal();
GUILayout.Label("File:", EditorStyles.boldLabel, GUILayout.Width(100));
if (
GUILayout.Button(
$"{_selectedIssue.FilePath}:{_selectedIssue.LineNumber}",
EditorStyles.linkLabel
)
)
{
OpenFileAtLine(_selectedIssue.FilePath, _selectedIssue.LineNumber);
}
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
GUILayout.BeginHorizontal();
GUILayout.Label("Class:", EditorStyles.boldLabel, GUILayout.Width(100));
GUILayout.Label(_selectedIssue.ClassName);
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
GUILayout.BeginHorizontal();
GUILayout.Label("Method:", EditorStyles.boldLabel, GUILayout.Width(100));
GUILayout.Label(_selectedIssue.MethodName);
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
GUILayout.BeginHorizontal();
GUILayout.Label("Issue Type:", EditorStyles.boldLabel, GUILayout.Width(100));
GUILayout.Label(_selectedIssue.IssueType);
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
GUILayout.BeginHorizontal();
GUILayout.Label("Severity:", EditorStyles.boldLabel, GUILayout.Width(100));
GUILayout.Label(GetSeverityDisplay(_selectedIssue.Severity));
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
GUILayout.BeginHorizontal();
GUILayout.Label("Category:", EditorStyles.boldLabel, GUILayout.Width(100));
GUILayout.Label(GetCategoryDisplay(_selectedIssue.Category));
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
GUILayout.Space(10);
GUILayout.Label("Description:", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(_selectedIssue.Description, MessageType.Warning);
GUILayout.Label("Recommended Fix:", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(_selectedIssue.RecommendedFix, MessageType.Info);
if (!string.IsNullOrEmpty(_selectedIssue.BaseClassName))
{
GUILayout.Space(10);
GUILayout.Label("Inheritance Details:", EditorStyles.boldLabel);
GUILayout.BeginHorizontal();
GUILayout.Label("Base Class:", GUILayout.Width(100));
GUILayout.Label(_selectedIssue.BaseClassName);
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
if (!string.IsNullOrEmpty(_selectedIssue.BaseMethodSignature))
{
GUILayout.BeginHorizontal();
GUILayout.Label("Base Method:", GUILayout.Width(100));
GUILayout.Label(_selectedIssue.BaseMethodSignature, EditorStyles.miniLabel);
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
}
if (!string.IsNullOrEmpty(_selectedIssue.DerivedMethodSignature))
{
GUILayout.BeginHorizontal();
GUILayout.Label("Derived Method:", GUILayout.Width(100));
GUILayout.Label(
_selectedIssue.DerivedMethodSignature,
EditorStyles.miniLabel
);
GUILayout.FlexibleSpace();
GUILayout.EndHorizontal();
}
}
GUILayout.EndScrollView();
}
GUILayout.EndVertical();
}
private static string GetSeverityDisplay(IssueSeverity severity)
{
return severity switch
{
IssueSeverity.Critical => "🔴 Critical - Will definitely cause bugs",
IssueSeverity.High => "🟠 High - Very likely to cause bugs",
IssueSeverity.Medium => "🟡 Medium - May cause subtle bugs",
IssueSeverity.Low => "🟢 Low - Code smell or maintainability issue",
IssueSeverity.Info => "🔵 Info - Informational only",
_ => "Unknown",
};
}
private static string GetCategoryDisplay(IssueCategory category)
{
return category switch
{
IssueCategory.UnityLifecycle => "🎮 Unity Lifecycle",
IssueCategory.UnityInheritance => "🔷 Unity Inheritance",
IssueCategory.GeneralInheritance => "📦 General Inheritance",
_ => "Unknown",
};
}
internal void StartAnalysis()
{
if (_isAnalyzing)
{
return;
}
if (_analyzer == null)
{
_statusMessage = "Analyzer not initialized";
return;
}
_isAnalyzing = true;
_analysisProgress = 0f;
_statusMessage = "Analyzing...";
_selectedIssue = null;
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = new CancellationTokenSource();
List directories = new();
string rootPath = GetProjectRoot();
if (_sourcePaths != null)
{
foreach (string sourcePath in _sourcePaths)
{
if (string.IsNullOrEmpty(sourcePath))
{
continue;
}
if (Directory.Exists(sourcePath))
{
directories.Add(sourcePath);
}
}
}
if (directories.Count == 0)
{
_statusMessage = "No valid directories selected";
FinalizeAnalysis();
return;
}
Progress progress = new(p =>
{
// Guard against late progress updates after analysis has been reset.
// Progress uses SynchronizationContext.Post() which can deliver callbacks
// after the analysis task completion callback has already run ResetAnalysisState().
if (_isAnalyzing)
{
_analysisProgress = p;
}
});
CancellationToken token = _cancellationTokenSource.Token;
Task analysisTask = _analyzer.AnalyzeAsync(rootPath, directories, progress, token);
_analysisTask = analysisTask;
analysisTask.ContinueWith(
task =>
{
EnqueueOnMainThread(() => HandleAnalysisCompletion(task));
},
CancellationToken.None,
TaskContinuationOptions.None,
TaskScheduler.Default
);
}
///
/// Handles the completion of the analysis task on the main thread.
///
private void HandleAnalysisCompletion(Task task)
{
try
{
// Capture to local variable to avoid TOCTOU race, since OnDisable() may null the field
CancellationTokenSource cts = _cancellationTokenSource;
if (task.IsCanceled || (cts?.IsCancellationRequested ?? false))
{
_statusMessage = "Analysis cancelled";
}
else if (task.IsFaulted)
{
Exception e = task.Exception?.GetBaseException() ?? task.Exception;
_statusMessage = $"Analysis failed: {e?.Message ?? "Unknown error"}";
}
else
{
UpdateIssueCounts();
string rootPath = GetProjectRoot();
_treeView?.SetIssues(_analyzer.Issues, rootPath);
_statusMessage = $"Analysis complete: {_totalCount} issues found";
}
}
catch (Exception e)
{
_statusMessage = $"Analysis failed";
this.LogError($"Analysis failed", e);
}
finally
{
FinalizeAnalysis();
}
}
///
/// Finalizes the analysis by resetting state and signaling completion.
/// Called after analysis completes, fails, or is cancelled.
///
private void FinalizeAnalysis()
{
ResetAnalysisState();
// Signal completion for test synchronization
_analysisCompletionSource?.TrySetResult(true);
}
private static readonly object MainThreadQueueLock = new();
private static readonly Queue MainThreadQueue = new();
private static bool _isUpdateSubscribed;
///
/// Enqueues an action to be executed on the main thread via EditorApplication.update.
/// This ensures async continuations are properly executed on the main thread in all scenarios,
/// including Unity Editor tests.
///
private static void EnqueueOnMainThread(Action action)
{
if (action == null)
{
return;
}
lock (MainThreadQueueLock)
{
MainThreadQueue.Enqueue(action);
if (!_isUpdateSubscribed)
{
EditorApplication.update += ProcessMainThreadQueue;
_isUpdateSubscribed = true;
}
}
}
private static void ProcessMainThreadQueue()
{
Action[] actionsToProcess;
lock (MainThreadQueueLock)
{
if (MainThreadQueue.Count == 0)
{
EditorApplication.update -= ProcessMainThreadQueue;
_isUpdateSubscribed = false;
return;
}
actionsToProcess = MainThreadQueue.ToArray();
MainThreadQueue.Clear();
}
foreach (Action action in actionsToProcess)
{
try
{
action();
}
catch (Exception e)
{
Debug.LogException(e);
}
}
}
///
/// Forces processing of the main thread queue. This is useful in test scenarios
/// where EditorApplication.update may not be called reliably.
///
internal static void FlushMainThreadQueue()
{
Action[] actionsToProcess;
lock (MainThreadQueueLock)
{
if (MainThreadQueue.Count == 0)
{
return;
}
actionsToProcess = MainThreadQueue.ToArray();
MainThreadQueue.Clear();
}
foreach (Action action in actionsToProcess)
{
try
{
action();
}
catch (Exception e)
{
Debug.LogException(e);
}
}
}
internal void CancelAnalysis()
{
CancellationTokenSource cts = _cancellationTokenSource;
if (cts == null)
{
return;
}
try
{
if (!cts.IsCancellationRequested)
{
cts.Cancel();
}
}
catch (ObjectDisposedException)
{
// CTS was already disposed, which means the analysis has completed
// or was already cancelled. Safe to ignore.
}
// Immediately reset state since the ContinueWith callback may not execute
// promptly in certain scenarios. The HandleAnalysisCompletion will also
// call FinalizeAnalysis, but calling it here ensures immediate responsiveness.
// FinalizeAnalysis is idempotent via TrySetResult, so multiple calls are safe.
_statusMessage = "Analysis cancelled";
FinalizeAnalysis();
}
///
/// Resets the analysis state and UI to allow new analysis.
/// Called after analysis completes, fails, or is cancelled.
///
internal void ResetAnalysisState()
{
_isAnalyzing = false;
_analysisProgress = 0f;
Repaint();
}
internal void UpdateIssueCounts()
{
IReadOnlyList issues = _analyzer.Issues;
_totalCount = issues.Count;
// Count all severities in a single pass
int criticalCount = 0;
int highCount = 0;
int mediumCount = 0;
int lowCount = 0;
int infoCount = 0;
foreach (AnalyzerIssue issue in issues)
{
switch (issue.Severity)
{
case IssueSeverity.Critical:
criticalCount++;
break;
case IssueSeverity.High:
highCount++;
break;
case IssueSeverity.Medium:
mediumCount++;
break;
case IssueSeverity.Low:
lowCount++;
break;
case IssueSeverity.Info:
infoCount++;
break;
}
}
_criticalCount = criticalCount;
_highCount = highCount;
_mediumCount = mediumCount;
_lowCount = lowCount;
_infoCount = infoCount;
}
private void UpdateTreeViewGrouping()
{
_treeView?.SetGrouping(_groupByFile, _groupBySeverity, _groupByCategory);
}
private void UpdateTreeViewFilters()
{
_treeView?.SetFilters(_severityFilter, _categoryFilter, _searchFilter);
}
private void OnIssueSelected(AnalyzerIssue issue)
{
_selectedIssue = issue;
Repaint();
}
private void OpenFileAtLine(string filePath, int lineNumber)
{
// Handle both absolute and relative paths
string fullPath;
if (Path.IsPathRooted(filePath))
{
// Already an absolute path, use it directly
fullPath = Path.GetFullPath(filePath);
}
else
{
// Relative path - try combining with project root
fullPath = Path.Combine(Application.dataPath, "..", filePath);
fullPath = Path.GetFullPath(fullPath);
}
if (!File.Exists(fullPath))
{
// Try as relative to Assets folder
string assetsPath = Path.Combine(Application.dataPath, filePath);
if (File.Exists(assetsPath))
{
fullPath = assetsPath;
}
else
{
// Search for the file by name in Assets folder
string[] foundFiles = Directory.GetFiles(
Application.dataPath,
Path.GetFileName(filePath),
SearchOption.AllDirectories
);
if (foundFiles.Length > 0)
{
fullPath = foundFiles[0];
}
}
}
// Convert to asset path for AssetDatabase
string assetPath = ConvertToAssetPath(fullPath);
if (assetPath != null)
{
Object asset = AssetDatabase.LoadAssetAtPath