Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Examples/NativeAot/NativeAot.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@

<ItemGroup>
<ProjectReference Include="..\..\Terminal.Gui\Terminal.Gui.csproj" />
<TrimmerRootAssembly Include="Terminal.Gui" />
</ItemGroup>

</Project>
65 changes: 51 additions & 14 deletions Terminal.Gui/Configuration/ConfigProperty.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -457,14 +458,27 @@ private static void UpdateSchemeDictionary (
private static ImmutableSortedDictionary<string, Type>? _classesWithConfigProps;

/// <summary>
/// INTERNAL: Called from the <see cref="ModuleInitializers.InitializeConfigurationManager"/> method to initialize the
/// _classesWithConfigProps dictionary.
/// INTERNAL: Called from the <see cref="ModuleInitializers.InitializeConfigurationManager"/> method to initialize the
/// <see cref="_classesWithConfigProps"/> dictionary.
/// </summary>
[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.")]
/// <remarks>
/// <para>
/// The set of <see cref="ConfigurationPropertyAttribute"/> host types that ship in Terminal.Gui is declared statically in
/// <see cref="ConfigPropertyHostTypes"/> and registered unconditionally. Declaring the set statically keeps initialization
/// trim- and AOT-safe without requiring consumer apps to carry <c>&lt;TrimmerRootAssembly Include="Terminal.Gui" /&gt;</c>.
/// See <see href="https://github.com/gui-cs/Terminal.Gui/issues/5069"/>.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[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 { })
Expand All @@ -474,8 +488,28 @@ internal static void Initialize ()

Dictionary<string, Type> 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<string, Type> dict)
{
Assembly [] assemblies = AppDomain.CurrentDomain.GetAssemblies ();

foreach (Assembly assembly in assemblies)
{
try
Expand All @@ -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)
{
Expand All @@ -516,8 +555,6 @@ internal static void Initialize ()
continue;
}
}

_classesWithConfigProps = dict.ToImmutableSortedDictionary ();
}

/// <summary>
Expand Down
103 changes: 103 additions & 0 deletions Terminal.Gui/Configuration/ConfigPropertyHostTypes.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// INTERNAL: The statically-known set of types that own
/// <see cref="ConfigurationPropertyAttribute"/>-decorated properties.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="ConfigurationManager"/> used to discover these types by reflecting over every loaded
/// assembly at module-init time. That scan was not trim-safe: with <c>PublishTrimmed=true</c>, types
/// not otherwise referenced by the consuming application were stripped and
/// <c>Scope&lt;T&gt;.GetUninitializedProperty</c> would throw at startup.
/// See <see href="https://github.com/gui-cs/Terminal.Gui/issues/5069"/>.
/// </para>
/// <para>
/// The per-type <see cref="DynamicDependencyAttribute"/> entries on <see cref="GetTypes"/> root
/// each host and preserve its <see cref="DynamicallyAccessedMemberTypes.PublicProperties"/> so the
/// trimmer does not remove the properties the scan depends on.
/// </para>
/// <para>
/// A unit test verifies this list stays in sync with the types that actually carry
/// <see cref="ConfigurationPropertyAttribute"/> so drift is caught at build time, not in AOT
/// consumer apps.
/// </para>
/// </remarks>
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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Claude - Opus 4.7
Comment thread
tig marked this conversation as resolved.
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Terminal.Gui.Configuration;

namespace ConfigurationTests;

public class ConfigPropertyHostTypesTests
{
/// <summary>
/// Guard-rail test: the hard-coded list in <see cref="ConfigPropertyHostTypes"/> must exhaustively cover every type in
/// the Terminal.Gui assembly that declares a <see cref="ConfigurationPropertyAttribute"/> property. Drift between
/// the list and the attribute usage would silently reintroduce the trim/AOT failure fixed by
/// <see href="https://github.com/gui-cs/Terminal.Gui/issues/5069"/>.
/// </summary>
[Fact]
public void ConfigPropertyHostTypes_GetTypes_Matches_Reflected_Hosts_In_TerminalGui_Assembly ()
{
// Arrange
Assembly terminalGuiAssembly = typeof (ConfigurationManager).Assembly;

HashSet<Type> reflected = terminalGuiAssembly
.GetTypes ()
.Where (HasConfigurationProperty)
.ToHashSet ();

// Act
HashSet<Type> 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))}");
}

/// <summary>
/// Guard-rail test: the <see cref="DynamicDependencyAttribute"/> set on <c>GetTypes</c> must exactly match the
/// <c>_types</c> 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.
/// </summary>
[Fact]
public void ConfigPropertyHostTypes_DynamicDependencies_Match_Returned_Types ()
{
// Arrange
MethodInfo getTypes = typeof (ConfigPropertyHostTypes).GetMethod (
nameof (ConfigPropertyHostTypes.GetTypes),
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)!;

HashSet<Type> rooted = getTypes
.GetCustomAttributes<DynamicDependencyAttribute> ()
.Select (a => a.Type!)
.ToHashSet ();

HashSet<Type> 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<ConfigurationPropertyAttribute> () is { })
Comment thread
harder marked this conversation as resolved.
{
return true;
}
}

return false;
}
}
Loading