// 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
}