Skip to content
36 changes: 31 additions & 5 deletions src/Allure.Net.Commons/AllureLifecycle.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading;
Expand Down Expand Up @@ -33,15 +32,18 @@ namespace Allure.Net.Commons;
public class AllureLifecycle
{
private readonly Dictionary<Type, ITypeFormatter> typeFormatters = new();
private readonly HierarchicalTypeFormatterLookup typeFormatterLookup;
private static readonly Lazy<AllureLifecycle> instance =
new(Initialize);

/// <summary>
/// The list of the currently registered formatters used by Allure to
/// convert test and step arguments to strings.
/// convert test and step arguments to strings. Looking up a type that
/// has no formatter registered for it directly falls back to its
/// generic type definition, the interfaces it implements, and its base
/// classes, in that order.
/// </summary>
public IReadOnlyDictionary<Type, ITypeFormatter> TypeFormatters =>
new ReadOnlyDictionary<Type, ITypeFormatter>(typeFormatters);
public IReadOnlyDictionary<Type, ITypeFormatter> TypeFormatters => typeFormatterLookup;

readonly AsyncLocal<AllureContext> context = new();

Expand Down Expand Up @@ -106,6 +108,7 @@ Func<AllureTestPlan> testPlanFactory
AllureConfiguration = AllureConfiguration.ReadFromJObject(config);
writer = writerFactory(AllureConfiguration);
lazyTestPlan = new(testPlanFactory);
typeFormatterLookup = new(typeFormatters);
}

/// <summary>
Expand All @@ -119,6 +122,7 @@ public AllureLifecycle(AllureConfiguration config, IAllureResultsWriter writer)
this.AllureConfiguration = config;
this.writer = writer;
this.lazyTestPlan = new(AllureTestPlan.FromEnvironment);
this.typeFormatterLookup = new(this.typeFormatters);
}

/// <summary>
Expand All @@ -138,6 +142,7 @@ Dictionary<Type, ITypeFormatter> typeFormatters
this.writer = writer;
this.lazyTestPlan = new(AllureTestPlan.FromEnvironment);
this.typeFormatters = typeFormatters;
this.typeFormatterLookup = new(this.typeFormatters);
}

/// <summary>
Expand Down Expand Up @@ -176,8 +181,29 @@ Dictionary<Type, ITypeFormatter> typeFormatters
public void AddTypeFormatter<T>(TypeFormatter<T> typeFormatter) =>
AddTypeFormatterImpl(typeof(T), typeFormatter);

private void AddTypeFormatterImpl(Type type, ITypeFormatter formatter) =>
/// <summary>
/// Registers a type formatter to be used when converting a test's or
/// step's argument to the string that will be included in the Allure
/// report. Unlike <see cref="AddTypeFormatter{T}(TypeFormatter{T})" />,
/// this overload accepts an open generic type definition (e.g.
/// <c>typeof(List&lt;&gt;)</c>), which cannot be expressed as a type
/// argument.
/// </summary>
/// <param name="type">
/// The type that the formatter converts. An argument is matched against
/// this type exactly, or, failing that, against its generic type
/// definition, the interfaces it implements, and its base classes, in
/// that order.
/// </param>
/// <param name="formatter">The formatter instance.</param>
public void AddTypeFormatter(Type type, ITypeFormatter formatter) =>
AddTypeFormatterImpl(type, formatter);

private void AddTypeFormatterImpl(Type type, ITypeFormatter formatter)
{
typeFormatters[type] = formatter;
typeFormatterLookup.Invalidate();
}

