// 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
{
///
/// Attribute used to mark properties that include other config files
///
[AttributeUsage(AttributeTargets.Property)]
public sealed class ConfigIncludeAttribute : Attribute
{
}
///
/// Specifies that a class is the root for including other files
///
[AttributeUsage(AttributeTargets.Class)]
public sealed class ConfigIncludeRootAttribute : Attribute
{
}
///
/// Attribute used to mark properties that are relative to their containing file
///
[AttributeUsage(AttributeTargets.Property)]
public sealed class ConfigRelativePathAttribute : Attribute
{
}
///
/// Attribute used to mark properties that are relative to their containing file
///
[AttributeUsage(AttributeTargets.Class)]
public sealed class ConfigMacroScopeAttribute : Attribute
{
}
///
/// Node in the preprocessor parse tree
///
public abstract class ConfigNode
{
///
/// Default options for new json node objects
///
protected static JsonNodeOptions DefaultJsonNodeOptions { get; } = new JsonNodeOptions { PropertyNameCaseInsensitive = true };
///
/// Parses macro definitions from this object
///
/// Node to parse macros from
/// Context for the preprocessor
/// Macros parsed from the boject
public abstract void ParseMacros(JsonNode? node, ConfigContext context, Dictionary macros);
///
/// Preprocesses a json document
///
/// Node to preprocess
/// Context for the preprocessor
/// Cancellation token for the operation
/// Preprocessed node
public Task PreprocessAsync(JsonNode? node, ConfigContext context, CancellationToken cancellationToken)
=> PreprocessAsync(node, null, context, cancellationToken);
///
/// Preprocesses a json document (possibly merging with an existing property)
///
/// Node to preprocess
/// Optional existing node to merge with. Can be modified.
/// Context for the preprocessor
/// Cancellation token for the operation
/// The preprocessed node. May be existingNode if changes are merged with it.
public abstract Task PreprocessAsync(JsonNode? node, JsonNode? existingNode, ConfigContext context, CancellationToken cancellationToken);
///
/// Traverse the property tree starting with 'node' and process any include directives, merging the results into the given target object.
///
///
/// Receives the included files
///
///
///
public abstract Task ParseIncludesAsync(JsonNode? node, List includes, ConfigContext context, CancellationToken cancellationToken);
///
/// Combine a relative path with a base URI to produce a new URI
///
/// Base uri to rebase relative to
/// Relative path
/// Absolute URI
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);
}
///
/// Helper method to expand all macros within a node without performing any other processing on it
///
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();
}
}
}
///
/// Implementation of for scalar types
///
public class ScalarConfigNode : ConfigNode
{
///
/// Whether this node represents an include directive
///
public bool Include { get; set; }
///
/// Specifies that this node is a string which should be made into an absolute path using the path of the current file.
///
public bool RelativeToContainingFile { get; set; }
///
/// Constructor
///
public ScalarConfigNode(bool include = false, bool relativePath = false)
{
Include = include;
RelativeToContainingFile = relativePath;
}
///
public override void ParseMacros(JsonNode? jsonNode, ConfigContext context, Dictionary macros)
{ }
///
public override Task 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);
}
///
public override async Task ParseIncludesAsync(JsonNode? node, List 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);
}
}
}
///
/// Property containing a binary resource
///
public class ResourceConfigNode : ConfigNode
{
///
public override Task ParseIncludesAsync(JsonNode? node, List includes, ConfigContext context, CancellationToken cancellationToken)
=> Task.CompletedTask;
///
public override void ParseMacros(JsonNode? node, ConfigContext context, Dictionary macros)
{ }
///
public override async Task PreprocessAsync(JsonNode? node, JsonNode? existingNode, ConfigContext context, CancellationToken cancellationToken)
{
Uri uri = CombinePaths(context.CurrentFile, JsonSerializer.Deserialize(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);
}
}
///
/// Array of Json values
///
public class ArrayConfigNode : ConfigNode
{
///
/// Type of each element of the array
///
public ConfigNode ElementType { get; }
///
/// Constructor
///
public ArrayConfigNode(ConfigNode elementType)
{
ElementType = elementType;
}
///
public override void ParseMacros(JsonNode? node, ConfigContext context, Dictionary macros)
{
if (node is JsonArray arrayNode)
{
foreach (JsonNode? elementNode in arrayNode)
{
ElementType.ParseMacros(elementNode, context, macros);
}
}
}
///
public override async Task 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;
}
///
public override async Task ParseIncludesAsync(JsonNode? node, List includes, ConfigContext context, CancellationToken cancellationToken)
{
if (node is JsonArray arrayNode)
{
foreach (JsonObject? element in arrayNode)
{
await ElementType.ParseIncludesAsync(element, includes, context, cancellationToken);
}
}
}
}
///
/// Arbitary mapping of string values to keys
///
public class DictionaryConfigNode : ConfigNode
{
///
/// Type of values in the dictionary
///
public ConfigNode ValueType { get; }
///
/// Constructor
///
public DictionaryConfigNode(ConfigNode valueType)
=> ValueType = valueType;
///
public override void ParseMacros(JsonNode? jsonNode, ConfigContext context, Dictionary macros)
{ }
///
public override async Task 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;
}
///
public override async Task ParseIncludesAsync(JsonNode? node, List 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);
}
}
}
}
///
/// Handles macro objects
///
public class MacroConfigNode : ConfigNode
{
///
public override Task ParseIncludesAsync(JsonNode? node, List includes, ConfigContext context, CancellationToken cancellationToken)
=> Task.CompletedTask;
///
public override void ParseMacros(JsonNode? node, ConfigContext context, Dictionary macros)
{
ConfigMacro? macro = JsonSerializer.Deserialize(node, context.JsonOptions);
if (macro != null)
{
macros.Add(macro.Name, macro.Value);
}
}
///
public override Task PreprocessAsync(JsonNode? node, JsonNode? existingNode, ConfigContext context, CancellationToken cancellationToken)
=> Task.FromResult(node?.DeepClone());
}
///
/// Implementation of to handle class types
///
public class ObjectConfigNode : ConfigNode
{
///
/// Whether this object should be treated as a root for nested include directies
///
public bool IncludeRoot { get; set; }
///
/// Declares a new macro scope
///
public bool MacroScope { get; set; }
///
/// Properties within this object
///
public Dictionary Properties { get; }
///
/// Constructor
///
public ObjectConfigNode(bool isIncludeRoot, bool isMacroScope)
: this(isIncludeRoot, isMacroScope, Array.Empty>())
{
}
///
/// Constructor
///
public ObjectConfigNode(bool isIncludeRoot, bool isMacroScope, IEnumerable> properties)
{
IncludeRoot = isIncludeRoot;
MacroScope = isMacroScope;
Properties = new Dictionary(properties, StringComparer.OrdinalIgnoreCase);
}
///
/// Constructor
///
/// Type to construct from
public ObjectConfigNode(Type type)
: this(type, new Dictionary())
{ }
ObjectConfigNode(Type type, Dictionary recursiveTypes)
{
recursiveTypes.Add(type, this);
IncludeRoot = type.GetCustomAttribute() != null;
MacroScope = type.GetCustomAttribute() != null;
Properties = new Dictionary(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() == null)
{
ConfigNode? propertyType = CreateTypeForProperty(propertyInfo, recursiveTypes);
if (propertyType != null)
{
string name = propertyInfo.GetCustomAttribute()?.Name ?? propertyInfo.Name;
Properties.Add(name, propertyType);
}
}
}
}
bool IsDefault()
=> !IncludeRoot && !MacroScope && Properties.Count == 0;
static ConfigNode? CreateTypeForProperty(PropertyInfo propertyInfo, Dictionary recursiveTypes)
{
Type propertyType = propertyInfo.PropertyType;
if (!propertyType.IsClass || propertyType == typeof(string))
{
bool include = propertyInfo.GetCustomAttribute() != null;
bool relativePath = propertyInfo.GetCustomAttribute() != null;
if (include || relativePath)
{
return new ScalarConfigNode(include, relativePath);
}
else
{
return null;
}
}
return CreateType(propertyType, recursiveTypes);
}
static ConfigNode? CreateType(Type type, Dictionary 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;
}
///
/// Preprocesses an object
///
/// Object to process
/// Preprocessor context
/// Cancellation token for the operation
public async Task PreprocessAsync(JsonObject obj, ConfigContext context, CancellationToken cancellationToken)
=> (JsonObject)(await PreprocessAsync(obj, null, context, cancellationToken))!;
///
public override void ParseMacros(JsonNode? jsonNode, ConfigContext context, Dictionary 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);
}
}
}
}
}
///
public override async Task 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 includes = new List();
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 macros = new Dictionary(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();
}
///
public override async Task ParseIncludesAsync(JsonNode? node, List includes, ConfigContext context, CancellationToken cancellationToken)
{
if (!IncludeRoot && node is JsonObject obj)
{
await ParseIncludesInternalAsync(obj, includes, context, cancellationToken);
}
}
async Task ParseIncludesInternalAsync(JsonObject obj, List 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);
}
}
}
///
/// Adds a new node at a given path
///
/// Path to the node to add
/// The node to add the requested path
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);
}
}
///
/// Tries to get a node by path
///
/// Path to the node to find
/// The node at the requested path
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;
}
}
}