// 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; } } }