Skip to content

Commit 61133db

Browse files
committed
Make KVDocument standalone, add Root property, string indexer and implicit cast to KVObject
1 parent dd8a1fb commit 61133db

39 files changed

Lines changed: 273 additions & 207 deletions

README.md

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ KeyValues is a simple key-value pair format used by Valve in Steam and the Sourc
1212

1313
The library is built around a single type:
1414

15-
- **`KVObject`** (class) -- a value node. Can be a scalar (string, int, float, bool, etc.), a binary blob, an array, or a named collection of children. Keys (names) are stored in the parent container, not on the child -- similar to how JSON works.
16-
- **`KVDocument`** (class, extends `KVObject`) -- a deserialized document with a root key `Name` and optional `Header`.
15+
- **`KVObject`** (class) -- a value node. Can be a scalar (string, int, float, bool, etc.), a binary blob, an array, or a named collection of children. Keys (names) are stored in the parent container, not on the child -- similar to how JSON works. Implements `IReadOnlyDictionary<string, KVObject>` and `IConvertible`.
16+
- **`KVDocument`** (class) -- a deserialized document containing a `Root` KVObject, a root key `Name`, and an optional `Header`. Has a read-only string indexer that delegates to `Root`, and an implicit conversion to `KVObject`.
1717

1818
All types are shared across KV1 and KV3 -- you can deserialize from one format and serialize to another. However, not all value types are supported by all formats:
1919

@@ -71,7 +71,7 @@ var nul = KVObject.Null();
7171
KVDocument data = kv.Deserialize(stream);
7272

7373
// Root key name (only on KVDocument)
74-
string rootName = data.Name;
74+
string? rootName = data.Name;
7575

7676
// String indexer returns KVObject (supports chaining)
7777
string name = (string)data["config"]["name"];
@@ -82,40 +82,43 @@ bool enabled = (bool)data["settings"]["enabled"];
8282
// Array elements by index
8383
float x = (float)data["position"][0];
8484

85-
// Check existence
86-
if (data.ContainsKey("optional")) { ... }
87-
if (data.TryGetValue("optional", out var child)) { ... }
85+
// Access the root KVObject for full API (mutations, ContainsKey, etc.)
86+
KVObject root = data.Root;
8887

89-
// Null-safe (indexer returns null for missing keys)
90-
KVObject val = data["missing"]; // null
88+
// Check existence (on the root KVObject)
89+
if (data.Root.ContainsKey("optional")) { ... }
90+
if (data.Root.TryGetValue("optional", out var child)) { ... }
9191

92-
// Direct access to value properties
93-
KVValueType type = data.ValueType;
92+
// Indexer throws KeyNotFoundException for missing keys
93+
// Use TryGetValue for safe access
94+
95+
// Direct access to value properties (on KVObject)
96+
KVValueType type = data.Root.ValueType;
9497
KVFlag flag = data["texture"].Flag;
95-
ReadOnlySpan<byte> bytes = data["blob"].AsSpan();
98+
byte[] bytes = data["blob"].AsBlob();
9699
```
97100

98101
### Modifying
99102

100103
```csharp
101-
// Set scalar (implicit conversion)
102-
data["name"] = "new name";
103-
data["count"] = 42;
104+
// Mutations require the Root KVObject (KVDocument indexer is read-only)
105+
data.Root["name"] = "new name";
106+
data.Root["count"] = 42;
104107

105-
// Chained writes work (reference semantics)
108+
// Chained writes work (reference semantics, first lookup goes through KVDocument indexer)
106109
data["config"]["resolution"] = "1920x1080";
107110

108111
// Add children to collections
109-
data.Add("newprop", 42); // implicit int -> KVObject
110-
data.Add("text", "value"); // implicit string -> KVObject
112+
data.Root.Add("newprop", 42); // implicit int -> KVObject
113+
data.Root.Add("text", "value"); // implicit string -> KVObject
111114
112115
// Add elements to arrays
113116
arr.Add(3.14f); // implicit float -> KVObject
114117
115118
// Remove
116-
data.Remove("deprecated");
119+
data.Root.Remove("deprecated");
117120
arr.RemoveAt(2);
118-
data.Clear();
121+
data.Root.Clear();
119122

120123
// Set flags directly
121124
data["texture"].Flag = KVFlag.Resource;
@@ -124,16 +127,16 @@ data["texture"].Flag = KVFlag.Resource;
124127
### Enumerating
125128

