diff --git a/src/Controls/src/Core/MauiServiceAttribute.cs b/src/Controls/src/Core/MauiServiceAttribute.cs new file mode 100644 index 000000000000..c78ee17a73f6 --- /dev/null +++ b/src/Controls/src/Core/MauiServiceAttribute.cs @@ -0,0 +1,30 @@ +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Maui.Controls +{ + [AttributeUsage(AttributeTargets.Class)] + public class MauiServiceAttribute : Attribute + { + /// + /// Type gets registered in the DI container, defaults to Singleton + /// + public MauiServiceAttribute(ServiceLifetime lifetime = ServiceLifetime.Singleton) => Lifetime = lifetime; + + /// + /// Lifetime of the Service type on which this attribute is defined. + /// + public ServiceLifetime Lifetime { get; } + + /// + /// The type for which this service would be resolved for. + /// builder.Services.AddSingleton<INavigationService, NavigationService>(); + /// + public Type? RegisterFor { get; set; } + + /// + /// If set to true, makes use of the TryAdd method construct while registering the service. + /// + public bool UseTryAdd { get; set; } + } +} diff --git a/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt index 328746133294..1811b0618d88 100644 --- a/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt +++ b/src/Controls/src/Core/PublicAPI/netstandard/PublicAPI.Unshipped.txt @@ -1,7 +1,26 @@ #nullable enable *REMOVED*override Microsoft.Maui.Controls.RefreshView.MeasureOverride(double widthConstraint, double heightConstraint) -> Microsoft.Maui.Graphics.Size Microsoft.Maui.Controls.LayoutOptions.Equals(Microsoft.Maui.Controls.LayoutOptions other) -> bool +Microsoft.Maui.Controls.MauiServiceAttribute +Microsoft.Maui.Controls.MauiServiceAttribute.MauiServiceAttribute(Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton) -> void +Microsoft.Maui.Controls.MauiServiceAttribute.RegisterFor.get -> System.Type? +Microsoft.Maui.Controls.MauiServiceAttribute.RegisterFor.set -> void +Microsoft.Maui.Controls.MauiServiceAttribute.Lifetime.get -> Microsoft.Extensions.DependencyInjection.ServiceLifetime +Microsoft.Maui.Controls.MauiServiceAttribute.UseTryAdd.get -> bool +Microsoft.Maui.Controls.MauiServiceAttribute.UseTryAdd.set -> void Microsoft.Maui.Controls.Region.Equals(Microsoft.Maui.Controls.Region other) -> bool +Microsoft.Maui.Controls.RouteAttribute +Microsoft.Maui.Controls.RouteAttribute.ImplicitViewModel.get -> bool +Microsoft.Maui.Controls.RouteAttribute.ImplicitViewModel.set -> void +Microsoft.Maui.Controls.RouteAttribute.Route.get -> string? +Microsoft.Maui.Controls.RouteAttribute.RouteAttribute() -> void +Microsoft.Maui.Controls.RouteAttribute.RouteAttribute(string! route, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton) -> void +Microsoft.Maui.Controls.RouteAttribute.Routes.get -> string![]? +Microsoft.Maui.Controls.RouteAttribute.Routes.set -> void +Microsoft.Maui.Controls.RouteAttribute.Lifetime.get -> Microsoft.Extensions.DependencyInjection.ServiceLifetime +Microsoft.Maui.Controls.RouteAttribute.Lifetime.set -> void +Microsoft.Maui.Controls.RouteAttribute.ViewModelType.get -> System.Type? +Microsoft.Maui.Controls.RouteAttribute.ViewModelType.set -> void Microsoft.Maui.Controls.Shapes.Matrix.Equals(Microsoft.Maui.Controls.Shapes.Matrix other) -> bool Microsoft.Maui.Controls.VisualElement.~VisualElement() -> void override Microsoft.Maui.Controls.LayoutOptions.GetHashCode() -> int diff --git a/src/Controls/src/Core/RouteAttribute.cs b/src/Controls/src/Core/RouteAttribute.cs new file mode 100644 index 000000000000..e92084cdd030 --- /dev/null +++ b/src/Controls/src/Core/RouteAttribute.cs @@ -0,0 +1,38 @@ +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Maui.Controls +{ + [AttributeUsage(AttributeTargets.Class)] + public class RouteAttribute : Attribute + { + public RouteAttribute() { } + + public RouteAttribute(string route, ServiceLifetime lifetime = ServiceLifetime.Singleton) + => (Route, Lifetime) = (route, lifetime); + + public string? Route { get; } + + /// + /// Routes of the View (page) type on which this attribute is defined. + /// When both Routes and Route properties are defined, Routes takes precedence. + /// + public string[]? Routes { get; set; } + + /// + /// Lifetime of the View (page) / ViewModel type on which this attribute is defined. + /// + public ServiceLifetime Lifetime { get; set; } + + /// + /// Indicates whether the ViewModel should be associated automatically. + /// + public bool ImplicitViewModel { get; set; } + + /// + /// Type of the ViewModel associated with the View (page) on which this attribute is defined. + /// When both ImplicitViewModel and ViewModelType properties are defined, ViewModelType takes precedence. + /// + public Type? ViewModelType { get; set; } + } +} diff --git a/src/Controls/src/SourceGen/Helper.cs b/src/Controls/src/SourceGen/Helper.cs new file mode 100644 index 000000000000..aa4e92519d35 --- /dev/null +++ b/src/Controls/src/SourceGen/Helper.cs @@ -0,0 +1,52 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Microsoft.Maui.Controls.SourceGen +{ + internal static class Helper + { + internal const string Singleton = nameof(Singleton); + internal const string Scoped = nameof(Scoped); + internal const string Transient = nameof(Transient); + + // Determine the namespace the class/enum/struct/record is declared in, if any + internal static string GetNamespace(BaseTypeDeclarationSyntax syntax) + { + // If we don't have a namespace at all we'll return an empty string + // This accounts for the "default namespace" case + string @namespace = string.Empty; + + // Get the containing syntax node for the type declaration + // (could be a nested type, for example) + SyntaxNode? potentialNamespaceParent = syntax.Parent; + + // Keep moving "out" of nested classes etc until we get to a namespace + // or until we run out of parents + while (potentialNamespaceParent is not null + && potentialNamespaceParent is not NamespaceDeclarationSyntax + && potentialNamespaceParent is not FileScopedNamespaceDeclarationSyntax) + { + potentialNamespaceParent = potentialNamespaceParent.Parent; + } + + // Build up the final namespace by looping until we no longer have a namespace declaration + if (potentialNamespaceParent is BaseNamespaceDeclarationSyntax namespaceParent) + { + // We have a namespace. Use that as the type + @namespace = namespaceParent.Name.ToString(); + + // Keep moving "out" of the namespace declarations until we + // run out of nested namespace declarations + while (namespaceParent.Parent is NamespaceDeclarationSyntax parent) + { + // Add the outer namespace as a prefix to the final namespace + @namespace = $"{parent.Name}.{@namespace}"; + namespaceParent = parent; + } + } + + // return the final namespace + return @namespace; + } + } +} diff --git a/src/Controls/src/SourceGen/RouteIncrementalGenerator.cs b/src/Controls/src/SourceGen/RouteIncrementalGenerator.cs new file mode 100644 index 000000000000..111bbc288b71 --- /dev/null +++ b/src/Controls/src/SourceGen/RouteIncrementalGenerator.cs @@ -0,0 +1,564 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +using static Microsoft.Maui.Controls.SourceGen.Helper; + +namespace Microsoft.Maui.Controls.SourceGen +{ + [Generator(LanguageNames.CSharp)] + public partial class RouteIncrementalGenerator : IIncrementalGenerator + { + static readonly string Indent = new string('\x20', 8); + const string AutoGeneratedHeaderText = """ + //------------------------------------------------------------------------------ + // + // This code was generated by a .NET MAUI source generator. + // + // Changes to this file may cause incorrect behavior and will be lost if + // the code is regenerated. + // + //------------------------------------------------------------------------------ + """; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var classDeclarations = context.CompilationProvider.Combine(context.SyntaxProvider.CreateSyntaxProvider( + static (node, _) => node is ClassDeclarationSyntax, + static (ctx, _) => GetSemanticTargetForGeneration(ctx)) + .Where(static x => x is not null) + .Collect()); + + context.RegisterSourceOutput(classDeclarations, (spc, source) => Execute(spc, source.Left, source.Right)); + } + + private static ClassDeclarationSyntax? GetSemanticTargetForGeneration(GeneratorSyntaxContext context) + { + if (context.Node is not ClassDeclarationSyntax cds) + { + return null; + } + + if (cds.Identifier.ValueText == "MauiProgram") + { + foreach (var member in cds.Members) + { + if (member is MethodDeclarationSyntax { Identifier.ValueText: "CreateMauiApp" }) + { + return cds; + } + } + } + + var routeAttribute = cds.AttributeLists.SelectMany(x => x.Attributes) + .Where(a => a.Name.NormalizeWhitespace().ToFullString() == "Route") + .FirstOrDefault(); + + if (routeAttribute is not null) + { + return cds; + } + + var serviceAttribute = cds.AttributeLists.SelectMany(x => x.Attributes) + .Where(a => a.Name.NormalizeWhitespace().ToFullString() == "MauiService") + .FirstOrDefault(); + + if (serviceAttribute is not null) + { + return cds; + } + + if (cds.BaseList is not null) + { + foreach (var baseType in cds.BaseList.Types) + { + if (baseType.Type is IdentifierNameSyntax identifierName) + { + return identifierName.Identifier.ValueText switch + { + "Application" => cds, + "Shell" => cds, + _ => null, + }; + } + } + } + + return null; + } + + private static void Execute(SourceProductionContext context, Compilation compilation, ImmutableArray classes) + { + if (classes.IsDefaultOrEmpty) + { + return; + } + + var mauiApp = GetMauiApp(classes); + var mauiStartup = GetMauiStartup(classes); + var mauiServices = GetMauiServices(classes); + var routedPages = GetRoutedPages(classes); + var shellPages = GetShellPages(classes); + + var routes = new StringBuilder(); + var services = new StringBuilder(); + + GenerateCode(compilation, routedPages, mauiServices, ref routes, ref services, Singleton); + GenerateCode(compilation, routedPages, mauiServices, ref routes, ref services, Scoped); + GenerateCode(compilation, routedPages, mauiServices, ref routes, ref services, Transient); + + string shellRoutes; + + foreach (var shellPage in shellPages) + { + shellRoutes = $$""" + {{AutoGeneratedHeaderText}} + + namespace {{GetNamespace(shellPage)}}; + + partial class {{shellPage.Identifier.ValueText}} + { + static partial void RegisterRoutes(); + + static partial void RegisterRoutes() + { + {{routes.ToString().Trim()}} + } + } + + """; + + context.AddSource($"{shellPage.Identifier.ValueText}.sg.cs", shellRoutes); + } + + if (mauiStartup is not null) + { + context.AddSource("MauiProgram.sg.cs", $$""" + {{AutoGeneratedHeaderText}} + + using Microsoft.Extensions.DependencyInjection; + + namespace {{GetNamespace(mauiStartup)}}; + + static partial class MauiProgram + { + static partial void ConfigureDependencies(this global::Microsoft.Maui.Hosting.MauiAppBuilder builder); + + static partial void ConfigureDependencies(this global::Microsoft.Maui.Hosting.MauiAppBuilder builder) + { + {{services.ToString().Trim()}} + } + } + + """); + } + + static void GenerateCode(Compilation compilation, IEnumerable routedPages, IEnumerable mauiServices, ref StringBuilder routes, ref StringBuilder services, string lifetime) + { + if (lifetime is null) + { + return; + } + + var addComment = true; + SymbolInfo symbolInfo; + + foreach (var routedPage in routedPages.Where(x => x.Lifetime == lifetime)) + { + foreach (var route in routedPage.Routes) + { + if (string.IsNullOrEmpty(route)) + { + routes.AppendLine($"{Indent}Routing.RegisterRoute(nameof(global::{GetNamespace(routedPage.Type)}.{routedPage.Type.Identifier}), typeof(global::{GetNamespace(routedPage.Type)}.{routedPage.Type.Identifier}));"); + } + else + { + routes.AppendLine($"{Indent}Routing.RegisterRoute({route}, typeof(global::{GetNamespace(routedPage.Type)}.{routedPage.Type.Identifier}));"); + } + } + + if (addComment) + { + addComment = false; + services.AppendLine($"{Indent}// {lifetime} Services"); + } + + switch (routedPage.ViewModelType) + { + case not null: + symbolInfo = compilation.GetSemanticModel(routedPage.ViewModelType.SyntaxTree).GetSymbolInfo(routedPage.ViewModelType); + services.AppendLine($"{Indent}builder.Services.{ServiceMethod(lifetime)}();"); + break; + default: + if (routedPage.ImplicitViewModel) + { + services.AppendLine($"{Indent}builder.Services.{ServiceMethod(lifetime)}<{routedPage.Type.Identifier.ValueText.Replace("Page", "ViewModel")}>();"); + } + break; + } + + services.AppendLine($"{Indent}builder.Services.{ServiceMethod(lifetime)}();"); + } + + foreach (var service in mauiServices.Where(x => x.Lifetime == lifetime)) + { + if (addComment) + { + addComment = false; + services.AppendLine($"{Indent}// {lifetime} Services"); + } + + switch (service.RegisterFor) + { + case not null: + symbolInfo = compilation.GetSemanticModel(service.RegisterFor.SyntaxTree).GetSymbolInfo(service.RegisterFor); + services.AppendLine($"{Indent}builder.Services.{ServiceMethod(lifetime, service.UseTryAdd)}();"); + break; + default: + services.AppendLine($"{Indent}builder.Services.{ServiceMethod(lifetime, service.UseTryAdd)}();"); + break; + } + } + + if (!addComment) + { + services.AppendLine(); + } + } + + static string ServiceMethod(string lifetime, bool useTryAdd = false) + => useTryAdd ? $"TryAdd{lifetime}" : $"Add{lifetime}"; + } + + private static IEnumerable GetRoutedPages(ImmutableArray classDeclarations) + { + var routedPages = new List(); + + foreach (var cds in classDeclarations) + { + if (cds is null) + { + continue; + } + + var routeAttribute = cds.AttributeLists.SelectMany(x => x.Attributes) + .Where(a => a.Name.NormalizeWhitespace().ToFullString() == "Route") + .FirstOrDefault(); + + if (routeAttribute is not null) + { + TypeSyntax? viewModelType = null; + var implicitViewModel = false; + var route = string.Empty; + var lifetime = Singleton; + var shellRoutes = new List(); + + var routeArgument = true; + + IEnumerable? arguments = routeAttribute.ArgumentList?.Arguments; + + if (arguments is not null) + { + lifetime = Singleton; + + var argWithoutName = arguments.Count(a => a.NameColon is null && a.NameEquals is null); + + foreach (var argument in arguments) + { + if (argument.NameColon is not null) + { + if (argument.NameColon.Name.Identifier.Text == "route") + { + route = GetRoute(cds, argument.Expression); + } + else if (argument.NameColon.Name.Identifier.Text == "lifetime") + { + lifetime = GetLifetime(argument.Expression); + } + } + else if (argument.NameEquals is not null) + { + if (argument.NameEquals.Name.Identifier.Text == "Routes") + { + if (argument.Expression is ImplicitArrayCreationExpressionSyntax implicitArrayCreationExpression) + { + if (implicitArrayCreationExpression.Initializer.Kind() == SyntaxKind.ArrayInitializerExpression) + { + foreach (var expression in implicitArrayCreationExpression.Initializer.Expressions) + { + shellRoutes.Add(GetRoute(cds, expression)); + } + } + } + else if (argument.Expression is ArrayCreationExpressionSyntax arrayCreationExpression) + { + if (arrayCreationExpression?.Initializer?.Kind() == SyntaxKind.ArrayInitializerExpression) + { + foreach (var expression in arrayCreationExpression.Initializer.Expressions) + { + shellRoutes.Add(GetRoute(cds, expression)); + } + } + } + } + else if (argument.NameEquals.Name.Identifier.Text == "Lifetime") + { + lifetime = GetLifetime(argument.Expression); + } + else if (argument.NameEquals.Name.Identifier.Text == "ImplicitViewModel") + { + if (argument.Expression is LiteralExpressionSyntax literalExpression) + { + _ = bool.TryParse(literalExpression.Token.ValueText, out implicitViewModel); + } + } + else if (argument.NameEquals.Name.Identifier.Text == "ViewModelType") + { + if (argument.Expression is TypeOfExpressionSyntax typeofExpression) + { + //viewModelType = $"global::{GetNamespace(cds)}.{((IdentifierNameSyntax)typeofExpression.Type).Identifier.Text}"; + //viewModelType = ((IdentifierNameSyntax)typeofExpression.Type).Identifier.Text; + viewModelType = typeofExpression.Type; + } + } + } + else if (argument.Expression is not null) + { + if (argWithoutName == 1) + { + if (argument.Expression.IsKind(SyntaxKind.StringLiteralExpression) + || argument.Expression.IsKind(SyntaxKind.SimpleMemberAccessExpression)) + { + route = GetRoute(cds, argument.Expression); + } + else + { + lifetime = GetLifetime(argument.Expression); + } + } + else + { + if (routeArgument) + { + routeArgument = false; + route = GetRoute(cds, argument.Expression); + } + else + { + lifetime = GetLifetime(argument.Expression); + } + } + } + } + } + + if (shellRoutes.Count == 0) + { + shellRoutes.Add(route); + } + + routedPages.Add(new RoutedPage(cds, shellRoutes, lifetime, implicitViewModel, viewModelType)); + } + } + + return routedPages; + } + + private static IEnumerable GetShellPages(ImmutableArray classDeclarations) + { + var shellPages = new List(); + + foreach (var cds in classDeclarations) + { + if (cds is null) + { + continue; + } + + if (cds.BaseList is not null) + { + foreach (var baseType in cds.BaseList.Types) + { + if (baseType.Type is IdentifierNameSyntax { Identifier.ValueText: "Shell" }) + { + shellPages.Add(cds); + break; + } + } + } + } + + return shellPages; + } + + private static IEnumerable GetMauiServices(ImmutableArray classDeclarations) + { + var services = new List(); + + foreach (var cds in classDeclarations) + { + if (cds is null) + { + continue; + } + + IEnumerable? arguments; + + var serviceAttribute = cds.AttributeLists.SelectMany(al => al.Attributes) + .Where(a => a.Name.NormalizeWhitespace().ToFullString().Replace("Attribute", string.Empty) == "MauiService") + .FirstOrDefault(); + + if (serviceAttribute is not null) + { + arguments = serviceAttribute.ArgumentList?.Arguments; + + var lifetime = Singleton; + TypeSyntax? registerFor = null; + var useTryAdd = false; + + if (arguments is not null) + { + foreach (var argument in arguments) + { + if (argument.NameColon?.Name.Identifier.Text == "lifetime") + { + lifetime = GetLifetime(argument.Expression); + } + + switch (argument.NameEquals?.Name.Identifier.Text) + { + case "RegisterFor" when argument.Expression is TypeOfExpressionSyntax typeOfExpression: + registerFor = typeOfExpression.Type; + break; + case "UseTryAdd" when argument.Expression is LiteralExpressionSyntax literalExpression: + _ = bool.TryParse(literalExpression.Token.ValueText, out useTryAdd); + break; + } + + if (argument.Expression is not null) + { + lifetime = GetLifetime(argument.Expression); + } + } + } + + services.Add(new Service(cds, lifetime, registerFor, useTryAdd)); + } + } + + return services; + } + + private static ClassDeclarationSyntax? GetMauiApp(ImmutableArray classDeclarations) + { + foreach (var cds in classDeclarations) + { + if (cds is null) + { + continue; + } + + if (cds.BaseList is not null) + { + foreach (var baseType in cds.BaseList.Types) + { + if (baseType.Type is IdentifierNameSyntax { Identifier.ValueText: "Application" }) + { + return cds; + } + } + } + } + + return null; + } + + private static ClassDeclarationSyntax? GetMauiStartup(ImmutableArray classDeclarations) + { + foreach (var cds in classDeclarations) + { + if (cds is null) + { + continue; + } + + if (cds.Identifier.ValueText == "MauiProgram") + { + foreach (var member in cds.Members) + { + if (member is MethodDeclarationSyntax { Identifier.ValueText: "CreateMauiApp" }) + { + return cds; + } + } + } + } + + return null; + } + + private static string GetRoute(ClassDeclarationSyntax classDeclaration, ExpressionSyntax? expression) => expression switch + { + LiteralExpressionSyntax literalExpression => $"\"{literalExpression.Token.ValueText}\"", + InvocationExpressionSyntax invocationExpression => ((IdentifierNameSyntax)invocationExpression.Expression).Identifier.Text switch + { + "nameof" => $"nameof(global::{GetNamespace(classDeclaration)}.{invocationExpression.ArgumentList.Arguments})", + _ => string.Empty,// Default route + }, + MemberAccessExpressionSyntax memberAccessExpression => $"{((IdentifierNameSyntax)memberAccessExpression.Expression).Identifier.Text}.{memberAccessExpression.Name.Identifier.Text}", + _ => string.Empty,// Default route + }; + + private static string GetLifetime(ExpressionSyntax? expression) => expression switch + { + IdentifierNameSyntax identifierName => identifierName.Identifier.ValueText, + MemberAccessExpressionSyntax memberAccessExpression => memberAccessExpression.Name.Identifier.Text, + _ => Singleton + }; + } + + internal class RoutedPage + { + public RoutedPage( + ClassDeclarationSyntax type, + IEnumerable routes, + string lifetime, + bool implicitViewModel = false, + TypeSyntax? viewModelType = null) + { + Type = type; + Routes = routes; + Lifetime = lifetime; + ImplicitViewModel = implicitViewModel; + ViewModelType = viewModelType; + } + + public ClassDeclarationSyntax Type { get; } + + public IEnumerable Routes { get; } + + public string Lifetime { get; } + + public bool ImplicitViewModel { get; } + + public TypeSyntax? ViewModelType { get; } + } + + internal class Service + { + public Service(ClassDeclarationSyntax type, string lifetime, TypeSyntax? registerFor, bool useTryAdd) + => (Type, Lifetime, RegisterFor, UseTryAdd) = (type, lifetime, registerFor, useTryAdd); + + public ClassDeclarationSyntax Type { get; } + + public string Lifetime { get; } + + public TypeSyntax? RegisterFor { get; } + + public bool UseTryAdd { get; } + } +} diff --git a/src/Controls/src/SourceGen/RouteSourceGenerator.cs b/src/Controls/src/SourceGen/RouteSourceGenerator.cs new file mode 100644 index 000000000000..0b9bebf75ad2 --- /dev/null +++ b/src/Controls/src/SourceGen/RouteSourceGenerator.cs @@ -0,0 +1,178 @@ +using System.Linq; +using System.Text; + +using Microsoft.CodeAnalysis; + +using static Microsoft.Maui.Controls.SourceGen.Helper; + +namespace Microsoft.Maui.Controls.SourceGen +{ + //[Generator(LanguageNames.CSharp)] + public class RouteSourceGenerator : ISourceGenerator + { + static readonly string Indent = new string('\x20', 8); + const string AutoGeneratedHeaderText = """ + //------------------------------------------------------------------------------ + // + // This code was generated by a .NET MAUI source generator. + // + // Changes to this file may cause incorrect behavior and will be lost if + // the code is regenerated. + // + //------------------------------------------------------------------------------ + """; + + public void Initialize(GeneratorInitializationContext context) + { + //#if DEBUG + // if (!System.Diagnostics.Debugger.IsAttached) + // { + // System.Diagnostics.Debugger.Launch(); + // } + //#endif + context.RegisterForSyntaxNotifications(() => new RouteSyntaxReceiver()); + } + + public void Execute(GeneratorExecutionContext context) + { + if (context.SyntaxReceiver is not RouteSyntaxReceiver syntaxReceiver) + { + return; + } + + var mauiApp = syntaxReceiver.MauiApp; + var mauiStartup = syntaxReceiver.MauiStartup; + var routedPages = syntaxReceiver.RoutedPages; + var shellPages = syntaxReceiver.ShellPages; + + var routes = new StringBuilder(); + var services = new StringBuilder(); + + GenerateCode(context, syntaxReceiver, ref routes, ref services, Singleton); + GenerateCode(context, syntaxReceiver, ref routes, ref services, Scoped); + GenerateCode(context, syntaxReceiver, ref routes, ref services, Transient); + + string shellRoutes; + + foreach (var shellPage in shellPages) + { + shellRoutes = $$""" + {{AutoGeneratedHeaderText}} + + namespace {{GetNamespace(shellPage)}}; + + partial class {{shellPage.Identifier.ValueText}} + { + static partial void RegisterRoutes(); + + static partial void RegisterRoutes() + { + {{routes.ToString().Trim()}} + } + } + + """; + + context.AddSource($"{shellPage.Identifier.ValueText}.sg.cs", shellRoutes); + } + + if (mauiStartup is not null) + { + context.AddSource("MauiProgram.sg.cs", $$""" + {{AutoGeneratedHeaderText}} + + using Microsoft.Extensions.DependencyInjection; + + namespace {{GetNamespace(mauiStartup)}}; + + static partial class MauiProgram + { + static partial void ConfigureDependencies(this global::Microsoft.Maui.Hosting.MauiAppBuilder builder); + + static partial void ConfigureDependencies(this global::Microsoft.Maui.Hosting.MauiAppBuilder builder) + { + {{services.ToString().Trim()}} + } + } + + """); + } + + static void GenerateCode(GeneratorExecutionContext context, RouteSyntaxReceiver syntaxReceiver, ref StringBuilder routes, ref StringBuilder services, string lifetime) + { + if (lifetime is null) + { + return; + } + + var addComment = true; + SymbolInfo symbolInfo; + + foreach (var routedPage in syntaxReceiver.RoutedPages.Where(x => x.Lifetime == lifetime)) + { + foreach (var route in routedPage.Routes) + { + if (string.IsNullOrEmpty(route)) + { + routes.AppendLine($"{Indent}Routing.RegisterRoute(nameof(global::{GetNamespace(routedPage.Type)}.{routedPage.Type.Identifier}), typeof(global::{GetNamespace(routedPage.Type)}.{routedPage.Type.Identifier}));"); + } + else + { + routes.AppendLine($"{Indent}Routing.RegisterRoute({route}, typeof(global::{GetNamespace(routedPage.Type)}.{routedPage.Type.Identifier}));"); + } + } + + if (addComment) + { + addComment = false; + services.AppendLine($"{Indent}// {lifetime} Services"); + } + + switch (routedPage.ViewModelType) + { + case not null: + symbolInfo = context.Compilation.GetSemanticModel(routedPage.ViewModelType.SyntaxTree).GetSymbolInfo(routedPage.ViewModelType); + services.AppendLine($"{Indent}builder.Services.{ServiceMethod(lifetime)}();"); + break; + default: + if (routedPage.ImplicitViewModel) + { + services.AppendLine($"{Indent}builder.Services.{ServiceMethod(lifetime)}<{routedPage.Type.Identifier.ValueText.Replace("Page", "ViewModel")}>();"); + } + break; + } + + services.AppendLine($"{Indent}builder.Services.{ServiceMethod(lifetime)}();"); + } + + foreach (var service in syntaxReceiver.Services.Where(x => x.Lifetime == lifetime)) + { + if (addComment) + { + addComment = false; + services.AppendLine($"{Indent}// {lifetime} Services"); + } + + switch (service.RegisterFor) + { + case not null: + symbolInfo = context.Compilation.GetSemanticModel(service.RegisterFor.SyntaxTree).GetSymbolInfo(service.RegisterFor); + services.AppendLine($"{Indent}builder.Services.{ServiceMethod(lifetime, service.UseTryAdd)}();"); + break; + default: + services.AppendLine($"{Indent}builder.Services.{ServiceMethod(lifetime, service.UseTryAdd)}();"); + break; + } + } + + if (!addComment) + { + services.AppendLine(); + } + } + + static string ServiceMethod(string lifetime, bool useTryAdd = false) + => useTryAdd ? $"TryAdd{lifetime}" : $"Add{lifetime}"; + } + } +} diff --git a/src/Controls/src/SourceGen/RouteSyntaxReceiver.cs b/src/Controls/src/SourceGen/RouteSyntaxReceiver.cs new file mode 100644 index 000000000000..3007891551b5 --- /dev/null +++ b/src/Controls/src/SourceGen/RouteSyntaxReceiver.cs @@ -0,0 +1,219 @@ +using System.Collections.Generic; +using System.Linq; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +using static Microsoft.Maui.Controls.SourceGen.Helper; + +namespace Microsoft.Maui.Controls.SourceGen +{ + public class RouteSyntaxReceiver : ISyntaxReceiver + { + const string RouteAttribute = "Microsoft.Maui.Controls.RouteAttribute"; + const string MauiServiceAttribute = "Microsoft.Maui.Controls.MauiServiceAttribute"; + + private readonly List _services = new(); + private readonly List _routedPages = new(); + private readonly List _shellPages = new(); + + internal IEnumerable Services => _services; + + internal IEnumerable RoutedPages => _routedPages; + + internal IEnumerable ShellPages => _shellPages; + + internal ClassDeclarationSyntax? MauiApp { get; private set; } + + internal ClassDeclarationSyntax? MauiStartup { get; private set; } + + public void OnVisitSyntaxNode(SyntaxNode syntaxNode) + { + if (syntaxNode is not ClassDeclarationSyntax cds) + { + return; + } + + if (cds.BaseList is not null) + { + foreach (var baseType in cds.BaseList.Types) + { + if (baseType.Type is not IdentifierNameSyntax identifierName) + { + continue; + } + + if (identifierName.Identifier.ValueText == "Application") + { + MauiApp = cds; + break; + } + + if (identifierName.Identifier.ValueText == "Shell") + { + _shellPages.Add(cds); + break; + } + } + } + + if (cds.Identifier.ValueText == "MauiProgram") + { + foreach (var member in cds.Members) + { + if (member is MethodDeclarationSyntax { Identifier.ValueText: "CreateMauiApp" }) + { + MauiStartup = cds; + break; + } + } + } + else if (cds.AttributeLists.Count > 0) + { + IEnumerable? arguments; + + var routeAttributes = cds.AttributeLists.SelectMany(al => al.Attributes) + .Where(a => a.Name.NormalizeWhitespace().ToFullString().Replace("Attribute", string.Empty) == "Route"); + + var routeAttribute = routeAttributes.FirstOrDefault(); + + var serviceAttributes = cds.AttributeLists.SelectMany(al => al.Attributes) + .Where(a => a.Name.NormalizeWhitespace().ToFullString().Replace("Attribute", string.Empty) == "MauiService"); + + var serviceAttribute = serviceAttributes.FirstOrDefault(); + + string lifetime; + + if (routeAttribute is not null) + { + TypeSyntax? viewModelType = null; + var implicitViewModel = false; + var route = string.Empty; + var routes = new List(); + + var routeArgument = true; + lifetime = Singleton; + arguments = routeAttribute.ArgumentList?.Arguments; + + if (arguments is not null) + { + foreach (var argument in arguments) + { + switch (argument.NameColon?.Name.Identifier.Text) + { + case "route": + route = GetRoute(cds, argument.Expression); + break; + case "lifetime": + lifetime = GetLifetime(argument.Expression); + break; + } + + switch (argument.NameEquals?.Name.Identifier.Text) + { + case "Routes" when argument.Expression is ArrayCreationExpressionSyntax arrayExpression: + if (arrayExpression?.Initializer?.Kind() == SyntaxKind.ArrayInitializerExpression) + { + routes.AddRange(arrayExpression.Initializer.Expressions.Select(expr => GetRoute(cds, expr))); + } + break; + case "Routes" when argument.Expression is ImplicitArrayCreationExpressionSyntax implicitArrayExpression: + if (implicitArrayExpression.Initializer.Kind() == SyntaxKind.ArrayInitializerExpression) + { + routes.AddRange(implicitArrayExpression.Initializer.Expressions.Select(expr => GetRoute(cds, expr))); + } + break; + case "Lifetime": + lifetime = GetLifetime(argument.Expression); + break; + case "ImplicitViewModel" when argument.Expression is LiteralExpressionSyntax literalExpression: + _ = bool.TryParse(literalExpression.Token.ValueText, out implicitViewModel); + break; + case "ViewModelType" when argument.Expression is TypeOfExpressionSyntax typeOfExpression: + viewModelType = typeOfExpression.Type; + break; + } + + if (argument.NameColon is null && argument.NameEquals is null) + { + if (routeArgument) + { + routeArgument = false; + route = GetRoute(cds, argument.Expression); + } + else + { + lifetime = GetLifetime(argument.Expression); + } + } + } + } + + if (routes.Count == 0) + { + routes.Add(route); + } + + _routedPages.Add(new RoutedPage(cds, routes, lifetime, implicitViewModel, viewModelType)); + } + + if (serviceAttribute is not null) + { + arguments = serviceAttribute.ArgumentList?.Arguments; + + lifetime = Singleton; + TypeSyntax? registerFor = null; + var useTryAdd = false; + + if (arguments is not null) + { + foreach (var argument in arguments) + { + if (argument.NameColon?.Name.Identifier.Text == "lifetime") + { + lifetime = GetLifetime(argument.Expression); + } + + switch (argument.NameEquals?.Name.Identifier.Text) + { + case "RegisterFor" when argument.Expression is TypeOfExpressionSyntax typeOfExpression: + registerFor = typeOfExpression.Type; + break; + case "UseTryAdd" when argument.Expression is LiteralExpressionSyntax literalExpression: + _ = bool.TryParse(literalExpression.Token.ValueText, out useTryAdd); + break; + } + + if (argument.Expression is not null) + { + lifetime = GetLifetime(argument.Expression); + } + } + } + + _services.Add(new Service(cds, lifetime, registerFor, useTryAdd)); + } + } + } + + private static string GetRoute(ClassDeclarationSyntax classDeclaration, ExpressionSyntax? expression) => expression switch + { + LiteralExpressionSyntax literalExpression => $"\"{literalExpression.Token.ValueText}\"", + InvocationExpressionSyntax invocationExpression => ((IdentifierNameSyntax)invocationExpression.Expression).Identifier.Text switch + { + "nameof" => $"nameof(global::{GetNamespace(classDeclaration)}.{invocationExpression.ArgumentList.Arguments})", + _ => string.Empty,// Default route + }, + MemberAccessExpressionSyntax memberAccessExpression => $"{((IdentifierNameSyntax)memberAccessExpression.Expression).Identifier.Text}.{memberAccessExpression.Name.Identifier.Text}", + _ => string.Empty,// Default route + }; + + private static string GetLifetime(ExpressionSyntax? expression) => expression switch + { + IdentifierNameSyntax identifierName => identifierName.Identifier.ValueText, + MemberAccessExpressionSyntax memberAccessExpression => memberAccessExpression.Name.Identifier.Text, + _ => Singleton + }; + } +}