using System; using System.Collections.Generic; using System.Reflection; using UnityEngine; namespace jeanf.validationTools { /// One concrete setup problem on a component: which field (when known) and why. public readonly struct ValidationIssue { public readonly Component Component; /// Serialized field name the issue points at — empty for component-level issues (IValidatable). public readonly string FieldName; public readonly string Message; public ValidationIssue(Component component, string fieldName, string message) { Component = component; FieldName = fieldName ?? string.Empty; Message = message ?? string.Empty; } } /// /// Collects setup issues from two sources, shared by every validation UI /// (field drawer, inspector banner, hierarchy marker, console log, build check): /// 1. Fields tagged [Validation("...")] that are unset (null Unity object, /// fake-null destroyed reference, empty string, or empty list of either). /// A RequiredIf gate on the attribute skips the check while its bool /// member (on the same component) is false. /// 2. Components implementing IValidatable whose IsValid is false. /// Runtime-safe: no UnityEditor — usable for play-mode logs and build checks. /// public static class ValidationScanner { private static readonly Dictionary ValidatedFieldsByType = new Dictionary(); private static readonly List FieldBuffer = new List(); /// Appends every issue found on this component to . public static void GetIssues(Component component, List results) { if (component == null || results == null) return; foreach (var field in GetValidatedFields(component.GetType())) { var attribute = (ValidationAttribute)field.GetCustomAttribute(typeof(ValidationAttribute)); if (!IsRequired(component, attribute)) continue; if (IsSet(field.GetValue(component))) continue; results.Add(new ValidationIssue(component, field.Name, attribute?.Text ?? $"'{field.Name}' is not assigned.")); } if (component is IValidatable validatable && !validatable.IsValid) results.Add(new ValidationIssue(component, string.Empty, $"{component.GetType().Name} reports an invalid setup (IValidatable.IsValid is false).")); } /// Appends every issue found on the GameObject's own components (not children). public static void GetIssues(GameObject gameObject, List results) { if (gameObject == null || results == null) return; foreach (var component in gameObject.GetComponents()) { if (component == null) continue; // missing script — Unity already flags those GetIssues(component, results); } } /// True when this single component has an issue. public static bool HasIssues(Component component) { if (component == null) return false; foreach (var field in GetValidatedFields(component.GetType())) if (IsRequired(component, (ValidationAttribute)field.GetCustomAttribute(typeof(ValidationAttribute))) && !IsSet(field.GetValue(component))) return true; return component is IValidatable validatable && !validatable.IsValid; } /// True when any component ON this GameObject (not children) has an issue. public static bool HasIssues(GameObject gameObject) { if (gameObject == null) return false; foreach (var component in gameObject.GetComponents()) if (HasIssues(component)) return true; return false; } /// /// A [Validation] field counts as SET unless it is: a null/destroyed Unity /// object, a null/empty string, a null/empty IList, or a list whose first /// entries include a null Unity object. Other value types always count as /// set — use IValidatable for custom rules on those. /// public static bool IsSet(object value) { switch (value) { case null: return false; case UnityEngine.Object unityObject: return unityObject != null; // fake-null (destroyed) counts as unset case string text: return !string.IsNullOrEmpty(text); case System.Collections.IList list: { if (list.Count == 0) return false; foreach (var entry in list) if (entry is UnityEngine.Object o && o == null) return false; return true; } default: return true; } } /// /// Evaluates the attribute's RequiredIf gate ('!name' inverts). An unresolvable /// or non-bool member counts as required — a typo must not silently disable a check. /// public static bool IsRequired(object target, ValidationAttribute attribute) { var condition = attribute?.RequiredIf; if (string.IsNullOrEmpty(condition) || target == null) return true; var invert = condition[0] == '!'; if (invert) condition = condition.Substring(1); const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; for (var type = target.GetType(); type != null; type = type.BaseType) { var field = type.GetField(condition, flags | BindingFlags.DeclaredOnly); if (field != null && field.FieldType == typeof(bool)) return (bool)field.GetValue(target) != invert; var property = type.GetProperty(condition, flags | BindingFlags.DeclaredOnly); if (property != null && property.PropertyType == typeof(bool) && property.CanRead) return (bool)property.GetValue(target) != invert; } return true; } private static FieldInfo[] GetValidatedFields(Type type) { if (ValidatedFieldsByType.TryGetValue(type, out var cached)) return cached; FieldBuffer.Clear(); for (var current = type; current != null && current != typeof(MonoBehaviour) && current != typeof(Component); current = current.BaseType) { foreach (var field in current.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)) if (field.IsDefined(typeof(ValidationAttribute), false)) FieldBuffer.Add(field); } var fields = FieldBuffer.ToArray(); ValidatedFieldsByType[type] = fields; return fields; } } }