Files
UnrealEngine/Engine/Source/Programs/Horde/HordeServer.Shared/Configuration/ConfigNode.cs
2025-05-18 13:04:45 +08:00

706 lines
22 KiB
C#

// Copyright Epic Games, Inc. All Rights Reserved.
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace HordeServer.Configuration
{
/// <summary>
/// Attribute used to mark <see cref="Uri"/> properties that include other config files
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public sealed class ConfigIncludeAttribute : Attribute
{
}
/// <summary>
/// Specifies that a class is the root for including other files
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public sealed class ConfigIncludeRootAttribute : Attribute
{
}
/// <summary>
/// Attribute used to mark <see cref="Uri"/> properties that are relative to their containing file
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public sealed class ConfigRelativePathAttribute : Attribute
{
}
/// <summary>
/// Attribute used to mark <see cref="Uri"/> properties that are relative to their containing file
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public sealed class ConfigMacroScopeAttribute : Attribute
{
}
/// <summary>
/// Node in the preprocessor parse tree
/// </summary>
public abstract class ConfigNode
{
/// <summary>
/// Default options for new json node objects
/// </summary>
protected static JsonNodeOptions DefaultJsonNodeOptions { get; } = new JsonNodeOptions { PropertyNameCaseInsensitive = true };
/// <summary>
/// Parses macro definitions from this object
/// </summary>
/// <param name="node">Node to parse macros from</param>
/// <param name="context">Context for the preprocessor</param>
/// <param name="macros">Macros parsed from the boject</param>
public abstract void ParseMacros(JsonNode? node, ConfigContext context, Dictionary<string, string> macros);
/// <summary>
/// Preprocesses a json document
/// </summary>
/// <param name="node">Node to preprocess</param>
/// <param name="context">Context for the preprocessor</param>
/// <param name="cancellationToken">Cancellation token for the operation</param>
/// <returns>Preprocessed node</returns>
public Task<JsonNode?> PreprocessAsync(JsonNode? node, ConfigContext context, CancellationToken cancellationToken)
=> PreprocessAsync(node, null, context, cancellationToken);
/// <summary>
/// Preprocesses a json document (possibly merging with an existing property)
/// </summary>
/// <param name="node">Node to preprocess</param>
/// <param name="existingNode">Optional existing node to merge with. Can be modified.</param>
/// <param name="context">Context for the preprocessor</param>
/// <param name="cancellationToken">Cancellation token for the operation</param>
/// <returns>The preprocessed node. May be existingNode if changes are merged with it.</returns>
public abstract Task<JsonNode?> PreprocessAsync(JsonNode? node, JsonNode? existingNode, ConfigContext context, CancellationToken cancellationToken);
/// <summary>
/// Traverse the property tree starting with 'node' and process any include directives, merging the results into the given target object.
/// </summary>
/// <param name="node"></param>
/// <param name="includes">Receives the included files</param>
/// <param name="context"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public abstract Task ParseIncludesAsync(JsonNode? node, List<IConfigFile> includes, ConfigContext context, CancellationToken cancellationToken);
/// <summary>
/// Combine a relative path with a base URI to produce a new URI
/// </summary>
/// <param name="baseUri">Base uri to rebase relative to</param>
/// <param name="path">Relative path</param>
/// <returns>Absolute URI</returns>
public static Uri CombinePaths(Uri baseUri, string path)
{
if (path.StartsWith("//", StringComparison.Ordinal))
{
const string PerforceScheme = "perforce";
if (baseUri.Scheme == PerforceScheme)
{
return new Uri($"{PerforceScheme}://{baseUri.Host}{path}");
}
else
{
return new Uri($"{PerforceScheme}://default{path}");
}
}
return new Uri(baseUri, path);
}
/// <summary>
/// Helper method to expand all macros within a node without performing any other processing on it
/// </summary>
protected static JsonNode? ExpandMacros(JsonNode? node, ConfigContext context)
{
if (node == null)
{
return node;
}
else if (node is JsonObject obj)
{
JsonObject result = new JsonObject(DefaultJsonNodeOptions);
foreach ((string propertyName, JsonNode? propertyNode) in obj)
{
result[propertyName] = ExpandMacros(propertyNode, context);
}
return result;
}
else if (node is JsonArray arr)
{
JsonArray result = new JsonArray();
foreach (JsonNode? elementNode in arr)
{
result.Add(ExpandMacros(elementNode, context));
}
return result;
}
else if (node is JsonValue val && val.GetValueKind() == JsonValueKind.String)
{
string strValue = ((string?)val)!;
string expandedStrValue = context.ExpandMacros(strValue);
return JsonValue.Create(expandedStrValue);
}
else
{
return node.DeepClone();
}
}
}
/// <summary>
/// Implementation of <see cref="ConfigNode"/> for scalar types
/// </summary>
public class ScalarConfigNode : ConfigNode
{
/// <summary>
/// Whether this node represents an include directive
/// </summary>
public bool Include { get; set; }
/// <summary>
/// Specifies that this node is a string which should be made into an absolute path using the path of the current file.
/// </summary>
public bool RelativeToContainingFile { get; set; }
/// <summary>
/// Constructor
/// </summary>
public ScalarConfigNode(bool include = false, bool relativePath = false)
{
Include = include;
RelativeToContainingFile = relativePath;
}
/// <inheritdoc/>
public override void ParseMacros(JsonNode? jsonNode, ConfigContext context, Dictionary<string, string> macros)
{ }
/// <inheritdoc/>
public override Task<JsonNode?> PreprocessAsync(JsonNode? node, JsonNode? existingNode, ConfigContext context, CancellationToken cancellationToken)
{
JsonNode? result;
if (node == null)
{
result = null;
}
else if (RelativeToContainingFile && node is JsonValue value && value.GetValueKind() == JsonValueKind.String)
{
result = JsonValue.Create(CombinePaths(context.CurrentFile, value.ToString()).AbsoluteUri);
}
else
{
result = ExpandMacros(node, context);
}
return Task.FromResult(result);
}
/// <inheritdoc/>
public override async Task ParseIncludesAsync(JsonNode? node, List<IConfigFile> includes, ConfigContext context, CancellationToken cancellationToken)
{
if (Include)
{
string? path = (string?)node;
Uri uri = CombinePaths(context.CurrentFile, context.ExpandMacros(path!));
IConfigFile file = await context.ReadFileAsync(uri, cancellationToken);
includes.Add(file);
}
}
}
/// <summary>
/// Property containing a binary resource
/// </summary>
public class ResourceConfigNode : ConfigNode
{
/// <inheritdoc/>
public override Task ParseIncludesAsync(JsonNode? node, List<IConfigFile> includes, ConfigContext context, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <inheritdoc/>
public override void ParseMacros(JsonNode? node, ConfigContext context, Dictionary<string, string> macros)
{ }
/// <inheritdoc/>
public override async Task<JsonNode?> PreprocessAsync(JsonNode? node, JsonNode? existingNode, ConfigContext context, CancellationToken cancellationToken)
{
Uri uri = CombinePaths(context.CurrentFile, JsonSerializer.Deserialize<string>(node, context.JsonOptions) ?? String.Empty);
IConfigFile file = await context.ReadFileAsync(uri, cancellationToken);
ConfigResource resource = new ConfigResource();
resource.Path = uri.AbsoluteUri;
resource.Data = await file.ReadAsync(cancellationToken);
return JsonSerializer.SerializeToNode(resource, context.JsonOptions);
}
}
/// <summary>
/// Array of Json values
/// </summary>
public class ArrayConfigNode : ConfigNode
{
/// <summary>
/// Type of each element of the array
/// </summary>
public ConfigNode ElementType { get; }
/// <summary>
/// Constructor
/// </summary>
public ArrayConfigNode(ConfigNode elementType)
{
ElementType = elementType;
}
/// <inheritdoc/>
public override void ParseMacros(JsonNode? node, ConfigContext context, Dictionary<string, string> macros)
{
if (node is JsonArray arrayNode)
{
foreach (JsonNode? elementNode in arrayNode)
{
ElementType.ParseMacros(elementNode, context, macros);
}
}
}
/// <inheritdoc/>
public override async Task<JsonNode?> PreprocessAsync(JsonNode? node, JsonNode? existingNode, ConfigContext context, CancellationToken cancellationToken)
{
JsonArray targetArray = ((JsonArray?)existingNode) ?? new JsonArray();
foreach (JsonNode? element in (JsonArray)node!)
{
context.EnterScope($"[{targetArray.Count}]");
JsonNode? elementValue = await ElementType.PreprocessAsync(element, context, cancellationToken);
targetArray.Add(elementValue);
context.LeaveScope();
}
return targetArray;
}
/// <inheritdoc/>
public override async Task ParseIncludesAsync(JsonNode? node, List<IConfigFile> includes, ConfigContext context, CancellationToken cancellationToken)
{
if (node is JsonArray arrayNode)
{
foreach (JsonObject? element in arrayNode)
{
await ElementType.ParseIncludesAsync(element, includes, context, cancellationToken);
}
}
}
}
/// <summary>
/// Arbitary mapping of string values to keys
/// </summary>
public class DictionaryConfigNode : ConfigNode
{
/// <summary>
/// Type of values in the dictionary
/// </summary>
public ConfigNode ValueType { get; }
/// <summary>
/// Constructor
/// </summary>
public DictionaryConfigNode(ConfigNode valueType)
=> ValueType = valueType;
/// <inheritdoc/>
public override void ParseMacros(JsonNode? jsonNode, ConfigContext context, Dictionary<string, string> macros)
{ }
/// <inheritdoc/>
public override async Task<JsonNode?> PreprocessAsync(JsonNode? node, JsonNode? existingNode, ConfigContext context, CancellationToken cancellationToken)
{
JsonObject? targetObject = ((JsonObject?)existingNode) ?? new JsonObject(DefaultJsonNodeOptions);
foreach ((string key, JsonNode? element) in (JsonObject)node!)
{
context.EnterScope($"[{key}]");
JsonNode? elementValue = await ValueType.PreprocessAsync(element, context, cancellationToken);
targetObject[key] = elementValue;
context.LeaveScope();
}
return targetObject;
}
/// <inheritdoc/>
public override async Task ParseIncludesAsync(JsonNode? node, List<IConfigFile> includes, ConfigContext context, CancellationToken cancellationToken)
{
if (node is JsonObject obj && ValueType is ObjectConfigNode classElementType)
{
foreach ((_, JsonNode? value) in obj)
{
await classElementType.ParseIncludesAsync(value, includes, context, cancellationToken);
}
}
}
}
/// <summary>
/// Handles macro objects
/// </summary>
public class MacroConfigNode : ConfigNode
{
/// <inheritdoc/>
public override Task ParseIncludesAsync(JsonNode? node, List<IConfigFile> includes, ConfigContext context, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <inheritdoc/>
public override void ParseMacros(JsonNode? node, ConfigContext context, Dictionary<string, string> macros)
{
ConfigMacro? macro = JsonSerializer.Deserialize<ConfigMacro>(node, context.JsonOptions);
if (macro != null)
{
macros.Add(macro.Name, macro.Value);
}
}
/// <inheritdoc/>
public override Task<JsonNode?> PreprocessAsync(JsonNode? node, JsonNode? existingNode, ConfigContext context, CancellationToken cancellationToken)
=> Task.FromResult<JsonNode?>(node?.DeepClone());
}
/// <summary>
/// Implementation of <see cref="ConfigNode"/> to handle class types
/// </summary>
public class ObjectConfigNode : ConfigNode
{
/// <summary>
/// Whether this object should be treated as a root for nested include directies
/// </summary>
public bool IncludeRoot { get; set; }
/// <summary>
/// Declares a new macro scope
/// </summary>
public bool MacroScope { get; set; }
/// <summary>
/// Properties within this object
/// </summary>
public Dictionary<string, ConfigNode> Properties { get; }
/// <summary>
/// Constructor
/// </summary>
public ObjectConfigNode(bool isIncludeRoot, bool isMacroScope)
: this(isIncludeRoot, isMacroScope, Array.Empty<KeyValuePair<string, ConfigNode>>())
{
}
/// <summary>
/// Constructor
/// </summary>
public ObjectConfigNode(bool isIncludeRoot, bool isMacroScope, IEnumerable<KeyValuePair<string, ConfigNode>> properties)
{
IncludeRoot = isIncludeRoot;
MacroScope = isMacroScope;
Properties = new Dictionary<string, ConfigNode>(properties, StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Constructor
/// </summary>
/// <param name="type">Type to construct from</param>
public ObjectConfigNode(Type type)
: this(type, new Dictionary<Type, ConfigNode>())
{ }
ObjectConfigNode(Type type, Dictionary<Type, ConfigNode> recursiveTypes)
{
recursiveTypes.Add(type, this);
IncludeRoot = type.GetCustomAttribute<ConfigIncludeRootAttribute>() != null;
MacroScope = type.GetCustomAttribute<ConfigMacroScopeAttribute>() != null;
Properties = new Dictionary<string, ConfigNode>(StringComparer.OrdinalIgnoreCase);
// Find all the direct include properties
PropertyInfo[] propertyInfos = type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.GetProperty);
foreach (PropertyInfo propertyInfo in propertyInfos)
{
if (propertyInfo.GetCustomAttribute<JsonIgnoreAttribute>() == null)
{
ConfigNode? propertyType = CreateTypeForProperty(propertyInfo, recursiveTypes);
if (propertyType != null)
{
string name = propertyInfo.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name ?? propertyInfo.Name;
Properties.Add(name, propertyType);
}
}
}
}
bool IsDefault()
=> !IncludeRoot && !MacroScope && Properties.Count == 0;
static ConfigNode? CreateTypeForProperty(PropertyInfo propertyInfo, Dictionary<Type, ConfigNode> recursiveTypes)
{
Type propertyType = propertyInfo.PropertyType;
if (!propertyType.IsClass || propertyType == typeof(string))
{
bool include = propertyInfo.GetCustomAttribute<ConfigIncludeAttribute>() != null;
bool relativePath = propertyInfo.GetCustomAttribute<ConfigRelativePathAttribute>() != null;
if (include || relativePath)
{
return new ScalarConfigNode(include, relativePath);
}
else
{
return null;
}
}
return CreateType(propertyType, recursiveTypes);
}
static ConfigNode? CreateType(Type type, Dictionary<Type, ConfigNode> recursiveTypes)
{
ConfigNode? value;
if (!type.IsClass || type == typeof(string) || type == typeof(JsonNode))
{
value = null;
}
else if (type == typeof(ConfigMacro))
{
value = new MacroConfigNode();
}
else if (type.IsAssignableTo(typeof(ConfigResource)))
{
value = new ResourceConfigNode();
}
else if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>))
{
ConfigNode? elementType = CreateType(type.GetGenericArguments()[0], recursiveTypes);
value = (elementType == null) ? null : new ArrayConfigNode(elementType);
}
else if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Dictionary<,>))
{
ConfigNode? elementType = CreateType(type.GetGenericArguments()[1], recursiveTypes);
value = (elementType == null) ? null : new DictionaryConfigNode(elementType);
}
else if (!recursiveTypes.TryGetValue(type, out value))
{
ObjectConfigNode objValue = new ObjectConfigNode(type);
value = objValue.IsDefault() ? null : objValue;
}
return value;
}
/// <summary>
/// Preprocesses an object
/// </summary>
/// <param name="obj">Object to process</param>
/// <param name="context">Preprocessor context</param>
/// <param name="cancellationToken">Cancellation token for the operation</param>
public async Task<JsonObject> PreprocessAsync(JsonObject obj, ConfigContext context, CancellationToken cancellationToken)
=> (JsonObject)(await PreprocessAsync(obj, null, context, cancellationToken))!;
/// <inheritdoc/>
public override void ParseMacros(JsonNode? jsonNode, ConfigContext context, Dictionary<string, string> macros)
{
if (jsonNode is JsonObject jsonObject)
{
foreach ((string name, JsonNode? node) in jsonObject)
{
if (node != null && Properties.TryGetValue(name, out ConfigNode? property))
{
if (property is not ObjectConfigNode propertyObj || !propertyObj.MacroScope)
{
property.ParseMacros(node, context, macros);
}
}
}
}
}
/// <inheritdoc/>
public override async Task<JsonNode?> PreprocessAsync(JsonNode? node, JsonNode? existingNode, ConfigContext context, CancellationToken cancellationToken)
{
JsonObject? obj = (JsonObject?)node;
JsonObject? target = (JsonObject?)existingNode;
if (obj == null)
{
return target;
}
// Before parsing properties into this object, read all the includes recursively
if (IncludeRoot)
{
// Find all the includes
List<IConfigFile> includes = new List<IConfigFile>();
await ParseIncludesInternalAsync(obj, includes, context, cancellationToken);
// Find all the files, merge them into target
foreach (IConfigFile include in includes)
{
context.IncludeStack.Push(include);
JsonObject includedJsonObject = await context.ParseFileAsync(include, cancellationToken);
target = (JsonObject?)await PreprocessAsync(includedJsonObject, target, context, cancellationToken);
context.IncludeStack.Pop();
}
}
// Ensure that the target object is valid so we can write properties into it
target ??= new JsonObject(DefaultJsonNodeOptions);
// Parse all the macros for this scope
if (MacroScope)
{
Dictionary<string, string> macros = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
ParseMacros(obj, context, macros);
context.MacroScopes.Add(macros);
}
// Parse all the properties into this object
foreach ((string name, JsonNode? newNode) in obj)
{
if (newNode is JsonValue)
{
context.AddProperty(name);
}
if (Properties.TryGetValue(name, out ConfigNode? property))
{
context.EnterScope(name);
target[name] = await property.PreprocessAsync(newNode, target[name], context, cancellationToken);
context.LeaveScope();
}
else
{
target[name] = Merge(ExpandMacros(newNode, context), target[name]);
}
}
// Parse all the macros for this scope
if (MacroScope)
{
context.MacroScopes.RemoveAt(context.MacroScopes.Count - 1);
}
return target;
}
static JsonNode? Merge(JsonNode? source, JsonNode? target)
{
if (source is JsonObject sourceObj && target is JsonObject targetObj)
{
JsonObject result = new JsonObject();
foreach ((string name, JsonNode? targetNode) in targetObj)
{
JsonNode? sourceNode = sourceObj[name];
if (sourceNode == null)
{
result[name] = targetNode?.DeepClone();
}
else
{
result[name] = Merge(sourceNode, targetNode);
}
}
foreach ((string name, JsonNode? sourceNode) in sourceObj)
{
if (!result.ContainsKey(name))
{
result[name] = sourceNode?.DeepClone();
}
}
return result;
}
else if (source is JsonArray sourceArr && target is JsonArray targetArr)
{
JsonArray result = (JsonArray)targetArr.DeepClone();
foreach (JsonNode? node in sourceArr)
{
result.Add(node?.DeepClone());
}
return result;
}
return source?.DeepClone();
}
/// <inheritdoc/>
public override async Task ParseIncludesAsync(JsonNode? node, List<IConfigFile> includes, ConfigContext context, CancellationToken cancellationToken)
{
if (!IncludeRoot && node is JsonObject obj)
{
await ParseIncludesInternalAsync(obj, includes, context, cancellationToken);
}
}
async Task ParseIncludesInternalAsync(JsonObject obj, List<IConfigFile> includes, ConfigContext context, CancellationToken cancellationToken)
{
foreach ((string name, JsonNode? propertyNode) in obj)
{
if (Properties.TryGetValue(name, out ConfigNode? property))
{
await property.ParseIncludesAsync(propertyNode, includes, context, cancellationToken);
}
}
}
/// <summary>
/// Adds a new node at a given path
/// </summary>
/// <param name="path">Path to the node to add</param>
/// <param name="node">The node to add the requested path</param>
public void AddChildNode(string path, ConfigNode node)
{
int nextDotIdx = path.IndexOf('.', StringComparison.Ordinal);
if (nextDotIdx == -1)
{
Properties[path] = node;
}
else
{
string nextName = path.Substring(0, nextDotIdx);
ConfigNode? nextNode;
if (!Properties.TryGetValue(nextName, out nextNode))
{
nextNode = new ObjectConfigNode(false, false);
Properties.Add(nextName, nextNode);
}
ObjectConfigNode nextObj = (ObjectConfigNode)nextNode;
nextObj.AddChildNode(path.Substring(nextDotIdx + 1), node);
}
}
/// <summary>
/// Tries to get a node by path
/// </summary>
/// <param name="path">Path to the node to find</param>
/// <param name="node">The node at the requested path</param>
public bool TryGetChildNode(string path, [NotNullWhen(true)] out ConfigNode? node)
{
int nextDotIdx = path.IndexOf('.', StringComparison.Ordinal);
if (nextDotIdx == -1)
{
return Properties.TryGetValue(path, out node);
}
if (Properties.TryGetValue(path.Substring(0, nextDotIdx), out ConfigNode? nextNode) && nextNode is ObjectConfigNode nextObj)
{
return nextObj.TryGetChildNode(path.Substring(nextDotIdx + 1), out node);
}
node = null;
return false;
}
}
}