diff --git a/StaticViewLocator.Tests/StaticViewLocator.Tests.csproj b/StaticViewLocator.Tests/StaticViewLocator.Tests.csproj new file mode 100644 index 0000000..081d394 --- /dev/null +++ b/StaticViewLocator.Tests/StaticViewLocator.Tests.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + diff --git a/StaticViewLocator.Tests/StaticViewLocatorGeneratorRuntimeTests.cs b/StaticViewLocator.Tests/StaticViewLocatorGeneratorRuntimeTests.cs new file mode 100644 index 0000000..52f5d88 --- /dev/null +++ b/StaticViewLocator.Tests/StaticViewLocatorGeneratorRuntimeTests.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Testing; +using StaticViewLocator; +using Xunit; +using PackageIdentity = Microsoft.CodeAnalysis.Testing.PackageIdentity; + +namespace StaticViewLocator.Tests; + +public class StaticViewLocatorGeneratorRuntimeTests +{ + private static readonly ReferenceAssemblies s_referenceAssemblies = ReferenceAssemblies.Net.Net80.AddPackages( + ImmutableArray.Create( + new PackageIdentity("Avalonia", "11.2.5"))); + + [AvaloniaFact] + public async Task CreatesRegisteredViewInstances() + { + const string source = @" +using System; +using Avalonia.Controls; +using StaticViewLocator; + +namespace TestApp +{ + [StaticViewLocator] + public partial class ViewLocator + { + public static Control Resolve(object vm) + { + if (vm is null) + { + throw new ArgumentNullException(nameof(vm)); + } + + return s_views[vm.GetType()](); + } + } +} + +namespace TestApp.ViewModels +{ + public class SampleViewModel + { + } + + public class MissingViewModel + { + } +} + +namespace TestApp.Views +{ + public class SampleView : UserControl + { + } +} +"; + + var compilation = await CreateCompilationAsync(source); + var sourceGenerator = new StaticViewLocatorGenerator().AsSourceGenerator(); + var driver = CSharpGeneratorDriver.Create(new[] { sourceGenerator }, parseOptions: (CSharpParseOptions)compilation.SyntaxTrees.First().Options); + driver.RunGeneratorsAndUpdateCompilation(compilation, out var updatedCompilation, out var diagnostics); + + Assert.Empty(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)); + + using var peStream = new MemoryStream(); + var emitResult = updatedCompilation.Emit(peStream); + Assert.True(emitResult.Success, string.Join(Environment.NewLine, emitResult.Diagnostics)); + + peStream.Seek(0, SeekOrigin.Begin); + var assembly = Assembly.Load(peStream.ToArray()); + + var locatorType = assembly.GetType("TestApp.ViewLocator") ?? throw new InvalidOperationException("Generated locator type not found."); + var resolveMethod = locatorType.GetMethod("Resolve", BindingFlags.Public | BindingFlags.Static) ?? throw new InvalidOperationException("Resolve method not found."); + var sampleViewModel = CreateInstance(assembly, "TestApp.ViewModels.SampleViewModel"); + var missingViewModel = CreateInstance(assembly, "TestApp.ViewModels.MissingViewModel"); + + _ = HeadlessUnitTestSession.GetOrStartForAssembly(typeof(StaticViewLocatorGeneratorRuntimeTests).Assembly); + + var sampleControl = (Control)resolveMethod.Invoke(null, new[] { sampleViewModel })!; + var missingControl = (Control)resolveMethod.Invoke(null, new[] { missingViewModel })!; + + Assert.Equal("TestApp.Views.SampleView", sampleControl.GetType().FullName); + Assert.Equal("Avalonia.Controls.TextBlock", missingControl.GetType().FullName); + } + + private static async Task CreateCompilationAsync(string source) + { + var parseOptions = new CSharpParseOptions(); + var syntaxTree = CSharpSyntaxTree.ParseText(source, parseOptions); + var references = await s_referenceAssemblies.ResolveAsync(LanguageNames.CSharp, default); + + return CSharpCompilation.Create( + assemblyName: "TestAssembly", + syntaxTrees: new[] { syntaxTree }, + references: references, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } + + [AvaloniaFact] + public async Task ResolvesMultipleViewModelsAndRespectsOrdering() + { + const string source = @" +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using StaticViewLocator; + +namespace Portal +{ + [StaticViewLocator] + public partial class PortalViewLocator + { + public static Control Locate(object vm) + { + if (vm is null) + { + throw new ArgumentNullException(nameof(vm)); + } + + return s_views[vm.GetType()](); + } + } +} + +namespace Portal.ViewModels +{ + public abstract class ViewModelBase + { + } + + public class HomeViewModel : ViewModelBase + { + } + + public class ReportsViewModel : ViewModelBase + { + } + + public class SettingsViewModel : ViewModelBase + { + } + + public abstract class WorkspaceViewModel : ViewModelBase + { + } +} + +namespace Portal.Views +{ + public class HomeView : UserControl + { + } + + public class ReportsView : UserControl + { + } +} +"; + + var compilation = await CreateCompilationAsync(source); + var sourceGenerator = new StaticViewLocatorGenerator().AsSourceGenerator(); + var driver = CSharpGeneratorDriver.Create(new[] { sourceGenerator }, parseOptions: (CSharpParseOptions)compilation.SyntaxTrees.First().Options); + driver.RunGeneratorsAndUpdateCompilation(compilation, out var updatedCompilation, out var diagnostics); + + Assert.Empty(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)); + + using var peStream = new MemoryStream(); + var emitResult = updatedCompilation.Emit(peStream); + Assert.True(emitResult.Success, string.Join(Environment.NewLine, emitResult.Diagnostics)); + + peStream.Seek(0, SeekOrigin.Begin); + var assembly = Assembly.Load(peStream.ToArray()); + + var locatorType = assembly.GetType("Portal.PortalViewLocator") ?? throw new InvalidOperationException("Generated locator type not found."); + var locateMethod = locatorType.GetMethod("Locate", BindingFlags.Public | BindingFlags.Static) ?? throw new InvalidOperationException("Locate method not found."); + var dictionaryField = locatorType.GetField("s_views", BindingFlags.NonPublic | BindingFlags.Static) ?? throw new InvalidOperationException("Dictionary field not found."); + var viewsMap = (Dictionary>)dictionaryField.GetValue(null)!; + + var expectedOrder = new[] + { + "Portal.ViewModels.HomeViewModel", + "Portal.ViewModels.ReportsViewModel", + "Portal.ViewModels.SettingsViewModel", + }; + + Assert.Equal(expectedOrder.Length, viewsMap.Count); + Assert.Equal(expectedOrder, viewsMap.Keys.Select(key => key.FullName).ToArray()); + + var homeViewModel = CreateInstance(assembly, "Portal.ViewModels.HomeViewModel"); + var reportsViewModel = CreateInstance(assembly, "Portal.ViewModels.ReportsViewModel"); + var settingsViewModel = CreateInstance(assembly, "Portal.ViewModels.SettingsViewModel"); + + var homeControl = (Control)locateMethod.Invoke(null, new[] { homeViewModel })!; + var reportsControl = (Control)locateMethod.Invoke(null, new[] { reportsViewModel })!; + var settingsControl = (Control)locateMethod.Invoke(null, new[] { settingsViewModel })!; + + Assert.Equal("Portal.Views.HomeView", homeControl.GetType().FullName); + Assert.Equal("Portal.Views.ReportsView", reportsControl.GetType().FullName); + + var fallback = Assert.IsType(settingsControl); + Assert.Equal("Not Found: Portal.Views.SettingsView", fallback.Text); + Assert.DoesNotContain(viewsMap.Keys, key => key.FullName?.Contains("WorkspaceViewModel", StringComparison.Ordinal) == true); + } + + private static object CreateInstance(Assembly assembly, string typeName) + { + var type = assembly.GetType(typeName, throwOnError: true) ?? + throw new InvalidOperationException($"Unable to locate type '{typeName}'."); + + return Activator.CreateInstance(type) ?? + throw new InvalidOperationException($"Unable to instantiate type '{typeName}'."); + } +} diff --git a/StaticViewLocator.Tests/StaticViewLocatorGeneratorSnapshotTests.cs b/StaticViewLocator.Tests/StaticViewLocatorGeneratorSnapshotTests.cs new file mode 100644 index 0000000..f0032a0 --- /dev/null +++ b/StaticViewLocator.Tests/StaticViewLocatorGeneratorSnapshotTests.cs @@ -0,0 +1,198 @@ +using System.Threading.Tasks; +using StaticViewLocator.Tests.TestHelpers; +using Xunit; + +namespace StaticViewLocator.Tests; + +public class StaticViewLocatorGeneratorSnapshotTests +{ + [Fact] + public async Task GeneratesAttributeAndLocatorSources() + { + const string input = @" +using Avalonia.Controls; +using StaticViewLocator; + +namespace TestApp.ViewModels +{ + public abstract class ViewModelBase + { + } + + public class MainWindowViewModel : ViewModelBase + { + } + + public class TestViewModel : ViewModelBase + { + } + + public abstract class IgnoredViewModel : ViewModelBase + { + } +} + +namespace TestApp.Views +{ + public class TestView : UserControl + { + } +} + +namespace TestApp +{ + [StaticViewLocator] + public partial class ViewLocator + { + } +} +"; + +const string expectedAttribute = """ +// +using System; + +namespace StaticViewLocator; + +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +public sealed class StaticViewLocatorAttribute : Attribute +{ +} + +"""; + +const string expectedLocator = """ +// +#nullable enable +using System; +using System.Collections.Generic; +using Avalonia.Controls; + +namespace TestApp; + +public partial class ViewLocator +{ + private static Dictionary> s_views = new() + { + [typeof(TestApp.ViewModels.MainWindowViewModel)] = () => new TextBlock() { Text = "Not Found: TestApp.Views.MainWindowView" }, + [typeof(TestApp.ViewModels.TestViewModel)] = () => new TestApp.Views.TestView(), + }; +} + +"""; + + await StaticViewLocatorGeneratorVerifier.VerifyGeneratedSourcesAsync( + input, + ("StaticViewLocatorAttribute.cs", expectedAttribute), + ("ViewLocator_StaticViewLocator.cs", expectedLocator)); + } + + [Fact] + public async Task GeneratesMappingsForMultipleLocators() + { + const string input = @" +using Avalonia.Controls; +using StaticViewLocator; + +namespace App.Modules.Admin +{ + [StaticViewLocator] + public partial class AdminViewLocator + { + } + + public class AdminDashboardViewModel + { + } + + public class AdminDashboardView : UserControl + { + } +} + +namespace App.Modules.Client +{ + [StaticViewLocator] + public partial class ClientViewLocator + { + } + + public class ClientDashboardViewModel + { + } + + public class ClientDashboardView : UserControl + { + } +} + +namespace App.Modules.Shared +{ + public class ActivityLogViewModel + { + } +} +"; + +const string expectedAttribute = """ +// +using System; + +namespace StaticViewLocator; + +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +public sealed class StaticViewLocatorAttribute : Attribute +{ +} + +"""; + +const string expectedAdminLocator = """ +// +#nullable enable +using System; +using System.Collections.Generic; +using Avalonia.Controls; + +namespace App.Modules.Admin; + +public partial class AdminViewLocator +{ + private static Dictionary> s_views = new() + { + [typeof(App.Modules.Admin.AdminDashboardViewModel)] = () => new App.Modules.Admin.AdminDashboardView(), + [typeof(App.Modules.Client.ClientDashboardViewModel)] = () => new App.Modules.Client.ClientDashboardView(), + [typeof(App.Modules.Shared.ActivityLogViewModel)] = () => new TextBlock() { Text = "Not Found: App.Modules.Shared.ActivityLogView" }, + }; +} + +"""; + +const string expectedClientLocator = """ +// +#nullable enable +using System; +using System.Collections.Generic; +using Avalonia.Controls; + +namespace App.Modules.Client; + +public partial class ClientViewLocator +{ + private static Dictionary> s_views = new() + { + [typeof(App.Modules.Admin.AdminDashboardViewModel)] = () => new App.Modules.Admin.AdminDashboardView(), + [typeof(App.Modules.Client.ClientDashboardViewModel)] = () => new App.Modules.Client.ClientDashboardView(), + [typeof(App.Modules.Shared.ActivityLogViewModel)] = () => new TextBlock() { Text = "Not Found: App.Modules.Shared.ActivityLogView" }, + }; +} + +"""; + + await StaticViewLocatorGeneratorVerifier.VerifyGeneratedSourcesAsync( + input, + ("StaticViewLocatorAttribute.cs", expectedAttribute), + ("AdminViewLocator_StaticViewLocator.cs", expectedAdminLocator), + ("ClientViewLocator_StaticViewLocator.cs", expectedClientLocator)); + } +} diff --git a/StaticViewLocator.Tests/TestHelpers/StaticViewLocatorGeneratorVerifier.cs b/StaticViewLocator.Tests/TestHelpers/StaticViewLocatorGeneratorVerifier.cs new file mode 100644 index 0000000..38e7e2a --- /dev/null +++ b/StaticViewLocator.Tests/TestHelpers/StaticViewLocatorGeneratorVerifier.cs @@ -0,0 +1,40 @@ +using System.Collections.Immutable; +using System.Text; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Testing.Verifiers; +using Microsoft.CodeAnalysis.Text; +using StaticViewLocator; +using PackageIdentity = Microsoft.CodeAnalysis.Testing.PackageIdentity; + +namespace StaticViewLocator.Tests.TestHelpers; + +internal static class StaticViewLocatorGeneratorVerifier +{ + private static readonly ReferenceAssemblies s_referenceAssemblies = ReferenceAssemblies.Net.Net80.AddPackages( + ImmutableArray.Create( + new PackageIdentity("Avalonia", "11.2.5"))); + + public static async Task VerifyGeneratedSourcesAsync(string source, params (string hintName, string source)[] generatedSources) + { + var test = new Test(); + test.TestState.Sources.Add(source); + + foreach (var (hintName, generatedSource) in generatedSources) + { + test.TestState.GeneratedSources.Add((typeof(StaticViewLocatorGenerator), hintName, SourceText.From(generatedSource, Encoding.UTF8))); + } + + await test.RunAsync(); + } + + private sealed class Test : CSharpSourceGeneratorTest + { + public Test() + { + ReferenceAssemblies = s_referenceAssemblies; + } + } +} diff --git a/StaticViewLocator.sln b/StaticViewLocator.sln index 987224b..f69e20d 100644 --- a/StaticViewLocator.sln +++ b/StaticViewLocator.sln @@ -1,22 +1,59 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StaticViewLocator", "StaticViewLocator\StaticViewLocator.csproj", "{44612354-2231-41AA-805A-C9EEF6DB4CF9}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StaticViewLocatorDemo", "StaticViewLocatorDemo\StaticViewLocatorDemo.csproj", "{92F9405F-926D-45E7-973C-3370D0F49348}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StaticViewLocator.Tests", "StaticViewLocator.Tests\StaticViewLocator.Tests.csproj", "{B654A784-6F67-4B9C-A138-0D93766D0CBD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {44612354-2231-41AA-805A-C9EEF6DB4CF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {44612354-2231-41AA-805A-C9EEF6DB4CF9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44612354-2231-41AA-805A-C9EEF6DB4CF9}.Debug|x64.ActiveCfg = Debug|Any CPU + {44612354-2231-41AA-805A-C9EEF6DB4CF9}.Debug|x64.Build.0 = Debug|Any CPU + {44612354-2231-41AA-805A-C9EEF6DB4CF9}.Debug|x86.ActiveCfg = Debug|Any CPU + {44612354-2231-41AA-805A-C9EEF6DB4CF9}.Debug|x86.Build.0 = Debug|Any CPU {44612354-2231-41AA-805A-C9EEF6DB4CF9}.Release|Any CPU.ActiveCfg = Release|Any CPU {44612354-2231-41AA-805A-C9EEF6DB4CF9}.Release|Any CPU.Build.0 = Release|Any CPU + {44612354-2231-41AA-805A-C9EEF6DB4CF9}.Release|x64.ActiveCfg = Release|Any CPU + {44612354-2231-41AA-805A-C9EEF6DB4CF9}.Release|x64.Build.0 = Release|Any CPU + {44612354-2231-41AA-805A-C9EEF6DB4CF9}.Release|x86.ActiveCfg = Release|Any CPU + {44612354-2231-41AA-805A-C9EEF6DB4CF9}.Release|x86.Build.0 = Release|Any CPU {92F9405F-926D-45E7-973C-3370D0F49348}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {92F9405F-926D-45E7-973C-3370D0F49348}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92F9405F-926D-45E7-973C-3370D0F49348}.Debug|x64.ActiveCfg = Debug|Any CPU + {92F9405F-926D-45E7-973C-3370D0F49348}.Debug|x64.Build.0 = Debug|Any CPU + {92F9405F-926D-45E7-973C-3370D0F49348}.Debug|x86.ActiveCfg = Debug|Any CPU + {92F9405F-926D-45E7-973C-3370D0F49348}.Debug|x86.Build.0 = Debug|Any CPU {92F9405F-926D-45E7-973C-3370D0F49348}.Release|Any CPU.ActiveCfg = Release|Any CPU {92F9405F-926D-45E7-973C-3370D0F49348}.Release|Any CPU.Build.0 = Release|Any CPU + {92F9405F-926D-45E7-973C-3370D0F49348}.Release|x64.ActiveCfg = Release|Any CPU + {92F9405F-926D-45E7-973C-3370D0F49348}.Release|x64.Build.0 = Release|Any CPU + {92F9405F-926D-45E7-973C-3370D0F49348}.Release|x86.ActiveCfg = Release|Any CPU + {92F9405F-926D-45E7-973C-3370D0F49348}.Release|x86.Build.0 = Release|Any CPU + {B654A784-6F67-4B9C-A138-0D93766D0CBD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B654A784-6F67-4B9C-A138-0D93766D0CBD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B654A784-6F67-4B9C-A138-0D93766D0CBD}.Debug|x64.ActiveCfg = Debug|Any CPU + {B654A784-6F67-4B9C-A138-0D93766D0CBD}.Debug|x64.Build.0 = Debug|Any CPU + {B654A784-6F67-4B9C-A138-0D93766D0CBD}.Debug|x86.ActiveCfg = Debug|Any CPU + {B654A784-6F67-4B9C-A138-0D93766D0CBD}.Debug|x86.Build.0 = Debug|Any CPU + {B654A784-6F67-4B9C-A138-0D93766D0CBD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B654A784-6F67-4B9C-A138-0D93766D0CBD}.Release|Any CPU.Build.0 = Release|Any CPU + {B654A784-6F67-4B9C-A138-0D93766D0CBD}.Release|x64.ActiveCfg = Release|Any CPU + {B654A784-6F67-4B9C-A138-0D93766D0CBD}.Release|x64.Build.0 = Release|Any CPU + {B654A784-6F67-4B9C-A138-0D93766D0CBD}.Release|x86.ActiveCfg = Release|Any CPU + {B654A784-6F67-4B9C-A138-0D93766D0CBD}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection EndGlobal diff --git a/StaticViewLocator/StaticViewLocatorGenerator.cs b/StaticViewLocator/StaticViewLocatorGenerator.cs index f3522b0..bfd7997 100644 --- a/StaticViewLocator/StaticViewLocatorGenerator.cs +++ b/StaticViewLocator/StaticViewLocatorGenerator.cs @@ -1,5 +1,6 @@ +using System; using System.Collections.Generic; -using System.Linq; +using System.Collections.Immutable; using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -8,164 +9,150 @@ namespace StaticViewLocator; -[Generator] -public class StaticViewLocatorGenerator : ISourceGenerator +[Generator(LanguageNames.CSharp)] +public sealed class StaticViewLocatorGenerator : IIncrementalGenerator { - private const string StaticViewLocatorAttributeDisplayString = "StaticViewLocator.StaticViewLocatorAttribute"; - - private const string ViewModelSuffix = "ViewModel"; - - private const string ViewSuffix = "View"; - - private const string AttributeText = - """ - // - using System; - - namespace StaticViewLocator; - - [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] - public sealed class StaticViewLocatorAttribute : Attribute - { - } - - """; - - public void Initialize(GeneratorInitializationContext context) - { - // System.Diagnostics.Debugger.Launch(); - context.RegisterForPostInitialization((i) => i.AddSource("StaticViewLocatorAttribute.cs", SourceText.From(AttributeText, Encoding.UTF8))); - - context.RegisterForSyntaxNotifications(() => new SyntaxReceiver()); - } - - public void Execute(GeneratorExecutionContext context) - { - if (context.SyntaxContextReceiver is not SyntaxReceiver receiver) - { - return; - } - - var attributeSymbol = context.Compilation.GetTypeByMetadataName(StaticViewLocatorAttributeDisplayString); - if (attributeSymbol is null) - { - return; - } - - foreach (var namedTypeSymbol in receiver.NamedTypeSymbolLocators) - { - var namedTypeSymbolViewModels = receiver.NamedTypeSymbolViewModels.ToList(); - namedTypeSymbolViewModels.Sort((x, y) => x.ToDisplayString().CompareTo(y.ToDisplayString())); - - var classSource = ProcessClass(context.Compilation, namedTypeSymbol, namedTypeSymbolViewModels); - if (classSource is not null) - { - context.AddSource($"{namedTypeSymbol.Name}_StaticViewLocator.cs", SourceText.From(classSource, Encoding.UTF8)); - } - } - } - - private static string? ProcessClass(Compilation compilation, INamedTypeSymbol namedTypeSymbolLocator, List namedTypeSymbolViewModels) - { - if (!namedTypeSymbolLocator.ContainingSymbol.Equals(namedTypeSymbolLocator.ContainingNamespace, SymbolEqualityComparer.Default)) - { - return null; - } - - string namespaceNameLocator = namedTypeSymbolLocator.ContainingNamespace.ToDisplayString(); - - var format = new SymbolDisplayFormat( - typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypes, - genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters | SymbolDisplayGenericsOptions.IncludeTypeConstraints | SymbolDisplayGenericsOptions.IncludeVariance); - - string classNameLocator = namedTypeSymbolLocator.ToDisplayString(format); - - var source = new StringBuilder( - $$""" - // - #nullable enable - using System; - using System.Collections.Generic; - using Avalonia.Controls; - - namespace {{namespaceNameLocator}}; - - public partial class {{classNameLocator}} - { - """); - - source.Append( - """ - - private static Dictionary> s_views = new() - { - - """); - - var userControlViewSymbol = compilation.GetTypeByMetadataName("Avalonia.Controls.UserControl"); - - foreach (var namedTypeSymbolViewModel in namedTypeSymbolViewModels) - { - string namespaceNameViewModel = namedTypeSymbolViewModel.ContainingNamespace.ToDisplayString(); - string classNameViewModel = $"{namespaceNameViewModel}.{namedTypeSymbolViewModel.ToDisplayString(format)}"; - string classNameView = classNameViewModel.Replace(ViewModelSuffix, ViewSuffix); - - var classNameViewSymbol = compilation.GetTypeByMetadataName(classNameView); - if (classNameViewSymbol is null || classNameViewSymbol.BaseType?.Equals(userControlViewSymbol, SymbolEqualityComparer.Default) != true) - { - source.AppendLine( - $$""" - [typeof({{classNameViewModel}})] = () => new TextBlock() { Text = {{("\"Not Found: " + classNameView + "\"")}} }, - """); - } - else - { - source.AppendLine( - $$""" - [typeof({{classNameViewModel}})] = () => new {{classNameView}}(), - """); - } - } - - source.Append( - """ - }; - } - - """); - - return source.ToString(); - } - - private class SyntaxReceiver : ISyntaxContextReceiver - { - public List NamedTypeSymbolLocators { get; } = new(); - - public List NamedTypeSymbolViewModels { get; } = new(); - - public void OnVisitSyntaxNode(GeneratorSyntaxContext context) - { - if (context.Node is ClassDeclarationSyntax classDeclarationSyntax) - { - var namedTypeSymbol = context.SemanticModel.GetDeclaredSymbol(classDeclarationSyntax); - if (namedTypeSymbol is null) - { - return; - } - - var attributes = namedTypeSymbol.GetAttributes(); - if (attributes.Any(ad => ad?.AttributeClass?.ToDisplayString() == StaticViewLocatorAttributeDisplayString)) - { - NamedTypeSymbolLocators.Add(namedTypeSymbol); - } - else if (namedTypeSymbol.Name.EndsWith(ViewModelSuffix)) - { - if (!namedTypeSymbol.IsAbstract) - { - NamedTypeSymbolViewModels.Add(namedTypeSymbol); - } - } - } - } - } + private const string StaticViewLocatorAttributeDisplayString = "StaticViewLocator.StaticViewLocatorAttribute"; + private const string ViewModelSuffix = "ViewModel"; + private const string ViewSuffix = "View"; + + private const string AttributeText = + """ + // + using System; + + namespace StaticViewLocator; + + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class StaticViewLocatorAttribute : Attribute + { + } + + """; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(static ctx => + ctx.AddSource("StaticViewLocatorAttribute.cs", SourceText.From(AttributeText, Encoding.UTF8))); + + var viewModelsProvider = context.SyntaxProvider + .CreateSyntaxProvider( + static (node, _) => node is ClassDeclarationSyntax classDeclaration && + classDeclaration.Identifier.ValueText.EndsWith(ViewModelSuffix, StringComparison.Ordinal), + static (generatorContext, cancellationToken) => + { + var classDeclaration = (ClassDeclarationSyntax)generatorContext.Node; + if (generatorContext.SemanticModel.GetDeclaredSymbol(classDeclaration, cancellationToken) is not INamedTypeSymbol symbol) + { + return null; + } + + return symbol.IsAbstract ? null : symbol; + }) + .Where(static symbol => symbol is not null) + .Select(static (symbol, _) => symbol!) + .Collect(); + + var locatorsProvider = context.SyntaxProvider.ForAttributeWithMetadataName( + StaticViewLocatorAttributeDisplayString, + static (node, _) => node is ClassDeclarationSyntax, + static (attributeContext, _) => (INamedTypeSymbol)attributeContext.TargetSymbol); + + var inputs = locatorsProvider + .Combine(context.CompilationProvider) + .Combine(viewModelsProvider); + + context.RegisterSourceOutput(inputs, static (sourceProductionContext, tuple) => + { + var ((locatorSymbol, compilation), viewModelSymbols) = tuple; + + var classSource = ProcessClass(compilation, locatorSymbol, viewModelSymbols); + if (classSource is not null) + { + sourceProductionContext.AddSource( + $"{locatorSymbol.Name}_StaticViewLocator.cs", + SourceText.From(classSource, Encoding.UTF8)); + } + }); + } + + private static string? ProcessClass(Compilation compilation, INamedTypeSymbol locatorSymbol, ImmutableArray viewModelSymbols) + { + if (!locatorSymbol.ContainingSymbol.Equals(locatorSymbol.ContainingNamespace, SymbolEqualityComparer.Default)) + { + return null; + } + + var namespaceNameLocator = locatorSymbol.ContainingNamespace.ToDisplayString(); + + var format = new SymbolDisplayFormat( + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypes, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters | + SymbolDisplayGenericsOptions.IncludeTypeConstraints | + SymbolDisplayGenericsOptions.IncludeVariance); + + var classNameLocator = locatorSymbol.ToDisplayString(format); + + var relevantViewModels = new List(); + var seen = new HashSet(SymbolEqualityComparer.Default); + + foreach (var symbol in viewModelSymbols) + { + if (!symbol.Name.EndsWith(ViewModelSuffix, StringComparison.Ordinal) || symbol.IsAbstract) + { + continue; + } + + if (seen.Add(symbol)) + { + relevantViewModels.Add(symbol); + } + } + + relevantViewModels.Sort(static (left, right) => + string.Compare(left.ToDisplayString(), right.ToDisplayString(), StringComparison.Ordinal)); + + var source = new StringBuilder( + $$""" + // + #nullable enable + using System; + using System.Collections.Generic; + using Avalonia.Controls; + + namespace {{namespaceNameLocator}}; + + public partial class {{classNameLocator}} + { + """); + + source.AppendLine(); + source.AppendLine("\tprivate static Dictionary> s_views = new()"); + source.AppendLine("\t{"); + + var userControlViewSymbol = compilation.GetTypeByMetadataName("Avalonia.Controls.UserControl"); + + foreach (var viewModelSymbol in relevantViewModels) + { + var namespaceNameViewModel = viewModelSymbol.ContainingNamespace.ToDisplayString(); + var classNameViewModel = $"{namespaceNameViewModel}.{viewModelSymbol.ToDisplayString(format)}"; + var classNameView = classNameViewModel.Replace(ViewModelSuffix, ViewSuffix); + + var viewSymbol = compilation.GetTypeByMetadataName(classNameView); + if (viewSymbol is null || viewSymbol.BaseType?.Equals(userControlViewSymbol, SymbolEqualityComparer.Default) != true) + { + source.AppendLine( + $"\t\t[typeof({classNameViewModel})] = () => new TextBlock() {{ Text = \"Not Found: {classNameView}\" }},"); + } + else + { + source.AppendLine($"\t\t[typeof({classNameViewModel})] = () => new {classNameView}(),"); + } + } + + source.AppendLine("\t};"); + source.AppendLine("}"); + + return source.ToString(); + } } diff --git a/StaticViewLocatorDemo/ViewModels/DashboardViewModel.cs b/StaticViewLocatorDemo/ViewModels/DashboardViewModel.cs new file mode 100644 index 0000000..6854f15 --- /dev/null +++ b/StaticViewLocatorDemo/ViewModels/DashboardViewModel.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +namespace StaticViewLocatorDemo.ViewModels; + +public class DashboardViewModel : ViewModelBase +{ + public DashboardViewModel() + { + Metrics = new List + { + new("Active Users", "people", 1280, "Users currently signed in across all platforms."), + new("Conversion", "%", 3.7, "Percentage of visitors completing the onboarding journey."), + new("Support Tickets", "open", 5, "Items waiting in the support queue."), + new("Build Duration", "min", 7.4, "Average CI build time over the last 24 hours."), + }; + } + + public override string Title => "Dashboard"; + + public string Summary => "A snapshot of the most important application metrics."; + + public IReadOnlyList Metrics { get; } +} diff --git a/StaticViewLocatorDemo/ViewModels/MainWindowViewModel.cs b/StaticViewLocatorDemo/ViewModels/MainWindowViewModel.cs index 65c0939..bb2c688 100644 --- a/StaticViewLocatorDemo/ViewModels/MainWindowViewModel.cs +++ b/StaticViewLocatorDemo/ViewModels/MainWindowViewModel.cs @@ -1,6 +1,30 @@ -namespace StaticViewLocatorDemo.ViewModels; +using System.Collections.Generic; +using ReactiveUI; + +namespace StaticViewLocatorDemo.ViewModels; public class MainWindowViewModel : ViewModelBase { - public TestViewModel TestViewModel { get; } = new (); + private ViewModelBase _selectedPage; + + public MainWindowViewModel() + { + Pages = new ViewModelBase[] + { + new DashboardViewModel(), + new TestViewModel(), + new SettingsViewModel(), + new ReportsViewModel(), + }; + + _selectedPage = Pages[0]; + } + + public IReadOnlyList Pages { get; } + + public ViewModelBase SelectedPage + { + get => _selectedPage; + set => this.RaiseAndSetIfChanged(ref _selectedPage, value); + } } diff --git a/StaticViewLocatorDemo/ViewModels/MetricViewModel.cs b/StaticViewLocatorDemo/ViewModels/MetricViewModel.cs new file mode 100644 index 0000000..eaa90d0 --- /dev/null +++ b/StaticViewLocatorDemo/ViewModels/MetricViewModel.cs @@ -0,0 +1,22 @@ +namespace StaticViewLocatorDemo.ViewModels; + +public class MetricViewModel : ViewModelBase +{ + public MetricViewModel(string name, string unit, double value, string description) + { + Name = name; + Unit = unit; + Value = value; + Description = description; + } + + public string Name { get; } + + public string Unit { get; } + + public double Value { get; } + + public string Description { get; } + + public override string Title => Name; +} diff --git a/StaticViewLocatorDemo/ViewModels/ReportViewModel.cs b/StaticViewLocatorDemo/ViewModels/ReportViewModel.cs new file mode 100644 index 0000000..d502312 --- /dev/null +++ b/StaticViewLocatorDemo/ViewModels/ReportViewModel.cs @@ -0,0 +1,22 @@ +using System; + +namespace StaticViewLocatorDemo.ViewModels; + +public class ReportViewModel : ViewModelBase +{ + public ReportViewModel(string title, string summary, DateTime lastGenerated, string status) + { + Title = title; + Summary = summary; + LastGenerated = lastGenerated; + Status = status; + } + + public override string Title { get; } + + public string Summary { get; } + + public DateTime LastGenerated { get; } + + public string Status { get; } +} diff --git a/StaticViewLocatorDemo/ViewModels/ReportsViewModel.cs b/StaticViewLocatorDemo/ViewModels/ReportsViewModel.cs new file mode 100644 index 0000000..95297c0 --- /dev/null +++ b/StaticViewLocatorDemo/ViewModels/ReportsViewModel.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; + +namespace StaticViewLocatorDemo.ViewModels; + +public class ReportsViewModel : ViewModelBase +{ + public ReportsViewModel() + { + Reports = new List + { + new("Usage", "Daily active user counts split by platform.", DateTime.Now.AddMinutes(-32), "Up to date"), + new("Infrastructure", "Server health with CPU and memory trends.", DateTime.Now.AddHours(-3), "Requires review"), + new("Commerce", "Payments accepted and refunds processed in the last week.", DateTime.Now.AddDays(-1), "Regenerating"), + }; + } + + public override string Title => "Reports"; + + public IReadOnlyList Reports { get; } +} diff --git a/StaticViewLocatorDemo/ViewModels/SettingsViewModel.cs b/StaticViewLocatorDemo/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..fe4b120 --- /dev/null +++ b/StaticViewLocatorDemo/ViewModels/SettingsViewModel.cs @@ -0,0 +1,30 @@ +using ReactiveUI; + +namespace StaticViewLocatorDemo.ViewModels; + +public class SettingsViewModel : ViewModelBase +{ + private bool _useDarkTheme = true; + private bool _enableAnimations = true; + private bool _receiveReleaseNotifications = true; + + public override string Title => "Settings"; + + public bool UseDarkTheme + { + get => _useDarkTheme; + set => this.RaiseAndSetIfChanged(ref _useDarkTheme, value); + } + + public bool EnableAnimations + { + get => _enableAnimations; + set => this.RaiseAndSetIfChanged(ref _enableAnimations, value); + } + + public bool ReceiveReleaseNotifications + { + get => _receiveReleaseNotifications; + set => this.RaiseAndSetIfChanged(ref _receiveReleaseNotifications, value); + } +} diff --git a/StaticViewLocatorDemo/ViewModels/TestViewModel.cs b/StaticViewLocatorDemo/ViewModels/TestViewModel.cs index f89c6eb..98807a0 100644 --- a/StaticViewLocatorDemo/ViewModels/TestViewModel.cs +++ b/StaticViewLocatorDemo/ViewModels/TestViewModel.cs @@ -2,5 +2,9 @@ namespace StaticViewLocatorDemo.ViewModels; public class TestViewModel : ViewModelBase { + public override string Title => "Welcome"; + public string Greeting => "Welcome to Avalonia!"; + + public string Description => "This page is produced entirely via the static view locator."; } diff --git a/StaticViewLocatorDemo/ViewModels/ViewModelBase.cs b/StaticViewLocatorDemo/ViewModels/ViewModelBase.cs index 0dc5e00..9b98710 100644 --- a/StaticViewLocatorDemo/ViewModels/ViewModelBase.cs +++ b/StaticViewLocatorDemo/ViewModels/ViewModelBase.cs @@ -1,7 +1,9 @@ -using ReactiveUI; +using System; +using ReactiveUI; namespace StaticViewLocatorDemo.ViewModels; public class ViewModelBase : ReactiveObject { + public virtual string Title => GetType().Name.Replace("ViewModel", string.Empty, StringComparison.Ordinal); } diff --git a/StaticViewLocatorDemo/Views/DashboardView.axaml b/StaticViewLocatorDemo/Views/DashboardView.axaml new file mode 100644 index 0000000..60b42ec --- /dev/null +++ b/StaticViewLocatorDemo/Views/DashboardView.axaml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + diff --git a/StaticViewLocatorDemo/Views/DashboardView.axaml.cs b/StaticViewLocatorDemo/Views/DashboardView.axaml.cs new file mode 100644 index 0000000..82d1167 --- /dev/null +++ b/StaticViewLocatorDemo/Views/DashboardView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace StaticViewLocatorDemo.Views; + +public partial class DashboardView : UserControl +{ + public DashboardView() + { + InitializeComponent(); + } +} diff --git a/StaticViewLocatorDemo/Views/MainWindow.axaml b/StaticViewLocatorDemo/Views/MainWindow.axaml index c7e552c..14ce15e 100644 --- a/StaticViewLocatorDemo/Views/MainWindow.axaml +++ b/StaticViewLocatorDemo/Views/MainWindow.axaml @@ -3,16 +3,28 @@ xmlns:vm="using:StaticViewLocatorDemo.ViewModels" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" + mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="560" x:Class="StaticViewLocatorDemo.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Icon="/Assets/avalonia-logo.ico" - Title="StaticViewLocatorDemo"> + Title="Static View Locator Demo"> - - + + + + + + + + + + + + + diff --git a/StaticViewLocatorDemo/Views/MetricView.axaml b/StaticViewLocatorDemo/Views/MetricView.axaml new file mode 100644 index 0000000..f7ec510 --- /dev/null +++ b/StaticViewLocatorDemo/Views/MetricView.axaml @@ -0,0 +1,21 @@ + + + + + + + + + + + + diff --git a/StaticViewLocatorDemo/Views/MetricView.axaml.cs b/StaticViewLocatorDemo/Views/MetricView.axaml.cs new file mode 100644 index 0000000..a666ac8 --- /dev/null +++ b/StaticViewLocatorDemo/Views/MetricView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace StaticViewLocatorDemo.Views; + +public partial class MetricView : UserControl +{ + public MetricView() + { + InitializeComponent(); + } +} diff --git a/StaticViewLocatorDemo/Views/ReportView.axaml b/StaticViewLocatorDemo/Views/ReportView.axaml new file mode 100644 index 0000000..3de45b1 --- /dev/null +++ b/StaticViewLocatorDemo/Views/ReportView.axaml @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/StaticViewLocatorDemo/Views/ReportView.axaml.cs b/StaticViewLocatorDemo/Views/ReportView.axaml.cs new file mode 100644 index 0000000..69146d9 --- /dev/null +++ b/StaticViewLocatorDemo/Views/ReportView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace StaticViewLocatorDemo.Views; + +public partial class ReportView : UserControl +{ + public ReportView() + { + InitializeComponent(); + } +} diff --git a/StaticViewLocatorDemo/Views/ReportsView.axaml b/StaticViewLocatorDemo/Views/ReportsView.axaml new file mode 100644 index 0000000..bf84c36 --- /dev/null +++ b/StaticViewLocatorDemo/Views/ReportsView.axaml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/StaticViewLocatorDemo/Views/ReportsView.axaml.cs b/StaticViewLocatorDemo/Views/ReportsView.axaml.cs new file mode 100644 index 0000000..70bb28c --- /dev/null +++ b/StaticViewLocatorDemo/Views/ReportsView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace StaticViewLocatorDemo.Views; + +public partial class ReportsView : UserControl +{ + public ReportsView() + { + InitializeComponent(); + } +} diff --git a/StaticViewLocatorDemo/Views/SettingsView.axaml b/StaticViewLocatorDemo/Views/SettingsView.axaml new file mode 100644 index 0000000..7883d5c --- /dev/null +++ b/StaticViewLocatorDemo/Views/SettingsView.axaml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/StaticViewLocatorDemo/Views/SettingsView.axaml.cs b/StaticViewLocatorDemo/Views/SettingsView.axaml.cs new file mode 100644 index 0000000..46562b7 --- /dev/null +++ b/StaticViewLocatorDemo/Views/SettingsView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace StaticViewLocatorDemo.Views; + +public partial class SettingsView : UserControl +{ + public SettingsView() + { + InitializeComponent(); + } +} diff --git a/StaticViewLocatorDemo/Views/TestView.axaml b/StaticViewLocatorDemo/Views/TestView.axaml index 22de57c..d697fe4 100644 --- a/StaticViewLocatorDemo/Views/TestView.axaml +++ b/StaticViewLocatorDemo/Views/TestView.axaml @@ -2,7 +2,14 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:vm="using:StaticViewLocatorDemo.ViewModels" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" - x:Class="StaticViewLocatorDemo.Views.TestView"> - Welcome to reflection free world! + x:Class="StaticViewLocatorDemo.Views.TestView" + x:DataType="vm:TestViewModel"> + + + + + +