diff --git a/Examples/NativeAot/NativeAot.csproj b/Examples/NativeAot/NativeAot.csproj index 8ae39a7f22..18f5bce920 100644 --- a/Examples/NativeAot/NativeAot.csproj +++ b/Examples/NativeAot/NativeAot.csproj @@ -9,7 +9,6 @@ - diff --git a/Terminal.Gui/Configuration/ConfigProperty.cs b/Terminal.Gui/Configuration/ConfigProperty.cs index 0a5d7dc8f9..1eb114887f 100644 --- a/Terminal.Gui/Configuration/ConfigProperty.cs +++ b/Terminal.Gui/Configuration/ConfigProperty.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Reflection; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; @@ -457,14 +458,27 @@ private static void UpdateSchemeDictionary ( private static ImmutableSortedDictionary? _classesWithConfigProps; /// - /// INTERNAL: Called from the method to initialize the - /// _classesWithConfigProps dictionary. + /// INTERNAL: Called from the method to initialize the + /// dictionary. /// - [RequiresDynamicCode ("Uses reflection to scan assemblies for configuration properties. " + - "Only called during initialization and not needed during normal operation. " + - "In AOT environments, ensure all types with ConfigurationPropertyAttribute are preserved.")] - [RequiresUnreferencedCode ("Reflection requires all types with ConfigurationPropertyAttribute to be preserved in AOT. " + - "Use the SourceGenerationContext to register all configuration property types.")] + /// + /// + /// The set of host types that ship in Terminal.Gui is declared statically in + /// and registered unconditionally. Declaring the set statically keeps initialization + /// trim- and AOT-safe without requiring consumer apps to carry <TrimmerRootAssembly Include="Terminal.Gui" />. + /// See . + /// + /// + /// Additional host types defined outside Terminal.Gui (test suites, plugins, embedding apps) are picked up via a + /// runtime assembly scan when dynamic code is supported. The scan is a no-op under AOT where dynamic code is disabled, + /// and any types it would find would have been trimmed anyway unless the consumer roots them. + /// + /// + [RequiresDynamicCode ("Uses reflection to scan assemblies for configuration properties. " + + "Only called during initialization and not needed during normal operation. " + + "In AOT environments, ensure all types with ConfigurationPropertyAttribute are preserved.")] + [RequiresUnreferencedCode ("Reflection requires all types with ConfigurationPropertyAttribute to be preserved in AOT. " + + "Use the SourceGenerationContext to register all configuration property types.")] internal static void Initialize () { if (_classesWithConfigProps is { }) @@ -474,8 +488,28 @@ internal static void Initialize () Dictionary dict = new (StringComparer.InvariantCultureIgnoreCase); - // Process assemblies directly to avoid LINQ overhead + // Trim/AOT-safe: statically-rooted host types that ship with Terminal.Gui. + foreach (Type type in ConfigPropertyHostTypes.GetTypes ()) + { + dict [type.Name] = type; + } + + // JIT-only supplement: discover host types defined outside Terminal.Gui (test suites, plugins). + // Under AOT, RuntimeFeature.IsDynamicCodeSupported is false and we skip the scan entirely. + if (RuntimeFeature.IsDynamicCodeSupported) + { + ScanLoadedAssembliesForConfigPropertyHosts (dict); + } + + _classesWithConfigProps = dict.ToImmutableSortedDictionary (); + } + + [RequiresDynamicCode ("Uses reflection to scan assemblies for configuration properties.")] + [RequiresUnreferencedCode ("Scanning for types via reflection is not trim-safe.")] + private static void ScanLoadedAssembliesForConfigPropertyHosts (Dictionary dict) + { Assembly [] assemblies = AppDomain.CurrentDomain.GetAssemblies (); + foreach (Assembly assembly in assemblies) { try @@ -488,24 +522,29 @@ internal static void Initialize () foreach (Type type in assembly.GetTypes ()) { PropertyInfo [] properties = type.GetProperties (); - - // Check if any property has the ConfigurationPropertyAttribute var hasConfigProp = false; + foreach (PropertyInfo prop in properties) { if (HasConfigurationPropertyAttribute (prop)) { hasConfigProp = true; + break; } } - if (hasConfigProp) + if (!hasConfigProp) { - dict [type.Name] = type; + continue; } + + // TryAdd — never overwrite a statically-registered Terminal.Gui host. An external assembly + // with a same-named type (e.g. a consumer's own `Window`) must not displace the built-in entry. + dict.TryAdd (type.Name, type); } } + // Skip problematic assemblies that can't be loaded or analyzed catch (ReflectionTypeLoadException) { @@ -516,8 +555,6 @@ internal static void Initialize () continue; } } - - _classesWithConfigProps = dict.ToImmutableSortedDictionary (); } /// diff --git a/Terminal.Gui/Configuration/ConfigPropertyHostTypes.cs b/Terminal.Gui/Configuration/ConfigPropertyHostTypes.cs new file mode 100644 index 0000000000..4b6ce6e712 --- /dev/null +++ b/Terminal.Gui/Configuration/ConfigPropertyHostTypes.cs @@ -0,0 +1,103 @@ +using System.Diagnostics.CodeAnalysis; +using Terminal.Gui.App; +using Terminal.Gui.Drawing; +using Terminal.Gui.Drivers; +using Terminal.Gui.Input; +using Terminal.Gui.Text; +using Terminal.Gui.Tracing; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace Terminal.Gui.Configuration; + +/// +/// INTERNAL: The statically-known set of types that own +/// -decorated properties. +/// +/// +/// +/// used to discover these types by reflecting over every loaded +/// assembly at module-init time. That scan was not trim-safe: with PublishTrimmed=true, types +/// not otherwise referenced by the consuming application were stripped and +/// Scope<T>.GetUninitializedProperty would throw at startup. +/// See . +/// +/// +/// The per-type entries on root +/// each host and preserve its so the +/// trimmer does not remove the properties the scan depends on. +/// +/// +/// A unit test verifies this list stays in sync with the types that actually carry +/// so drift is caught at build time, not in AOT +/// consumer apps. +/// +/// +internal static class ConfigPropertyHostTypes +{ + private const DynamicallyAccessedMemberTypes PreservedMembers = DynamicallyAccessedMemberTypes.PublicProperties; + + private static readonly Type [] _types = + [ + typeof (Application), + typeof (ConfigurationManager), + typeof (SchemeManager), + typeof (ThemeManager), + typeof (Color), + typeof (Glyphs), + typeof (Driver), + typeof (Key), + typeof (NerdFonts), + typeof (Trace), + typeof (View), + typeof (Button), + typeof (CharMap), + typeof (CheckBox), + typeof (Dialog), + typeof (FileDialog), + typeof (FileDialogStyle), + typeof (FrameView), + typeof (HexView), + typeof (LinearRange), + typeof (Menu), + typeof (MenuBar), + typeof (MessageBox), + typeof (PopoverMenu), + typeof (SelectorBase), + typeof (StatusBar), + typeof (TextField), + typeof (TextView), + typeof (Window) + ]; + + [DynamicDependency (PreservedMembers, typeof (Application))] + [DynamicDependency (PreservedMembers, typeof (ConfigurationManager))] + [DynamicDependency (PreservedMembers, typeof (SchemeManager))] + [DynamicDependency (PreservedMembers, typeof (ThemeManager))] + [DynamicDependency (PreservedMembers, typeof (Color))] + [DynamicDependency (PreservedMembers, typeof (Glyphs))] + [DynamicDependency (PreservedMembers, typeof (Driver))] + [DynamicDependency (PreservedMembers, typeof (Key))] + [DynamicDependency (PreservedMembers, typeof (NerdFonts))] + [DynamicDependency (PreservedMembers, typeof (Trace))] + [DynamicDependency (PreservedMembers, typeof (View))] + [DynamicDependency (PreservedMembers, typeof (Button))] + [DynamicDependency (PreservedMembers, typeof (CharMap))] + [DynamicDependency (PreservedMembers, typeof (CheckBox))] + [DynamicDependency (PreservedMembers, typeof (Dialog))] + [DynamicDependency (PreservedMembers, typeof (FileDialog))] + [DynamicDependency (PreservedMembers, typeof (FileDialogStyle))] + [DynamicDependency (PreservedMembers, typeof (FrameView))] + [DynamicDependency (PreservedMembers, typeof (HexView))] + [DynamicDependency (PreservedMembers, typeof (LinearRange))] + [DynamicDependency (PreservedMembers, typeof (Menu))] + [DynamicDependency (PreservedMembers, typeof (MenuBar))] + [DynamicDependency (PreservedMembers, typeof (MessageBox))] + [DynamicDependency (PreservedMembers, typeof (PopoverMenu))] + [DynamicDependency (PreservedMembers, typeof (SelectorBase))] + [DynamicDependency (PreservedMembers, typeof (StatusBar))] + [DynamicDependency (PreservedMembers, typeof (TextField))] + [DynamicDependency (PreservedMembers, typeof (TextView))] + [DynamicDependency (PreservedMembers, typeof (Window))] + internal static Type [] GetTypes () => _types; +} diff --git a/Tests/UnitTestsParallelizable/Configuration/ConfigPropertyHostTypesTests.cs b/Tests/UnitTestsParallelizable/Configuration/ConfigPropertyHostTypesTests.cs new file mode 100644 index 0000000000..a68abb4b3e --- /dev/null +++ b/Tests/UnitTestsParallelizable/Configuration/ConfigPropertyHostTypesTests.cs @@ -0,0 +1,87 @@ +// Claude - Opus 4.7 +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Terminal.Gui.Configuration; + +namespace ConfigurationTests; + +public class ConfigPropertyHostTypesTests +{ + /// + /// Guard-rail test: the hard-coded list in must exhaustively cover every type in + /// the Terminal.Gui assembly that declares a property. Drift between + /// the list and the attribute usage would silently reintroduce the trim/AOT failure fixed by + /// . + /// + [Fact] + public void ConfigPropertyHostTypes_GetTypes_Matches_Reflected_Hosts_In_TerminalGui_Assembly () + { + // Arrange + Assembly terminalGuiAssembly = typeof (ConfigurationManager).Assembly; + + HashSet reflected = terminalGuiAssembly + .GetTypes () + .Where (HasConfigurationProperty) + .ToHashSet (); + + // Act + HashSet registered = ConfigPropertyHostTypes.GetTypes ().ToHashSet (); + + // Assert + Type [] missing = reflected.Except (registered).ToArray (); + Type [] extra = registered.Except (reflected).ToArray (); + + Assert.True ( + missing.Length == 0 && extra.Length == 0, + $"ConfigPropertyHostTypes drift detected.\nMissing (add to list): {string.Join (", ", missing.Select (t => t.FullName))}\nExtra (remove from list): {string.Join (", ", extra.Select (t => t.FullName))}"); + } + + /// + /// Guard-rail test: the set on GetTypes must exactly match the + /// _types array it returns. The attributes are what the trimmer actually reads to preserve members; if + /// someone adds a type to the array but forgets the matching attribute (or vice-versa), AOT builds would silently + /// lose rooting again without the reflected-hosts test above catching it. + /// + [Fact] + public void ConfigPropertyHostTypes_DynamicDependencies_Match_Returned_Types () + { + // Arrange + MethodInfo getTypes = typeof (ConfigPropertyHostTypes).GetMethod ( + nameof (ConfigPropertyHostTypes.GetTypes), + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)!; + + HashSet rooted = getTypes + .GetCustomAttributes () + .Select (a => a.Type!) + .ToHashSet (); + + HashSet returned = ConfigPropertyHostTypes.GetTypes ().ToHashSet (); + + // Assert + Type [] missingRoots = returned.Except (rooted).ToArray (); + Type [] extraRoots = rooted.Except (returned).ToArray (); + + Assert.True ( + missingRoots.Length == 0 && extraRoots.Length == 0, + $"ConfigPropertyHostTypes [DynamicDependency] drift detected.\nTypes in array but not rooted by attribute: {string.Join (", ", missingRoots.Select (t => t.FullName))}\nTypes rooted by attribute but not in array: {string.Join (", ", extraRoots.Select (t => t.FullName))}"); + } + + private static bool HasConfigurationProperty (Type type) + { + // Mirror the production scan in ConfigProperty.Initialize (), which calls type.GetProperties () — i.e., + // public properties only (includes inherited public members; excludes non-public and, by default, statics + // not declared on `type` itself). Widening the binding flags here would flag types the production scan + // could never discover and the trimmer wouldn't preserve via PublicProperties. + PropertyInfo [] properties = type.GetProperties (); + + foreach (PropertyInfo property in properties) + { + if (property.GetCustomAttribute () is { }) + { + return true; + } + } + + return false; + } +}