126129
```csharp
127-
// KVObject implements IEnumerable<KeyValuePair<string, KVObject>>
130+
// KVObject implements IReadOnlyDictionary<string, KVObject>
128131
// Keys are the child names, values are the child KVObjects
129-
foreach (var (key, child) in data)
132+
foreach (var (key, child) in data.Root)
130133
{
131134
Console.WriteLine($"{key} = {(string)child}");
132135
}
133136

134137
// Keys and Values properties
135-
var keys = data.Keys; // IEnumerable<string>
136-
var values = data.Values; // IEnumerable<KVObject>
138+
var keys = data.Root.Keys; // IEnumerable<string>
139+
var values = data.Root.Values; // IEnumerable<KVObject>
137140
138141
// Array elements have null keys
139142
foreach (var (key, element) in arrayObj)
@@ -179,7 +182,7 @@ public class SimpleObject
179182
var stream = File.OpenRead("file.vdf"); // or any other Stream
180183
181184
var kv = KVSerializer.Create(KVSerializationFormat.KeyValues1Text);
182-
KVObject data = kv.Deserialize<SimpleObject>(stream);
185+
SimpleObject data = kv.Deserialize<SimpleObject>(stream);
183186
```
184187

185188
### Options
@@ -193,6 +196,7 @@ By default, operating system specific conditionals are enabled based on the OS t
193196
* `HasEscapeSequences` - Whether the parser should translate escape sequences (e.g. `\n`, `\t`).
194197
* `EnableValveNullByteBugBehavior` - Whether invalid escape sequences should truncate strings rather than throwing an `InvalidDataException`.
195198
* `FileLoader` - Provider for referenced files with `#include` or `#base` directives.
199+
* `SkipHeader` - Whether to skip writing the KV3 header comment during serialization.
196200

