From 3e06749006d59772a9073b5570eee54394198f74 Mon Sep 17 00:00:00 2001 From: Paul Chote Date: Thu, 1 May 2025 22:38:50 +0100 Subject: [PATCH] Rewrite fluent lint check. --- OpenRA.Game/FluentBundle.cs | 35 ++ OpenRA.Game/Game.cs | 1 + OpenRA.Game/GameSpeed.cs | 2 + OpenRA.Game/Input/IInputHandler.cs | 2 +- OpenRA.Game/Manifest.cs | 4 +- OpenRA.Game/Server/Server.cs | 1 + OpenRA.Mods.Common/Commands/ChatCommands.cs | 1 + .../Commands/DebugVisualizationCommands.cs | 1 + OpenRA.Mods.Common/Commands/DevCommands.cs | 1 + OpenRA.Mods.Common/Commands/HelpCommand.cs | 1 + OpenRA.Mods.Common/Commands/PlayerCommands.cs | 1 + .../Lint/CheckFluentReferences.cs | 356 ++++++++---------- .../Player/ConquestVictoryConditions.cs | 1 + .../Traits/Player/DeveloperMode.cs | 1 + .../Player/StrategicVictoryConditions.cs | 1 + .../Render/CustomTerrainDebugOverlay.cs | 1 + .../Traits/World/CellTriggerOverlay.cs | 1 + .../Traits/World/EditorActionManager.cs | 1 + .../Traits/World/ExitsDebugOverlayManager.cs | 1 + .../World/HierarchicalPathFinderOverlay.cs | 1 + .../Traits/World/PathFinderOverlay.cs | 1 + .../Traits/World/ResourceRenderer.cs | 1 + .../Traits/World/TerrainGeometryOverlay.cs | 1 + .../Traits/World/TimeLimitManager.cs | 1 + .../Widgets/BackgroundWidget.cs | 1 + .../Widgets/EditorViewportControllerWidget.cs | 6 + .../Widgets/Logic/Editor/ActorEditLogic.cs | 1 + .../Logic/Editor/ActorSelectorLogic.cs | 1 + .../Logic/Editor/LayerSelectorLogic.cs | 1 + .../Logic/Editor/MapEditorSelectionLogic.cs | 1 + .../Logic/Editor/MapMarkerTilesLogic.cs | 4 + .../Widgets/Logic/Editor/TileSelectorLogic.cs | 4 + .../Widgets/Logic/Lobby/LobbyLogic.cs | 1 + .../Widgets/Logic/ReplayBrowserLogic.cs | 1 + .../Logic/Settings/HotkeysSettingsLogic.cs | 9 + 35 files changed, 250 insertions(+), 198 deletions(-) diff --git a/OpenRA.Game/FluentBundle.cs b/OpenRA.Game/FluentBundle.cs index b7511d44f1..b132c4f009 100644 --- a/OpenRA.Game/FluentBundle.cs +++ b/OpenRA.Game/FluentBundle.cs @@ -48,6 +48,41 @@ namespace OpenRA } } + [AttributeUsage(AttributeTargets.Class)] + public sealed class IncludeStaticFluentReferencesAttribute : Attribute + { + public readonly Type[] Types; + + public IncludeStaticFluentReferencesAttribute(params Type[] types) + { + Types = types; + } + } + + [AttributeUsage(AttributeTargets.Class)] + public sealed class IncludeChromeLogicArgsFluentReferencesAttribute : Attribute + { + public readonly string[] MethodNames; + + public IncludeChromeLogicArgsFluentReferencesAttribute(params string[] methodNames) + { + MethodNames = methodNames; + } + } + + [AttributeUsage(AttributeTargets.Field)] + public sealed class IncludeFluentReferencesAttribute : Attribute + { + public readonly LintDictionaryReference DictionaryReference; + + public IncludeFluentReferencesAttribute() { } + + public IncludeFluentReferencesAttribute(LintDictionaryReference dictionaryReference = LintDictionaryReference.None) + { + DictionaryReference = dictionaryReference; + } + } + public class FluentBundle { readonly Linguini.Bundle.FluentBundle bundle; diff --git a/OpenRA.Game/Game.cs b/OpenRA.Game/Game.cs index a53073e2b8..c53d12b247 100644 --- a/OpenRA.Game/Game.cs +++ b/OpenRA.Game/Game.cs @@ -27,6 +27,7 @@ using OpenRA.Widgets; namespace OpenRA { + [IncludeStaticFluentReferences(typeof(Server.Server), typeof(Player), typeof(UnitOrders), typeof(OrderManager))] public static class Game { [FluentReference("filename")] diff --git a/OpenRA.Game/GameSpeed.cs b/OpenRA.Game/GameSpeed.cs index f8943cf181..76ae036713 100644 --- a/OpenRA.Game/GameSpeed.cs +++ b/OpenRA.Game/GameSpeed.cs @@ -10,6 +10,7 @@ #endregion using System.Collections.Generic; +using OpenRA.Traits; namespace OpenRA { @@ -31,6 +32,7 @@ namespace OpenRA [FieldLoader.Require] public readonly string DefaultSpeed; + [IncludeFluentReferences(LintDictionaryReference.Values)] [FieldLoader.LoadUsing(nameof(LoadSpeeds))] public readonly Dictionary Speeds; diff --git a/OpenRA.Game/Input/IInputHandler.cs b/OpenRA.Game/Input/IInputHandler.cs index 01d64a087d..5c20f632ef 100644 --- a/OpenRA.Game/Input/IInputHandler.cs +++ b/OpenRA.Game/Input/IInputHandler.cs @@ -47,7 +47,7 @@ namespace OpenRA public static class ModifiersExts { [FluentReference] - public const string Cmd = "keycode-modifier.cmd"; + const string Cmd = "keycode-modifier.cmd"; [FluentReference(Traits.LintDictionaryReference.Values)] public static readonly IReadOnlyDictionary ModifierFluentKeys = new Dictionary() diff --git a/OpenRA.Game/Manifest.cs b/OpenRA.Game/Manifest.cs index 06f1f79ae3..2914d257ed 100644 --- a/OpenRA.Game/Manifest.cs +++ b/OpenRA.Game/Manifest.cs @@ -48,12 +48,12 @@ namespace OpenRA // FieldLoader used here, must matching naming in YAML. #pragma warning disable IDE1006 // Naming Styles [FluentReference] - readonly string Title; + public readonly string Title; public readonly string Version; public readonly string Website; public readonly string WebIcon32; [FluentReference] - readonly string WindowTitle; + public readonly string WindowTitle; public readonly bool Hidden; #pragma warning restore IDE1006 // Naming Styles diff --git a/OpenRA.Game/Server/Server.cs b/OpenRA.Game/Server/Server.cs index 9e93aea345..6247d2a731 100644 --- a/OpenRA.Game/Server/Server.cs +++ b/OpenRA.Game/Server/Server.cs @@ -45,6 +45,7 @@ namespace OpenRA.Server Dedicated = 3 } + [IncludeStaticFluentReferences(typeof(PlayerMessageTracker), typeof(VoteKickTracker))] public sealed class Server { [FluentReference] diff --git a/OpenRA.Mods.Common/Commands/ChatCommands.cs b/OpenRA.Mods.Common/Commands/ChatCommands.cs index 02f816d778..a6bd1a34d2 100644 --- a/OpenRA.Mods.Common/Commands/ChatCommands.cs +++ b/OpenRA.Mods.Common/Commands/ChatCommands.cs @@ -16,6 +16,7 @@ using OpenRA.Traits; namespace OpenRA.Mods.Common.Commands { [TraitLocation(SystemActors.World)] + [IncludeStaticFluentReferences(typeof(ChatCommands))] [Desc("Enables commands triggered by typing them into the chatbox. Attach this to the world actor.")] public class ChatCommandsInfo : TraitInfo { } diff --git a/OpenRA.Mods.Common/Commands/DebugVisualizationCommands.cs b/OpenRA.Mods.Common/Commands/DebugVisualizationCommands.cs index 31568d109c..9833ed60c3 100644 --- a/OpenRA.Mods.Common/Commands/DebugVisualizationCommands.cs +++ b/OpenRA.Mods.Common/Commands/DebugVisualizationCommands.cs @@ -18,6 +18,7 @@ using OpenRA.Traits; namespace OpenRA.Mods.Common.Commands { [TraitLocation(SystemActors.World)] + [IncludeStaticFluentReferences(typeof(DebugVisualizationCommands))] [Desc("Enables visualization commands via the chatbox. Attach this to the world actor.")] public class DebugVisualizationCommandsInfo : TraitInfo { } diff --git a/OpenRA.Mods.Common/Commands/DevCommands.cs b/OpenRA.Mods.Common/Commands/DevCommands.cs index e5ca60b48a..3313cd3334 100644 --- a/OpenRA.Mods.Common/Commands/DevCommands.cs +++ b/OpenRA.Mods.Common/Commands/DevCommands.cs @@ -19,6 +19,7 @@ using OpenRA.Traits; namespace OpenRA.Mods.Common.Commands { [TraitLocation(SystemActors.World)] + [IncludeStaticFluentReferences(typeof(DevCommands))] [Desc("Enables developer cheats via the chatbox. Attach this to the world actor.")] public class DevCommandsInfo : TraitInfo { } diff --git a/OpenRA.Mods.Common/Commands/HelpCommand.cs b/OpenRA.Mods.Common/Commands/HelpCommand.cs index 7709ba826d..331fc51348 100644 --- a/OpenRA.Mods.Common/Commands/HelpCommand.cs +++ b/OpenRA.Mods.Common/Commands/HelpCommand.cs @@ -17,6 +17,7 @@ using OpenRA.Traits; namespace OpenRA.Mods.Common.Commands { [TraitLocation(SystemActors.World)] + [IncludeStaticFluentReferences(typeof(HelpCommand))] [Desc("Shows a list of available commands in the chatbox. Attach this to the world actor.")] public class HelpCommandInfo : TraitInfo { } diff --git a/OpenRA.Mods.Common/Commands/PlayerCommands.cs b/OpenRA.Mods.Common/Commands/PlayerCommands.cs index b2d41c4a7f..dd108a057c 100644 --- a/OpenRA.Mods.Common/Commands/PlayerCommands.cs +++ b/OpenRA.Mods.Common/Commands/PlayerCommands.cs @@ -15,6 +15,7 @@ using OpenRA.Traits; namespace OpenRA.Mods.Common.Commands { [TraitLocation(SystemActors.World)] + [IncludeStaticFluentReferences(typeof(PlayerCommands))] [Desc("Allows the player to pause or surrender the game via the chatbox. Attach this to the world actor.")] public class PlayerCommandsInfo : TraitInfo { } diff --git a/OpenRA.Mods.Common/Lint/CheckFluentReferences.cs b/OpenRA.Mods.Common/Lint/CheckFluentReferences.cs index 86c84beef6..3d75571449 100644 --- a/OpenRA.Mods.Common/Lint/CheckFluentReferences.cs +++ b/OpenRA.Mods.Common/Lint/CheckFluentReferences.cs @@ -10,6 +10,7 @@ #endregion using System; +using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; @@ -19,9 +20,6 @@ using Linguini.Syntax.Ast; using Linguini.Syntax.Parser; using OpenRA.Mods.Common.Scripting; using OpenRA.Mods.Common.Scripting.Global; -using OpenRA.Mods.Common.Traits; -using OpenRA.Mods.Common.Warheads; -using OpenRA.Mods.Common.Widgets.Logic; using OpenRA.Scripting; using OpenRA.Traits; using OpenRA.Widgets; @@ -30,13 +28,14 @@ namespace OpenRA.Mods.Common.Lint { sealed class CheckFluentReferences : ILintPass, ILintMapPass { + const BindingFlags StaticBinding = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static; + void ILintMapPass.Run(Action emitError, Action emitWarning, ModData modData, Map map) { if (map.FluentMessageDefinitions == null) return; - var usedKeys = GetUsedFluentKeysInMap(map, emitWarning); - + var usedKeys = ExtractMapFluentKeys(modData, map, emitWarning); foreach (var context in usedKeys.EmptyKeyContexts) emitWarning($"Empty key in map ftl files required by {context}"); @@ -45,8 +44,7 @@ namespace OpenRA.Mods.Common.Lint // For maps we don't warn on unused keys. They might be unused on *this* map, // but the mod or another map may use them and we don't have sight of that. - CheckKeys(modMessages.Concat(mapMessages), map.Open, usedKeys, - _ => false, emitError, emitWarning); + CheckKeys(modMessages.Concat(mapMessages), map.Open, usedKeys, _ => false, emitError, emitWarning); var modFluentBundle = new FluentBundle(modData.Manifest.FluentCulture, modMessages, modData.DefaultFileSystem, _ => { }); var mapFluentBundle = new FluentBundle(modData.Manifest.FluentCulture, mapMessages, map, error => emitError(error.Message)); @@ -74,85 +72,40 @@ namespace OpenRA.Mods.Common.Lint void ILintPass.Run(Action emitError, Action emitWarning, ModData modData) { Console.WriteLine("Testing Fluent references"); - var (usedKeys, testedFields) = GetUsedFluentKeysInMod(modData); - + var usedKeys = ExtractModFluentKeys(modData); foreach (var context in usedKeys.EmptyKeyContexts) emitWarning($"Empty key in mod translation files required by {context}"); var modMessages = modData.Manifest.FluentMessages.ToArray(); - CheckModWidgets(modData, usedKeys, testedFields); // With the fully populated keys, check keys and variables are not missing and not unused across all language files. - var keyWithAttrs = CheckKeys( - modMessages, modData.DefaultFileSystem.Open, usedKeys, + var keyWithAttrs = CheckKeys(modMessages, modData.DefaultFileSystem.Open, usedKeys, file => !modData.Manifest.AllowUnusedFluentMessagesInExternalPackages || !modData.DefaultFileSystem.IsExternalFile(file), emitError, emitWarning); foreach (var group in usedKeys.KeysWithContext) - { - if (keyWithAttrs.Contains(group.Key)) - continue; - - foreach (var context in group) - emitWarning($"Missing key `{group.Key}` in mod ftl files required by {context}"); - } - - // Check if we couldn't test any fields. - const BindingFlags Binding = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; - var allFluentFields = modData.ObjectCreator.GetTypes().SelectMany(t => - t.GetFields(Binding).Where(m => Utility.HasAttribute(m))).ToArray(); - var untestedFields = allFluentFields.Except(testedFields); - foreach (var field in untestedFields) - emitError( - $"Lint pass ({nameof(CheckFluentReferences)}) lacks the know-how to test translatable field " + - $"`{field.ReflectedType.Name}.{field.Name}` - previous warnings may be incorrect"); + if (!keyWithAttrs.Contains(group.Key)) + foreach (var context in group) + emitWarning($"Missing key `{group.Key}` in mod ftl files required by {context}"); } - static Keys GetUsedFluentKeysInRuleset(Ruleset rules) + static void ExtractRulesetFluentKeys(ModData modData, Ruleset rules, Keys keys) { - var usedKeys = new Keys(); foreach (var actorInfo in rules.Actors) - { - foreach (var traitInfo in actorInfo.Value.TraitInfos()) - { - var traitType = traitInfo.GetType(); - foreach (var field in Utility.GetFields(traitType)) - { - var fluentReference = Utility.GetCustomAttributes(field, true).SingleOrDefault(); - if (fluentReference == null) - continue; + foreach (var ti in actorInfo.Value.TraitInfos()) + ExtractFluentKeys(modData, ti, $"Actor `{actorInfo.Key}` trait {ti.GetType().Name[..^4]}", keys); - foreach (var key in LintExts.GetFieldValues(traitInfo, field, fluentReference.DictionaryReference)) - usedKeys.Add(key, fluentReference, $"Actor `{actorInfo.Key}` trait `{traitType.Name[..^4]}.{field.Name}`"); - } - } - } - - foreach (var weapon in rules.Weapons) - { - foreach (var warhead in weapon.Value.Warheads) - { - var warheadType = warhead.GetType(); - foreach (var field in Utility.GetFields(warheadType)) - { - var fluentReference = Utility.GetCustomAttributes(field, true).SingleOrDefault(); - if (fluentReference == null) - continue; - - foreach (var key in LintExts.GetFieldValues(warhead, field, fluentReference.DictionaryReference)) - usedKeys.Add(key, fluentReference, $"Weapon `{weapon.Key}` warhead `{warheadType.Name[..^7]}.{field.Name}`"); - } - } - } - - return usedKeys; + foreach (var w in rules.Weapons) + foreach (var wh in w.Value.Warheads) + ExtractFluentKeys(modData, wh, $"Weapon `{w.Key}` warhead {wh.GetType().Name[..^7]}", keys); } - static Keys GetUsedFluentKeysInMap(Map map, Action emitWarning) + static Keys ExtractMapFluentKeys(ModData modData, Map map, Action emitWarning) { - var usedKeys = GetUsedFluentKeysInRuleset(map.Rules); + var keys = new Keys(); + ExtractRulesetFluentKeys(modData, map.Rules, keys); var luaScriptInfo = map.Rules.Actors[SystemActors.World].TraitInfoOrDefault(); if (luaScriptInfo != null) @@ -211,7 +164,7 @@ namespace OpenRA.Mods.Common.Lint foreach (var (key, attrs, variable, line) in references) { var context = $"Script {script}:{line}"; - usedKeys.Add(key, new FluentReferenceAttribute(attrs), context); + keys.Add(key, new FluentReferenceAttribute(attrs), context); if (variable != "") { @@ -226,136 +179,134 @@ namespace OpenRA.Mods.Common.Lint } } - return usedKeys; + return keys; } - static (Keys UsedKeys, List TestedFields) GetUsedFluentKeysInMod(ModData modData) + static Keys ExtractModFluentKeys(ModData modData) { - const BindingFlags ConstBinding = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static; - const BindingFlags InstanceBinding = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + var keys = new Keys(); - var usedKeys = GetUsedFluentKeysInRuleset(modData.DefaultRules); - var testedFields = new List(); - testedFields.AddRange( - modData.ObjectCreator.GetTypes() - .Where(t => t.IsSubclassOf(typeof(TraitInfo)) || t.IsSubclassOf(typeof(Warhead))) - .SelectMany(t => Utility.GetFields(t).Where(Utility.HasAttribute))); + // Extract hardcoded core engine references + ExtractConstFluentKeys(modData, typeof(Game), keys); - // TODO: linter does not work with LoadUsing - foreach (var speed in modData.Manifest.Get().Speeds) - GetUsedFluentKeys( - usedKeys, testedFields, - Utility.GetFields(typeof(GameSpeed)), - [speed.Value], - (obj, field) => $"`GameSpeeds.Speeds.{speed.Key}.{field.Name}` in mod.yaml"); + // Extract references from mod.yaml (metadata, server traits, IGlobalModData) + ExtractFluentKeys(modData, modData.Manifest.Metadata, "mod.yaml", keys); + foreach (var traitName in modData.Manifest.ServerTraits) + { + var traitType = modData.ObjectCreator.FindType(traitName); + if (traitType != null) + ExtractConstFluentKeys(modData, traitType, keys); + } - // TODO: linter does not work with LoadUsing - foreach (var resource in modData.DefaultRules.Actors - .SelectMany(actorInfo => actorInfo.Value.TraitInfos()) - .SelectMany(info => info.ResourceTypes)) - GetUsedFluentKeys( - usedKeys, testedFields, - Utility.GetFields(typeof(ResourceRendererInfo.ResourceTypeInfo)), - [resource.Value], - (obj, field) => $"`ResourceRenderer.ResourceTypes.{resource.Key}.{field.Name}` in rules yaml"); + var hasModule = modData.Manifest.GetType().GetMethod(nameof(Manifest.Contains)); + var getModule = modData.Manifest.GetType().GetMethod(nameof(Manifest.Get), []); + var globalModData = modData.ObjectCreator.GetTypesImplementing() + .Where(t => (bool)hasModule.MakeGenericMethod(t).Invoke(modData.Manifest, [])); + foreach (var module in globalModData) + { + var value = getModule.MakeGenericMethod(module).Invoke(modData.Manifest, []); + ExtractFluentKeys(modData, value, "mod.yaml", keys); + } + + // Load screen + var loadScreenType = modData.ObjectCreator.FindType(modData.Manifest.LoadScreen.Value); + if (loadScreenType != null) + ExtractConstFluentKeys(modData, loadScreenType, keys); + + // Traits, Weapons + ExtractRulesetFluentKeys(modData, modData.DefaultRules, keys); + foreach (var hotkey in modData.Hotkeys.Definitions) + ExtractFluentKeys(modData, hotkey, $"Hotkey {hotkey.GetType().Name}", keys); + + // TerrainInfo foreach (var terrainInfo in modData.DefaultTerrainInfo.Values) - GetUsedFluentKeys( - usedKeys, testedFields, - terrainInfo.GetType().GetFields(InstanceBinding), - [terrainInfo], - (obj, field) => $"`{field.ReflectedType.Name}.{field.Name}`"); + ExtractFluentKeys(modData, terrainInfo, $"Tileset {terrainInfo.Id}", keys); - var constFields = modData.ObjectCreator.GetTypes().SelectMany(modType => modType.GetFields(ConstBinding)).Where(f => f.IsLiteral); - GetUsedFluentKeys( - usedKeys, testedFields, - constFields, - [(object)null], - (obj, field) => $"`{field.ReflectedType.Name}.{field.Name}`"); + // Chrome + ExtractChromeFluentKeys(modData, keys); - var modMetadataFields = typeof(ModMetadata).GetFields(InstanceBinding); - GetUsedFluentKeys( - usedKeys, testedFields, - modMetadataFields, - [modData.Manifest.Metadata], - (obj, field) => $"`Metadata.{field.Name}` in mod.yaml"); - - var modContent = modData.Manifest.Get(); - GetUsedFluentKeys( - usedKeys, testedFields, - Utility.GetFields(typeof(ModContent)), - [modContent], - (obj, field) => $"`ModContent.{field.Name}` in mod.yaml"); - GetUsedFluentKeys( - usedKeys, testedFields, - Utility.GetFields(typeof(ModContent.ModPackage)), - modContent.Packages.Values.ToArray(), - (obj, field) => $"`ModContent.Packages.ContentPackage.{field.Name}` in mod.yaml"); - - GetUsedFluentKeys( - usedKeys, testedFields, - Utility.GetFields(typeof(HotkeyDefinition)), - modData.Hotkeys.Definitions, - (obj, field) => $"`{obj.Name}.{field.Name}` in hotkeys yaml"); - - // All keycodes and modifiers should be marked as used, as they can all be configured for use at hotkeys at runtime. - GetUsedFluentKeys( - usedKeys, testedFields, - Utility.GetFields(typeof(KeycodeExts)).Concat(Utility.GetFields(typeof(ModifiersExts))), - [(object)null], - (obj, field) => $"`{field.ReflectedType.Name}.{field.Name}`"); - - foreach (var filename in modData.Manifest.ChromeLayout) - CheckHotkeysSettingsLogic(usedKeys, MiniYaml.FromStream(modData.DefaultFileSystem.Open(filename), filename)); - - static void CheckHotkeysSettingsLogic(Keys usedKeys, IEnumerable nodes) - { - foreach (var node in nodes) - { - if (node.Value.Nodes != null) - CheckHotkeysSettingsLogic(usedKeys, node.Value.Nodes); - - if (node.Key != "Logic" || node?.Value.Value != "HotkeysSettingsLogic") - continue; - - var hotkeyGroupsNode = node.Value.NodeWithKeyOrDefault("HotkeyGroups"); - if (hotkeyGroupsNode == null) - continue; - - var hotkeyGroupsKeys = hotkeyGroupsNode?.Value.Nodes.Select(n => n.Key); - foreach (var key in hotkeyGroupsKeys) - usedKeys.Add(key, new FluentReferenceAttribute(), $"`{nameof(HotkeysSettingsLogic)}.HotkeyGroups`"); - } - } - - return (usedKeys, testedFields); + return keys; } - static void GetUsedFluentKeys( - Keys usedKeys, List testedFields, - IEnumerable newFields, IEnumerable objects, - Func getContext) + static void ExtractFluentKeys(ModData modData, object o, string prefix, Keys keys) { - var fieldsWithAttribute = - newFields - .Select(f => (Field: f, FluentReference: Utility.GetCustomAttributes(f, true).SingleOrDefault())) - .Where(x => x.FluentReference != null) - .ToArray(); - testedFields.AddRange(fieldsWithAttribute.Select(x => x.Field)); - foreach (var obj in objects) + var type = o.GetType(); + ExtractConstFluentKeys(modData, type, keys); + foreach (var f in Utility.GetFields(type)) { - foreach (var (field, fluentReference) in fieldsWithAttribute) + var reference = Utility.GetCustomAttributes(f, true).SingleOrDefault(); + if (reference != null) + foreach (var key in LintExts.GetFieldValues(o, f, reference.DictionaryReference)) + keys.Add(key, reference, $"{prefix}.{f.Name}"); + + var lint = Utility.GetCustomAttributes(f, true).SingleOrDefault(); + if (lint != null) + ExtractChildFluentKeys(modData, lint.DictionaryReference, f.GetValue(o), $"{prefix}.{f.Name}", keys); + } + } + + static void ExtractConstFluentKeys(ModData modData, Type t, Keys keys) + { + var classReferences = t.GetCustomAttributes(true); + foreach (var classReference in classReferences) + foreach (var referencedType in classReference.Types) + ExtractConstFluentKeys(modData, referencedType, keys); + + foreach (var f in t.GetFields(StaticBinding)) + { + var reference = Utility.GetCustomAttributes(f, true).SingleOrDefault(); + if (reference != null) + foreach (var key in LintExts.GetFieldValues(null, f, reference.DictionaryReference)) + keys.Add(key, reference, $"{t.Name}.{f.Name}"); + + var lint = Utility.GetCustomAttributes(f, true).SingleOrDefault(); + if (lint != null) + ExtractChildFluentKeys(modData, lint.DictionaryReference, f.GetValue(null), $"{t.Name}.{f.Name}", keys); + } + } + + static void ExtractChildFluentKeys(ModData modData, LintDictionaryReference dictionaryReference, + object fieldValue, string prefix, Keys keys) + { + var type = fieldValue.GetType(); + if (typeof(IEnumerable).IsAssignableFrom(type)) + foreach (var o in (IEnumerable)fieldValue) + ExtractFluentKeys(modData, o, prefix, keys); + + if (type.IsGenericType && + (type.GetGenericTypeDefinition() == typeof(Dictionary<,>) || + type.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>))) + { + // Use an intermediate list to cover the unlikely case where both keys and values are lintable. + if (dictionaryReference.HasFlag(LintDictionaryReference.Keys)) { - var keys = LintExts.GetFieldValues(obj, field, fluentReference.DictionaryReference); - foreach (var key in keys) - usedKeys.Add(key, fluentReference, getContext(obj, field)); + IEnumerable fieldKeys = ((IDictionary)fieldValue).Keys; + if (typeof(IEnumerable).IsAssignableFrom(type.GenericTypeArguments[0])) + fieldKeys = ((ICollection>)fieldKeys).SelectMany(v => v); + + foreach (var k in fieldKeys) + ExtractFluentKeys(modData, k, prefix, keys); + } + + if (dictionaryReference.HasFlag(LintDictionaryReference.Values)) + { + IEnumerable fieldValues = ((IDictionary)fieldValue).Values; + if (typeof(IEnumerable).IsAssignableFrom(type.GenericTypeArguments[1])) + fieldValues = ((ICollection>)fieldValues).SelectMany(v => v); + + foreach (var v in fieldValues) + ExtractFluentKeys(modData, v, prefix, keys); } } } - static void CheckModWidgets(ModData modData, Keys usedKeys, List testedFields) + static void ExtractChromeFluentKeys(ModData modData, Keys usedKeys) { - var chromeLayoutNodes = BuildChromeTree(modData); + // Gather all the nodes together for evaluation. + var chromeLayoutNodes = modData.Manifest.ChromeLayout + .SelectMany(filename => MiniYaml.FromStream(modData.DefaultFileSystem.Open(filename), filename)) + .ToArray(); var widgetTypes = modData.ObjectCreator.GetTypes() .Where(t => t.Name.EndsWith("Widget", StringComparison.InvariantCulture) && t.IsSubclassOf(typeof(Widget))) @@ -376,27 +327,15 @@ namespace OpenRA.Mods.Common.Lint x => (x.WidgetName, x.FieldName), x => x.FluentReference); - testedFields.AddRange(widgetTypes.SelectMany( - t => Utility.GetFields(t).Where(Utility.HasAttribute))); - foreach (var node in chromeLayoutNodes) - CheckChrome(node, fluentReferencesByWidgetField, usedKeys); + ExtractChromeFluentKeys(modData, node, fluentReferencesByWidgetField, usedKeys); } - static MiniYamlNode[] BuildChromeTree(ModData modData) - { - // Gather all the nodes together for evaluation. - var chromeLayoutNodes = modData.Manifest.ChromeLayout - .SelectMany(filename => MiniYaml.FromStream(modData.DefaultFileSystem.Open(filename), filename)) - .ToArray(); - - return chromeLayoutNodes; - } - - static void CheckChrome( + static void ExtractChromeFluentKeys( + ModData modData, MiniYamlNode rootNode, Dictionary<(string WidgetName, string FieldName), FluentReferenceAttribute> fluentReferencesByWidgetField, - Keys usedKeys) + Keys keys) { var nodeType = rootNode.Key.Split('@')[0]; foreach (var childNode in rootNode.Value.Nodes) @@ -406,13 +345,40 @@ namespace OpenRA.Mods.Common.Lint continue; var key = childNode.Value.Value; - usedKeys.Add(key, reference, $"Widget `{rootNode.Key}` field `{childType}` in {rootNode.Location}"); + keys.Add(key, reference, $"Widget `{rootNode.Key}` field `{childType}` in {rootNode.Location}"); } + var widgetType = modData.ObjectCreator.FindType(nodeType + "Widget"); + ExtractConstFluentKeys(modData, widgetType, keys); + + Type[] logicArgsTypes = [typeof(Dictionary)]; foreach (var childNode in rootNode.Value.Nodes) + { + if (childNode.Key == "Logic") + { + foreach (var logicName in FieldLoader.GetValue(childNode.Key, childNode.Value.Value)) + { + var logicType = modData.ObjectCreator.FindType(logicName); + if (logicType == null) + continue; + + ExtractConstFluentKeys(modData, logicType, keys); + + var chromeArgsReferences = logicType.GetCustomAttributes(true); + foreach (var methodName in chromeArgsReferences.SelectMany(a => a.MethodNames)) + { + var dynamicReferencesMethod = logicType.GetMethod(methodName, StaticBinding, logicArgsTypes); + var dynamicReferences = dynamicReferencesMethod.Invoke(null, [childNode.Value.ToDictionary()]); + foreach (var (key, reference) in (IEnumerable<(string Key, FluentReferenceAttribute Reference)>)dynamicReferences) + keys.Add(key, reference, logicType.Name); + } + } + } + if (childNode.Key == "Children") foreach (var n in childNode.Value.Nodes) - CheckChrome(n, fluentReferencesByWidgetField, usedKeys); + ExtractChromeFluentKeys(modData, n, fluentReferencesByWidgetField, keys); + } } static HashSet CheckKeys( diff --git a/OpenRA.Mods.Common/Traits/Player/ConquestVictoryConditions.cs b/OpenRA.Mods.Common/Traits/Player/ConquestVictoryConditions.cs index d26a000a44..a79a251a9c 100644 --- a/OpenRA.Mods.Common/Traits/Player/ConquestVictoryConditions.cs +++ b/OpenRA.Mods.Common/Traits/Player/ConquestVictoryConditions.cs @@ -16,6 +16,7 @@ using OpenRA.Traits; namespace OpenRA.Mods.Common.Traits { [TraitLocation(SystemActors.Player)] + [IncludeStaticFluentReferences(typeof(ConquestVictoryConditions))] public class ConquestVictoryConditionsInfo : TraitInfo, Requires { [Desc("Delay for the end game notification in milliseconds.")] diff --git a/OpenRA.Mods.Common/Traits/Player/DeveloperMode.cs b/OpenRA.Mods.Common/Traits/Player/DeveloperMode.cs index 1be21db357..b78b4fa374 100644 --- a/OpenRA.Mods.Common/Traits/Player/DeveloperMode.cs +++ b/OpenRA.Mods.Common/Traits/Player/DeveloperMode.cs @@ -17,6 +17,7 @@ using OpenRA.Traits; namespace OpenRA.Mods.Common.Traits { [TraitLocation(SystemActors.Player)] + [IncludeStaticFluentReferences(typeof(DeveloperMode))] [Desc("Attach this to the player actor.")] public class DeveloperModeInfo : TraitInfo, ILobbyOptions { diff --git a/OpenRA.Mods.Common/Traits/Player/StrategicVictoryConditions.cs b/OpenRA.Mods.Common/Traits/Player/StrategicVictoryConditions.cs index d8d55a5281..8f8a20b254 100644 --- a/OpenRA.Mods.Common/Traits/Player/StrategicVictoryConditions.cs +++ b/OpenRA.Mods.Common/Traits/Player/StrategicVictoryConditions.cs @@ -21,6 +21,7 @@ namespace OpenRA.Mods.Common.Traits public class StrategicPoint { } [Desc("Allows King of the Hill (KotH) style gameplay.")] + [IncludeStaticFluentReferences(typeof(StrategicVictoryConditions))] public class StrategicVictoryConditionsInfo : TraitInfo, Requires { [Desc("Amount of time (in game ticks) that the player has to hold all the strategic points.", "Defaults to 7500 ticks (5 minutes at default speed).")] diff --git a/OpenRA.Mods.Common/Traits/Render/CustomTerrainDebugOverlay.cs b/OpenRA.Mods.Common/Traits/Render/CustomTerrainDebugOverlay.cs index 022b87314a..577c31ee65 100644 --- a/OpenRA.Mods.Common/Traits/Render/CustomTerrainDebugOverlay.cs +++ b/OpenRA.Mods.Common/Traits/Render/CustomTerrainDebugOverlay.cs @@ -18,6 +18,7 @@ using OpenRA.Traits; namespace OpenRA.Mods.Common.Traits { [TraitLocation(SystemActors.World)] + [IncludeStaticFluentReferences(typeof(CustomTerrainDebugOverlay))] [Desc("Displays custom terrain types.")] sealed class CustomTerrainDebugOverlayInfo : TraitInfo { diff --git a/OpenRA.Mods.Common/Traits/World/CellTriggerOverlay.cs b/OpenRA.Mods.Common/Traits/World/CellTriggerOverlay.cs index f162941983..226c10877b 100644 --- a/OpenRA.Mods.Common/Traits/World/CellTriggerOverlay.cs +++ b/OpenRA.Mods.Common/Traits/World/CellTriggerOverlay.cs @@ -20,6 +20,7 @@ using OpenRA.Traits; namespace OpenRA.Mods.Common.Traits { [TraitLocation(SystemActors.World)] + [IncludeStaticFluentReferences(typeof(CellTriggerOverlay))] [Desc("Renders a debug overlay showing the script triggers. Attach this to the world actor.")] public class CellTriggerOverlayInfo : TraitInfo { diff --git a/OpenRA.Mods.Common/Traits/World/EditorActionManager.cs b/OpenRA.Mods.Common/Traits/World/EditorActionManager.cs index a1399cab28..18fa0298ab 100644 --- a/OpenRA.Mods.Common/Traits/World/EditorActionManager.cs +++ b/OpenRA.Mods.Common/Traits/World/EditorActionManager.cs @@ -17,6 +17,7 @@ using OpenRA.Traits; namespace OpenRA.Mods.Common.Traits { [TraitLocation(SystemActors.EditorWorld)] + [IncludeStaticFluentReferences(typeof(OpenMapAction))] public class EditorActionManagerInfo : TraitInfo { } public class EditorActionManager : IWorldLoaded diff --git a/OpenRA.Mods.Common/Traits/World/ExitsDebugOverlayManager.cs b/OpenRA.Mods.Common/Traits/World/ExitsDebugOverlayManager.cs index 2a3faa2627..1f8f04adf6 100644 --- a/OpenRA.Mods.Common/Traits/World/ExitsDebugOverlayManager.cs +++ b/OpenRA.Mods.Common/Traits/World/ExitsDebugOverlayManager.cs @@ -16,6 +16,7 @@ using OpenRA.Traits; namespace OpenRA.Mods.Common.Traits { [TraitLocation(SystemActors.World)] + [IncludeStaticFluentReferences(typeof(ExitsDebugOverlayManager))] public class ExitsDebugOverlayManagerInfo : TraitInfo { [Desc("The font used to draw cell vectors. Should match the value as-is in the Fonts section of the mod manifest (do not convert to lowercase).")] diff --git a/OpenRA.Mods.Common/Traits/World/HierarchicalPathFinderOverlay.cs b/OpenRA.Mods.Common/Traits/World/HierarchicalPathFinderOverlay.cs index b0b8a24736..dca1b6e35d 100644 --- a/OpenRA.Mods.Common/Traits/World/HierarchicalPathFinderOverlay.cs +++ b/OpenRA.Mods.Common/Traits/World/HierarchicalPathFinderOverlay.cs @@ -21,6 +21,7 @@ using OpenRA.Traits; namespace OpenRA.Mods.Common.Traits { [TraitLocation(SystemActors.World)] + [IncludeStaticFluentReferences(typeof(HierarchicalPathFinderOverlay))] [Desc("Renders a debug overlay showing the abstract graph of the hierarchical pathfinder. Attach this to the world actor.")] public class HierarchicalPathFinderOverlayInfo : TraitInfo, Requires { diff --git a/OpenRA.Mods.Common/Traits/World/PathFinderOverlay.cs b/OpenRA.Mods.Common/Traits/World/PathFinderOverlay.cs index 39deb195bf..211a14754a 100644 --- a/OpenRA.Mods.Common/Traits/World/PathFinderOverlay.cs +++ b/OpenRA.Mods.Common/Traits/World/PathFinderOverlay.cs @@ -23,6 +23,7 @@ using OpenRA.Traits; namespace OpenRA.Mods.Common.Traits { [TraitLocation(SystemActors.World)] + [IncludeStaticFluentReferences(typeof(PathFinderOverlay))] [Desc("Renders a visualization overlay showing how the pathfinder searches for paths. Attach this to the world actor.")] public class PathFinderOverlayInfo : TraitInfo, Requires { diff --git a/OpenRA.Mods.Common/Traits/World/ResourceRenderer.cs b/OpenRA.Mods.Common/Traits/World/ResourceRenderer.cs index 3a6124056c..89ebbf2000 100644 --- a/OpenRA.Mods.Common/Traits/World/ResourceRenderer.cs +++ b/OpenRA.Mods.Common/Traits/World/ResourceRenderer.cs @@ -48,6 +48,7 @@ namespace OpenRA.Mods.Common.Traits } } + [IncludeFluentReferences(LintDictionaryReference.Values)] [FieldLoader.LoadUsing(nameof(LoadResourceTypes))] public readonly Dictionary ResourceTypes = null; diff --git a/OpenRA.Mods.Common/Traits/World/TerrainGeometryOverlay.cs b/OpenRA.Mods.Common/Traits/World/TerrainGeometryOverlay.cs index 61b8759438..16f9cd5c44 100644 --- a/OpenRA.Mods.Common/Traits/World/TerrainGeometryOverlay.cs +++ b/OpenRA.Mods.Common/Traits/World/TerrainGeometryOverlay.cs @@ -19,6 +19,7 @@ using OpenRA.Traits; namespace OpenRA.Mods.Common.Traits { [TraitLocation(SystemActors.World | SystemActors.EditorWorld)] + [IncludeStaticFluentReferences(typeof(TerrainGeometryOverlay))] [Desc("Renders a debug overlay showing the terrain cells. Attach this to the world actor.")] public class TerrainGeometryOverlayInfo : TraitInfo { } diff --git a/OpenRA.Mods.Common/Traits/World/TimeLimitManager.cs b/OpenRA.Mods.Common/Traits/World/TimeLimitManager.cs index 58142c2522..d15e993ec2 100644 --- a/OpenRA.Mods.Common/Traits/World/TimeLimitManager.cs +++ b/OpenRA.Mods.Common/Traits/World/TimeLimitManager.cs @@ -19,6 +19,7 @@ using OpenRA.Widgets; namespace OpenRA.Mods.Common.Traits { [TraitLocation(SystemActors.World)] + [IncludeStaticFluentReferences(typeof(TimeLimitManager))] [Desc("This trait allows setting a time limit on matches. Attach this to the World actor.")] public class TimeLimitManagerInfo : TraitInfo, ILobbyOptions, IRulesetLoaded { diff --git a/OpenRA.Mods.Common/Widgets/BackgroundWidget.cs b/OpenRA.Mods.Common/Widgets/BackgroundWidget.cs index 03cdb6f7fe..cf5d96c67b 100644 --- a/OpenRA.Mods.Common/Widgets/BackgroundWidget.cs +++ b/OpenRA.Mods.Common/Widgets/BackgroundWidget.cs @@ -13,6 +13,7 @@ using OpenRA.Widgets; namespace OpenRA.Mods.Common.Widgets { + [IncludeStaticFluentReferences(typeof(WidgetUtils))] public class BackgroundWidget : Widget { public readonly bool ClickThrough = false; diff --git a/OpenRA.Mods.Common/Widgets/EditorViewportControllerWidget.cs b/OpenRA.Mods.Common/Widgets/EditorViewportControllerWidget.cs index d649cf78bb..558ab00b06 100644 --- a/OpenRA.Mods.Common/Widgets/EditorViewportControllerWidget.cs +++ b/OpenRA.Mods.Common/Widgets/EditorViewportControllerWidget.cs @@ -17,6 +17,12 @@ using Color = OpenRA.Primitives.Color; namespace OpenRA.Mods.Common.Widgets { + [IncludeStaticFluentReferences( + typeof(ChangeSelectionAction), + typeof(DeleteAreaAction), + typeof(RemoveActorAction), + typeof(RemoveResourceAction), + typeof(MoveActorAction))] public class EditorViewportControllerWidget : Widget { [Desc("Main color of the selection grid.")] diff --git a/OpenRA.Mods.Common/Widgets/Logic/Editor/ActorEditLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/Editor/ActorEditLogic.cs index 199553fb0a..54f9018486 100644 --- a/OpenRA.Mods.Common/Widgets/Logic/Editor/ActorEditLogic.cs +++ b/OpenRA.Mods.Common/Widgets/Logic/Editor/ActorEditLogic.cs @@ -19,6 +19,7 @@ using OpenRA.Widgets; namespace OpenRA.Mods.Common.Widgets.Logic { + [IncludeStaticFluentReferences(typeof(EditActorEditorAction))] public class ActorEditLogic : ChromeLogic { [FluentReference] diff --git a/OpenRA.Mods.Common/Widgets/Logic/Editor/ActorSelectorLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/Editor/ActorSelectorLogic.cs index 7ba382c53b..cb48a4791e 100644 --- a/OpenRA.Mods.Common/Widgets/Logic/Editor/ActorSelectorLogic.cs +++ b/OpenRA.Mods.Common/Widgets/Logic/Editor/ActorSelectorLogic.cs @@ -19,6 +19,7 @@ using OpenRA.Widgets; namespace OpenRA.Mods.Common.Widgets.Logic { + [IncludeStaticFluentReferences(typeof(AddActorAction), typeof(CommonSelectorLogic))] public class ActorSelectorLogic : CommonSelectorLogic { [FluentReference("actorType")] diff --git a/OpenRA.Mods.Common/Widgets/Logic/Editor/LayerSelectorLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/Editor/LayerSelectorLogic.cs index 4e83fce53e..95aaa828e7 100644 --- a/OpenRA.Mods.Common/Widgets/Logic/Editor/LayerSelectorLogic.cs +++ b/OpenRA.Mods.Common/Widgets/Logic/Editor/LayerSelectorLogic.cs @@ -15,6 +15,7 @@ using OpenRA.Widgets; namespace OpenRA.Mods.Common.Widgets.Logic { + [IncludeStaticFluentReferences(typeof(AddResourcesEditorAction))] public class LayerSelectorLogic : ChromeLogic { readonly EditorViewportControllerWidget editor; diff --git a/OpenRA.Mods.Common/Widgets/Logic/Editor/MapEditorSelectionLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/Editor/MapEditorSelectionLogic.cs index 35d8499add..b623eea294 100644 --- a/OpenRA.Mods.Common/Widgets/Logic/Editor/MapEditorSelectionLogic.cs +++ b/OpenRA.Mods.Common/Widgets/Logic/Editor/MapEditorSelectionLogic.cs @@ -17,6 +17,7 @@ using OpenRA.Widgets; namespace OpenRA.Mods.Common.Widgets.Logic { + [IncludeStaticFluentReferences(typeof(CopyPasteEditorAction))] public class MapEditorSelectionLogic : ChromeLogic { [FluentReference] diff --git a/OpenRA.Mods.Common/Widgets/Logic/Editor/MapMarkerTilesLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/Editor/MapMarkerTilesLogic.cs index 9fffe410dd..ae2f626345 100644 --- a/OpenRA.Mods.Common/Widgets/Logic/Editor/MapMarkerTilesLogic.cs +++ b/OpenRA.Mods.Common/Widgets/Logic/Editor/MapMarkerTilesLogic.cs @@ -20,6 +20,10 @@ using static OpenRA.Mods.Common.Traits.MarkerLayerOverlay; namespace OpenRA.Mods.Common.Widgets.Logic { + [IncludeStaticFluentReferences( + typeof(PaintMarkerTileEditorAction), + typeof(ClearSelectedMarkerTilesEditorAction), + typeof(ClearAllMarkerTilesEditorAction))] public class MapMarkerTilesLogic : ChromeLogic { [FluentReference] diff --git a/OpenRA.Mods.Common/Widgets/Logic/Editor/TileSelectorLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/Editor/TileSelectorLogic.cs index 34703c947a..5ed7ed699f 100644 --- a/OpenRA.Mods.Common/Widgets/Logic/Editor/TileSelectorLogic.cs +++ b/OpenRA.Mods.Common/Widgets/Logic/Editor/TileSelectorLogic.cs @@ -19,6 +19,10 @@ using OpenRA.Widgets; namespace OpenRA.Mods.Common.Widgets.Logic { + [IncludeStaticFluentReferences( + typeof(PaintTileEditorAction), + typeof(FloodFillEditorAction), + typeof(CommonSelectorLogic))] public class TileSelectorLogic : CommonSelectorLogic { sealed class TileSelectorTemplate diff --git a/OpenRA.Mods.Common/Widgets/Logic/Lobby/LobbyLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/Lobby/LobbyLogic.cs index c68e305668..9ab382b03f 100644 --- a/OpenRA.Mods.Common/Widgets/Logic/Lobby/LobbyLogic.cs +++ b/OpenRA.Mods.Common/Widgets/Logic/Lobby/LobbyLogic.cs @@ -21,6 +21,7 @@ using OpenRA.Widgets; namespace OpenRA.Mods.Common.Widgets.Logic { + [IncludeStaticFluentReferences(typeof(LobbyUtils))] public class LobbyLogic : ChromeLogic, INotificationHandler { [FluentReference] diff --git a/OpenRA.Mods.Common/Widgets/Logic/ReplayBrowserLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/ReplayBrowserLogic.cs index d80aeb7d1c..a6ab52650b 100644 --- a/OpenRA.Mods.Common/Widgets/Logic/ReplayBrowserLogic.cs +++ b/OpenRA.Mods.Common/Widgets/Logic/ReplayBrowserLogic.cs @@ -24,6 +24,7 @@ using OpenRA.Widgets; namespace OpenRA.Mods.Common.Widgets.Logic { + [IncludeStaticFluentReferences(typeof(ReplayUtils))] public class ReplayBrowserLogic : ChromeLogic { [FluentReference("time")] diff --git a/OpenRA.Mods.Common/Widgets/Logic/Settings/HotkeysSettingsLogic.cs b/OpenRA.Mods.Common/Widgets/Logic/Settings/HotkeysSettingsLogic.cs index 3061a6cb9e..9daf35b2c4 100644 --- a/OpenRA.Mods.Common/Widgets/Logic/Settings/HotkeysSettingsLogic.cs +++ b/OpenRA.Mods.Common/Widgets/Logic/Settings/HotkeysSettingsLogic.cs @@ -17,6 +17,8 @@ using OpenRA.Widgets; namespace OpenRA.Mods.Common.Widgets.Logic { + [IncludeChromeLogicArgsFluentReferences(nameof(DynamicFluentReferences))] + [IncludeStaticFluentReferences(typeof(KeycodeExts), typeof(ModifiersExts))] public class HotkeysSettingsLogic : ChromeLogic { [FluentReference("key")] @@ -28,6 +30,13 @@ namespace OpenRA.Mods.Common.Widgets.Logic [FluentReference] const string AnyContext = HotkeyDefinition.ContextFluentPrefix + "-any"; + public static IEnumerable<(string Key, FluentReferenceAttribute Reference)> DynamicFluentReferences(Dictionary logicArgs) + { + if (logicArgs.TryGetValue("HotkeyGroups", out var hotkeyGroupsYaml)) + foreach (var node in hotkeyGroupsYaml.Nodes) + yield return (node.Key, new FluentReferenceAttribute()); + } + readonly ModData modData; readonly Dictionary logicArgs;