Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 199 additions & 0 deletions src/SharpYaml.Tests/Serialization/YamlReaderCurrentKeyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
#nullable enable

using System;
using System.Collections.Generic;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using SharpYaml.Serialization;

namespace SharpYaml.Tests.Serialization;

[TestClass]
public class YamlReaderCurrentKeyTests
{
// ---- Custom converter that captures CurrentKey ----

private sealed class KeyCapturingConverter : YamlConverter<string>
{
public override string? Read(YamlReader reader)
{
var currentKey = reader.CurrentKey;
var scalarValue = reader.ScalarValue;
reader.Read();

// Replace ${KEY} placeholder with the actual key
if (scalarValue is not null && scalarValue.Contains("${KEY}", StringComparison.Ordinal))
{
return scalarValue.Replace("${KEY}", currentKey ?? string.Empty, StringComparison.Ordinal);
}

return scalarValue;
}

public override void Write(YamlWriter writer, string? value)
{
writer.WriteScalar(value ?? string.Empty);
}
}

// ---- Dictionary value gets CurrentKey ----

[TestMethod]
public void DictionaryValueConverterReceivesCurrentKey()
{
var options = new YamlSerializerOptions
{
Converters = [new KeyCapturingConverter()]
};

var yaml = "alpha: name is ${KEY}\nbeta: name is ${KEY}\n";
var result = YamlSerializer.Deserialize<Dictionary<string, string>>(yaml, options);

Assert.IsNotNull(result);
Assert.AreEqual("name is alpha", result["alpha"]);
Assert.AreEqual("name is beta", result["beta"]);
}

// ---- Object property gets CurrentKey ----

private sealed class Config
{
public string Host { get; set; } = string.Empty;
public string Port { get; set; } = string.Empty;
}

[TestMethod]
public void ObjectPropertyConverterReceivesCurrentKey()
{
var options = new YamlSerializerOptions
{
Converters = [new KeyCapturingConverter()]
};

var yaml = "Host: prop=${KEY}\nPort: prop=${KEY}\n";
var result = YamlSerializer.Deserialize<Config>(yaml, options);

Assert.IsNotNull(result);
Assert.AreEqual("prop=Host", result.Host);
Assert.AreEqual("prop=Port", result.Port);
}

// ---- Nested dictionary restores CurrentKey after inner read ----

[TestMethod]
public void NestedDictionaryRestoresCurrentKeyAfterInnerRead()
{
var options = new YamlSerializerOptions
{
Converters = [new KeyCapturingConverter()]
};

var yaml = """
outer1:
inner1: ${KEY}
inner2: ${KEY}
outer2:
inner3: ${KEY}
""";

var result = YamlSerializer.Deserialize<Dictionary<string, Dictionary<string, string>>>(yaml, options);

Assert.IsNotNull(result);
Assert.AreEqual("inner1", result["outer1"]["inner1"]);
Assert.AreEqual("inner2", result["outer1"]["inner2"]);
Assert.AreEqual("inner3", result["outer2"]["inner3"]);
}

// ---- CurrentKey is null at the top level ----

[TestMethod]
public void TopLevelScalarHasNullCurrentKey()
{
var options = new YamlSerializerOptions
{
Converters = [new KeyCapturingConverter()]
};

var yaml = "hello world\n";
var result = YamlSerializer.Deserialize<string>(yaml, options);

// No key context at top level, so ${KEY} stays (replaced with empty)
Assert.AreEqual("hello world", result);
}

// ---- Object with nested object verifies CurrentKey tracking ----

private sealed class Outer
{
public Inner Details { get; set; } = new();
public string Name { get; set; } = string.Empty;
}

private sealed class Inner
{
public string Value { get; set; } = string.Empty;
}

[TestMethod]
public void NestedObjectPropertySetsCurrentKey()
{
var options = new YamlSerializerOptions
{
Converters = [new KeyCapturingConverter()]
};

var yaml = """
Details:
Value: ${KEY}
Name: ${KEY}
""";

var result = YamlSerializer.Deserialize<Outer>(yaml, options);

Assert.IsNotNull(result);
// Inside Details mapping, current key is "Value"
Assert.AreEqual("Value", result.Details.Value);
// Back at outer level, current key is "Name"
Assert.AreEqual("Name", result.Name);
}

// ---- Dictionary<string, object> also sets CurrentKey ----

[TestMethod]
public void DictionaryStringObjectSetsCurrentKey()
{
var options = new YamlSerializerOptions
{
Converters = [new KeyCapturingConverter()]
};

var yaml = "x: ${KEY}\ny: ${KEY}\n";
var result = YamlSerializer.Deserialize<Dictionary<string, string>>(yaml, options);

Assert.IsNotNull(result);
Assert.AreEqual("x", result["x"]);
Assert.AreEqual("y", result["y"]);
}

// ---- Object converter properly restores key after nested mapping ----

[TestMethod]
public void ObjectConverterRestoresKeyAfterNestedMapping()
{
var options = new YamlSerializerOptions
{
Converters = [new KeyCapturingConverter()]
};

var yaml = """
Details:
Value: inner-${KEY}
Name: outer-${KEY}
""";

var result = YamlSerializer.Deserialize<Outer>(yaml, options);

Assert.IsNotNull(result);
Assert.AreEqual("inner-Value", result.Details.Value);
Assert.AreEqual("outer-Name", result.Name);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,10 @@ internal static string FormatNonStringKey<T>(T key)
continue;
}

var previousKey = reader.CurrentKey;
reader.CurrentKey = key;
var value = valueConverter.Read(reader, typeof(TValue));
reader.CurrentKey = previousKey;
dictionary[key] = (TValue)value!;
}

