Skip to content
Draft
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
6 changes: 6 additions & 0 deletions MvvmEssentials.sln
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nkraft.CrossUtility", "..\C
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nkraft.MvvmEssentials.UnitTest", "Tests\Nkraft.MvvmEssentials.UnitTest.csproj", "{E4346076-4B31-4966-BDF1-7653B584296F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FoodDelivery", "..\FoodDelivery\FoodDelivery\FoodDelivery.csproj", "{73066B82-45E7-4AD0-AD36-852A369F6E77}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -32,6 +34,10 @@ Global
{E4346076-4B31-4966-BDF1-7653B584296F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E4346076-4B31-4966-BDF1-7653B584296F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E4346076-4B31-4966-BDF1-7653B584296F}.Release|Any CPU.Build.0 = Release|Any CPU
{73066B82-45E7-4AD0-AD36-852A369F6E77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{73066B82-45E7-4AD0-AD36-852A369F6E77}.Debug|Any CPU.Build.0 = Debug|Any CPU
{73066B82-45E7-4AD0-AD36-852A369F6E77}.Release|Any CPU.ActiveCfg = Release|Any CPU
{73066B82-45E7-4AD0-AD36-852A369F6E77}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
6 changes: 0 additions & 6 deletions Source/Attributes/ParameterAttribute.cs

This file was deleted.

File renamed without changes.
9 changes: 9 additions & 0 deletions src/Attributes/ParameterAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Nkraft.MvvmEssentials.Attributes;

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public class ParameterAttribute : Attribute
{
public bool IsRequired { get; set; }

public string? PreferredPropertyName { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,24 @@
// ReSharper disable once CheckNamespace
namespace Nkraft.MvvmEssentials;

public interface INavigationTarget;

public static class NavigationExtension
{
extension(INavigationService navigationService)
{
public async Task<IResult> NavigateAsync<TViewModel>(
INavigationTarget parameters, bool animated = true)
where TViewModel : PageViewModel
{
var pageName = PageHelper.ToPageName<TViewModel>("Page");
var navParam = new NavigationParameters();
foreach (var (key, value) in ObjectHelper.ToDictionary(parameters))
navParam.Add(key, value);
return await navigationService.NavigateAsync(pageName, navParam, animated);
}


/// <summary>
/// Navigates to the page associated with the specified ViewModel type, passing parameters as a strongly-typed object.
/// </summary>
Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion Source/IsExternalInit.cs → src/IsExternalInit.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// IsExternalInit.cs
// ReSharper disable once CheckNamespace
namespace System.Runtime.CompilerServices;

internal static class IsExternalInit { }
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,14 @@
<ItemGroup>
<ProjectReference Include="..\..\CrossUtility\src\Nkraft.CrossUtility.csproj" />
<ProjectReference Include="SourceGenerator\Nkraft.MvvmEssentials.SourceGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false"
PrivateAssets="all" />
PrivateAssets="all"
Condition="'$(Configuration)' == 'Debug'" />
<ProjectReference Include="SourceGenerator\Nkraft.MvvmEssentials.SourceGenerator.csproj"
ReferenceOutputAssembly="false"
PrivateAssets="all"
Condition="'$(Configuration)' != 'Debug'" />
</ItemGroup>

</Project>
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
7 changes: 7 additions & 0 deletions src/SourceGenerator/IsExternalInit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using System.ComponentModel;

// ReSharper disable once CheckNamespace
namespace System.Runtime.CompilerServices;

[EditorBrowsable(EditorBrowsableState.Never)]
internal static class IsExternalInit { }
152 changes: 152 additions & 0 deletions src/SourceGenerator/NavigationParametersGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace Nkraft.MvvmEssentials.SourceGenerator;

// per-property (your type, now a record so it has value equality for the incremental cache)
public sealed record ViewModelParameters(string PropertyName, string PropertyType, bool IsRequired);

// per-VM — this is what was missing: carries where to emit
internal sealed record ViewModelTarget(string Namespace, string ClassName, EquatableArray<ViewModelParameters> Parameters);

[Generator]
public class NavigationParametersGenerator : IIncrementalGenerator
{
private const string NavigableViewModelName = "Nkraft.MvvmEssentials.ViewModels.NavigableEntryViewModel";
private const string NavigationParameterName = "Nkraft.MvvmEssentials.Attributes.ParameterAttribute";

void IIncrementalGenerator.Initialize(IncrementalGeneratorInitializationContext context)
{
var targets = context.SyntaxProvider.CreateSyntaxProvider(
predicate: static (node, _) => ClassPropertiesHasAttributes(node),
transform: static (ctx, _) => ToViewModelTarget(ctx))
.Where(static t => t is not null)
.Select(static (t, _) => t!);

context.RegisterSourceOutput(targets, static (spc, t) => Emit(spc, t));
}

private static bool ClassPropertiesHasAttributes(SyntaxNode node) =>
node is ClassDeclarationSyntax cls &&
cls.Members.OfType<PropertyDeclarationSyntax>().Any(p => p.AttributeLists.Count > 0);

private static ViewModelTarget? ToViewModelTarget(GeneratorSyntaxContext ctx)
{
var classDecl = (ClassDeclarationSyntax)ctx.Node;
if (ctx.SemanticModel.GetDeclaredSymbol(classDecl) is not INamedTypeSymbol symbol)
return null;
if (IsDerivedFrom(symbol, NavigableViewModelName) == false)
return null;

var props = symbol.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString() == NavigationParameterName))
.Select(p =>
{
var attr = p.GetAttributes().First(a => a.AttributeClass?.ToDisplayString() == NavigationParameterName);
// absent → required by default; opt out with [Parameter(IsRequired = false)]
var isRequired = attr.NamedArguments.FirstOrDefault(na => na.Key == "IsRequired").Value.Value as bool? ?? true;
return new ViewModelParameters(
p.Name,
p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), // global:: → no resolution surprises
isRequired);
})
.ToArray();

if (props.Length == 0)
return null;

var ns = symbol.ContainingNamespace.IsGlobalNamespace
? string.Empty
: symbol.ContainingNamespace.ToDisplayString();

return new ViewModelTarget(ns, symbol.Name, new EquatableArray<ViewModelParameters>(props));
}

private static void Emit(SourceProductionContext spc, ViewModelTarget t)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
if (t.Namespace.Length > 0)
sb.Append("namespace ").Append(t.Namespace).AppendLine(";").AppendLine();

sb.Append("partial class ").AppendLine(t.ClassName);
sb.AppendLine("{");

sb.Append(" public sealed record Parameters : global::Nkraft.MvvmEssentials.INavigationTarget<")
.Append(t.ClassName).AppendLine(">");
sb.AppendLine(" {");
foreach (var p in t.Parameters.Array)
{
sb.Append(" public ");
if (p.IsRequired) sb.Append("required ");
sb.Append(p.PropertyType).Append(' ').Append(p.PropertyName).AppendLine(" { get; init; }");
}
sb.AppendLine();
sb.AppendLine(" public global::Nkraft.MvvmEssentials.Services.INavigationParameters ToNavigationParameters()");
sb.AppendLine(" {");
sb.AppendLine(" var __p = new global::Nkraft.MvvmEssentials.Services.NavigationParameters();");
foreach (var p in t.Parameters.Array)
sb.Append(" __p.Add(\"").Append(p.PropertyName).Append("\", ").Append(p.PropertyName).AppendLine(");");
sb.AppendLine(" return __p;");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine();

sb.AppendLine(" protected internal override void ApplyNavigationParameters(global::Nkraft.MvvmEssentials.Services.INavigationParameters parameters)");
sb.AppendLine(" {");
var i = 0;
foreach (var p in t.Parameters.Array)
{
var v = "__v" + i++;
sb.Append(" if (parameters.TryGetValue<").Append(p.PropertyType).Append(">(\"")
.Append(p.PropertyName).Append("\", out var ").Append(v).Append(")) this.")
.Append(p.PropertyName).Append(" = ").Append(v).AppendLine("!;");
}
sb.AppendLine(" }");

sb.AppendLine("}");

var hint = (t.Namespace.Length > 0 ? t.Namespace + "." : string.Empty) + t.ClassName + ".NavigationParameters.g.cs";
spc.AddSource(hint, sb.ToString());
}

private static bool IsDerivedFrom(INamedTypeSymbol symbol, string baseTypeName)
{
for (var t = symbol; t is not null; t = t.BaseType)
if (t.ToDisplayString() == baseTypeName)
return true;
return false;
}
}

// arrays don't have structural equality, which silently breaks incremental caching.
// this wrapper restores it so the pipeline actually memoizes per-VM.
internal readonly struct EquatableArray<T>(T[] array) : IEquatable<EquatableArray<T>> where T : IEquatable<T>
{
public readonly T[] Array = array;

public bool Equals(EquatableArray<T> other)
{
if (Array is null) return false;
if (Array.Length != other.Array.Length) return false;
return Array.Where((t, i) => !t.Equals(other.Array[i])).Any() == false;
}

public override bool Equals(object? obj) => obj is EquatableArray<T> o && Equals(o);

public override int GetHashCode()
{
if (Array is null) return 0;
unchecked
{
return Array.Aggregate(17, (current, item) => current * 31 + item.GetHashCode());
}
}
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Reflection;
using Nkraft.CrossUtility.Helpers;
using Nkraft.MvvmEssentials.Attributes;
using Nkraft.MvvmEssentials.Services;
using Nkraft.MvvmEssentials.Services.Navigation;
using Nkraft.MvvmEssentials.Services.Pages;
Expand All @@ -12,9 +14,10 @@ public abstract class NavigableEntryViewModel : BaseViewModel,
{
internal void SetNavigationParameter(string key, object? value)
{
var bindingFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
const BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
var property = GetType().GetProperty(key, bindingFlags);
if (property is null || property.CanWrite == false)
var hasParameterAttribute = property?.GetCustomAttributes<ParameterAttribute>().Any();
if (hasParameterAttribute == false || property is null || property.CanWrite == false)
return;

if (value is null || AreTypesEqual(property.PropertyType, value.GetType()))
Expand Down
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
using Nkraft.CrossUtility.Patterns;
using Nkraft.MvvmEssentials.Attributes;
using Nkraft.MvvmEssentials.Helpers;
using Nkraft.MvvmEssentials.Services;
using Nkraft.MvvmEssentials.Services.Navigation;
using Nkraft.MvvmEssentials.Services.TabbedPages;

namespace Nkraft.MvvmEssentials.ViewModels;

public abstract class TabHostViewModel : PageViewModel, ITabHost
public abstract partial class TabHostViewModel : PageViewModel, ITabHost
{
protected override void OnInitialized()
{
Expand Down Expand Up @@ -40,6 +41,7 @@ protected async Task<IResult> SwitchTabAsync<TTabViewModel>(

protected ITabComponent CurrentTab => Tabs.ElementAt(SelectedTabIndex);

[Parameter]
protected int SelectedTabIndex { get; set; }

IReadOnlyCollection<ITabComponent> ITabHost.Tabs => Tabs;
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@

<ItemGroup>
<ProjectReference Include="..\Source\Nkraft.MvvmEssentials.csproj" />
<ProjectReference Include="..\Source\SourceGenerator\Nkraft.MvvmEssentials.SourceGenerator.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
<PackageReference Include="NSubstitute" Version="6.0.0-rc.1" />
<PackageReference Include="NUnit" Version="4.6.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
Expand Down
58 changes: 58 additions & 0 deletions tests/ViewModels/NavigationParametersGeneratorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Nkraft.MvvmEssentials.SourceGenerator;
using NUnit.Framework;

namespace Nkraft.MvvmEssentials.UnitTest.ViewModels;

[TestFixture]
public class NavigationParametersGeneratorTests
{
[Test]
public void Generator_ShouldGenerateRecord_WhenClassHasParameterAttribute()
{
var source = """
using Nkraft.MvvmEssentials.ViewModels;
using Nkraft.MvvmEssentials.Attributes;

public class MyViewModel : NavigableEntryViewModel
{
[Parameter]
public string Name { get; set; }
}
""";

var dotNetAssemblies = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
.Select(a => MetadataReference.CreateFromFile(a.Location));

var mvvmEssentialsAssembly = typeof(Nkraft.MvvmEssentials.ViewModels.NavigableEntryViewModel).Assembly.Location;

// 1. Create an EMPTY compilation baseline first
var baselineCompilation = CSharpCompilation.Create(
assemblyName: "TestAssembly",
syntaxTrees: [CSharpSyntaxTree.ParseText(source)],
references: [
..dotNetAssemblies,
MetadataReference.CreateFromFile(mvvmEssentialsAssembly) // Ensure this is explicitly linked!
],
options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
);

var generator = new NavigationParametersGenerator();
GeneratorDriver driver = CSharpGeneratorDriver.Create([generator.AsSourceGenerator()]);

// 2. Run the driver on the empty baseline to initialize its internal cache
driver = driver.RunGenerators(baselineCompilation);

// 3. NOW add your source file to the compilation (creating a delta change)
var updatedCompilation = baselineCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(source));

// 4. Run the driver again. Roslyn sees the new tree and forces the predicate to run!
driver = driver.RunGeneratorsAndUpdateCompilation(updatedCompilation, out var outputCompilation, out var diagnostics);

// Verify results
var runResult = driver.GetRunResult();
Assert.That(runResult.GeneratedTrees.Length, Is.GreaterThan(0), "No source files were generated!");
}
}
Loading