// MIT License - Copyright (c) 2025 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Editor.Extensions { #if UNITY_EDITOR using System; using System.Collections.Generic; using System.Reflection; using UnityEditor; /// /// Editor-only extension methods for working with SerializedProperty objects. /// public static class SerializedPropertyExtensions { private static readonly Dictionary PathSplitCache = new( StringComparer.Ordinal ); private static readonly Dictionary TrimmedPathSplitCache = new( StringComparer.Ordinal ); private static readonly char[] PathSeparator = { '.' }; internal static void ClearCache() { PathSplitCache.Clear(); TrimmedPathSplitCache.Clear(); } private static string[] GetCachedPathParts(string propertyPath) { if (PathSplitCache.TryGetValue(propertyPath, out string[] cached)) { return cached; } string[] parts = propertyPath.Split(PathSeparator); PathSplitCache[propertyPath] = parts; return parts; } private static string[] GetTrimmedPathParts(string propertyPath, string propertyName) { if (TrimmedPathSplitCache.TryGetValue(propertyPath, out string[] cached)) { return cached; } string[] pathParts = GetCachedPathParts(propertyPath); if ( string.Equals(propertyName, "data", StringComparison.Ordinal) && pathParts.Length > 1 && pathParts[^1].Contains('[') && pathParts[^1].Contains(']') && string.Equals(pathParts[^2], "Array", StringComparison.Ordinal) ) { string[] trimmed = new string[pathParts.Length - 2]; Array.Copy(pathParts, trimmed, trimmed.Length); TrimmedPathSplitCache[propertyPath] = trimmed; return trimmed; } TrimmedPathSplitCache[propertyPath] = pathParts; return pathParts; } /// /// Appends a new default element to the end of an array/list property and returns it. /// Unlike InsertArrayElementAtIndex, this works even when the array is empty and avoids duplicating the last entry. /// /// Serialized array/list property. /// The SerializedProperty representing the newly added element. /// Thrown if arrayProperty is null. public static SerializedProperty AppendArrayElement(this SerializedProperty arrayProperty) { if (arrayProperty == null) { throw new ArgumentNullException(nameof(arrayProperty)); } if (!arrayProperty.isArray) { throw new InvalidOperationException( $"SerializedProperty '{arrayProperty.propertyPath}' is not an array." ); } int newIndex = arrayProperty.arraySize; arrayProperty.arraySize = newIndex + 1; return arrayProperty.GetArrayElementAtIndex(newIndex); } /// /// Gets the instance object that contains (encloses) the given SerializedProperty, along with the field's metadata. /// /// The SerializedProperty to reflect upon. /// Outputs the FieldInfo of the field represented by this property. /// The instance object that owns the field, or null if the property or its target is null. /// /// This method walks the property path to find the parent object that contains the field. /// It handles nested objects, arrays, and collections properly. /// Useful for implementing custom property drawers that need access to the containing object. /// Null handling: Returns null if the property or its target object is null. /// Thread-safe: No. Must be called from the main Unity thread. /// Performance: Uses reflection to traverse the property path. Cache results if called frequently. /// Array handling: Properly handles "Array.data[index]" patterns in property paths. /// /// Never thrown explicitly, but may occur if property is null. public static object GetEnclosingObject( this SerializedProperty property, out FieldInfo fieldInfo ) { fieldInfo = null; object obj = property.serializedObject.targetObject; if (obj == null) { return null; } Type type = obj.GetType(); string[] pathParts = GetTrimmedPathParts(property.propertyPath, property.name); // Traverse the path but stop at the second-to-last field for (int i = 0; i < pathParts.Length - 1; ++i) { string fieldName = pathParts[i]; if (string.Equals(fieldName, "Array", StringComparison.Ordinal)) { // Move to "data[i]", no need to length-check, we're guarded above ++i; fieldName = pathParts[i]; if (!TryParseArrayIndex(fieldName, out int index)) { // Unexpected, die fieldInfo = null; return null; } obj = GetElementAtIndex(obj, index); type = obj?.GetType(); UpdateField(fieldName, ref fieldInfo); if (i == pathParts.Length - 2) { fieldName = pathParts[i + 1]; UpdateField(fieldName, ref fieldInfo); } continue; } UpdateField(fieldName, ref fieldInfo); if (fieldInfo == null) { return null; } // Move deeper but stop before the last property in the path if (i < pathParts.Length - 2) { obj = fieldInfo.GetValue(obj); type = fieldInfo.FieldType; } } if (fieldInfo == null) { // Use the last segment of the possibly-trimmed path (actual field name), not property.name (which can be "data") if (pathParts.Length > 0) { UpdateField(pathParts[^1], ref fieldInfo); } } return obj; void UpdateField(string fieldName, ref FieldInfo field) { FieldInfo newField = type?.GetField( fieldName, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance ); if (newField != null) { field = newField; } } } /// /// Gets the final target object and its FieldInfo for a given SerializedProperty. /// /// The SerializedProperty to reflect upon. /// Outputs the FieldInfo of the field represented by this property. /// The instance value of the field itself, or null if the property or its target is null. /// /// Unlike GetEnclosingObject, this method returns the value of the field itself, not its parent. /// This walks the full property path including the final field, retrieving the actual value. /// Handles arrays and nested objects properly. /// Null handling: Returns null if the property, its target object, or any intermediate field is null. /// Thread-safe: No. Must be called from the main Unity thread. /// Performance: Uses reflection to traverse the property path. Cache results if called frequently. /// Array handling: Properly handles "Array.data[index]" patterns in property paths. /// /// Never thrown explicitly, but may occur if property is null. public static object GetTargetObjectWithField( this SerializedProperty property, out FieldInfo fieldInfo ) { fieldInfo = null; object obj = property.serializedObject.targetObject; if (obj == null) { return null; } Type type = obj.GetType(); string[] pathParts = GetCachedPathParts(property.propertyPath); for (int i = 0; i < pathParts.Length; ++i) { string fieldName = pathParts[i]; if (string.Equals(fieldName, "Array", StringComparison.Ordinal)) { // Move to "data[i]" ++i; if (pathParts.Length <= i) { break; } if (!TryParseArrayIndex(pathParts[i], out int index)) { // Unexpected, die fieldInfo = null; return null; } obj = GetElementAtIndex(obj, index); type = obj?.GetType(); continue; } fieldInfo = type?.GetField( fieldName, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance ); if (fieldInfo == null) { return null; } // Move deeper into the object tree obj = fieldInfo.GetValue(obj); type = fieldInfo.FieldType; } return obj; } private static readonly Dictionary ArrayIndexCache = new( StringComparer.Ordinal ); private static bool TryParseArrayIndex(string dataField, out int index) { if (ArrayIndexCache.TryGetValue(dataField, out index)) { return index >= 0; } if ( dataField.StartsWith("data[", StringComparison.Ordinal) && dataField.EndsWith("]", StringComparison.Ordinal) ) { ReadOnlySpan span = dataField.AsSpan(5, dataField.Length - 6); if (int.TryParse(span, out index)) { ArrayIndexCache[dataField] = index; return true; } } index = -1; ArrayIndexCache[dataField] = index; return false; } private static object GetElementAtIndex(object obj, int index) { if (obj is System.Collections.IList list && index >= 0 && index < list.Count) { return list[index]; } return null; } } #endif }