using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using Newtonsoft.Json; using Unity.Pipeline.Commands; using UnityEditor; namespace Unity.Pipeline.Editor.Commands { /// /// Executes an Editor menu item by its path (e.g. "Assets/Reimport All"), or — when called with /// no path — lists the available menu items. Backs the unity menu CLI command (CLI-110): /// unity menu lists options, unity menu "<path>" executes one. The CLI only /// forwards the request; all logic lives here, per the two-commands design. /// /// Named MenuItemCommand (not MenuCommand) to avoid colliding with the built-in /// type, which would make unqualified references ambiguous /// in any file that also imports UnityEditor. /// /// Execution wraps , which returns false /// when the item could not be invoked — either because the path does not exist or because the /// item is currently disabled (its validation function returned false). The API does not /// distinguish those cases, so a failure is reported as "not found or disabled"; the CLI maps a /// failed result to its "menu item does not exist" exit code. /// /// Listing reflects the actual Editor menu via the internal /// UnityEditor.Menu.GetMenuItems API (accessed by reflection, mirroring /// ). Unlike scanning [MenuItem] attributes, this also /// covers menus Unity defines natively (much of GameObject/…, Assets/Create/…, /// File/…, etc.), which carry no attribute and would otherwise be missed. /// /// Note: some menu items are heavy, show a modal confirmation, or trigger a domain reload/quit /// (which tears the server down before it can reply). That is the nature of the invoked item; /// such cases are a known limitation of driving arbitrary menus over a request/response call. /// public static class MenuItemCommand { [CliCommand("menu", "Execute an Editor menu item by path, or list available items when no path is given", MainThreadRequired = true)] public static MenuResponse ExecuteMenu( [CliArg("path", "Menu item path to execute, e.g. \"Assets/Reimport All\". Omit to list available menu items.")] string path = "") { // No path → discovery mode: return the available menu items instead of executing. if (string.IsNullOrWhiteSpace(path)) { List items; try { items = DiscoverMenuItems(); } catch (Exception ex) { return MenuResponse.Fail(null, $"Failed to list menu items: {ex.Message}"); } return new MenuResponse { Success = true, Items = items, Message = $"{items.Count} menu item(s) available." }; } bool executed; try { executed = EditorApplication.ExecuteMenuItem(path); } catch (Exception ex) { return MenuResponse.Fail(path, $"Failed to execute menu item '{path}': {ex.Message}"); } if (!executed) return MenuResponse.Fail(path, $"Menu item '{path}' was not found or is currently disabled."); return new MenuResponse { Success = true, Path = path, Message = $"Executed menu item '{path}'" }; } const BindingFlags k_All = BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; // Reflection handles for the internal UnityEditor.Menu.GetMenuItems(string, bool, bool) API, // which returns an array of internal UnityEditor.ScriptingMenuItem. Resolved once and cached; // we read each item's `path` and `isSeparator` member (property or field, depending on the // Unity version). See FocusEditorCommand for the same reflect-into-internal-API approach. static MethodInfo s_GetMenuItems; static Func s_GetPath; static Func s_GetIsSeparator; static bool s_Resolved; static void EnsureReflection() { if (s_Resolved) return; s_Resolved = true; var editorAsm = typeof(EditorApplication).Assembly; var menuType = editorAsm.GetType("UnityEditor.Menu"); s_GetMenuItems = menuType?.GetMethod( "GetMenuItems", k_All, null, new[] { typeof(string), typeof(bool), typeof(bool) }, null); var itemType = editorAsm.GetType("UnityEditor.ScriptingMenuItem"); s_GetPath = BuildAccessor(itemType, "path", "m_Path"); s_GetIsSeparator = BuildAccessor(itemType, "isSeparator", "m_IsSeparator"); } /// /// Build a getter for a member that may be exposed as a property or a backing field, so the /// reflection survives the internal type's shape changing across Unity versions. /// static Func BuildAccessor(Type type, string propertyName, string fieldName) { var prop = type?.GetProperty(propertyName, k_All); if (prop != null) return o => (T)prop.GetValue(o); var field = type?.GetField(propertyName, k_All) ?? type?.GetField(fieldName, k_All); if (field != null) return o => (T)field.GetValue(o); return null; } // Standard top-level Editor menus. Unity defines these natively (no [MenuItem]), so they are // not discoverable from attributes; we seed them so their native children are enumerated. static readonly string[] k_NativeRoots = { "File", "Edit", "Assets", "GameObject", "Component", "Window", "Help" }; /// /// Collect the distinct menu paths from the live Editor menu. Menu.GetMenuItems returns /// the full (recursive) set of items under a given root — including ones Unity defines /// natively — but it does not enumerate the roots themselves, and no internal API does. So we /// union the native roots above with the first path segment of every [MenuItem] (which /// surfaces custom/package roots), then expand each root through the internal API. The items /// always come from the live menu, so native entries are included. /// static List DiscoverMenuItems() { EnsureReflection(); if (s_GetMenuItems == null || s_GetPath == null) throw new InvalidOperationException( "UnityEditor.Menu.GetMenuItems is unavailable in this Unity version."); var paths = new SortedSet(StringComparer.Ordinal); foreach (var root in CollectRoots()) CollectItemsUnder(root, paths); return paths.ToList(); } /// /// The top-level menu names to expand: the native roots, plus the first segment of every /// attributed menu item (covering custom/package menus). Component context menus /// (CONTEXT/…) are excluded, as they are not part of the main menu bar. /// static IEnumerable CollectRoots() { var roots = new SortedSet(StringComparer.Ordinal); foreach (var r in k_NativeRoots) roots.Add(r); foreach (var method in TypeCache.GetMethodsWithAttribute()) { foreach (var attr in method.GetCustomAttributes(typeof(MenuItem), false).Cast()) { if (attr.validate || string.IsNullOrEmpty(attr.menuItem)) continue; var slash = attr.menuItem.IndexOf('/'); var root = slash > 0 ? attr.menuItem.Substring(0, slash) : attr.menuItem; if (root.StartsWith("CONTEXT", StringComparison.Ordinal)) continue; roots.Add(root); } } return roots; } /// /// Append every non-separator menu path under to /// . A root that isn't present in this Editor simply contributes /// nothing (the API returns an empty array). /// static void CollectItemsUnder(string root, SortedSet paths) { if (!(s_GetMenuItems.Invoke(null, new object[] { root, false, false }) is Array items)) return; foreach (var item in items) { if (item == null) continue; if (s_GetIsSeparator != null && s_GetIsSeparator(item)) continue; var path = s_GetPath(item); if (string.IsNullOrEmpty(path)) continue; paths.Add(path); } } } /// /// Result of the menu command. On execution, false means the item /// could not be invoked (not found or disabled). In list mode, holds the /// available menu paths. /// [Serializable] public class MenuResponse { [JsonProperty("success")] public bool Success { get; set; } [JsonProperty("path")] public string Path { get; set; } [JsonProperty("items")] public List Items { get; set; } [JsonProperty("message")] public string Message { get; set; } public static MenuResponse Fail(string path, string message) => new MenuResponse { Success = false, Path = path, Message = message }; } }