Editor/HumanoidRetargeter/Embedded/ValveResourceFormat/Serialization/KeyValues/KVObject.cs
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using HumanoidRetargeterVrf.Utils;
//#define DEBUG_ADD_KV_TYPE_COMMENTS
using System.Collections;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Text;
using KVValueType = HumanoidRetargeterKeyValue.KVValueType;
#nullable disable
namespace HumanoidRetargeterVrf.Serialization.KeyValues
{
/// <summary>
/// Represents a KeyValue object data structure.
/// </summary>
[DebuggerDisplay("{DebugRepresentation,nq}")]
[DebuggerTypeProxy(typeof(DebugView))]
public class KVObject : IEnumerable<KeyValuePair<string, object>>
{
/// <summary>
/// Gets the key name of this object.
/// </summary>
public string Key { get; }
/// <summary>
/// Gets the properties dictionary.
/// </summary>
public Dictionary<string, KVValue> Properties { get; }
/// <summary>
/// Gets a value indicating whether this object is an array.
/// </summary>
public bool IsArray { get; }
/// <summary>
/// Gets the number of properties or items in this object.
/// </summary>
public int Count { get; private set; }
/// <summary>
/// Initializes a new instance of the <see cref="KVObject"/> class.
/// </summary>
/// <param name="name">The key name.</param>
/// <param name="capacity">The initial capacity.</param>
public KVObject(string name, int capacity = 0)
{
Key = name;
Properties = new Dictionary<string, KVValue>(capacity);
Count = 0;
}
/// <summary>
/// Initializes a new instance of the <see cref="KVObject"/> class.
/// </summary>
/// <param name="name">The key name.</param>
/// <param name="isArray">Whether this object is an array.</param>
/// <param name="capacity">The initial capacity.</param>
public KVObject(string name, bool isArray, int capacity = 0)
: this(name, capacity)
{
IsArray = isArray;
}
/// <summary>
/// Initializes a new instance of the <see cref="KVObject"/> class with array items.
/// </summary>
/// <param name="name">The key name.</param>
/// <param name="arrayItems">The array items.</param>
public KVObject(string name, IList<KVValue> arrayItems)
: this(name, true, arrayItems.Count)
{
foreach (var arrayItem in arrayItems)
{
AddProperty(null, arrayItem);
}
}
/// <summary>
/// Initializes a new instance of the <see cref="KVObject"/> class with properties.
/// </summary>
/// <param name="name">The key name.</param>
/// <param name="properties">The properties to add.</param>
public KVObject(string name, params (string Name, object Value)[] properties)
: this(name, properties.Length)
{
foreach (var prop in properties)
{
AddProperty(prop.Name, prop.Value);
}
}
/// <summary>
/// Adds a property to the structure.
/// </summary>
/// <param name="name">The property name.</param>
/// <param name="value">The property value.</param>
public virtual void AddProperty(string name, KVValue value)
{
if (IsArray)
{
// Make up a key for the dictionary
Properties.Add(Count.ToString(CultureInfo.InvariantCulture), value);
}
else
{
#if DEBUG
if (!Properties.TryAdd(name, value))
{
Console.WriteLine($"This KV3 object contains a duplicate key: {name} with value {value.Value}:\n{new StackTrace()}");
Properties[name] = value;
}
#else
Properties[name] = value;
#endif
}
Count++;
}
/// <summary>
/// Adds a property to the structure.
/// </summary>
/// <param name="name">The property name.</param>
/// <param name="value">The property value.</param>
public void AddProperty(string name, object value)
{
AddProperty(name, new KVValue(value));
}
internal void AddItem(object item)
{
Debug.Assert(IsArray);
AddProperty(null, item);
}
/// <summary>
/// Gets the value at the specified array index.
/// </summary>
/// <param name="arrayIndex">The array index.</param>
/// <returns>The value at the specified index.</returns>
public KVValue this[int arrayIndex]
{
get => Properties[arrayIndex.ToString(CultureInfo.InvariantCulture)];
}
/// <summary>
/// Serializes this object to the specified writer.
/// </summary>
/// <param name="writer">The writer to serialize to.</param>
public void Serialize(IndentedTextWriter writer)
{
writer.Grow(12 + Count * 3 + (writer.Indent + 1) * Count); // Not exact
if (IsArray)
{
SerializeArray(writer);
}
else
{
SerializeObject(writer);
}
}
//Serialize the contents of the KV object
private void SerializeObject(IndentedTextWriter writer)
{
//Don't enter the top-most object
if (Key != null)
{
writer.WriteLine();
}
writer.WriteLine("{");
writer.Indent++;
foreach (var pair in Properties)
{
WriteKey(writer, pair.Key);
KV3TextSerializer.WriteValue(pair.Value, writer);
#if DEBUG_ADD_KV_TYPE_COMMENTS
writer.Write($" // {pair.Value.Type}");
#endif
writer.WriteLine();
}
writer.Indent--;
writer.Write("}");
}
private void SerializeArray(IndentedTextWriter writer)
{
writer.WriteLine();
writer.WriteLine("[");
writer.Indent++;
// Need to preserve the order
for (var i = 0; i < Count; i++)
{
var value = this[i];
KV3TextSerializer.WriteValue(value, writer);
#if DEBUG_ADD_KV_TYPE_COMMENTS
writer.WriteLine($", // {value.Type}");
#else
writer.WriteLine(",");
#endif
}
writer.Indent--;
writer.Write("]");
}
// Copied from HumanoidRetargeterKeyValue kv3 branch
private static void WriteKey(IndentedTextWriter writer, string key)
{
if (key == null)
{
return;
}
var escaped = key.Length == 0; // Quote empty strings
var sb = new StringBuilder(key.Length + 2);
sb.Append('"');
if (key.Length > 0 && char.IsAsciiDigit(key[0]))
{
// Quote when first character is a digit
escaped = true;
}
foreach (var @char in key)
{
switch (@char)
{
case '\t':
escaped = true;
sb.Append('\\');
sb.Append('t');
break;
case '\n':
escaped = true;
sb.Append('\\');
sb.Append('n');
break;
case '"':
escaped = true;
sb.Append('\\');
sb.Append('"');
break;
case '\\':
escaped = true;
sb.Append('\\');
sb.Append('\\');
break;
default:
if (@char != '.' && @char != '_' && !char.IsAsciiLetterOrDigit(@char))
{
escaped = true;
}
sb.Append(@char);
break;
}
}
if (escaped)
{
sb.Append('"');
writer.Write(sb.ToString());
}
else
{
writer.Write(key);
}
writer.Write(" = ");
}
/// <summary>
/// Determines whether this object contains the specified key.
/// </summary>
/// <param name="name">The key name.</param>
/// <returns>True if the key exists; otherwise, false.</returns>
public bool ContainsKey(string name) => Properties.ContainsKey(name);
/// <summary>
/// Gets a property value by name with type conversion.
/// </summary>
/// <typeparam name="T">The type to convert to.</typeparam>
/// <param name="name">The property name.</param>
/// <param name="defaultValue">The default value if the property doesn't exist.</param>
/// <returns>The property value or default value.</returns>
public T GetProperty<T>(string name, T defaultValue = default)
{
if (Properties.TryGetValue(name, out var value))
{
return (T)value.Value;
}
return defaultValue;
}
/// <summary>
/// Gets a property value by name with unchecked type conversion, attempting to parse strings as numbers.
/// </summary>
/// <typeparam name="T">The type to convert to.</typeparam>
/// <param name="name">The property name.</param>
/// <param name="defaultValue">The default value if the property doesn't exist.</param>
/// <returns>The property value or default value.</returns>
public T GetPropertyUnchecked<T>(string name, T defaultValue = default)
{
if (Properties.TryGetValue(name, out var property))
{
var valueObject = property.Value;
// We typicallly want to get a bool, int, uint, or float property,
// however it might be stored as string, which will raise FormatException.
// So here we try to convert the string to floating point number.
if (typeof(T) != typeof(string) && valueObject is string stringValue)
{
if (float.TryParse(stringValue, CultureInfo.InvariantCulture, out var floatVal))
{
valueObject = floatVal;
}
}
return (T)Convert.ChangeType(valueObject, typeof(T), CultureInfo.InvariantCulture);
}
return defaultValue;
}
/// <summary>
/// Gets an array property by name.
/// </summary>
/// <typeparam name="T">The element type of the array.</typeparam>
/// <param name="name">The property name.</param>
/// <returns>The array, or default if the property doesn't exist.</returns>
public T[] GetArray<T>(string name)
{
if (Properties.TryGetValue(name, out var value))
{
if (value.Type == KVValueType.Collection && value.Value is KVObject kvObject && kvObject.IsArray)
{
var properties = new List<T>(capacity: kvObject.Count);
var index = 0;
var property = kvObject.GetProperty<T>(index.ToString(CultureInfo.InvariantCulture));
while (!property.Equals(default(T)))
{
properties.Add(property);
++index;
}
return [.. properties];
}
if (value.Type == KVValueType.BinaryBlob)
{
if (typeof(T) == typeof(byte))
{
return (T[])value.Value;
}
return ((byte[])value.Value).Cast<T>().ToArray();
}
if (value.Type != KVValueType.Array) // && value.Type != KV3BinaryNodeType.ARRAY_TYPED)
{
throw new InvalidOperationException($"Tried to cast non-array property {name} to array. Actual type: {value.Type}");
}
// TODO: Why are we trying to read floats as doubles
if (typeof(T) == typeof(double))
{
return ((KVObject)value.Value).Properties.Values.Select(static (v) =>
{
return v.Type == KVValueType.FloatingPoint ? (double)(float)v.Value : (double)v.Value;
}).Cast<T>().ToArray();
}
return ((KVObject)value.Value).Properties.Values.Select(static v => (T)v.Value).ToArray();
}
else
{
return default;
}
}
/// <inheritdoc/>
public IEnumerator<KeyValuePair<string, object>> GetEnumerator()
=> Properties
.Select(p => new KeyValuePair<string, object>(p.Key, p.Value.Value))
.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator()
=> GetEnumerator();
#region Debugging
#pragma warning disable IDE0051, IDE0052 // Remove unread private members
internal string DebugRepresentation => DebugView.GetRepresentation(this);
internal class DebugView
{
readonly KVObject obj;
internal DebugView(KVObject obj)
{
this.obj = obj;
}
internal static string GetRepresentation(KVObject obj)
{
if (!obj.IsArray)
{
return $"Properties = {obj.Count}";
}
if (obj.Count > 0)
{
var first = obj.Properties.First();
var type = first.Value.Type;
var allSameType = obj.Properties.All(p => p.Value.Type == type);
if (allSameType)
{
return $"KVArray<{type}> Items = {obj.Count}";
}
}
return $"KVArray Items = {obj.Count}";
}
[DebuggerDisplay("{Key,nq} = {ValueDebugRepresentation,nq}")]
internal class KeyValue
{
readonly string Key;
readonly object Value;
readonly KVValueType Type;
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
readonly string ValueDebugRepresentation;
internal KeyValue(KeyValuePair<string, KVValue> keyValuePair)
{
(Key, Value, Type) = (keyValuePair.Key, keyValuePair.Value.Value, keyValuePair.Value.Type);
ValueDebugRepresentation = Value switch
{
KVObject kvObject => $"<{(kvObject.IsArray ? "KVArray" : "KVObject")}>",
_ => keyValuePair.Value.Value?.ToString() ?? "null",
};
}
}
[DebuggerBrowsable(DebuggerBrowsableState.RootHidden)]
KeyValue[] Properties => obj.IsArray
? []
: obj.Properties.Select(p => new KeyValue(p)).ToArray();
[DebuggerBrowsable(DebuggerBrowsableState.RootHidden)]
KVValue[] Items => obj.IsArray
? [.. obj.Properties.Values]
: [];
}
#pragma warning restore IDE0051, IDE0052
#endregion Debugging
}
}