/// <summary>
/// Binds the provided value as the current Allure context and executes
Expand Down
4 changes: 4 additions & 0 deletions src/Allure.Net.Commons/Functions/FormatFunctions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ public static string Format(object? value)
/// if JSON serialization failed.
/// The serializer skips fields that contain loop references
/// and fields that could not be serialized
/// Passing <see cref="AllureLifecycle.TypeFormatters" /> matches a
/// formatter by the value's exact type or, failing that, by its
/// generic type definition, the interfaces it implements, or its
/// base classes. A plain dictionary matches by exact type only.
/// </summary>
public static string Format(
object? value,
Expand Down
132 changes: 132 additions & 0 deletions src/Allure.Net.Commons/HierarchicalTypeFormatterLookup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;

#nullable enable

namespace Allure.Net.Commons;

/// <summary>
/// Wraps a dictionary of explicitly registered type formatters and resolves
/// a formatter for a given runtime type by walking its type hierarchy when
/// no formatter is registered for the exact type.
/// </summary>
/// <remarks>
/// Resolution order:
/// <list type="number">
/// <item>The exact type.</item>
/// <item>The type's generic type definition, if it is a closed generic type.</item>
/// <item>Each interface the type implements, in the order returned by <see cref="Type.GetInterfaces" />.</item>
/// <item>The generic type definition of each such interface.</item>
/// <item>Each base class, from the closest to the furthest.</item>
/// <item>The generic type definition of each such base class.</item>
/// </list>
/// Resolved results are cached per runtime type. The cache is invalidated
/// whenever a new formatter is registered.
/// </remarks>
internal sealed class HierarchicalTypeFormatterLookup : IReadOnlyDictionary<Type, ITypeFormatter>
{
private readonly Dictionary<Type, ITypeFormatter> registered;
private readonly ConcurrentDictionary<Type, ITypeFormatter?> resolveCache = new();

public HierarchicalTypeFormatterLookup(Dictionary<Type, ITypeFormatter> registered)
{
this.registered = registered;
}

/// <summary>
/// Drops all cached resolution results. Must be called whenever the
/// underlying registered-formatters dictionary is mutated.
/// </summary>
public void Invalidate() => resolveCache.Clear();

public bool TryGetValue(Type key, out ITypeFormatter value)
{
var resolved = resolveCache.GetOrAdd(key, Resolve);
value = resolved!;
return resolved is not null;
}

private ITypeFormatter? Resolve(Type type)
{
if (registered.TryGetValue(type, out var exact))
{
return exact;
}

if (type.IsGenericType &&
registered.TryGetValue(type.GetGenericTypeDefinition(), out var genericDefinition))
{
return genericDefinition;
}

var interfaces = type.GetInterfaces();

foreach (var i in interfaces)
{
if (registered.TryGetValue(i, out var iface))
{
return iface;
}
}

foreach (var i in interfaces)
{
if (i.IsGenericType &&
registered.TryGetValue(i.GetGenericTypeDefinition(), out var ifaceGenericDefinition))
{
return ifaceGenericDefinition;
}
}

for (var b = type.BaseType; b is not null; b = b.BaseType)
{
if (registered.TryGetValue(b, out var baseExact))
{
return baseExact;
}
}

for (var b = type.BaseType; b is not null; b = b.BaseType)
{
if (b.IsGenericType &&
registered.TryGetValue(b.GetGenericTypeDefinition(), out var baseGenericDefinition))
{
return baseGenericDefinition;
}
}

return null;
}

/// <summary>
/// The explicitly registered types. Unlike <see cref="TryGetValue" />,
/// this doesn't include every type that could be resolved through them
/// (e.g., subclasses or implementors).
/// </summary>
public IEnumerable<Type> Keys => registered.Keys;

/// <summary>
/// The formatters registered for the types in <see cref="Keys" />, in
/// the same order.
/// </summary>
public IEnumerable<ITypeFormatter> Values => registered.Values;

/// <summary>
/// The number of explicitly registered types. See <see cref="Keys" />.
/// </summary>
public int Count => registered.Count;

public ITypeFormatter this[Type key] =>
TryGetValue(key, out var value)
? value
: throw new KeyNotFoundException($"No type formatter is registered for {key}.");

public bool ContainsKey(Type key) => TryGetValue(key, out _);

public IEnumerator<KeyValuePair<Type, ITypeFormatter>> GetEnumerator() =>
registered.GetEnumerator();

IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
Loading