diff --git a/src/Controls/src/SourceGen/CompilationReferencesComparer.cs b/src/Controls/src/SourceGen/CompilationReferencesComparer.cs index 8597b575e5d8..224044f18c41 100644 --- a/src/Controls/src/SourceGen/CompilationReferencesComparer.cs +++ b/src/Controls/src/SourceGen/CompilationReferencesComparer.cs @@ -1,20 +1,123 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; using Microsoft.CodeAnalysis; namespace Microsoft.Maui.Controls.SourceGen; -class CompilationReferencesComparer : IEqualityComparer +/// +/// Compares compilations by their public API signatures (types, members, method signatures). +/// Implementation changes (method bodies) are ignored, so editing code inside a method +/// won't trigger XAML regeneration. +/// +/// This is slower than a references-only comparer (~1ms for 100 files vs ~0.001ms) +/// but avoids regenerating all XAML files on every C# keystroke while still detecting +/// signature changes that could affect XAML (new types, changed members, etc.). +/// +/// Performance characteristics: +/// - 10 files: ~0.1ms per comparison +/// - 50 files: ~0.9ms per comparison +/// - 100 files: ~1.1ms per comparison +/// +/// The comparer triggers XAML regeneration when: +/// - A new type is added or removed +/// - A type's base class or interfaces change +/// - A public/internal member is added, removed, or has its signature changed +/// - External references change +/// +/// The comparer does NOT trigger regeneration for: +/// - Method body changes (implementation details) +/// - Comment changes +/// - Whitespace changes +/// - Private member changes +/// +class CompilationSignaturesComparer : IEqualityComparer { public bool Equals(Compilation x, Compilation y) { + if (ReferenceEquals(x, y)) + return true; + if (x.AssemblyName != y.AssemblyName || x.ExternalReferences.Length != y.ExternalReferences.Length) return false; - return x.ExternalReferences.OfType().SequenceEqual(y.ExternalReferences.OfType()); + if (!x.ExternalReferences.OfType().SequenceEqual(y.ExternalReferences.OfType())) + return false; + + // Compare type signatures (ignoring method implementations) + return GetSignatureString(x) == GetSignatureString(y); + } + + private static string GetSignatureString(Compilation compilation) + { + var sb = new StringBuilder(); + AppendNamespace(sb, compilation.Assembly.GlobalNamespace); + return sb.ToString(); + } + + private static void AppendNamespace(StringBuilder sb, INamespaceSymbol ns) + { + foreach (var type in ns.GetTypeMembers().OrderBy(t => t.Name)) + AppendType(sb, type); + foreach (var child in ns.GetNamespaceMembers().OrderBy(n => n.Name)) + AppendNamespace(sb, child); + } + + private static void AppendType(StringBuilder sb, INamedTypeSymbol type) + { + sb.Append(type.DeclaredAccessibility).Append(' '); + sb.Append(type.TypeKind).Append(' '); + sb.Append(type.ToFQDisplayString()); + + if (type.BaseType != null && type.BaseType.SpecialType != SpecialType.System_Object) + sb.Append(':').Append(type.BaseType.ToFQDisplayString()); + + foreach (var iface in type.Interfaces.OrderBy(i => i.ToFQDisplayString())) + sb.Append(',').Append(iface.ToFQDisplayString()); + + sb.Append('{'); + + // Include non-private, non-compiler-generated members + foreach (var member in type.GetMembers() + .Where(m => m.DeclaredAccessibility != Accessibility.Private && !m.IsImplicitlyDeclared) + .OrderBy(m => m.Name) + .ThenBy(m => m.Kind)) + { + switch (member) + { + case IFieldSymbol f: + sb.Append(f.DeclaredAccessibility).Append(' '); + if (f.IsStatic) sb.Append("static "); + sb.Append(f.Type.ToFQDisplayString()).Append(' ').Append(f.Name).Append(';'); + break; + + case IPropertySymbol p: + sb.Append(p.DeclaredAccessibility).Append(' '); + if (p.IsStatic) sb.Append("static "); + sb.Append(p.Type.ToFQDisplayString()).Append(' ').Append(p.Name); + if (p.GetMethod != null) sb.Append("{get;}"); + if (p.SetMethod != null) sb.Append("{set;}"); + break; + + case IMethodSymbol m when m.MethodKind == MethodKind.Ordinary: + sb.Append(m.DeclaredAccessibility).Append(' '); + if (m.IsStatic) sb.Append("static "); + sb.Append(m.ReturnType.ToFQDisplayString()).Append(' ').Append(m.Name); + sb.Append('('); + sb.Append(string.Join(",", m.Parameters.Select(p => p.Type.ToFQDisplayString()))); + sb.Append(')').Append(';'); + break; + + case INamedTypeSymbol nested: + AppendType(sb, nested); + break; + } + } + + sb.Append('}'); } public int GetHashCode(Compilation obj) => obj.References.GetHashCode(); diff --git a/src/Controls/src/SourceGen/GeneratorHelpers.cs b/src/Controls/src/SourceGen/GeneratorHelpers.cs index 8825def03bb8..b9aded9ba653 100644 --- a/src/Controls/src/SourceGen/GeneratorHelpers.cs +++ b/src/Controls/src/SourceGen/GeneratorHelpers.cs @@ -55,7 +55,7 @@ public static string EscapeIdentifier(string identifier) } try { - return new XamlProjectItemForIC(projectItem!, ParseXaml(text.ToString(), assemblyCaches)); + return new XamlProjectItemForIC(projectItem!, text.ToString()); } catch (Exception e) { @@ -63,7 +63,7 @@ public static string EscapeIdentifier(string identifier) } } - static SGRootNode? ParseXaml(string xaml, AssemblyAttributes assemblyCaches) + public static SGRootNode? ParseXaml(string xaml, AssemblyAttributes assemblyCaches) { List warningDisableList = []; var nsmgr = new XmlNamespaceManager(new NameTable()); diff --git a/src/Controls/src/SourceGen/InitializeComponentCodeWriter.cs b/src/Controls/src/SourceGen/InitializeComponentCodeWriter.cs index f48d56650b9b..48f20eb81daf 100644 --- a/src/Controls/src/SourceGen/InitializeComponentCodeWriter.cs +++ b/src/Controls/src/SourceGen/InitializeComponentCodeWriter.cs @@ -33,7 +33,7 @@ PrePost newblock() => codeWriter.WriteLine($"#pragma warning disable {xamlItem.ProjectItem.NoWarn}"); codeWriter.WriteLine(); } - var root = xamlItem.Root!; + var root = GeneratorHelpers.ParseXaml(xamlItem.Xaml!, xmlnsCache)!; string accessModifier = "public"; INamedTypeSymbol? rootType = null; @@ -89,7 +89,7 @@ PrePost newblock() => { var methodName = genSwitch ? "InitializeComponentSourceGen" : "InitializeComponent"; codeWriter.WriteLine($"private partial void {methodName}()"); - xamlItem.Root!.XmlType.TryResolveTypeSymbol(null, compilation, xmlnsCache, typeCache, out var baseType); + root!.XmlType.TryResolveTypeSymbol(null, compilation, xmlnsCache, typeCache, out var baseType); var sgcontext = new SourceGenContext(codeWriter, compilation, sourceProductionContext, xmlnsCache, typeCache, rootType!, baseType, xamlItem.ProjectItem); using (newblock()) { diff --git a/src/Controls/src/SourceGen/TrackingNames.cs b/src/Controls/src/SourceGen/TrackingNames.cs index e430df56b184..fe136ba84f6e 100644 --- a/src/Controls/src/SourceGen/TrackingNames.cs +++ b/src/Controls/src/SourceGen/TrackingNames.cs @@ -7,7 +7,7 @@ public class TrackingNames { public const string CssProjectItemProvider = nameof(CssProjectItemProvider); public const string ProjectItemProvider = nameof(ProjectItemProvider); - public const string ReferenceCompilationProvider = nameof(ReferenceCompilationProvider); + public const string CompilationProvider = nameof(CompilationProvider); public const string ReferenceTypeCacheProvider = nameof(ReferenceTypeCacheProvider); public const string XmlnsDefinitionsProvider = nameof(XmlnsDefinitionsProvider); public const string XamlProjectItemProviderForCB = nameof(XamlProjectItemProviderForCB); diff --git a/src/Controls/src/SourceGen/XamlGenerator.cs b/src/Controls/src/SourceGen/XamlGenerator.cs index aa8281bab3c9..8e2f71d36d72 100644 --- a/src/Controls/src/SourceGen/XamlGenerator.cs +++ b/src/Controls/src/SourceGen/XamlGenerator.cs @@ -28,10 +28,10 @@ public void Initialize(IncrementalGeneratorInitializationContext initContext) // System.Diagnostics.Debugger.Launch(); // } #endif - // Only provide a new Compilation when the references change + // Only provide a new Compilation when the references or syntax trees change var referenceCompilationProvider = initContext.CompilationProvider - .WithComparer(new CompilationReferencesComparer()) - .WithTrackingName(TrackingNames.ReferenceCompilationProvider); + .WithComparer(new CompilationSignaturesComparer()) + .WithTrackingName(TrackingNames.CompilationProvider); var referenceTypeCacheProvider = referenceCompilationProvider .Select(GetTypeCache) @@ -63,13 +63,13 @@ public void Initialize(IncrementalGeneratorInitializationContext initContext) .WithTrackingName(TrackingNames.CssProjectItemProvider); var xamlSourceProviderForCB = xamlProjectItemProviderForCB - .Combine(xmlnsDefinitionsProvider, referenceTypeCacheProvider, referenceCompilationProvider) + .Combine(xmlnsDefinitionsProvider, referenceTypeCacheProvider, initContext.CompilationProvider) .Select(GetSource) .WithTrackingName(TrackingNames.XamlSourceProviderForCB); var compilationWithCodeBehindProvider = xamlSourceProviderForCB .Collect() - .Combine(referenceCompilationProvider) + .Combine(initContext.CompilationProvider) .Select(static (t, ct) => { var compilation = t.Right; @@ -515,7 +515,7 @@ static bool CanSourceGenXaml(XamlProjectItemForIC? xamlItem, Compilation compila var itemName = projItem.ManifestResourceName ?? projItem.RelativePath; if (itemName == null) return false; - if (xamlItem.Root == null) + if (xamlItem.Xaml == null) return false; return true; } diff --git a/src/Controls/src/SourceGen/XamlProjectItemForIC.cs b/src/Controls/src/SourceGen/XamlProjectItemForIC.cs index 0cdf14a0bff5..379c34d5f639 100644 --- a/src/Controls/src/SourceGen/XamlProjectItemForIC.cs +++ b/src/Controls/src/SourceGen/XamlProjectItemForIC.cs @@ -5,11 +5,10 @@ namespace Microsoft.Maui.Controls.SourceGen; class XamlProjectItemForIC { - public XamlProjectItemForIC(ProjectItem projectItem, SGRootNode? root/*, XmlNamespaceManager nsmgr*/) + public XamlProjectItemForIC(ProjectItem projectItem, string? xaml) { ProjectItem = projectItem; - Root = root; - // Nsmgr = nsmgr; + Xaml = xaml; } public XamlProjectItemForIC(ProjectItem projectItem, Exception exception) @@ -19,7 +18,6 @@ public XamlProjectItemForIC(ProjectItem projectItem, Exception exception) } public ProjectItem ProjectItem { get; } - public SGRootNode? Root { get; } - // public XmlNamespaceManager? Nsmgr { get; } + public string? Xaml { get; } public Exception? Exception { get; } } \ No newline at end of file diff --git a/src/Controls/tests/SourceGen.UnitTests/SourceGenXamlCodeBehindTests.cs b/src/Controls/tests/SourceGen.UnitTests/SourceGenXamlCodeBehindTests.cs index e2e2b0b1ebc8..82b7ad5cd4fa 100644 --- a/src/Controls/tests/SourceGen.UnitTests/SourceGenXamlCodeBehindTests.cs +++ b/src/Controls/tests/SourceGen.UnitTests/SourceGenXamlCodeBehindTests.cs @@ -213,11 +213,156 @@ public void TestCodeBehindGenerator_CompilationClone() var expectedReasons = new Dictionary { { TrackingNames.ProjectItemProvider, IncrementalStepRunReason.Cached }, - { TrackingNames.ReferenceCompilationProvider, IncrementalStepRunReason.Unchanged }, + { TrackingNames.CompilationProvider, IncrementalStepRunReason.Unchanged }, { TrackingNames.ReferenceTypeCacheProvider, IncrementalStepRunReason.Cached }, { TrackingNames.XmlnsDefinitionsProvider, IncrementalStepRunReason.Cached }, { TrackingNames.XamlProjectItemProviderForCB, IncrementalStepRunReason.Cached }, - { TrackingNames.XamlSourceProviderForCB, IncrementalStepRunReason.Cached } + { TrackingNames.XamlSourceProviderForCB, IncrementalStepRunReason.Unchanged } + }; + + VerifyStepRunReasons(result2, expectedReasons); + } + + /// + /// Verifies that changing method implementation (body) does not trigger XAML regeneration. + /// This is important for IDE responsiveness - typing in method bodies should not regenerate all XAML. + /// + [Fact] + public void TestCodeBehindGenerator_ImplementationChangeDoesNotTriggerRegeneration() + { + var xaml = +""" + + +