Expand Down Expand Up @@ -317,6 +320,8 @@ internal static string FormatNonStringKey<T>(T key)
throw new YamlException(reader.SourceName, keyStart, keyEnd, "Dictionary key cannot be null.");
}

var previousKey = reader.CurrentKey;
reader.CurrentKey = rawKey.ToString();
object? rawValue;
try
{
Expand All @@ -330,6 +335,10 @@ internal static string FormatNonStringKey<T>(T key)
{
throw new YamlException(reader.SourceName, keyStart, keyEnd, exception.Message, exception);
}
finally
{
reader.CurrentKey = previousKey;
}

var key = (TKey)rawKey;
if (dictionary.ContainsKey(key))
Expand Down
6 changes: 6 additions & 0 deletions src/SharpYaml/Serialization/Converters/YamlObjectConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ public override void Write(YamlWriter writer, T? value)
var keyEnd = reader.End;
var key = reader.ScalarValue ?? string.Empty;
reader.Read();
reader.CurrentKey = key;

if (mergeEnabled && string.Equals(key, "<<", StringComparison.Ordinal))
{
Expand Down Expand Up @@ -384,6 +385,7 @@ private static void PopulateObjectCore(YamlReader reader, Contract contract, obj
var keyEnd = reader.End;
var key = reader.ScalarValue ?? string.Empty;
reader.Read();
reader.CurrentKey = key;

if (mergeEnabled && string.Equals(key, "<<", StringComparison.Ordinal))
{
Expand Down Expand Up @@ -508,6 +510,7 @@ private static void PopulateObjectCore(YamlReader reader, Contract contract, obj
var keyEnd = reader.End;
var key = reader.ScalarValue ?? string.Empty;
reader.Read();
reader.CurrentKey = key;

if (mergeEnabled && string.Equals(key, "<<", StringComparison.Ordinal))
{
Expand Down Expand Up @@ -830,6 +833,7 @@ private void ApplyMergeMappingToInstance(YamlReader reader, object instance, Con
var keyEnd = reader.End;
var key = reader.ScalarValue ?? string.Empty;
reader.Read();
reader.CurrentKey = key;

if (IsMergeKeyEnabled(reader.Options) && string.Equals(key, "<<", StringComparison.Ordinal))
{
Expand Down Expand Up @@ -930,6 +934,7 @@ private static void ApplyMergeMappingToPopulatedInstance(YamlReader reader, obje
var keyEnd = reader.End;
var key = reader.ScalarValue ?? string.Empty;
reader.Read();
reader.CurrentKey = key;

if (IsMergeKeyEnabled(reader.Options) && string.Equals(key, "<<", StringComparison.Ordinal))
{
Expand Down Expand Up @@ -1195,6 +1200,7 @@ private void ApplyMergeMappingToConstructorBuffers(
var keyEnd = reader.End;
var key = reader.ScalarValue ?? string.Empty;
reader.Read();
reader.CurrentKey = key;

if (IsMergeKeyEnabled(reader.Options) && string.Equals(key, "<<", StringComparison.Ordinal))
{
Expand Down
18 changes: 18 additions & 0 deletions src/SharpYaml/Serialization/YamlReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,24 @@ internal static YamlReader Create(string yaml, YamlReferenceReader? referenceRea
/// </summary>
public Mark End => _state.End;

/// <summary>
/// Gets the most recent mapping key encountered by dictionary and object converters.
/// </summary>
/// <remarks>
/// <para>
/// This property is set by built-in dictionary and object converters immediately before
/// deserializing each value. Custom <see cref="YamlConverter{T}"/> implementations and
/// <see cref="YamlConverterFactory"/> instances can read this property to perform
/// context-dependent transformations — for example, replacing a <c>${KEY}</c> placeholder
/// with the actual dictionary key name.
/// </para>
/// <para>
/// For nested structures, this reflects the innermost (most recent) key. Source-generated
/// converters can also set this property to maintain consistency.
/// </para>
/// </remarks>
public string? CurrentKey { get; set; }

internal YamlReferenceReader? ReferenceReader => _state.ReferenceReader;

/// <summary>
Expand Down