CultLib/src/GameCult.Caching/CultInspectorModel.cs
Meta Vens 384c7df02b Make group node id escaping injective
Uri.EscapeDataString maps every unpaired surrogate to %EF%BF%BD, so distinct
values shared one node id. An unpaired surrogate now spells %uXXXX.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 14:03:58 +02:00

854 lines
45 KiB
C#

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using MessagePack;
namespace GameCult.Caching
{
// The engine-free inspection model: what an inspector draws for a document and which edits it may make. Lowerings
// (the Unity editor Studio's IMGUI, a runtime CultUI panel) own only widgets and layout; members, metadata, value
// shapes, drawer claims and edit rules come from here.
public enum CultInspectorValueKind
{
Unsupported,
String,
Integer,
Float,
Bool,
Enum,
RecordRef,
List,
Dictionary,
Union,
Nested
}
// What the inspection attributes on one member say. A bare value has none.
public sealed class CultInspectorMetadata
{
internal static readonly CultInspectorMetadata None = new CultInspectorMetadata(null);
internal CultInspectorMetadata(MemberInfo? member)
{
Attributes = member == null ? Array.Empty<Attribute>() : Attribute.GetCustomAttributes(member, true);
var label = Find<CultInspectorLabelAttribute>()?.Label;
Label = string.IsNullOrWhiteSpace(label) ? null : label;
Hidden = Find<CultInspectorHiddenAttribute>() != null;
ReadOnly = Find<CultInspectorReadOnlyAttribute>() != null;
Order = Find<CultInspectorOrderAttribute>()?.Order;
Range = Find<CultInspectorRangeAttribute>();
TextArea = Find<CultInspectorTextAreaAttribute>();
AssetGuid = Find<CultInspectorAssetGuidAttribute>();
}
public IReadOnlyList<Attribute> Attributes { get; }
// The declared label, or null; the lowering derives one from the member name.
public string? Label { get; }
public bool Hidden { get; }
public bool ReadOnly { get; }
public int? Order { get; }
public CultInspectorRangeAttribute? Range { get; }
public CultInspectorTextAreaAttribute? TextArea { get; }
public CultInspectorAssetGuidAttribute? AssetGuid { get; }
public T? Find<T>() where T : Attribute => Attributes.OfType<T>().FirstOrDefault();
}
public sealed class CultInspectorMember
{
internal CultInspectorMember(MemberInfo member, Type valueType, int slot, bool assignable, CultInspectorMetadata metadata)
{
Member = member;
ValueType = valueType;
Slot = slot;
IsAssignable = assignable;
Metadata = metadata;
IsReadOnly = metadata.ReadOnly || !assignable;
}
public MemberInfo Member { get; }
public string Name => Member.Name;
public Type ValueType { get; }
public int Slot { get; }
public CultInspectorMetadata Metadata { get; }
// SetValue works: MessagePack's own rule, the one the registry persists by (a public setter or non-readonly field, or
// any setter or readonly field under AllowPrivate).
public bool IsAssignable { get; }
// The lowering offers no edit: declared [CultInspectorReadOnly], or not assignable.
public bool IsReadOnly { get; }
public object? GetValue(object target) =>
Member is FieldInfo field ? field.GetValue(target) : ((PropertyInfo)Member).GetValue(target);
public void SetValue(object target, object? value)
{
if (!IsAssignable)
throw new InvalidOperationException($"{Member.DeclaringType?.Name}.{Name} is not assignable.");
if (Member is FieldInfo field) field.SetValue(target, value);
else ((PropertyInfo)Member).SetValue(target, value);
}
}
public sealed class CultInspectorShape
{
internal CultInspectorShape(Type type, CultInspectorValueKind kind)
{
Type = type;
Kind = kind;
}
public Type Type { get; }
public CultInspectorValueKind Kind { get; }
// List
public Type? ElementType { get; internal set; }
// Dictionary
public Type? KeyType { get; internal set; }
public Type? ValueType { get; internal set; }
// RecordRef: the T of CultRecordRef<T>.
public Type? RecordTarget { get; internal set; }
// Union: the declared [Union] subtypes in key order, and nothing else.
public IReadOnlyList<Type> UnionChoices { get; internal set; } = Array.Empty<Type>();
// Nested: the members in inspection order.
public IReadOnlyList<CultInspectorMember> Members { get; internal set; } = Array.Empty<CultInspectorMember>();
// Unsupported: why.
public string? Reason { get; internal set; }
internal Type? BuildType { get; set; }
}
public readonly struct CultInspectorClaim
{
internal CultInspectorClaim(Type? drawer, string? conflict)
{
Drawer = drawer;
Conflict = conflict;
}
// The claiming drawer class, or null for the lowering's built-in drawing.
public Type? Drawer { get; }
// Set when the claim is contested; the lowering shows it instead of drawing.
public string? Conflict { get; }
}
// Which lowering drawer draws a value: a claim on one of the member's attributes, then a claim on the value's type
// (exact, then its open generic definition), then none. A lowering builds this once from the drawer classes it
// discovers; an invalid drawer or a contested claim is reported in Errors and draws nothing. An attribute claim
// reaches every value drawn under the member, list elements and dictionary keys and values included, so the drawer
// decides by the value type it is handed.
public sealed class CultInspectorDrawerClaims
{
private readonly Dictionary<Type, Type> _claims = new Dictionary<Type, Type>();
private readonly Dictionary<Type, string> _contested = new Dictionary<Type, string>();
private readonly List<string> _errors = new List<string>();
private readonly Dictionary<(Type, MemberInfo?), CultInspectorClaim> _resolved = new Dictionary<(Type, MemberInfo?), CultInspectorClaim>();
public CultInspectorDrawerClaims(IEnumerable<Type> drawerTypes, Type drawerContract)
{
if (drawerTypes == null) throw new ArgumentNullException(nameof(drawerTypes));
if (drawerContract == null) throw new ArgumentNullException(nameof(drawerContract));
foreach (var drawer in drawerTypes)
{
var attribute = drawer.GetCustomAttribute<CultInspectorDrawerAttribute>(false);
if (attribute == null)
continue;
if (!drawerContract.IsAssignableFrom(drawer) || drawer.IsAbstract || drawer.GetConstructor(Type.EmptyTypes) == null)
{
_errors.Add($"{drawer.FullName} is marked [CultInspectorDrawer] but is not a concrete {drawerContract.Name} with a parameterless constructor.");
continue;
}
var claimed = attribute.Claimed;
if (_contested.TryGetValue(claimed, out var claimants))
{
_contested[claimed] = claimants + ", " + drawer.FullName;
}
else if (_claims.TryGetValue(claimed, out var holder))
{
_claims.Remove(claimed);
_contested[claimed] = holder.FullName + ", " + drawer.FullName;
}
else
{
_claims[claimed] = drawer;
}
}
foreach (var pair in _contested.OrderBy(pair => pair.Key.FullName, StringComparer.Ordinal))
_errors.Add($"{pair.Value} all claim {pair.Key.FullName}; none of them draws it.");
}
public IReadOnlyList<string> Errors => _errors;
public CultInspectorClaim Resolve(Type valueType, MemberInfo? member)
{
if (valueType == null) throw new ArgumentNullException(nameof(valueType));
lock (_resolved)
{
if (!_resolved.TryGetValue((valueType, member), out var claim))
_resolved[(valueType, member)] = claim = ResolveCore(valueType, member);
return claim;
}
}
private CultInspectorClaim ResolveCore(Type valueType, MemberInfo? member)
{
Type? drawer = null;
foreach (var attribute in member == null ? Array.Empty<Attribute>() : Attribute.GetCustomAttributes(member, true))
{
for (var type = attribute.GetType(); type != null && type != typeof(Attribute); type = type.BaseType)
{
if (_contested.TryGetValue(type, out var claimants))
return new CultInspectorClaim(null, $"[{type.Name}] is claimed by {claimants}");
if (!_claims.TryGetValue(type, out var claimant))
continue;
if (drawer != null && drawer != claimant)
return new CultInspectorClaim(null, $"{member!.Name} carries attributes claimed by {drawer.FullName} and {claimant.FullName}");
drawer = claimant;
break;
}
}
if (drawer != null)
return new CultInspectorClaim(drawer, null);
foreach (var type in valueType.IsGenericType ? new[] { valueType, valueType.GetGenericTypeDefinition() } : new[] { valueType })
{
if (_contested.TryGetValue(type, out var claimants))
return new CultInspectorClaim(null, $"{type.FullName} is claimed by {claimants}");
if (_claims.TryGetValue(type, out var claimant))
return new CultInspectorClaim(claimant, null);
}
return default;
}
}
// A private copy of one stored document. A lowering draws into Document; the cached object is never touched.
public sealed class CultInspectorEdit
{
private readonly Func<object> _snapshot;
private object? _record;
internal CultInspectorEdit(CultStoredDocument source, object document, Func<object> snapshot)
{
Source = source;
Document = document;
_snapshot = snapshot;
}
public CultStoredDocument Source { get; }
public object Document { get; }
// The record being drawn, as stored when this edit began, for drawers that read the document they sit in. It is a
// second copy, decoded on first read from the bytes Document came from, so a cache change in between cannot split
// them: not the cached object, and not Document, which a lowering commits. Nothing
// reads it back, so writing to it saves nothing and changes neither the edit nor the cache.
public object Record => _record ??= _snapshot();
public bool IsSpent { get; private set; }
public bool IsFor(CultStoredDocument record) => !IsSpent && ReferenceEquals(Source, record);
// Upserts the copy under the source key. Committed, the cache holds the copy; refused, the cached object is
// unchanged. Either way the edit is spent and the copy belongs to nobody: begin a new edit from the cache.
public bool Commit(CultCache cache, out string? error)
{
if (cache == null) throw new ArgumentNullException(nameof(cache));
if (IsSpent) throw new InvalidOperationException("This edit was already committed; begin a new edit.");
IsSpent = true;
try
{
cache.UpsertAsync(Source.Descriptor.DocumentType, Document, Source.Key).GetAwaiter().GetResult();
error = null;
return true;
}
catch (Exception exception)
{
error = exception.GetBaseException().Message;
return false;
}
}
}
public sealed class CultInspectorModel
{
private const string RecordRefIdentity = "ref:";
private const BindingFlags Public = BindingFlags.Public | BindingFlags.Instance;
private static readonly HashSet<Type> IntegerTypes = new HashSet<Type>
{
typeof(int), typeof(long), typeof(uint), typeof(short), typeof(ushort), typeof(byte), typeof(sbyte)
};
private readonly CultCodec _codec;
private readonly Dictionary<Type, CultInspectorShape> _shapes = new Dictionary<Type, CultInspectorShape>();
private readonly Dictionary<Type, CultInspectorGrouping> _groupings = new Dictionary<Type, CultInspectorGrouping>();
private readonly Dictionary<MemberInfo, CultInspectorMetadata> _metadata = new Dictionary<MemberInfo, CultInspectorMetadata>();
// The codec is the store's (CultCacheMessagePack.CreateInspectorModel for .cc stores), the one a CultCache resolves
// variants with. Edits clone documents through it; dictionary keys compare by its Serialize under their owning
// document, exactly as the store writes them inside it.
public CultInspectorModel(CultDocumentRegistry registry, CultCodec codec)
{
Registry = registry ?? throw new ArgumentNullException(nameof(registry));
_codec = codec ?? throw new ArgumentNullException(nameof(codec));
}
private byte[] _serialize(object value, Type type, Type document) => _codec.Serialize(value, type, document);
private object _deserialize(Type type, byte[] bytes) =>
_codec.Deserialize(type, type, bytes) ?? throw new InvalidOperationException($"The codec returned null for {type.FullName}.");
public CultDocumentRegistry Registry { get; }
public static string RecordLabel(CultStoredDocument record)
{
var name = record.Descriptor.NameAccessor?.Invoke(record.Document);
return string.IsNullOrWhiteSpace(name) ? record.Key.Value : name!;
}
// The key a CultRecordRef<T> holds; "" when unset.
public static string RecordKey(object? recordRef) => (recordRef as ICultRecordRef)?.Key.Value ?? string.Empty;
public CultInspectorShape ShapeOf(Type type)
{
if (type == null) throw new ArgumentNullException(nameof(type));
lock (_shapes)
{
if (!_shapes.TryGetValue(type, out var shape))
_shapes[type] = shape = Classify(type);
return shape;
}
}
public IReadOnlyList<CultInspectorMember> MembersOf(Type type)
{
var shape = ShapeOf(type);
return shape.Kind == CultInspectorValueKind.Nested ? shape.Members : Array.Empty<CultInspectorMember>();
}
public CultInspectorMetadata MetadataOf(MemberInfo? member)
{
if (member == null)
return CultInspectorMetadata.None;
lock (_metadata)
{
if (!_metadata.TryGetValue(member, out var metadata))
_metadata[member] = metadata = new CultInspectorMetadata(member);
return metadata;
}
}
public object Clone(object document, Type type) => _deserialize(type, _serialize(document, type, type));
public CultInspectorEdit BeginEdit(CultStoredDocument record)
{
if (record == null) throw new ArgumentNullException(nameof(record));
var type = record.Descriptor.DocumentType;
var snapshot = _serialize(record.Document, type, type);
return new CultInspectorEdit(record, _deserialize(type, snapshot), () => _deserialize(type, snapshot));
}
// Whether CreateDefault makes a value: a string, struct, list or dictionary, or a concrete class with a parameterless constructor.
public bool CanCreate(Type type) =>
type == typeof(string) || type.IsValueType ||
ShapeOf(type).Kind is CultInspectorValueKind.List or CultInspectorValueKind.Dictionary ||
!type.IsAbstract && !type.IsInterface && type.GetConstructor(Type.EmptyTypes) != null;
public object? CreateDefault(Type type)
{
if (!CanCreate(type)) return null;
if (type == typeof(string)) return string.Empty;
var shape = ShapeOf(type);
if (type.IsArray) return Array.CreateInstance(shape.ElementType!, 0);
return Activator.CreateInstance(shape.Kind == CultInspectorValueKind.Dictionary ? shape.BuildType! : type);
}
// What a new value for a slot of elementType (a list element, a dictionary value, a union pick) may be: a union's
// declared subtypes, or elementType itself.
public IReadOnlyList<Type> ElementChoices(Type elementType)
{
var shape = ShapeOf(elementType);
return shape.Kind == CultInspectorValueKind.Union ? shape.UnionChoices : new[] { elementType };
}
// A new value of choice for a slot of elementType, or null with a notice when choice cannot be made.
public object? CreateElement(Type elementType, Type choice, out string? notice)
{
// A slot's own type is always offered; an abstract one is refused with the notice below.
if (choice != elementType && !ElementChoices(elementType).Contains(choice))
throw new ArgumentException($"{choice.Name} is not a choice for {elementType.Name}.", nameof(choice));
var created = CreateDefault(choice);
// Null is a value only for a Nullable<T> slot; anywhere else it is nothing made.
notice = created == null && !choice.IsValueType
? $"{choice.Name} is abstract or has no parameterless constructor; nothing was created."
: null;
return created;
}
// An integer edit as a value of integerType, clamped to that type's range instead of overflowing.
public object NarrowInteger(Type integerType, long value)
{
if (!IntegerTypes.Contains(integerType))
throw new ArgumentException($"{integerType.Name} is not an integer the inspector edits.", nameof(integerType));
long Bound(string name) => Convert.ToInt64(integerType.GetField(name)!.GetValue(null));
return Convert.ChangeType(Math.Max(Bound("MinValue"), Math.Min(Bound("MaxValue"), value)), integerType);
}
// A new collection holding items; the value it replaces is never mutated.
public object BuildList(Type listType, IReadOnlyList<object?> items)
{
var shape = ShapeOf(listType);
if (shape.Kind != CultInspectorValueKind.List)
throw new ArgumentException($"{listType.Name} is not a list the inspector builds.", nameof(listType));
if (listType.IsArray)
{
var array = Array.CreateInstance(shape.ElementType!, items.Count);
for (var i = 0; i < items.Count; i++) array.SetValue(items[i], i);
return array;
}
var list = (IList)Activator.CreateInstance(listType)!;
foreach (var item in items) list.Add(item);
return list;
}
public object BuildDictionary(Type dictionaryType, IEnumerable<KeyValuePair<object?, object?>> entries)
{
var dictionary = (IDictionary)Activator.CreateInstance(DictionaryShape(dictionaryType).BuildType!)!;
foreach (var entry in entries) dictionary.Add(entry.Key!, entry.Value);
return dictionary;
}
// The key entry `index` of a dictionary in a documentType document holding `keys` takes when a lowering offers
// `candidate`: the candidate, or the kept key and a notice saying why. An unchanged key is never refused. Any other
// key must be non-null, not an empty record reference, and not taken by another entry, so BuildDictionary cannot
// throw. A key the document cannot serialize is refused: the store could not write it.
public object? ReplaceKey(Type documentType, Type dictionaryType, IReadOnlyList<object?> keys, int index, object? candidate, out string? notice)
{
var shape = DictionaryShape(dictionaryType);
string? refusal;
try
{
var identity = KeyIdentity(documentType, shape, candidate);
refusal = identity == KeyIdentity(documentType, shape, keys[index]) ? null
: identity == null ? "a null key"
: identity == RecordRefIdentity ? "an empty record reference as a key"
: Taken(documentType, shape, candidate!, keys.Where((_, i) => i != index)) ? "a duplicate key"
: null;
}
catch (Exception exception)
{
refusal = $"a key {documentType.Name} cannot serialize ({exception.GetBaseException().Message})";
}
notice = refusal == null ? null : $"Refused {refusal} on entry {index}; its key was kept.";
return refusal == null ? candidate : keys[index];
}
// A key to add beside `keys` in a documentType document: a record-reference key takes the first candidate record not
// already used; any other key is the type's default. Null with a notice when no such key is free or serializable.
public object? FreshKey(Type documentType, Type dictionaryType, IReadOnlyList<object?> keys, IEnumerable<CultStoredDocument> records, out string? notice)
{
var shape = DictionaryShape(dictionaryType);
var keyType = shape.KeyType!;
var candidates = ShapeOf(keyType).Kind == CultInspectorValueKind.RecordRef
? RecordCandidates(keyType, records).Select(record => CreateRecordRef(keyType, record.Key.Value))
: new[] { CreateDefault(keyType) };
try
{
var fresh = candidates.FirstOrDefault(key => key != null && !Taken(documentType, shape, key, keys));
notice = fresh == null ? $"No unused {keyType.Name} key is available; nothing was added." : null;
return fresh;
}
catch (Exception exception)
{
notice = $"{documentType.Name} cannot serialize a {keyType.Name} key ({exception.GetBaseException().Message}); nothing was added.";
return null;
}
}
// A key is taken when another serializes the same (a CultRecordRef<T> by its key string) or when
// the rebuilt dictionary type's default comparer calls them equal (0.0 and -0.0 serialize apart but are one double key).
private bool Taken(Type documentType, CultInspectorShape dictionary, object key, IEnumerable<object?> keys)
{
var identity = KeyIdentity(documentType, dictionary, key);
var probe = (IDictionary)Activator.CreateInstance(dictionary.BuildType!)!;
var filler = dictionary.ValueType!.IsValueType ? Activator.CreateInstance(dictionary.ValueType) : null;
foreach (var other in keys.Where(other => other != null))
{
if (KeyIdentity(documentType, dictionary, other) == identity) return true;
probe[other!] = filler;
}
return probe.Contains(key);
}
// A key's bytes as its declared key type under the owning document's serializer, the store's own path. Throws when
// that serializer cannot write the key.
private string? KeyIdentity(Type documentType, CultInspectorShape dictionary, object? key) =>
key == null ? null
: key is ICultRecordRef reference ? RecordRefIdentity + reference.Key.Value
: "value:" + Convert.ToBase64String(_serialize(key, dictionary.KeyType!, documentType));
private CultInspectorShape DictionaryShape(Type dictionaryType)
{
var shape = ShapeOf(dictionaryType);
return shape.Kind == CultInspectorValueKind.Dictionary
? shape
: throw new ArgumentException($"{dictionaryType.Name} is not a dictionary the inspector builds.", nameof(dictionaryType));
}
// The records a CultRecordRef<T> may point at: every record whose document is a T, by label.
public IReadOnlyList<CultStoredDocument> RecordCandidates(Type recordRefType, IEnumerable<CultStoredDocument> records)
{
var target = RecordTargetOf(recordRefType);
return records
.Where(record => IsCandidateFor(target, record))
.OrderBy(RecordLabel, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
// Whether record may be the value of a recordRefType: the one rule the picker's candidates and a drop share.
public bool IsRecordCandidate(Type recordRefType, CultStoredDocument record) =>
IsCandidateFor(RecordTargetOf(recordRefType), record ?? throw new ArgumentNullException(nameof(record)));
private Type RecordTargetOf(Type recordRefType) =>
ShapeOf(recordRefType).RecordTarget
?? throw new ArgumentException($"{recordRefType.Name} is not a CultRecordRef<T>.", nameof(recordRefType));
private static bool IsCandidateFor(Type target, CultStoredDocument record) =>
target.IsInstanceOfType(record.Document) && record.Key.Value.Length > 0;
public object CreateRecordRef(Type recordRefType, string key)
{
if (ShapeOf(recordRefType).Kind != CultInspectorValueKind.RecordRef)
throw new ArgumentException($"{recordRefType.Name} is not a CultRecordRef<T>.", nameof(recordRefType));
return Activator.CreateInstance(recordRefType, new CultRecordKey(key))!;
}
// How a listed document type's records group, from the nearest [CultInspectorGroupBy] up its inheritance chain. Members
// is empty when nothing groups, with a Notice when a declaration was refused.
public CultInspectorGrouping GroupingOf(Type listed)
{
if (listed == null) throw new ArgumentNullException(nameof(listed));
lock (_groupings)
{
if (!_groupings.TryGetValue(listed, out var grouping))
_groupings[listed] = grouping = ResolveGrouping(listed);
return grouping;
}
}
private CultInspectorGrouping ResolveGrouping(Type listed)
{
var declared = (CultInspectorGroupByAttribute?)Attribute.GetCustomAttribute(listed, typeof(CultInspectorGroupByAttribute), true);
if (declared == null || declared.Members.Count == 0)
return new CultInspectorGrouping(listed, Array.Empty<CultInspectorMember>(), null);
CultInspectorGrouping Refused(string reason) =>
new CultInspectorGrouping(listed, Array.Empty<CultInspectorMember>(), $"[CultInspectorGroupBy] on {listed.Name}: {reason} The list is not grouped.");
if (listed.GetCustomAttribute<CultGlobalAttribute>() != null)
return Refused("a global document is never grouped.");
var members = new List<CultInspectorMember>();
foreach (var name in declared.Members)
{
var member = MembersOf(listed).FirstOrDefault(candidate => candidate.Name == name);
if (member == null) return Refused($"{name} is not an inspector member.");
if (members.Contains(member)) return Refused($"{name} is named twice.");
if (member.Metadata.Hidden) return Refused($"{name} is hidden.");
if (member.IsReadOnly) return Refused($"{name} is read-only.");
var kind = ShapeOf(member.ValueType).Kind;
var groupable = kind is CultInspectorValueKind.String or CultInspectorValueKind.Integer or CultInspectorValueKind.Bool or CultInspectorValueKind.RecordRef
|| kind == CultInspectorValueKind.Enum && !member.ValueType.IsDefined(typeof(FlagsAttribute), false);
if (!groupable) return Refused($"{name} is a {member.ValueType.Name}, which does not group.");
members.Add(member);
}
return new CultInspectorGrouping(listed, members, null);
}
// The tree of listed's candidate records: one level per grouped member, occupied values only. Leaves hold the records,
// in candidate order, and together exactly RecordCandidates(CultRecordRef<listed>, records); a variant groups by its
// resolved values. Empty when GroupingOf(listed) groups nothing: the lowering lists the candidates flat.
public IReadOnlyList<CultInspectorRecordGroup> GroupRecords(Type listed, IEnumerable<CultStoredDocument> records)
{
var grouping = GroupingOf(listed);
if (grouping.Members.Count == 0)
return Array.Empty<CultInspectorRecordGroup>();
var all = records.ToArray();
var targets = grouping.Members
.Where(member => ShapeOf(member.ValueType).Kind == CultInspectorValueKind.RecordRef)
.ToDictionary(member => member, member => RefTargets(member.ValueType, all));
var candidates = RecordCandidates(typeof(CultRecordRef<>).MakeGenericType(listed), all);
return Partition(grouping, candidates, 0, Array.Empty<object?>(), targets);
}
private IReadOnlyList<CultInspectorRecordGroup> Partition(
CultInspectorGrouping grouping,
IReadOnlyList<CultStoredDocument> records,
int depth,
IReadOnlyList<object?> path,
IReadOnlyDictionary<CultInspectorMember, Dictionary<string, CultStoredDocument>> targets)
{
var member = grouping.Members[depth];
targets.TryGetValue(member, out var refTargets);
return records
.Select(record => (Record: record, Key: LevelOf(member, member.GetValue(record.Document), refTargets)))
.GroupBy(entry => entry.Key.Identity)
.Select(bucket => (Key: bucket.First().Key, Records: bucket.Select(entry => entry.Record).ToArray()))
.OrderBy(bucket => bucket.Key, GroupLevel.Order)
.Select(bucket =>
{
var values = path.Append(bucket.Key.Value).ToArray();
var children = depth + 1 < grouping.Members.Count
? Partition(grouping, bucket.Records, depth + 1, values, targets)
: Array.Empty<CultInspectorRecordGroup>();
return new CultInspectorRecordGroup(
GroupId(grouping.Members, values), bucket.Key.Label, depth, values, children,
children.Count == 0 ? bucket.Records : Array.Empty<CultStoredDocument>(), bucket.Records.Length);
})
.ToArray();
}
// A new listed document set to every grouped value on group's path, so it lands in group. Null with a notice when
// listed cannot be created. The group must come from GroupRecords for this listed type.
public object? CreateInGroup(Type listed, CultInspectorRecordGroup group, out string? notice)
{
if (group == null) throw new ArgumentNullException(nameof(group));
var members = GroupingOf(listed).Members;
if (group.Values.Count == 0 || group.Values.Count > members.Count || group.Id != GroupId(members, group.Values))
throw new ArgumentException($"Group {group.Id} is not a node of {listed.Name}'s grouping.", nameof(group));
var created = CreateElement(listed, listed, out notice);
if (created == null)
return null;
for (var i = 0; i < group.Values.Count; i++)
members[i].SetValue(created, group.Values[i]);
return created;
}
// What a CultRecordRef<T> value shows: None, the target's label, or Missing <key> when the key names no candidate.
public string RecordRefLabel(Type recordRefType, object? value, IEnumerable<CultStoredDocument> records) =>
RefLabel(RecordKey(value), RefTargets(recordRefType, records));
private Dictionary<string, CultStoredDocument> RefTargets(Type recordRefType, IEnumerable<CultStoredDocument> records) =>
RecordCandidates(recordRefType, records).ToDictionary(record => record.Key.Value, StringComparer.Ordinal);
private static string RefLabel(string key, IReadOnlyDictionary<string, CultStoredDocument> targets) =>
key.Length == 0 ? "None" : targets.TryGetValue(key, out var target) ? RecordLabel(target) : "Missing " + key;
// A node's id is its members' value identities, so it survives relabelling: a ref by key, an enum by its underlying
// value, anything else by the value's invariant string.
private static string GroupId(IReadOnlyList<CultInspectorMember> members, IReadOnlyList<object?> values) =>
string.Join("/", values.Select((value, i) => members[i].Name + "=" + EscapeIdentity(IdentityOf(value))));
// Injective: text is spelled as Uri.EscapeDataString spells it, which turns '/' and '%' into %XX; an unpaired
// surrogate, which that method would flatten to U+FFFD, becomes %uXXXX, a form no escaped run contains.
private static string EscapeIdentity(string identity)
{
var escaped = new StringBuilder();
var run = 0;
for (var i = 0; i < identity.Length; i++)
{
if (char.IsHighSurrogate(identity[i]) && i + 1 < identity.Length && char.IsLowSurrogate(identity[i + 1]))
i++;
else if (char.IsSurrogate(identity[i]))
{
escaped.Append(Uri.EscapeDataString(identity.Substring(run, i - run))).Append("%u").Append(((int)identity[i]).ToString("X4"));
run = i + 1;
}
}
return escaped.Append(Uri.EscapeDataString(identity.Substring(run))).ToString();
}
private static string IdentityOf(object? value) =>
value switch
{
null => string.Empty,
ICultRecordRef reference => reference.Key.Value,
Enum enumeration => Convert.ToString(Convert.ChangeType(enumeration, Enum.GetUnderlyingType(enumeration.GetType())), System.Globalization.CultureInfo.InvariantCulture)!,
_ => Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)!
};
private GroupLevel LevelOf(CultInspectorMember member, object? value, IReadOnlyDictionary<string, CultStoredDocument>? refTargets)
{
var identity = IdentityOf(value);
switch (ShapeOf(member.ValueType).Kind)
{
case CultInspectorValueKind.RecordRef:
var label = RefLabel(identity, refTargets!);
var rank = identity.Length == 0 ? 0 : refTargets!.ContainsKey(identity) ? 1 : 2;
return new GroupLevel(identity, label, CreateRecordRef(member.ValueType, identity), rank, 0m, label, identity);
case CultInspectorValueKind.Enum:
var number = Convert.ToDecimal(Convert.ChangeType(value, Enum.GetUnderlyingType(member.ValueType)), System.Globalization.CultureInfo.InvariantCulture);
return new GroupLevel(identity, Enum.GetName(member.ValueType, value!) ?? identity, value, 1, number);
case CultInspectorValueKind.Integer:
return new GroupLevel(identity, identity, value, 1, Convert.ToDecimal(value, System.Globalization.CultureInfo.InvariantCulture));
case CultInspectorValueKind.Bool:
return new GroupLevel(identity, identity, value, 1, (bool)value! ? 1m : 0m);
default:
return new GroupLevel(identity, identity.Length == 0 ? "(empty)" : identity, identity, 1, 0m, identity, identity);
}
}
private sealed class GroupLevel
{
public GroupLevel(string identity, string label, object? value, int rank, decimal number, string text = "", string tie = "")
{
Identity = identity;
Label = label;
Value = value;
Rank = rank;
Number = number;
Text = text;
Tie = tie;
}
public string Identity { get; }
public string Label { get; }
public object? Value { get; }
private int Rank { get; }
private decimal Number { get; }
private string Text { get; }
private string Tie { get; }
// None first and Missing last among refs; then the value, the text ignoring case, then the text exactly.
public static readonly IComparer<GroupLevel> Order = Comparer<GroupLevel>.Create((a, b) =>
{
var order = a.Rank.CompareTo(b.Rank);
if (order == 0) order = a.Number.CompareTo(b.Number);
if (order == 0) order = StringComparer.OrdinalIgnoreCase.Compare(a.Text, b.Text);
return order != 0 ? order : StringComparer.Ordinal.Compare(a.Tie, b.Tie);
});
}
private CultInspectorShape Classify(Type type)
{
if (type == typeof(string)) return new CultInspectorShape(type, CultInspectorValueKind.String);
if (type == typeof(bool)) return new CultInspectorShape(type, CultInspectorValueKind.Bool);
if (type.IsEnum) return new CultInspectorShape(type, CultInspectorValueKind.Enum);
if (IntegerTypes.Contains(type)) return new CultInspectorShape(type, CultInspectorValueKind.Integer);
if (type == typeof(float) || type == typeof(double)) return new CultInspectorShape(type, CultInspectorValueKind.Float);
if (type.IsArray && type.GetArrayRank() != 1)
return Unsupported(type, $"{type.Name} is a multi-dimensional array, which the inspector does not edit.");
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>))
return new CultInspectorShape(type, CultInspectorValueKind.RecordRef) { RecordTarget = type.GetGenericArguments()[0] };
var dictionary = type.GetInterfaces().Prepend(type)
.FirstOrDefault(candidate => candidate.IsGenericType && candidate.GetGenericTypeDefinition() == typeof(IDictionary<,>));
if (dictionary != null)
{
var arguments = dictionary.GetGenericArguments();
var build = type.IsInterface || type.IsAbstract ? typeof(Dictionary<,>).MakeGenericType(arguments) : type;
return typeof(IDictionary).IsAssignableFrom(build) && build.GetConstructor(Type.EmptyTypes) != null
? new CultInspectorShape(type, CultInspectorValueKind.Dictionary) { KeyType = arguments[0], ValueType = arguments[1], BuildType = build }
: Unsupported(type, $"{type.Name} is a dictionary the inspector cannot rebuild.");
}
if (type.IsArray)
return new CultInspectorShape(type, CultInspectorValueKind.List) { ElementType = type.GetElementType() };
if (type.IsGenericType && !type.IsAbstract && !type.IsInterface && typeof(IList).IsAssignableFrom(type) && type.GetConstructor(Type.EmptyTypes) != null)
return new CultInspectorShape(type, CultInspectorValueKind.List) { ElementType = type.GetGenericArguments()[0] };
if (type.IsAbstract || type.IsInterface)
{
var choices = type.GetCustomAttributes<UnionAttribute>(false).OrderBy(union => union.Key).Select(union => union.SubType).ToArray();
return choices.Length == 0
? Unsupported(type, $"{type.Name} declares no [Union] subtypes.")
: new CultInspectorShape(type, CultInspectorValueKind.Union) { UnionChoices = choices };
}
var keyed = KeyedMembers(type);
if (keyed.Length > 0)
return new CultInspectorShape(type, CultInspectorValueKind.Nested) { Members = keyed };
// An unkeyed struct is edited in place. With public fields it shows only them, readonly ones read-only: its
// properties alias those values (a Quaternion's eulerAngles, a Rect's min and max). Without, it shows its public
// properties with a setter, init-only ones read-only (record structs, matrices over private rows). Get-only
// properties are computed or kept by the in-place edit, so they are never rows.
if (type.IsValueType)
{
var fields = type.GetFields(Public);
var members = (fields.Length > 0
? fields.OrderBy(field => field.MetadataToken)
.Select(field => (Member: (MemberInfo)field, Type: field.FieldType, Assignable: !field.IsInitOnly))
: type.GetProperties(Public).OrderBy(property => property.MetadataToken)
.Where(property => property.GetIndexParameters().Length == 0 && property.GetGetMethod() != null && property.GetSetMethod() != null)
.Select(property => (Member: (MemberInfo)property, Type: property.PropertyType, Assignable: !IsInitOnly(property.GetSetMethod()!))))
.Select((member, slot) => new CultInspectorMember(member.Member, member.Type, slot, member.Assignable, MetadataOf(member.Member)))
.ToArray();
if (members.Length > 0)
return new CultInspectorShape(type, CultInspectorValueKind.Nested) { Members = members };
}
return Unsupported(type, "no drawer for " + type.FullName);
}
// Documents take their members and slots from the registry's catalog; other MessagePack objects from their [Key]s.
private CultInspectorMember[] KeyedMembers(Type type)
{
var allowPrivate = type.GetCustomAttribute<MessagePackObjectAttribute>(true)?.AllowPrivate == true;
var candidates = type.GetMembers(Public)
.Where(member => member is FieldInfo field && !field.IsLiteral ||
member is PropertyInfo property && property.GetGetMethod() != null && property.GetIndexParameters().Length == 0)
.Where(member => !member.IsDefined(typeof(IgnoreMemberAttribute), true))
// A member hidden with `new` is its most-derived declaration, as the registry's own-member scan reads it.
.GroupBy(member => member.Name)
.Select(hides => hides.OrderByDescending(member => Ancestry(member.DeclaringType)).First());
IEnumerable<(MemberInfo Member, int Slot)> slotted;
if (type.IsDefined(typeof(CultDocumentAttribute), false))
{
var slots = Registry.GetRequired(type).ToCatalogEntry().Members.ToDictionary(member => member.MemberName, member => member.Slot, StringComparer.Ordinal);
slotted = candidates.Where(member => slots.ContainsKey(member.Name)).Select(member => (member, slots[member.Name]));
}
else
{
slotted = candidates
.Select(member => (Member: member, Slot: member.GetCustomAttribute<KeyAttribute>(true)?.IntKey))
.Where(entry => entry.Slot != null)
.Select(entry => (entry.Member, entry.Slot!.Value));
}
return slotted
.Select(entry => new CultInspectorMember(entry.Member, TypeOf(entry.Member), entry.Slot, Assignable(entry.Member, allowPrivate), MetadataOf(entry.Member)))
.OrderBy(member => member.Metadata.Order ?? member.Slot)
.ThenBy(member => member.Slot)
.ToArray();
}
private static CultInspectorShape Unsupported(Type type, string reason) =>
new CultInspectorShape(type, CultInspectorValueKind.Unsupported) { Reason = reason };
private static int Ancestry(Type? type) => type == null ? 0 : 1 + Ancestry(type.BaseType);
// `init` is a setter whose return carries the IsExternalInit modreq; the language contract, matched by name because
// netstandard2.1 does not ship the type. A runtime that does not report the modreq (unverified on Unity's Mono) shows
// init members editable instead of read-only; SetValue still assigns them, so their edits round-trip either way.
private static bool IsInitOnly(MethodInfo setter) =>
setter.ReturnParameter.GetRequiredCustomModifiers().Any(modifier => modifier.FullName == "System.Runtime.CompilerServices.IsExternalInit");
private static Type TypeOf(MemberInfo member) => member is FieldInfo field ? field.FieldType : ((PropertyInfo)member).PropertyType;
private static bool Assignable(MemberInfo member, bool allowPrivate)
{
if (member is FieldInfo field)
return !field.IsInitOnly || allowPrivate;
var setter = ((PropertyInfo)member).GetSetMethod(true);
return setter != null && (setter.IsPublic || allowPrivate);
}
}
}