197201
```csharp
198202
var options = new KVSerializerOptions
@@ -214,6 +218,20 @@ Essentially the same as text, just change `KeyValues1Text` to `KeyValues1Binary`
214218

215219
## Serializing to text
216220

221+
### Dynamic serialization
222+
```csharp
223+
var root = KVObject.ListCollection();
224+
root.Add("Developer", "Valve Software");
225+
root.Add("Name", "Dota 2");
226+
var doc = new KVDocument(null, "root object name", root);
227+
228+
using var stream = File.OpenWrite("file.vdf");
229+
230+
var kv = KVSerializer.Create(KVSerializationFormat.KeyValues1Text);
231+
kv.Serialize(stream, doc);
232+
```
233+
234+
### Typed serialization
217235
```csharp
218236
class DataObject
219237
{

ValveKeyValue/ValveKeyValue.Test/ApiSurfaceTestCase.cs

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections.Generic;
12
using System.Globalization;
23
using System.Linq;
34
using System.Reflection;
@@ -44,10 +45,18 @@ static void GenerateTypeApiSurface(StringBuilder sb, Type type)
4445

4546
sb.Append("public ");
4647

47-
if (typeInfo.IsSealed)
48+
if (typeInfo.IsAbstract && typeInfo.IsSealed && typeInfo.IsClass)
49+
{
50+
sb.Append("static ");
51+
}
52+
else if (typeInfo.IsSealed)
4853
{
4954
sb.Append("sealed ");
5055
}
56+
else if (typeInfo.IsAbstract && !typeInfo.IsInterface)
57+
{
58+
sb.Append("abstract ");
59+
}
5160

5261
if (typeInfo.IsClass)
5362
{
@@ -68,6 +77,33 @@ static void GenerateTypeApiSurface(StringBuilder sb, Type type)
6877

6978
sb.Append(' ');
7079
sb.Append(GetTypeAsString(type));
80+
81+
var baseTypes = new List<string>();
82+
83+
if (typeInfo.BaseType != null && typeInfo.BaseType != typeof(object) && typeInfo.BaseType != typeof(ValueType) && typeInfo.BaseType != typeof(Enum))
84+
{
85+
baseTypes.Add(GetTypeAsString(typeInfo.BaseType));
86+
}
87+
88+
var directInterfaces = type.GetInterfaces()
89+
.Except(typeInfo.BaseType?.GetInterfaces() ?? Type.EmptyTypes)
90+
.ToArray();
91+
92+
var ifaceChildren = directInterfaces.ToDictionary(i => i, i => (ICollection<Type>)i.GetInterfaces());
93+
94+
foreach (var iface in directInterfaces
95+
.Where(i => !directInterfaces.Any(other => other != i && ifaceChildren[other].Contains(i)))
96+
.OrderBy(i => GetTypeAsString(i), StringComparer.InvariantCulture))
97+
{
98+
baseTypes.Add(GetTypeAsString(iface));
99+
}
100+
101+
if (baseTypes.Count > 0)
102+
{
103+
sb.Append(" : ");
104+
sb.Append(string.Join(", ", baseTypes));
105+
}
106+
71107
sb.Append("\n{\n");
72108

73109
if (typeInfo.IsEnum)
@@ -88,6 +124,48 @@ static void GenerateTypeApiSurface(StringBuilder sb, Type type)
88124
sb.Append('\n');
89125
}
90126

127+
if (!typeInfo.IsEnum)
128+
{
129+
var fields = type
130+
.GetFields(BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
131+
.Where(t => !t.IsPrivate && !t.IsAssembly && !t.IsFamilyAndAssembly)
132+
.OrderBy(t => t.Name, StringComparer.InvariantCulture);
133+
134+
foreach (var field in fields)
135+
{
136+
sb.Append(" ");
137+
138+
if (field.IsPublic)
139+
{
140+
sb.Append("public");
141+
}
142+
else
143+
{
144+
sb.Append("protected");
145+
}
146+
147+
if (field.IsStatic)
148+
{
149+
sb.Append(" static");
150+
}
151+
152+
if (field.IsLiteral)
153+
{
154+
sb.Append(" const");
155+
}
156+
else if (field.IsInitOnly)
157+
{
158+
sb.Append(" readonly");
159+
}
160+
161+
sb.Append(' ');
162+
sb.Append(GetTypeAsString(field.FieldType));
163+
sb.Append(' ');
164+
sb.Append(field.Name);
165+
sb.Append(";\n");
166+
}
167+
}
168+
91169
var constructors = type
92170
.GetConstructors(BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
93171
.Where(t => !t.IsPrivate && !t.IsAssembly && !t.IsFamilyAndAssembly)

ValveKeyValue/ValveKeyValue.Test/Binary/BinaryObjectConsecutiveSerializationTestCase.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ public void SerializesToBinaryStructure()
77
{
88
var firstObj = KVObject.ListCollection();
99
firstObj.Add("firstkey", "firstvalue");
10-
var first = new KVDocument(null!, "FirstObject", firstObj);
10+
var first = new KVDocument(null, "FirstObject", firstObj);
1111

1212
var secondObj = KVObject.ListCollection();
1313
secondObj.Add("secondkey", "secondvalue");
14-
var second = new KVDocument(null!, "SecondObject", secondObj);
14+
var second = new KVDocument(null, "SecondObject", secondObj);
1515

1616
var expectedData = new byte[]
1717
{

ValveKeyValue/ValveKeyValue.Test/Binary/BinaryObjectSerializationTestCase.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ public void SerializesToBinaryStructure()
1313
kvo.Add("ptr", new IntPtr(0x12345678));
1414
kvo.Add("lng", 0x8877665544332211u);
1515
kvo.Add("i64", 0x0102030405060708);
16-
var doc = new KVDocument(null!, "TestObject", kvo);
16+
var doc = new KVDocument(null, "TestObject", kvo);
1717

1818
var expectedData = new byte[]
1919
{
@@ -71,7 +71,7 @@ public void NewValueTypesAreWidenedInBinarySerialization()
7171
kvo.Add("f64", 3.14);
7272
kvo.Add("blob", KVObject.Blob([0xAB, 0xCD]));
7373
kvo.Add("null", KVObject.Null());
74-
var doc = new KVDocument(null!, "Test", kvo);
74+
var doc = new KVDocument(null, "Test", kvo);
7575

7676
using var ms = new MemoryStream();
7777
KVSerializer.Create(KVSerializationFormat.KeyValues1Binary).Serialize(ms, doc);

ValveKeyValue/ValveKeyValue.Test/Binary/SimpleBinaryTestCase.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ public void HasName()
1515

1616
[Test]
1717
public void IsObjectWithChildren()
18-
=> Assert.That(obj.ValueType, Is.EqualTo(KVValueType.Collection));
18+
=> Assert.That(obj.Root.ValueType, Is.EqualTo(KVValueType.Collection));
1919

2020
[TestCase(ExpectedResult = 7)]
2121
public int HasChildren()
22-
=> obj.Children.Count();
22+
=> obj.Root.Children.Count();
2323

2424
[TestCase("key", "value", typeof(string))]
2525
[TestCase("int", 0x01020304, typeof(int))]

ValveKeyValue/ValveKeyValue.Test/Binary/StringTableFromScratchTestCase.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public void PopulatesStringTableDuringSerialization()
1212
var kv = KVObject.ListCollection();
1313
kv.Add("key", "value");
1414
kv.Add("child", child);
15-
var doc = new KVDocument(null!, "root", kv);
15+
var doc = new KVDocument(null, "root", kv);
1616

1717
var stringTable = new StringTable();
1818

ValveKeyValue/ValveKeyValue.Test/Binary/StringTableTestCase.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ public void HasName()
1515

1616
[Test]
1717
public void IsObjectWithChildren()
18-
=> Assert.That(obj.ValueType, Is.EqualTo(KVValueType.Collection));
18+
=> Assert.That(obj.Root.ValueType, Is.EqualTo(KVValueType.Collection));
1919

2020
[TestCase(ExpectedResult = 5)]
2121
public int HasChildren()
22-
=> obj.Children.Count();
22+
=> obj.Root.Children.Count();
2323

2424
[TestCase("key", "value", typeof(string))]
2525
[TestCase("int", 0x01020304, typeof(int))]

ValveKeyValue/ValveKeyValue.Test/ConversionCoverageTestCase.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ public void DictBackedCollectionAddAndLookup()
314314
{
315315
var kv3Text = "<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:generic:version{7412167c-06e9-4698-aff2-e63eb59037e7} -->\n{\n\tkey1 = \"value1\"\n\tkey2 = 42\n}";
316316
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(kv3Text));
317-
var data = KVSerializer.Create(KVSerializationFormat.KeyValues3Text).Deserialize(stream);
317+
var data = KVSerializer.Create(KVSerializationFormat.KeyValues3Text).Deserialize(stream).Root;
318318

319319
// Verify initial state
320320
Assert.That(data.ContainsKey("key1"), Is.True);
@@ -343,7 +343,7 @@ public void DictBackedCollectionSetChildViaIndexer()
343343
{
344344
var kv3Text = "<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:generic:version{7412167c-06e9-4698-aff2-e63eb59037e7} -->\n{\n\tkey1 = \"value1\"\n}";
345345
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(kv3Text));
346-
var data = KVSerializer.Create(KVSerializationFormat.KeyValues3Text).Deserialize(stream);
346+
var data = KVSerializer.Create(KVSerializationFormat.KeyValues3Text).Deserialize(stream).Root;
347347

348348
// Set via indexer
349349
data["key1"] = "updated";
@@ -360,7 +360,7 @@ public void DictBackedCollectionSetNullStoresNullValue()
360360
{
361361
var kv3Text = "<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:generic:version{7412167c-06e9-4698-aff2-e63eb59037e7} -->\n{\n\tkey1 = \"value1\"\n\tkey2 = 42\n}";
362362
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(kv3Text));
363-
var data = KVSerializer.Create(KVSerializationFormat.KeyValues3Text).Deserialize(stream);
363+
var data = KVSerializer.Create(KVSerializationFormat.KeyValues3Text).Deserialize(stream).Root;
364364

365365
Assert.That(data.Count, Is.EqualTo(2));
366366

ValveKeyValue/ValveKeyValue.Test/EdgeCaseTestCase.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ public void TryGetValueOnDictBackedCollection()
437437
{
438438
var kv3Text = "<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} format:generic:version{7412167c-06e9-4698-aff2-e63eb59037e7} -->\n{\n\tkey1 = \"value1\"\n}";
439439
using var stream = new System.IO.MemoryStream(System.Text.Encoding.UTF8.GetBytes(kv3Text));
440-
var obj = KVSerializer.Create(KVSerializationFormat.KeyValues3Text).Deserialize(stream);
440+
var obj = KVSerializer.Create(KVSerializationFormat.KeyValues3Text).Deserialize(stream).Root;
441441

442442
Assert.That(obj.TryGetValue("key1", out var found), Is.True);
443443
Assert.That((string)found!, Is.EqualTo("value1"));

ValveKeyValue/ValveKeyValue.Test/IKVTextReader.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ namespace ValveKeyValue.Test
22
{
33
interface IKVTextReader
44
{
5-
KVObject Read(string resourceName, KVSerializerOptions? options = null);
5+
KVDocument Read(string resourceName, KVSerializerOptions? options = null);
66

77
T Read<T>(string resourceName, KVSerializerOptions? options = null);
88
}

0 commit comments

Comments
 (0)