using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using UnityEngine; #if UNITY_6000_3_OR_NEWER using UnityEngine.Assemblies; #endif #if UNITY_6000_5_OR_NEWER using Unity.Scripting.LifecycleManagement; #endif namespace Unity.Pipeline.Commands { /// /// Registry for discovering and managing CLI commands via pluggable discovery mechanism. /// Scans for methods marked with [CliCommand] attribute across all assemblies. /// Based on unity-tools ToolRegistry patterns adapted for Pipeline requirements. /// /// /// [NoAutoStaticsCleanup] on purpose: m_DiscoveryLock and the injected discovery mechanism are /// set up once per domain and must not be reset under the running server. The dynamic-command /// table is the one static here with a shorter useful life, and PlayModeSessionEnded resets the /// part of it that a Play session owns. /// #if UNITY_6000_5_OR_NEWER [NoAutoStaticsCleanup] #endif static class CommandRegistry { private static IReadOnlyList m_CachedCommands; private static IReadOnlyList m_CachedAllCommands; private static readonly Dictionary m_DynamicCommands = new Dictionary(StringComparer.Ordinal); // Names in m_DynamicCommands that predate the Play session currently running; null outside // one. Lets PlayModeSessionEnded tell a registration made during the session apart from one // that was already there. See PlayModeSessionStarted. private static HashSet m_PrePlayModeCommands; private static ICommandDiscovery m_Discovery; private static readonly object m_DiscoveryLock = new object(); /// /// Set the command discovery mechanism. /// Editor assembly provides TypeCache-based discovery, Runtime uses reflection fallback. /// /// The discovery mechanism to use. public static void SetDiscovery(ICommandDiscovery discovery) { m_Discovery = discovery; ClearCache(); // Re-discover with new mechanism } /// /// Discover every available command: the methods marked with [CliCommand], plus any /// registered through . /// Attribute discovery uses the injected mechanism (TypeCache in Editor, reflection in /// Runtime) and is cached until domain reload; the combined result is re-cached whenever /// a dynamic command is registered or unregistered. /// /// All discovered commands. public static IEnumerable DiscoverCommands() { var cached = m_CachedAllCommands; if (cached != null) { return cached; } lock (m_DiscoveryLock) { if (m_CachedCommands == null) { m_CachedCommands = DiscoverCommandsInternal().ToList(); } if (m_CachedAllCommands == null) { m_CachedAllCommands = m_DynamicCommands.Count == 0 ? m_CachedCommands : m_CachedCommands.Concat(m_DynamicCommands.Values).ToList(); } } return m_CachedAllCommands; } /// /// Register a command at runtime, without a [CliCommand] attribute. The resulting command /// is indistinguishable from an attribute-declared one: it is listed by /api/commands /// and executable through /api/exec under . /// /// Parameter metadata is read from the handler's own parameters exactly as it is for an /// attribute-declared command — a [CliArg] on a parameter supplies its CLI name, /// description, and required flag; a parameter without one falls back to its declared name /// and default value. Note that a lambda cannot carry [CliArg], so registering a named /// method is what yields full metadata. /// /// Registrations live until they are unregistered or the domain reloads; they survive /// and . One made while the Editor is in /// Play Mode is additionally dropped when Play Mode exits — see /// . /// /// Unique command name for CLI execution. /// Human-readable description, shown in command listings. /// /// Method to invoke. An instance-method delegate keeps its receiver, so the command runs /// against that instance; a static one runs with no receiver. /// /// Whether the command must run on the Unity main thread. /// Whether the command belongs to the Player command surface only. /// Optional path-style tags used to group and browse the command. /// /// A command called is already registered. Attribute-declared /// commands can never be replaced; a dynamic one must be unregistered first. /// public static void RegisterCommand(string name, string description, Delegate handler, bool mainThreadRequired = true, bool runtimeOnly = false, string[] tags = null) { if (name == null) throw new ArgumentNullException(nameof(name)); if (description == null) throw new ArgumentNullException(nameof(description)); if (handler == null) throw new ArgumentNullException(nameof(handler)); if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Command name must not be empty or whitespace.", nameof(name)); var method = handler.Method; var command = new CommandInfo( name, description, mainThreadRequired, method, DiscoverParameters(method).ToList(), runtimeOnly, tags, method.DeclaringType?.Assembly.GetName().Name, handler.Target); lock (m_DiscoveryLock) { if (m_DynamicCommands.ContainsKey(name)) { throw new InvalidOperationException( $"A command named '{name}' is already registered dynamically. " + "Unregister it before registering it again."); } // Anything left matching here is attribute-declared: the dynamic table was just checked. if (DiscoverCommands().Any(c => c.Name == name)) { throw new InvalidOperationException( $"A command named '{name}' is already declared with a [CliCommand] attribute " + "and cannot be replaced."); } m_DynamicCommands[name] = command; m_CachedAllCommands = null; } } /// /// Remove a command previously registered with . /// Attribute-declared commands are part of the code and are never removable. /// /// Name the command was registered under. /// /// True if a dynamic command was removed; false if no command is registered under that /// name dynamically, including when the name belongs to an attribute-declared command. /// public static bool UnregisterCommand(string name) { if (name == null) throw new ArgumentNullException(nameof(name)); lock (m_DiscoveryLock) { if (!m_DynamicCommands.Remove(name)) return false; m_CachedAllCommands = null; return true; } } /// /// Note that a Play session is starting, so registrations made from here on can be told /// apart from the ones that predate it. Called by the Editor on Play Mode entry, before any /// user code runs. /// internal static void PlayModeSessionStarted() { lock (m_DiscoveryLock) { m_PrePlayModeCommands = new HashSet(m_DynamicCommands.Keys, StringComparer.Ordinal); } } /// /// Drop every dynamic registration made during the Play session that just ended, the way a /// domain reload would have. Registrations that predate the session are left alone: nothing /// re-runs to restore them (an [InitializeOnLoadMethod] runs once per domain). /// /// A script registering from Awake or OnEnable re-registers on every Play Mode entry, so /// without this the second entry would throw "already registered dynamically" whenever no /// domain reload happened in between — and any surviving entry would still hold a delegate /// bound to the previous session's destroyed object. That covers Enter Play Mode with domain /// reload disabled on any Editor version, and on 6000.5+ the class-wide /// [NoAutoStaticsCleanup] means AutoStaticsCleanup does not step in either. /// internal static void PlayModeSessionEnded() { lock (m_DiscoveryLock) { // No recorded session start (e.g. a domain reload wiped it on the way in, having // already cleared the registry): nothing is known to belong to the session, so // removing anything would be guesswork. if (m_PrePlayModeCommands == null) return; foreach (var name in m_DynamicCommands.Keys.Where(n => !m_PrePlayModeCommands.Contains(n)).ToList()) m_DynamicCommands.Remove(name); m_PrePlayModeCommands = null; m_CachedAllCommands = null; } } /// /// Clear the discovered-command cache, so the next discovery re-scans for [CliCommand] /// methods. Called automatically on domain reload, and whenever the discovery mechanism /// changes. Commands registered through are not affected. /// public static void ClearCache() { lock (m_DiscoveryLock) { m_CachedCommands = null; m_CachedAllCommands = null; } } /// /// Internal implementation of command discovery using injected discovery mechanism. /// private static IEnumerable DiscoverCommandsInternal() { var commands = new List(); try { // Use injected discovery mechanism if available, otherwise fallback to reflection IEnumerable methods; if (m_Discovery != null) { methods = m_Discovery.GetMethodsWithAttribute(); } else { // Fallback: Use reflection to scan loaded assemblies methods = GetMethodsWithAttributeViaReflection(); } foreach (var method in methods) { try { var commandInfo = CreateCommandInfo(method); if (commandInfo != null) { commands.Add(commandInfo); } } catch (Exception ex) { Debug.LogWarning($"Failed to register command from method {method.DeclaringType?.Name}.{method.Name}: {ex.Message}"); } } } catch (Exception ex) { Debug.LogError($"Failed to discover commands: {ex.Message}"); } return commands; } /// /// Fallback command discovery using reflection when TypeCache is not available. /// private static IEnumerable GetMethodsWithAttributeViaReflection() where T : Attribute { var methods = new List(); try { var assemblies = PipelineUtils.GetLoadedAssemblies(); foreach (var assembly in assemblies) { // Skip system assemblies for performance if (IsSystemAssembly(assembly)) continue; try { foreach (var type in assembly.GetTypes()) { foreach (var method in type.GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)) { if (method.GetCustomAttribute() != null) { methods.Add(method); } } } } catch (ReflectionTypeLoadException) { // Skip assemblies that can't be loaded continue; } } } catch (Exception ex) { Debug.LogWarning($"Reflection-based command discovery failed: {ex.Message}"); } return methods; } /// /// Check if assembly is a system assembly that should be skipped during discovery. /// private static bool IsSystemAssembly(Assembly assembly) { var name = assembly.GetName().Name; return name.StartsWith("System.") || name.StartsWith("Microsoft.") || name.StartsWith("mscorlib") || name.StartsWith("netstandard") || name.Equals("UnityEngine") || name.Equals("UnityEditor"); } /// /// Create CommandInfo from a method with CliCommand attribute. /// private static CommandInfo CreateCommandInfo(MethodInfo method) { var commandAttr = method.GetCustomAttribute(); if (commandAttr == null) return null; // Validate method is static (required for CLI commands). // Any static method may be registered regardless of accessibility // (public, internal, or private) — invocation goes through MethodInfo.Invoke. if (!method.IsStatic) { Debug.LogWarning($"Command method {method.DeclaringType?.Name}.{method.Name} must be static"); return null; } // Discover parameters var parameters = DiscoverParameters(method).ToList(); return new CommandInfo( commandAttr.Name, commandAttr.Description, commandAttr.MainThreadRequired, method, parameters, commandAttr.RuntimeOnly, commandAttr.Tags, method.DeclaringType?.Assembly.GetName().Name ); } /// /// Discover parameter information from method parameters. /// private static IEnumerable DiscoverParameters(MethodInfo method) { foreach (var param in method.GetParameters()) { var argAttr = param.GetCustomAttribute(); // Parameters without CliArg attribute get default metadata var name = argAttr?.Name ?? param.Name; var description = argAttr?.Description ?? $"Parameter: {param.Name}"; var required = argAttr?.Required ?? !param.HasDefaultValue; var defaultValue = param.HasDefaultValue ? param.DefaultValue : argAttr?.DefaultValue; yield return new CommandParameterInfo(name, description, required, param.ParameterType, defaultValue); } } } }