diff --git a/README.md b/README.md index 24bdb7d..7ac798e 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,9 @@ _**Now available as a Source Generator!**_ ⚡ [How to Install ReswPlus](https://github.com/reswplus/ReswPlus/wiki/How-to-install-ReswPlus) – Step-by-step installation guide. -ReswPlus recognizes a UWP project by the `Windows.Foundation.UniversalApiContract` reference it carries, and a Windows App SDK project by its own. A **UWP project built for Native AOT** has no such reference, so it declares what it is with the standard `UseUwp` property instead, which ReswPlus reads and trusts over the references: +The source generator supports UWP, Windows App SDK, and Uno WinUI applications and libraries. Uno WinUI is detected automatically from its referenced types and uses `Microsoft.UI.Xaml` helpers with `Windows.ApplicationModel.Resources.ResourceLoader`; no `UseUwp=true` override or `Windows.UI.Xaml` compatibility types are needed. + +ReswPlus recognizes a UWP project by the `Windows.Foundation.UniversalApiContract` reference it carries, and a Windows App SDK project by its own. A **UWP project built for Native AOT** has no such reference, so it declares what it is with the standard `UseUwp` property instead. This property fills in the app model only when the references cannot identify it: ```xml @@ -46,7 +48,21 @@ ReswPlus recognizes a UWP project by the `Windows.Foundation.UniversalApiContrac ``` -Without it, such a project is reported as `RESWP0005` and no code is generated for it. The `samples/UWP` folder has one sample of each kind: `ReswPlusUWPSample` built with .NET Native, and `ReswPlusNativeAotUwpSample` built on modern .NET with Native AOT. +Without `UseUwp=true` or an explicit `ReswPlusAppType=UWP`, such a project is reported as `RESWP0005` and no code is generated for it. The `samples/UWP` folder has one sample of each kind: `ReswPlusUWPSample` built with .NET Native, and `ReswPlusNativeAotUwpSample` built on modern .NET with Native AOT. + +### Override application model detection + +Automatic detection remains the default. If it cannot recognize your configuration or selects the wrong app model, opt in to an explicit `ReswPlusAppType`. For example, an Uno application can override its non-Windows heads without changing a Windows App SDK head: + +```xml + + UnoWinUI + +``` + +Supported values are `Auto`, `WindowsAppSDK`, `UWP`, and `UnoWinUI`, matched case-insensitively with surrounding whitespace ignored. Unset, empty, or `Auto` keeps automatic detection and the existing `UseUwp` fallback. Any other value reports `RESWP0021` and stops generation rather than silently falling back. + +An explicit app model takes precedence over both the detected references and `UseUwp`. The override selects the generated XAML helpers, resource loader, and optional application-language resolver; it does not add platform references. Choose the model whose APIs your target actually provides, and condition the property per target framework when different heads use different app models. ### Injectable resource interfaces diff --git a/docs/FAQ.md b/docs/FAQ.md index e21a307..bbd1b77 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -1,32 +1,21 @@ ## What's the minimum version of Windows 10 supported for applications using Resw? -Because ReswPlus's markup extension (deprecated — see below) uses [MarkupExtension](https://docs.microsoft.com/en-us/uwp/api/windows.ui.xaml.markup.markupextension), an app that still uses it needs -Windows 10 Fall Creators Update (1709). An app that uses only the generated class and `x:Bind` has no such requirement. +Because ReswPlus's markup extension (deprecated — see below) uses [MarkupExtension](https://docs.microsoft.com/en-us/uwp/api/windows.ui.xaml.markup.markupextension), an app that still uses it needs Windows 10 Fall Creators Update (1709). An app that uses only the generated class and `x:Bind` has no such requirement. ## Can I use the markup extension in an app compiled with Native AOT? -No, and it is deprecated for that reason. A markup extension is created by the XAML parser while it reads a -page, and a UWP app compiled with Native AOT cannot create it, so a page that uses one fails to load with -`Markup extension could not provide value`. Preserving the generated types with `rd.xml` or a trimmer root -does not change it. +No, and it is deprecated for that reason. A markup extension is created by the XAML parser while it reads a page, and a UWP app compiled with Native AOT cannot create it, so a page that uses one fails to load with `Markup extension could not provide value`. Preserving the generated types with `rd.xml` or a trimmer root does not change it. -It is still generated, so an app that does not use Native AOT keeps building, but it is marked `[Obsolete]` -and the build tells you what to write instead. A page that uses it reports `WMC1500` on the line of the -markup itself, and the XAML compiler's own generated type table reports `CS0618` alongside it. A build with -`TreatWarningsAsErrors` will therefore fail until the markup is rewritten or the warning is suppressed. +It is still generated, so an app that does not use Native AOT keeps building, but it is marked `[Obsolete]` and the build tells you what to write instead. A page that uses it reports `WMC1500` on the line of the markup itself, and the XAML compiler's own generated type table reports `CS0618` alongside it. A build with `TreatWarningsAsErrors` will therefore fail until the markup is rewritten or the warning is suppressed. -Use `x:Bind`, which reads the same generated members, is resolved while the app is compiled, and works -whichever way the app is built: +Use `x:Bind`, which reads the same generated members, is resolved while the app is compiled, and works whichever way the app is built: | Instead of | Write | | --- | --- | | `{strings:Resources Key=Foo}` | `{x:Bind strings:Resources.Foo}` | | `{strings:Resources Key=Foo, Converter={StaticResource C}}` | `{x:Bind strings:Resources.Foo, Converter={StaticResource C}}` | -`x:Bind` is not available everywhere the markup extension was. A `Setter` in a `Style`, and a standalone -`ResourceDictionary` with no code-behind — a shared `Styles.xaml`, say — are the two common cases: `x:Bind` -needs a compiled-binding host, which those do not have. Set those from code-behind using the same generated -members: +`x:Bind` is not available everywhere the markup extension was. A `Setter` in a `Style`, and a standalone `ResourceDictionary` with no code-behind — a shared `Styles.xaml`, say — are the two common cases: `x:Bind` needs a compiled-binding host, which those do not have. Set those from code-behind using the same generated members: ```csharp myTextBlock.Text = Resources.Foo; @@ -38,6 +27,20 @@ Everything else ReswPlus generates works with Native AOT. VB support is currently under development. C++/CX and C++/WinRT support are planned for a future update. +## Does the source generator support Uno WinUI? + +Yes. ReswPlus detects `Microsoft.UI.Xaml.DependencyObject` from the `Uno.UI` assembly or the `Uno.WinUI` assembly used by Uno 7.0 and later, regardless of the NuGet package path or whether the type is a class or interface. It generates `Microsoft.UI.Xaml` helpers and uses `Windows.ApplicationModel.Resources.ResourceLoader.GetForViewIndependentUse`. + +Generated macro helpers are still subject to Uno's Windows API support. Uno may report `Uno0001` warnings for unsupported APIs in those helpers, even when the resource uses a different macro. + +If you previously worked around `RESWP0005` by setting `UseUwp=true` and adding `Windows.UI.Xaml` versions of `MarkupExtension`, `MarkupExtensionReturnTypeAttribute`, and `IValueConverter`, remove that override and those compatibility types after updating. A separate `WinRT.DynamicWindowsRuntimeCastAttribute` polyfill, if required by your app, is unrelated to ReswPlus and should be kept. + +## Can I override the detected application model? + +Yes. Set `ReswPlusAppType` to `WindowsAppSDK`, `UWP`, or `UnoWinUI` to explicitly select the app model. Leaving it unset, empty, or `Auto` preserves automatic detection and the existing `UseUwp` fallback. Supported names are case-insensitive and ignore surrounding whitespace; invalid values report `RESWP0021` and stop generation. + +An explicit `ReswPlusAppType` takes precedence over both references and `UseUwp`, but does not supply any missing platform APIs. For a multi-targeting Uno app, apply the override only to the affected target frameworks: Windows App SDK heads should keep their own app model. See the [configuration example](../README.md#override-application-model-detection). + ## Is it free? Yes and it won't change. diff --git a/nuget/README.md b/nuget/README.md index a754e7b..8811bcc 100644 --- a/nuget/README.md +++ b/nuget/README.md @@ -10,6 +10,10 @@ dotnet add package ReswPlus The package adds the generator and its transitive MSBuild integration. Add or update the default-language `.resw` file in your project and ReswPlus generates resource properties and methods during compilation. +Supports UWP, Windows App SDK, and Uno WinUI applications and libraries. Uno WinUI is detected automatically and uses WinUI XAML helpers with the Windows resource loader, without `UseUwp=true` or `Windows.UI.Xaml` compatibility types. + +If automatic detection cannot recognize your configuration or selects the wrong app model, opt in by setting `ReswPlusAppType` to `WindowsAppSDK`, `UWP`, or `UnoWinUI`. The default `Auto` preserves detection and the existing `UseUwp` fallback; an explicit selection overrides both. Configure it per target framework when needed, and retain the corresponding platform references. Invalid values report `RESWP0021`. + ## Features - Strongly typed resource properties for XAML and C# diff --git a/nuget/ReswPlus.targets b/nuget/ReswPlus.targets index f99e603..c0697d1 100644 --- a/nuget/ReswPlus.targets +++ b/nuget/ReswPlus.targets @@ -7,12 +7,14 @@ + $(AdditionalFileItemNames);PRIResource + Auto false true diff --git a/src/ReswPlus.SourceGenerator/AnalyzerReleases.Unshipped.md b/src/ReswPlus.SourceGenerator/AnalyzerReleases.Unshipped.md index 6108aa0..292ac88 100644 --- a/src/ReswPlus.SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/src/ReswPlus.SourceGenerator/AnalyzerReleases.Unshipped.md @@ -14,3 +14,4 @@ RESWP0017 | Resources | Info | A translated resource does not exist in th RESWP0018 | Resources | Info | A translated value is identical to its default-language value. RESWP0019 | Resources | Warning | A translation cannot serve the resource shape generated from the default language. RESWP0020 | Resources | Info | A translation defines variants that do not exist in the default language. +RESWP0021 | Compatibility | Error | ReswPlusAppType specifies an unsupported application model. diff --git a/src/ReswPlus.SourceGenerator/CodeGenerators/CsharpCodeGenerator.cs b/src/ReswPlus.SourceGenerator/CodeGenerators/CsharpCodeGenerator.cs index 4f8478b..f31bbe7 100644 --- a/src/ReswPlus.SourceGenerator/CodeGenerators/CsharpCodeGenerator.cs +++ b/src/ReswPlus.SourceGenerator/CodeGenerators/CsharpCodeGenerator.cs @@ -336,7 +336,7 @@ private static string DescribeParameter(Localization item, FunctionFormatTagPara /// private static string XamlNamespace(AppType appType) { - return appType == AppType.WindowsAppSDK ? "global::Microsoft.UI.Xaml" : "global::Windows.UI.Xaml"; + return appType is AppType.WindowsAppSDK or AppType.UnoWinUI ? "global::Microsoft.UI.Xaml" : "global::Windows.UI.Xaml"; } /// @@ -349,7 +349,7 @@ private IEnumerable GetUsings(AppType appType) UsingDirective(ParseName("System")) }; - if (appType == AppType.WindowsAppSDK) + if (appType is AppType.WindowsAppSDK or AppType.UnoWinUI) { usings.Add(UsingDirective(ParseName("Microsoft.UI.Xaml.Markup"))); usings.Add(UsingDirective(ParseName("Microsoft.UI.Xaml.Data"))); diff --git a/src/ReswPlus.SourceGenerator/Diagnostics.cs b/src/ReswPlus.SourceGenerator/Diagnostics.cs index 0b9d4f7..06991d8 100644 --- a/src/ReswPlus.SourceGenerator/Diagnostics.cs +++ b/src/ReswPlus.SourceGenerator/Diagnostics.cs @@ -71,12 +71,12 @@ internal static class Diagnostics isEnabledByDefault: true); /// - /// RESWP0005: the project is neither a UWP nor a WinAppSDK project. + /// RESWP0005: the project is not a UWP, WinAppSDK, or Uno WinUI project. /// public static readonly DiagnosticDescriptor UnrecognizedAppType = new( "RESWP0005", "Project type not recognized", - "ReswPlus only supports UWP and WinAppSDK applications/libraries", + "ReswPlus only supports UWP, WinAppSDK, and Uno WinUI applications/libraries; set ReswPlusAppType to WindowsAppSDK, UWP, or UnoWinUI to override automatic detection", CompatibilityCategory, DiagnosticSeverity.Error, isEnabledByDefault: true); @@ -272,6 +272,17 @@ internal static class Diagnostics DiagnosticSeverity.Info, isEnabledByDefault: true); + /// + /// RESWP0021: the explicit application model selection is not supported. + /// + public static readonly DiagnosticDescriptor InvalidAppTypeOverride = new( + "RESWP0021", + "Invalid application model override", + "ReswPlusAppType must be Auto, WindowsAppSDK, UWP, or UnoWinUI", + CompatibilityCategory, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + /// /// Returns the descriptor of a diagnostic from its identifier. /// @@ -291,6 +302,7 @@ public static DiagnosticDescriptor GetDescriptor(string id) "RESWP0003" => MissingRootPath, "RESWP0004" => UnknownProjectType, "RESWP0005" => UnrecognizedAppType, + "RESWP0021" => InvalidAppTypeOverride, _ => throw new System.ArgumentOutOfRangeException(nameof(id), id, "No diagnostic carries that identifier."), }; } diff --git a/src/ReswPlus.SourceGenerator/Pipeline/AppTypeSelection.cs b/src/ReswPlus.SourceGenerator/Pipeline/AppTypeSelection.cs new file mode 100644 index 0000000..d45a4dc --- /dev/null +++ b/src/ReswPlus.SourceGenerator/Pipeline/AppTypeSelection.cs @@ -0,0 +1,10 @@ +namespace ReswPlus.SourceGenerator.Pipeline; + +internal enum AppTypeSelection +{ + Auto, + WindowsAppSDK, + UWP, + UnoWinUI, + Invalid, +} diff --git a/src/ReswPlus.SourceGenerator/Pipeline/ReswBuildOptions.cs b/src/ReswPlus.SourceGenerator/Pipeline/ReswBuildOptions.cs index 4f27908..218f8bb 100644 --- a/src/ReswPlus.SourceGenerator/Pipeline/ReswBuildOptions.cs +++ b/src/ReswPlus.SourceGenerator/Pipeline/ReswBuildOptions.cs @@ -25,6 +25,7 @@ private ReswBuildOptions( string? rootNamespace, bool useApplicationLanguages, bool useUwp, + AppTypeSelection appTypeOverride, bool generateResourceInterfaces) { ProjectDir = projectDir; @@ -35,6 +36,7 @@ private ReswBuildOptions( RootNamespace = rootNamespace; UseApplicationLanguages = useApplicationLanguages; UseUwp = useUwp; + AppTypeOverride = appTypeOverride; GenerateResourceInterfaces = generateResourceInterfaces; } @@ -63,10 +65,16 @@ private ReswBuildOptions( /// A UWP project is otherwise recognized by the Windows.Foundation.UniversalApiContract reference it /// carries, which a UWP project built for Native AOT does not have. Such a project says so with this /// property instead. It fills in what the references don't say rather than overriding them: a compilation - /// whose references positively identify it is left alone. + /// whose references positively identify it is left alone. An explicit ReswPlusAppType takes + /// precedence over both the references and this fallback. /// public bool UseUwp { get; } + /// + /// Gets the explicit application model selection, or to use detection. + /// + public AppTypeSelection AppTypeOverride { get; } + /// /// Gets whether injectable resource interfaces and providers should be generated. /// @@ -88,11 +96,39 @@ public static ReswBuildOptions Read(AnalyzerConfigOptions globalOptions) Get("build_property.RootNamespace"), bool.TryParse(Get("build_property.ReswPlusUseApplicationLanguages"), out var parsed) && parsed, bool.TryParse(Get("build_property.UseUwp"), out var parsedUseUwp) && parsedUseUwp, + ParseAppTypeOverride(Get("build_property.ReswPlusAppType")), !bool.TryParse(Get("build_property.ReswPlusGenerateResourceInterfaces"), out var parsedInterfaces) || parsedInterfaces); string? Get(string key) => globalOptions.TryGetValue(key, out var value) ? value : null; } + private static AppTypeSelection ParseAppTypeOverride(string? value) + { + value = value?.Trim(); + + if (string.IsNullOrEmpty(value) || string.Equals(value, "Auto", StringComparison.OrdinalIgnoreCase)) + { + return AppTypeSelection.Auto; + } + + if (string.Equals(value, "WindowsAppSDK", StringComparison.OrdinalIgnoreCase)) + { + return AppTypeSelection.WindowsAppSDK; + } + + if (string.Equals(value, "UWP", StringComparison.OrdinalIgnoreCase)) + { + return AppTypeSelection.UWP; + } + + if (string.Equals(value, "UnoWinUI", StringComparison.OrdinalIgnoreCase)) + { + return AppTypeSelection.UnoWinUI; + } + + return AppTypeSelection.Invalid; + } + /// /// Returns the directory the project is rooted at. /// @@ -119,6 +155,7 @@ public bool Equals(ReswBuildOptions? other) && RootNamespace == other.RootNamespace && UseApplicationLanguages == other.UseApplicationLanguages && UseUwp == other.UseUwp + && AppTypeOverride == other.AppTypeOverride && GenerateResourceInterfaces == other.GenerateResourceInterfaces; } @@ -137,6 +174,7 @@ public override int GetHashCode() hash = (hash * 31) + UseApplicationLanguages.GetHashCode(); hash = (hash * 31) + UseUwp.GetHashCode(); + hash = (hash * 31) + AppTypeOverride.GetHashCode(); return (hash * 31) + GenerateResourceInterfaces.GetHashCode(); } } diff --git a/src/ReswPlus.SourceGenerator/Pipeline/ReswProject.cs b/src/ReswPlus.SourceGenerator/Pipeline/ReswProject.cs index e4ae65a..3bbff3c 100644 --- a/src/ReswPlus.SourceGenerator/Pipeline/ReswProject.cs +++ b/src/ReswPlus.SourceGenerator/Pipeline/ReswProject.cs @@ -108,15 +108,24 @@ public static ReswProject Create(CompilationInfo compilationInfo, ReswBuildOptio problems.Add(Diagnostics.UnknownProjectType.Id); } - // A UWP project built for Native AOT carries no 'Windows.Foundation.UniversalApiContract' reference for - // the compilation to be recognized by, and says what it is with the 'UseUwp' property instead. The - // property fills in what the references don't say rather than overriding them, so that a project whose - // references positively identify it can never be taken for something else by a stray property. - var appType = compilationInfo.AppType is AppType.Unknown && options.UseUwp - ? AppType.UWP - : compilationInfo.AppType; - - if (appType is not (AppType.WindowsAppSDK or AppType.UWP)) + if (options.AppTypeOverride == AppTypeSelection.Invalid) + { + return Unsupported([.. problems, Diagnostics.InvalidAppTypeOverride.Id]); + } + + // The explicit selection overrides detection; UseUwp remains only a fallback for unrecognized projects. + var appType = options.AppTypeOverride switch + { + AppTypeSelection.WindowsAppSDK => AppType.WindowsAppSDK, + AppTypeSelection.UWP => AppType.UWP, + AppTypeSelection.UnoWinUI => AppType.UnoWinUI, + AppTypeSelection.Auto => compilationInfo.AppType is AppType.Unknown && options.UseUwp + ? AppType.UWP + : compilationInfo.AppType, + _ => throw new ArgumentOutOfRangeException(nameof(options.AppTypeOverride)), + }; + + if (appType is not (AppType.WindowsAppSDK or AppType.UWP or AppType.UnoWinUI)) { return Unsupported([.. problems, Diagnostics.UnrecognizedAppType.Id]); } diff --git a/src/ReswPlus.SourceGenerator/ReswGenerator.cs b/src/ReswPlus.SourceGenerator/ReswGenerator.cs index 8597bfc..5177d60 100644 --- a/src/ReswPlus.SourceGenerator/ReswGenerator.cs +++ b/src/ReswPlus.SourceGenerator/ReswGenerator.cs @@ -26,6 +26,7 @@ public enum AppType Unknown, WindowsAppSDK, UWP, + UnoWinUI, } /// @@ -264,10 +265,17 @@ private static void EmitSupport(SourceProductionContext spc, ReswSupport support } /// - /// Determines the application type (WindowsAppSDK, UWP, or Unknown) by inspecting the compilation's external references. + /// Determines the application type by inspecting the compilation's referenced types and assemblies. /// private static AppType RetrieveAppType(Compilation compilation) { + // Uno provides WinUI XAML types, but implements the Windows resource loader. + var dependencyObject = compilation.GetTypeByMetadataName("Microsoft.UI.Xaml.DependencyObject"); + if (dependencyObject?.ContainingAssembly.Identity.Name is "Uno.UI" or "Uno.WinUI") + { + return AppType.UnoWinUI; + } + return compilation.ExternalReferences.Any(r => r.Display?.IndexOf("Microsoft.WindowsAppSdk", StringComparison.OrdinalIgnoreCase) >= 0) ? AppType.WindowsAppSDK diff --git a/tests/ReswPlusUnitTests/AppTypeBuildIntegration.cs b/tests/ReswPlusUnitTests/AppTypeBuildIntegration.cs new file mode 100644 index 0000000..99e425f --- /dev/null +++ b/tests/ReswPlusUnitTests/AppTypeBuildIntegration.cs @@ -0,0 +1,138 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Xunit; + +namespace ReswPlusUnitTests; + +public class AppTypeBuildIntegration +{ + [Theory] + [InlineData(null, "Auto")] + [InlineData("", "Auto")] + [InlineData("WindowsAppSDK", "WindowsAppSDK")] + [InlineData("UWP", "UWP")] + [InlineData("UnoWinUI", "UnoWinUI")] + [InlineData(" uWp ", " uWp ")] + [InlineData("Invalid", "Invalid")] + public async Task ThePackagedTargetsExposeTheDefaultOrUserSelection(string? selection, string expected) + { + var properties = new XElement("PropertyGroup"); + if (selection is not null) + { + properties.Add(new XElement("ReswPlusAppType", selection)); + } + + await AssertSelectionAsync(properties, expected); + } + + [Theory] + [InlineData("", "Auto")] + [InlineData("net10.0-android", "UnoWinUI")] + [InlineData("net10.0-ios", "UnoWinUI")] + [InlineData("net10.0", "UnoWinUI")] + [InlineData("net10.0-windows10.0.19041.0", "Auto")] + public async Task TheDocumentedConditionLeavesWindowsHeadsOnAutomaticDetection(string targetFramework, string expected) + { + var readme = File.ReadAllText(Path.Combine(RepositoryPath(), "README.md")); + var example = Regex.Match( + readme, + @"### Override application model detection.*?```xml\s*(?.*?)```", + RegexOptions.Singleline); + Assert.True(example.Success, "The README must include the per-target-framework override example."); + + var properties = XElement.Parse(example.Groups["xml"].Value); + await AssertSelectionAsync( + new XElement("PropertyGroup", new XElement("TargetFramework", targetFramework)), + expected, + properties); + } + + private static async Task AssertSelectionAsync(XElement properties, string expected, XElement? additionalProperties = null) + { + var root = Path.Combine(Path.GetTempPath(), "ReswPlusUnitTests", $"AppType-{Guid.NewGuid():N}"); + var logDirectory = Path.Combine(AppContext.BaseDirectory, "BuildLogs"); + var binlog = Path.Combine(logDirectory, $"app-type-{Guid.NewGuid():N}.binlog"); + + try + { + Directory.CreateDirectory(root); + Directory.CreateDirectory(logDirectory); + var project = new XElement( + "Project", + properties, + additionalProperties, + new XElement("Import", new XAttribute( + "Project", + Path.Combine(RepositoryPath(), "nuget", "ReswPlus.targets")))); + var projectPath = Path.Combine(root, "PropertyProbe.proj"); + project.Save(projectPath); + + var startInfo = new ProcessStartInfo("dotnet") + { + WorkingDirectory = RepositoryPath(), + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + foreach (var argument in new[] + { + "msbuild", + projectPath, + "-nologo", + "-verbosity:quiet", + "-getProperty:ReswPlusAppType", + "-getItem:CompilerVisibleProperty", + $"-bl:{binlog}", + }) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Could not start dotnet msbuild."); + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + throw; + } + var output = await outputTask; + var errors = await errorTask; + Assert.True(process.ExitCode == 0, $"MSBuild property evaluation failed:{Environment.NewLine}{output}{errors}{Environment.NewLine}Binlog: {binlog}"); + + using var result = JsonDocument.Parse(output); + Assert.Equal(expected, result.RootElement.GetProperty("Properties").GetProperty("ReswPlusAppType").GetString()); + var exportedProperties = result.RootElement.GetProperty("Items").GetProperty("CompilerVisibleProperty") + .EnumerateArray() + .Select(item => item.GetProperty("Identity").GetString()); + Assert.Contains("ReswPlusAppType", exportedProperties); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + + private static string RepositoryPath([CallerFilePath] string sourceFile = "") + { + return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(sourceFile)!, "..", "..")); + } +} diff --git a/tests/ReswPlusUnitTests/AppTypeDetection.cs b/tests/ReswPlusUnitTests/AppTypeDetection.cs new file mode 100644 index 0000000..571ebdd --- /dev/null +++ b/tests/ReswPlusUnitTests/AppTypeDetection.cs @@ -0,0 +1,388 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using ReswPlus.SourceGenerator; +using ReswPlus.SourceGenerator.Pipeline; +using Xunit; + +namespace ReswPlusUnitTests; + +public class AppTypeDetection +{ + private static ImmutableArray FrameworkReferences => PlatformStubs.ReferencesFor(AppType.Unknown); + + private static string SampleResources => ReswTestHelpers.CreateResw( + ("Welcome", "Welcome!", null), + ("Greeting", "Hello {0}", "#Format[String name]"), + ("FileCount_None", "No files", null), + ("FileCount_One", "{0} file", "#Format[Plural Int count]"), + ("FileCount_Other", "{0} files", "#Format[Plural Int count]")); + + [Theory] + [InlineData("Uno.UI", "interface", false, false)] + [InlineData("Uno.UI", "interface", true, false)] + [InlineData("Uno.UI", "class", false, false)] + [InlineData("Uno.UI", "class", true, false)] + [InlineData("Uno.UI", "interface", false, true)] + [InlineData("Uno.UI", "interface", true, true)] + [InlineData("Uno.UI", "class", false, true)] + [InlineData("Uno.UI", "class", true, true)] + [InlineData("Uno.WinUI", "interface", false, false)] + [InlineData("Uno.WinUI", "interface", true, false)] + [InlineData("Uno.WinUI", "class", false, false)] + [InlineData("Uno.WinUI", "class", true, false)] + [InlineData("Uno.WinUI", "interface", false, true)] + [InlineData("Uno.WinUI", "interface", true, true)] + [InlineData("Uno.WinUI", "class", false, true)] + [InlineData("Uno.WinUI", "class", true, true)] + public void UnoWinUIUsesWinUIHelpersAndWindowsResources(string unoAssemblyName, string typeKind, bool isLibrary, bool useApplicationLanguages) + { + var reference = CreatePlatformReference(AppType.UnoWinUI, typeKind, unoAssemblyName); + Assert.DoesNotContain("Uno", reference.Display!); + + var run = Run([reference], isLibrary, useApplicationLanguages); + Assert.Empty(run.Diagnostics); + + var resources = run.Source("Resources.resw"); + Assert.Contains("using Microsoft.UI.Xaml.Markup;", resources); + Assert.Contains("using Microsoft.UI.Xaml.Data;", resources); + Assert.Contains(isLibrary ? "\"TestProject/Resources\"" : "\"Resources\"", resources); + Assert.Contains("public static string Welcome", resources); + Assert.Contains("public static string Greeting(string name)", resources); + Assert.Contains("public static string FileCount(int count)", resources); + Assert.Contains("[global::Microsoft.UI.Xaml.Markup.MarkupExtensionReturnType(ReturnType = typeof(string))]", resources); + Assert.Contains("public global::Microsoft.UI.Xaml.Data.IValueConverter Converter", resources); + Assert.Contains("public interface IResources", resources); + Assert.Contains("public sealed class ResourcesProvider : IResources", resources); + + var provider = run.Source("ResourceStringProvider"); + Assert.Contains("using Windows.ApplicationModel.Resources;", provider); + Assert.Contains("ResourceLoader.GetForViewIndependentUse(resourceMapId)", provider); + var plurals = run.Source("ResourceLoaderExtension"); + Assert.Contains("GetPlural", plurals); + if (useApplicationLanguages) + { + Assert.Contains("global::Windows.Globalization.ApplicationLanguages.Languages", plurals); + } + else + { + Assert.DoesNotContain("global::Windows.Globalization.ApplicationLanguages", plurals); + } + + foreach (var source in run.Sources.Values) + { + Assert.DoesNotContain("Windows.UI.Xaml", source); + Assert.DoesNotContain("Microsoft.Windows.ApplicationModel.Resources", source); + Assert.DoesNotContain("Microsoft.Windows.Globalization", source); + } + + run.AssertCompiles(); + AssertHelperTypes(run.OutputCompilation, "Microsoft.UI.Xaml", unoAssemblyName); + } + + [Theory] + [InlineData(AppType.WindowsAppSDK, "Microsoft.UI.Xaml", "Microsoft.Windows.ApplicationModel.Resources")] + [InlineData(AppType.UWP, "Windows.UI.Xaml", "Windows.ApplicationModel.Resources")] + public void ExistingPlatformsKeepTheirHelpersAndResourceLoaders(AppType appType, string xamlNamespace, string resourceNamespace) + { + var run = Run([CreatePlatformReference(appType)]); + + Assert.Empty(run.Diagnostics); + Assert.Equal(ReswTestHelpers.GenerateCode(SampleResources, appType), run.Source("Resources.resw")); + var provider = run.Source("ResourceStringProvider"); + Assert.Contains($"using {resourceNamespace};", provider); + Assert.Contains( + appType == AppType.UWP + ? "ResourceLoader.GetForViewIndependentUse(resourceMapId)" + : "new ResourceLoader(ResourceLoader.GetDefaultResourceFilePath(), resourceMapId)", + provider); + run.AssertCompiles(); + AssertHelperTypes(run.OutputCompilation, xamlNamespace, appType == AppType.UWP ? "Windows.Foundation.UniversalApiContract" : "Microsoft.WinUI"); + } + + [Theory] + [InlineData("Unrelated.UI", "Microsoft.UI.Xaml")] + [InlineData("Uno.WinUI.Unrelated", "Microsoft.UI.Xaml")] + [InlineData("Uno.UI", "Windows.UI.Xaml")] + [InlineData("Uno.WinUI", "Windows.UI.Xaml")] + public void MatchingNamespaceOrSimilarAssemblyNameAloneDoesNotSelectUno(string assemblyName, string xamlNamespace) + { + var reference = CreateReference(assemblyName, $"namespace {xamlNamespace} {{ public interface DependencyObject {{ }} }}"); + + AssertUnrecognized(Run([reference])); + } + + [Theory] + [InlineData("Uno.UI")] + [InlineData("Uno.WinUI")] + public void MissingDependencyObjectDoesNotSelectUno(string unoAssemblyName) + { + AssertUnrecognized(Run([CreateReference(unoAssemblyName, "public class UnrelatedType { }")])); + } + + [Theory] + [InlineData("Uno.UI")] + [InlineData("Uno.WinUI")] + public void AmbiguousDependencyObjectDoesNotSelectUno(string unoAssemblyName) + { + const string source = "namespace Microsoft.UI.Xaml { public interface DependencyObject { } }"; + var run = Run([CreateReference(unoAssemblyName, source), CreateReference("Unrelated.UI", source)]); + + Assert.Null(run.OutputCompilation.GetTypeByMetadataName("Microsoft.UI.Xaml.DependencyObject")); + AssertUnrecognized(run); + } + + [Theory] + [InlineData(AppType.WindowsAppSDK, "Uno.UI")] + [InlineData(AppType.UWP, "Uno.UI")] + [InlineData(AppType.WindowsAppSDK, "Uno.WinUI")] + [InlineData(AppType.UWP, "Uno.WinUI")] + public void UnoWinUIDetectionTakesPrecedenceOverReferencePathHints(AppType hintedAppType, string unoAssemblyName) + { + var hintedReference = CreateReference("PathHint", "public class UnrelatedType { }", GetReferencePath(hintedAppType)); + var run = Run([CreatePlatformReference(AppType.UnoWinUI, unoAssemblyName: unoAssemblyName), hintedReference]); + + Assert.Empty(run.Diagnostics); + Assert.Contains("using Microsoft.UI.Xaml.Markup;", run.Source("Resources.resw")); + Assert.Contains("using Windows.ApplicationModel.Resources;", run.Source("ResourceStringProvider")); + } + + [Theory] + [InlineData(AppType.WindowsAppSDK, false)] + [InlineData(AppType.WindowsAppSDK, true)] + [InlineData(AppType.UWP, false)] + [InlineData(AppType.UWP, true)] + public void RejectedUnoTypesPreserveExistingFallbacks(AppType appType, bool ambiguous) + { + var references = ImmutableArray.Create(CreatePlatformReference(appType)); + if (ambiguous) + { + const string source = "namespace Microsoft.UI.Xaml { public interface DependencyObject { } }"; + references = references.Add(CreateReference("Uno.UI", source)).Add(CreateReference("Unrelated.UI", source)); + } + else + { + references = references.Add(CreateReference("Uno.UI", "public class UnrelatedType { }")); + } + + var run = Run(references); + + Assert.Empty(run.Diagnostics); + Assert.Equal(ReswTestHelpers.GenerateCode(SampleResources, appType), run.Source("Resources.resw")); + Assert.Contains( + appType == AppType.UWP ? "using Windows.ApplicationModel.Resources;" : "using Microsoft.Windows.ApplicationModel.Resources;", + run.Source("ResourceStringProvider")); + } + + [Fact] + public void WinAppSDKStillTakesPrecedenceOverUWPReferencePath() + { + var run = Run([ + CreatePlatformReference(AppType.WindowsAppSDK), + CreateReference("UwpPathHint", "public class UnrelatedType { }", GetReferencePath(AppType.UWP))]); + + Assert.Empty(run.Diagnostics); + Assert.Equal(ReswTestHelpers.GenerateCode(SampleResources), run.Source("Resources.resw")); + Assert.Contains("using Microsoft.Windows.ApplicationModel.Resources;", run.Source("ResourceStringProvider")); + } + + [Theory] + [InlineData("Uno.UI")] + [InlineData("Uno.WinUI")] + public void ChangingReferencesInvalidatesTheIncrementalAppType(string unoAssemblyName) + { + var before = Run([CreatePlatformReference(AppType.WindowsAppSDK)]); + Assert.Contains("using Microsoft.Windows.ApplicationModel.Resources;", before.Source("ResourceStringProvider")); + + var after = before.RunAgainWithReferences(FrameworkReferences.Add(CreatePlatformReference(AppType.UnoWinUI, unoAssemblyName: unoAssemblyName))); + + Assert.Empty(after.Diagnostics); + Assert.Contains("using Windows.ApplicationModel.Resources;", after.Source("ResourceStringProvider")); + Assert.Equal(before.Source("Resources.resw"), after.Source("Resources.resw")); + Assert.Equal(1, after.RecomputedCount(TrackingNames.CompilationInfo)); + Assert.Equal(1, after.RecomputedCount(TrackingNames.Project)); + Assert.Equal(1, after.RecomputedCount(TrackingNames.Support)); + after.AssertCompiles(); + + Assert.NotEqual( + new CompilationInfo(true, AppType.WindowsAppSDK, "TestProject"), + new CompilationInfo(true, AppType.UnoWinUI, "TestProject")); + Assert.Equal( + new CompilationInfo(true, AppType.UnoWinUI, "TestProject"), + new CompilationInfo(true, AppType.UnoWinUI, "TestProject")); + } + + [Fact] + public void MovingDependencyObjectToTheUnoWinUIAssemblyKeepsTheAppModelUnchanged() + { + var before = Run([CreatePlatformReference(AppType.UnoWinUI)]); + var after = before.RunAgainWithReferences(FrameworkReferences.Add( + CreatePlatformReference(AppType.UnoWinUI, unoAssemblyName: "Uno.WinUI"))); + + Assert.Empty(after.Diagnostics); + Assert.Equal(before.Source("Resources.resw"), after.Source("Resources.resw")); + Assert.Equal(before.Source("ResourceStringProvider"), after.Source("ResourceStringProvider")); + after.AssertReused(TrackingNames.CompilationInfo); + after.AssertReused(TrackingNames.Project); + after.AssertReused(TrackingNames.Generation); + after.AssertReused(TrackingNames.Support); + AssertHelperTypes(after.OutputCompilation, "Microsoft.UI.Xaml", "Uno.WinUI"); + after.AssertCompiles(); + } + + [Fact] + public void ADependencyObjectDefinedInBothUnoAssembliesIsAmbiguous() + { + const string source = "namespace Microsoft.UI.Xaml { public interface DependencyObject { } }"; + var run = Run([CreateReference("Uno.UI", source), CreateReference("Uno.WinUI", source)]); + + Assert.Null(run.OutputCompilation.GetTypeByMetadataName("Microsoft.UI.Xaml.DependencyObject")); + AssertUnrecognized(run); + } + + private static void AssertHelperTypes(Compilation compilation, string xamlNamespace, string assemblyName) + { + var extension = compilation.GetTypeByMetadataName("TestProject.Strings.ResourcesExtension"); + Assert.NotNull(extension); + Assert.Equal($"{xamlNamespace}.Markup.MarkupExtension", extension.BaseType!.ToDisplayString()); + Assert.Equal(assemblyName, extension.BaseType.ContainingAssembly.Identity.Name); + var converter = Assert.IsAssignableFrom(Assert.Single(extension.GetMembers("Converter"))); + Assert.Equal($"{xamlNamespace}.Data.IValueConverter", converter.Type.ToDisplayString()); + Assert.Equal(assemblyName, converter.Type.ContainingAssembly.Identity.Name); + Assert.Contains(extension.GetAttributes(), attribute => + attribute.AttributeClass?.ToDisplayString() == $"{xamlNamespace}.Markup.MarkupExtensionReturnTypeAttribute" && + attribute.AttributeClass.ContainingAssembly.Identity.Name == assemblyName); + } + + [Theory] + [InlineData(false, "Uno.UI")] + [InlineData(true, "Uno.UI")] + [InlineData(false, "Uno.WinUI")] + [InlineData(true, "Uno.WinUI")] + public void UseUwpDoesNotOverrideUnoWinUIReferences(bool useUwp, string unoAssemblyName) + { + var run = Run([CreatePlatformReference(AppType.UnoWinUI, unoAssemblyName: unoAssemblyName)], useUwp: useUwp); + + Assert.Empty(run.Diagnostics); + AssertHelperTypes(run.OutputCompilation, "Microsoft.UI.Xaml", unoAssemblyName); + run.AssertCompiles(); + } + + [Theory] + [InlineData(false, "Uno.UI")] + [InlineData(true, "Uno.UI")] + [InlineData(false, "Uno.WinUI")] + [InlineData(true, "Uno.WinUI")] + public void UnoWinUIHonorsResourceInterfaceGeneration(bool generateResourceInterfaces, string unoAssemblyName) + { + var run = Run( + [CreatePlatformReference(AppType.UnoWinUI, unoAssemblyName: unoAssemblyName)], + generateResourceInterfaces: generateResourceInterfaces); + var resources = run.Source("Resources.resw"); + + Assert.Empty(run.Diagnostics); + Assert.Equal(generateResourceInterfaces, resources.Contains("public interface IResources")); + Assert.Equal(generateResourceInterfaces, resources.Contains("public sealed class ResourcesProvider : IResources")); + run.AssertCompiles(); + } + + private static void AssertUnrecognized(ReswGeneratorRun run) + { + Assert.Equal("RESWP0005", Assert.Single(run.Diagnostics).Id); + Assert.Empty(run.Sources); + } + + private static ReswGeneratorRun Run( + ImmutableArray references, + bool isLibrary = false, + bool useApplicationLanguages = false, + bool? useUwp = null, + bool? generateResourceInterfaces = null) + { + return ReswGeneratorHarness.Run( + [ReswGeneratorHarness.File("en-US", SampleResources)], + outputType: isLibrary ? "Library" : "WinExe", + defaultLanguage: "en-US", + useApplicationLanguages: useApplicationLanguages, + useUwp: useUwp, + generateResourceInterfaces: generateResourceInterfaces, + references: FrameworkReferences.AddRange(references), + projectDir: null, + msBuildProjectFullPath: @"C:\Project\TestProject.csproj"); + } + + private static MetadataReference CreateReference(string assemblyName, string source, string? filePath = null) + { + return PlatformStubs.Compile(assemblyName, source, filePath ?? @"C:\References\Platform.dll"); + } + + private static string GetReferencePath(AppType appType) + { + return appType switch + { + AppType.WindowsAppSDK => @"C:\Packages\Microsoft.WindowsAppSdk\Platform.dll", + AppType.UWP => @"C:\References\Windows.Foundation.UniversalApiContract.winmd", + _ => @"C:\References\Platform.dll" + }; + } + + private static MetadataReference CreatePlatformReference(AppType appType, string typeKind = "class", string unoAssemblyName = "Uno.UI") + { + var xamlNamespace = appType == AppType.UWP ? "Windows.UI.Xaml" : "Microsoft.UI.Xaml"; + var resourceNamespace = appType == AppType.WindowsAppSDK + ? "Microsoft.Windows.ApplicationModel.Resources" + : "Windows.ApplicationModel.Resources"; + var assemblyName = appType switch + { + AppType.WindowsAppSDK => "Microsoft.WinUI", + AppType.UWP => "Windows.Foundation.UniversalApiContract", + _ => unoAssemblyName + }; + var source = $$""" + namespace {{xamlNamespace}} + { + public {{typeKind}} DependencyObject { } + } + namespace {{xamlNamespace}}.Markup + { + public abstract class MarkupExtension + { + protected abstract object ProvideValue(); + } + public sealed class MarkupExtensionReturnTypeAttribute : System.Attribute + { + public System.Type ReturnType { get; set; } + } + } + namespace {{xamlNamespace}}.Data + { + public interface IValueConverter + { + object Convert(object value, System.Type targetType, object parameter, string language); + object ConvertBack(object value, System.Type targetType, object parameter, string language); + } + } + namespace {{resourceNamespace}} + { + public sealed class ResourceLoader + { + {{(appType == AppType.WindowsAppSDK ? """ + public ResourceLoader(string filePath, string resourceMapId) { } + public static string GetDefaultResourceFilePath() => ""; + """ : """ + public static ResourceLoader GetForViewIndependentUse(string resourceMapId) => new ResourceLoader(); + """)}} + public string GetString(string resourceId) => resourceId; + } + } + namespace Windows.Globalization + { + public static class ApplicationLanguages + { + public static System.Collections.Generic.IReadOnlyList Languages { get; set; } + } + } + """; + + return CreateReference(assemblyName, source, GetReferencePath(appType)); + } +} diff --git a/tests/ReswPlusUnitTests/AppTypeOverride.cs b/tests/ReswPlusUnitTests/AppTypeOverride.cs new file mode 100644 index 0000000..926b534 --- /dev/null +++ b/tests/ReswPlusUnitTests/AppTypeOverride.cs @@ -0,0 +1,428 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using ReswPlus.SourceGenerator; +using ReswPlus.SourceGenerator.Pipeline; +using Xunit; + +namespace ReswPlusUnitTests; + +public class AppTypeOverride +{ + private static string SampleResources => ReswTestHelpers.CreateResw( + ("Welcome", "Welcome!", null), + ("Greeting", "Hello {0}", "#Format[String name]"), + ("Items_One", "{0} item", "#Format[Plural Int count]"), + ("Items_Other", "{0} items", "#Format[Plural Int count]")); + + [Theory] + [InlineData(AppType.WindowsAppSDK, null)] + [InlineData(AppType.WindowsAppSDK, "")] + [InlineData(AppType.WindowsAppSDK, " \t ")] + [InlineData(AppType.WindowsAppSDK, "Auto")] + [InlineData(AppType.WindowsAppSDK, " auto ")] + [InlineData(AppType.UWP, null)] + [InlineData(AppType.UWP, "")] + [InlineData(AppType.UWP, " \t ")] + [InlineData(AppType.UWP, "Auto")] + [InlineData(AppType.UWP, " auto ")] + [InlineData(AppType.UnoWinUI, null)] + [InlineData(AppType.UnoWinUI, "")] + [InlineData(AppType.UnoWinUI, " \t ")] + [InlineData(AppType.UnoWinUI, "Auto")] + [InlineData(AppType.UnoWinUI, " auto ")] + public void AutomaticSelectionPreservesDetection(AppType appType, string? selection) + { + var run = Run(appType, selection); + + Assert.Empty(run.Diagnostics); + AssertAppModel(run, appType); + run.AssertCompiles(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" \t ")] + [InlineData("Auto")] + [InlineData(" auto ")] + public void AutomaticSelectionPreservesTheLegacyUseUwpFallback(string? selection) + { + var run = Run( + AppType.Unknown, + selection, + useUwp: true, + references: PlatformStubs.NativeAotUwpReferences()); + + Assert.Empty(run.Diagnostics); + AssertAppModel(run, AppType.UWP); + run.AssertCompiles(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Auto")] + public void AutomaticSelectionDoesNotInventAnAppModel(string? selection) + { + var run = Run(AppType.Unknown, selection); + var diagnostic = Assert.Single(run.Diagnostics); + + Assert.Equal("RESWP0005", diagnostic.Id); + Assert.Contains("ReswPlusAppType", diagnostic.GetMessage()); + Assert.Empty(run.Sources); + } + + [Theory] + [InlineData(AppType.WindowsAppSDK, false)] + [InlineData(AppType.WindowsAppSDK, true)] + [InlineData(AppType.UWP, false)] + [InlineData(AppType.UWP, true)] + [InlineData(AppType.UnoWinUI, false)] + [InlineData(AppType.UnoWinUI, true)] + public void AnExplicitSelectionRecognizesAnOtherwiseUnknownProjection(AppType appType, bool useApplicationLanguages) + { + var references = PlatformStubs.ReferencesWithDetectionHint(appType); + Assert.Equal("RESWP0005", Assert.Single(Run(AppType.Unknown, references: references).Diagnostics).Id); + + var run = Run( + AppType.Unknown, + appType.ToString(), + useApplicationLanguages: useApplicationLanguages, + references: references); + + Assert.Empty(run.Diagnostics); + AssertAppModel(run, appType); + Assert.Contains("public interface IResources", run.Source("Resources.resw")); + Assert.Contains("public sealed class ResourcesProvider : IResources", run.Source("Resources.resw")); + if (useApplicationLanguages) + { + var pluralSupport = run.Source("ResourceLoaderExtension"); + Assert.Contains("global::Windows.Globalization.ApplicationLanguages.Languages", pluralSupport); + Assert.Equal( + appType == AppType.WindowsAppSDK, + pluralSupport.Contains("global::Microsoft.Windows.Globalization.ApplicationLanguages.PrimaryLanguageOverride")); + } + run.AssertCompiles(); + } + + [Theory] + [InlineData(AppType.WindowsAppSDK, AppType.UWP)] + [InlineData(AppType.UWP, AppType.WindowsAppSDK)] + [InlineData(AppType.UnoWinUI, AppType.WindowsAppSDK)] + [InlineData(AppType.UnoWinUI, AppType.UWP)] + public void AnExplicitSelectionOverridesMisleadingReferences(AppType actualAppType, AppType hintedAppType) + { + var references = PlatformStubs.ReferencesWithDetectionHint(actualAppType, hintedAppType); + var automatic = Run(AppType.Unknown, references: references); + Assert.Empty(automatic.Diagnostics); + AssertAppModel(automatic, hintedAppType); + + var explicitSelection = Run( + AppType.Unknown, + actualAppType.ToString(), + useApplicationLanguages: true, + references: references); + + Assert.Empty(explicitSelection.Diagnostics); + AssertAppModel(explicitSelection, actualAppType); + explicitSelection.AssertCompiles(); + } + + [Theory] + [InlineData(AppType.WindowsAppSDK)] + [InlineData(AppType.UWP)] + [InlineData(AppType.UnoWinUI)] + public void AnExplicitSelectionTakesPrecedenceOverUseUwp(AppType appType) + { + var run = Run( + AppType.Unknown, + appType.ToString(), + useUwp: true, + references: PlatformStubs.ReferencesWithDetectionHint(appType)); + + Assert.Empty(run.Diagnostics); + AssertAppModel(run, appType); + run.AssertCompiles(); + } + + [Theory] + [InlineData(AppType.WindowsAppSDK, AppType.UWP)] + [InlineData(AppType.WindowsAppSDK, AppType.UnoWinUI)] + [InlineData(AppType.UWP, AppType.WindowsAppSDK)] + [InlineData(AppType.UWP, AppType.UnoWinUI)] + [InlineData(AppType.UnoWinUI, AppType.WindowsAppSDK)] + [InlineData(AppType.UnoWinUI, AppType.UWP)] + public void AnExplicitSelectionTakesPrecedenceOverEveryDetectedModel(AppType detectedAppType, AppType selectedAppType) + { + var run = Run(detectedAppType, selectedAppType.ToString(), useUwp: true); + + Assert.Empty(run.Diagnostics); + AssertAppModel(run, selectedAppType); + } + + [Theory] + [InlineData(AppType.WindowsAppSDK)] + [InlineData(AppType.UWP)] + [InlineData(AppType.UnoWinUI)] + public void OverridesPreserveTheResourceInterfaceGenerationSetting(AppType appType) + { + var run = ReswGeneratorHarness.Run( + [ReswGeneratorHarness.File("en-US", SampleResources)], + references: PlatformStubs.ReferencesWithDetectionHint(appType), + appTypeOverride: appType.ToString(), + generateResourceInterfaces: false); + + Assert.Empty(run.Diagnostics); + Assert.DoesNotContain("public interface IResources", run.Source("Resources.resw")); + Assert.DoesNotContain("public sealed class ResourcesProvider", run.Source("Resources.resw")); + run.AssertCompiles(); + } + + [Theory] + [InlineData(" windowsappsdk ", AppType.WindowsAppSDK)] + [InlineData(" uwp ", AppType.UWP)] + [InlineData(" unowinui ", AppType.UnoWinUI)] + public void NamedSelectionsIgnoreCaseAndSurroundingWhitespace(string selection, AppType appType) + { + var run = Run(AppType.Unknown, selection, references: PlatformStubs.ReferencesWithDetectionHint(appType)); + + Assert.Empty(run.Diagnostics); + AssertAppModel(run, appType); + run.AssertCompiles(); + } + + [Theory] + [InlineData("Unknown")] + [InlineData("Uno.WinUI")] + [InlineData("WinAppSDK")] + [InlineData("3")] + [InlineData(" 1 ")] + [InlineData("-1")] + [InlineData("WindowsAppSDK, UWP")] + [InlineData("Auto;UnoWinUI")] + [InlineData("UnowinU\u0131")] + public void InvalidSelectionsReportAnErrorInsteadOfFallingBack(string selection) + { + foreach (var detectedAppType in new[] { AppType.Unknown, AppType.WindowsAppSDK, AppType.UWP, AppType.UnoWinUI }) + { + var run = Run(detectedAppType, selection, useUwp: true); + var diagnostic = Assert.Single(run.Diagnostics); + + Assert.Equal("RESWP0021", diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity); + Assert.Contains("ReswPlusAppType", diagnostic.GetMessage()); + Assert.Contains("Auto, WindowsAppSDK, UWP, or UnoWinUI", diagnostic.GetMessage()); + Assert.Empty(run.Sources); + } + } + + [Fact] + public void AnInvalidSelectionPreservesOtherSetupProblemsWithoutReportingUnrecognizedAppType() + { + var run = ReswGeneratorHarness.Run( + [ReswGeneratorHarness.File("en-US", SampleResources)], + outputType: null, + appTypeOverride: "Unknown"); + + Assert.Equal(new[] { "RESWP0004", "RESWP0021" }, run.DiagnosticIds); + Assert.Empty(run.Sources); + } + + [Fact] + public void AnOverrideDoesNotSupplyMissingPlatformReferences() + { + var run = Run(AppType.Unknown, "UnoWinUI"); + + Assert.Empty(run.Diagnostics); + AssertAppModel(run, AppType.UnoWinUI); + Assert.Contains(run.OutputCompilation.GetDiagnostics(), diagnostic => diagnostic.Severity == DiagnosticSeverity.Error); + } + + [Theory] + [InlineData(null, "Auto")] + [InlineData("", " auto ")] + [InlineData(" \t ", "Auto")] + [InlineData("WindowsAppSDK", " windowsappsdk ")] + [InlineData("UWP", " uwp ")] + [InlineData("UnoWinUI", " unowinui ")] + [InlineData("Unknown", "AnotherInvalidValue")] + public void EquivalentSelectionsCompareAndHashAlike(string? first, string? second) + { + var firstOptions = ReadOptions(first); + var secondOptions = ReadOptions(second); + + Assert.Equal(firstOptions, secondOptions); + Assert.Equal(firstOptions.GetHashCode(), secondOptions.GetHashCode()); + } + + [Theory] + [InlineData("auto", "Auto")] + [InlineData("windowsappsdk", "WindowsAppSDK")] + [InlineData("uwp", "UWP")] + [InlineData("unowinui", "UnoWinUI")] + [InlineData("Unknown", "Invalid")] + public void OptionsCaptureTheParsedSelection(string selection, string expected) + { + Assert.Equal(expected, ReadOptions(selection).AppTypeOverride.ToString()); + } + + [Fact] + public void AChangedSelectionInvalidatesTheOptionsModel() + { + Assert.NotEqual(ReadOptions(null), ReadOptions("UnoWinUI")); + Assert.NotEqual(ReadOptions("UnoWinUI"), ReadOptions("Unknown")); + } + + [Fact] + public void SelectionParsingIsIndependentOfTheCurrentCulture() + { + var previousCulture = CultureInfo.CurrentCulture; + + try + { + CultureInfo.CurrentCulture = new CultureInfo("tr-TR"); + Assert.Equal(AppTypeSelection.UnoWinUI, ReadOptions("unowinui").AppTypeOverride); + Assert.Equal(AppTypeSelection.WindowsAppSDK, ReadOptions("windowsappsdk").AppTypeOverride); + Assert.Equal(AppTypeSelection.Auto, ReadOptions("auto").AppTypeOverride); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + } + + [Fact] + public void SelectingTheDetectedModelDoesNotRegenerateSources() + { + var automatic = Run(AppType.WindowsAppSDK); + var explicitSelection = automatic.RunAgainWithOptions(Options("WindowsAppSDK")); + + Assert.Empty(explicitSelection.Diagnostics); + Assert.Equal(1, explicitSelection.RecomputedCount(TrackingNames.Options)); + explicitSelection.AssertReused(TrackingNames.CompilationInfo); + explicitSelection.AssertReused(TrackingNames.Project); + explicitSelection.AssertReused(TrackingNames.Generation); + explicitSelection.AssertReused(TrackingNames.Support); + AssertSameSources(automatic, explicitSelection); + } + + [Fact] + public void EquivalentSelectionSpellingsDoNotInvalidateThePipeline() + { + var before = Run(AppType.UnoWinUI, "UnoWinUI"); + var after = before.RunAgainWithOptions(Options(" unowinui ")); + + after.AssertReused(TrackingNames.Options); + after.AssertReused(TrackingNames.Project); + after.AssertReused(TrackingNames.Generation); + after.AssertReused(TrackingNames.Support); + AssertSameSources(before, after); + } + + [Fact] + public void ChangingOnlyTheSelectionUpdatesEveryAppModelDependentOutput() + { + var before = Run(AppType.WindowsAppSDK); + var after = before.RunAgainWithOptions(Options("UWP")); + + Assert.Empty(after.Diagnostics); + Assert.Equal(1, after.RecomputedCount(TrackingNames.Options)); + after.AssertReused(TrackingNames.CompilationInfo); + Assert.Equal(1, after.RecomputedCount(TrackingNames.Project)); + Assert.Equal(1, after.RecomputedCount(TrackingNames.Generation)); + Assert.Equal(1, after.RecomputedCount(TrackingNames.Support)); + AssertAppModel(after, AppType.UWP); + Assert.NotEqual(before.Source("Resources.resw"), after.Source("Resources.resw")); + Assert.NotEqual(before.Source("ResourceStringProvider"), after.Source("ResourceStringProvider")); + } + + [Fact] + public void InvalidatingAndRestoringTheSelectionRemovesAndRestoresAllSources() + { + var before = Run(AppType.UnoWinUI, "UnoWinUI"); + var invalid = before.RunAgainWithOptions(Options("Unknown")); + Assert.Equal("RESWP0021", Assert.Single(invalid.Diagnostics).Id); + Assert.Empty(invalid.Sources); + + var restored = invalid.RunAgainWithOptions(Options("UnoWinUI")); + Assert.Empty(restored.Diagnostics); + AssertSameSources(before, restored); + restored.AssertCompiles(); + + var automatic = restored.RunAgainWithOptions(Options(null)); + Assert.Empty(automatic.Diagnostics); + AssertSameSources(before, automatic); + automatic.AssertReused(TrackingNames.Generation); + } + + private static ReswGeneratorRun Run( + AppType appType, + string? selection = null, + bool useUwp = false, + bool useApplicationLanguages = false, + IEnumerable? references = null) + { + return ReswGeneratorHarness.Run( + [ReswGeneratorHarness.File("en-US", SampleResources)], + appType, + defaultLanguage: "en-US", + useUwp: useUwp, + useApplicationLanguages: useApplicationLanguages, + references: references, + appTypeOverride: selection); + } + + private static Dictionary Options(string? selection) + { + var options = new Dictionary(AnalyzerConfigOptions.KeyComparer) + { + ["build_property.ProjectDir"] = ReswGeneratorHarness.ProjectDir, + ["build_property.OutputType"] = "Library", + ["build_property.RootNamespace"] = "TestProject", + ["build_property.DefaultLanguage"] = "en-US", + }; + if (selection is not null) + { + options["build_property.ReswPlusAppType"] = selection; + } + return options; + } + + private static ReswBuildOptions ReadOptions(string? selection) + { + return ReswBuildOptions.Read(new TestAnalyzerConfigOptionsProvider(Options(selection)).GlobalOptions); + } + + private static void AssertSameSources(ReswGeneratorRun first, ReswGeneratorRun second) + { + Assert.Equal( + first.Sources.OrderBy(source => source.Key, StringComparer.Ordinal), + second.Sources.OrderBy(source => source.Key, StringComparer.Ordinal)); + } + + private static void AssertAppModel(ReswGeneratorRun run, AppType appType) + { + var xamlNamespace = appType == AppType.UWP ? "Windows.UI.Xaml" : "Microsoft.UI.Xaml"; + var generated = run.Source("Resources.resw"); + Assert.Contains($"using {xamlNamespace}.Markup;", generated); + Assert.Contains($"global::{xamlNamespace}.Markup.MarkupExtensionReturnType", generated); + Assert.Contains($"global::{xamlNamespace}.Markup.MarkupExtension", generated); + Assert.Contains($"global::{xamlNamespace}.Data.IValueConverter", generated); + + var provider = run.Source("ResourceStringProvider"); + Assert.Contains( + appType == AppType.WindowsAppSDK + ? "using Microsoft.Windows.ApplicationModel.Resources;" + : "using Windows.ApplicationModel.Resources;", + provider); + Assert.Contains( + appType == AppType.WindowsAppSDK + ? "new ResourceLoader(ResourceLoader.GetDefaultResourceFilePath(), resourceMapId)" + : "ResourceLoader.GetForViewIndependentUse(resourceMapId)", + provider); + } +} diff --git a/tests/ReswPlusUnitTests/GeneratorHarness.cs b/tests/ReswPlusUnitTests/GeneratorHarness.cs index f595b1f..f561573 100644 --- a/tests/ReswPlusUnitTests/GeneratorHarness.cs +++ b/tests/ReswPlusUnitTests/GeneratorHarness.cs @@ -68,6 +68,8 @@ public static ReswFile File(string language, string content, string baseName = " /// The name of the assembly being compiled. /// Whether the compilation should look like a UWP project built for Native AOT, which has the UWP types but no recognizable API contract reference. /// Files to pass to the compiler on top of , to cover what the generator does with the ones it doesn't own. + /// Custom compilation references, or to use the platform stubs. + /// The opt-in ReswPlusAppType selection, or to leave it undeclared. /// The result of the run. public static ReswGeneratorRun Run( IEnumerable files, @@ -83,7 +85,9 @@ public static ReswGeneratorRun Run( bool? generateResourceInterfaces = null, string assemblyName = "TestProject", bool nativeAotUwp = false, - IEnumerable? additionalFiles = null) + IEnumerable? additionalFiles = null, + IEnumerable? references = null, + string? appTypeOverride = null) { var texts = files .Concat(additionalFiles ?? []) @@ -102,12 +106,13 @@ public static ReswGeneratorRun Run( Declare("build_property.RootNamespace", rootNamespace); Declare("build_property.ReswPlusUseApplicationLanguages", useApplicationLanguages?.ToString().ToLowerInvariant()); Declare("build_property.UseUwp", useUwp?.ToString().ToLowerInvariant()); + Declare("build_property.ReswPlusAppType", appTypeOverride); Declare("build_property.ReswPlusGenerateResourceInterfaces", generateResourceInterfaces?.ToString().ToLowerInvariant()); var compilation = CSharpCompilation.Create( assemblyName, syntaxTrees: null, - nativeAotUwp ? PlatformStubs.NativeAotUwpReferences() : PlatformStubs.ReferencesFor(appType), + references ?? (nativeAotUwp ? PlatformStubs.NativeAotUwpReferences() : PlatformStubs.ReferencesFor(appType)), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); var driver = CSharpGeneratorDriver.Create( @@ -243,6 +248,29 @@ public ReswGeneratorRun RunAgain(IEnumerable files) return From(_driver.ReplaceAdditionalTexts(texts), _inputCompilation, texts); } + /// + /// Runs the generator again with new compilation references, reusing the state of the previous run. + /// + /// The references of the new compilation. + /// The result of the run with the new references. + public ReswGeneratorRun RunAgainWithReferences(IEnumerable references) + { + return From(_driver, _inputCompilation.RemoveAllReferences().AddReferences(references), _texts); + } + + /// + /// Runs the generator again with new project options, reusing its compilation, files, and cached state. + /// + /// The complete set of global project options for the new run. + /// The result of the run with the new options. + public ReswGeneratorRun RunAgainWithOptions(IReadOnlyDictionary globalOptions) + { + return From( + _driver.WithUpdatedAnalyzerConfigOptions(new TestAnalyzerConfigOptionsProvider(globalOptions)), + _inputCompilation, + _texts); + } + /// /// Returns the source emitted under the hint name holding the given text. /// @@ -550,10 +578,19 @@ public static class ApplicationLanguages .Select(path => (MetadataReference)MetadataReference.CreateFromFile(path))]); private static readonly Lazy WindowsAppSdk = new(() => - Compile("ReswPlusTests.WindowsAppSdkStub", WindowsAppSdkStub, @"C:\Packages\Microsoft.WindowsAppSdk\Microsoft.WindowsAppSdk.dll")); + Compile("ReswPlusTests.WindowsAppSdkStub", WindowsAppSdkStub, @"C:\Packages\Microsoft.WindowsAppSdk\Microsoft.WindowsAppSdk.dll", registerForRuntime: true)); private static readonly Lazy Uwp = new(() => - Compile("ReswPlusTests.UwpStub", UwpStub, @"C:\Packages\UAP\Windows.Foundation.UniversalApiContract.winmd")); + Compile("ReswPlusTests.UwpStub", UwpStub, @"C:\Packages\UAP\Windows.Foundation.UniversalApiContract.winmd", registerForRuntime: true)); + + private static readonly string UnoWinUIStub = UwpStub.Replace("Windows.UI.Xaml", "Microsoft.UI.Xaml"); + + private static readonly Lazy UnoWinUI = new(() => + Compile( + "Uno.WinUI", + UnoWinUIStub + "\nnamespace Microsoft.UI.Xaml { public interface DependencyObject { } }", + @"C:\References\Platform.dll", + registerForRuntime: true)); /// /// The same UWP types, under a name the app type detection cannot recognize. @@ -564,7 +601,7 @@ public static class ApplicationLanguages /// such a project look like no supported app type at all unless it declares UseUwp. /// private static readonly Lazy NativeAotUwp = new(() => - Compile("ReswPlusTests.NativeAotUwpStub", UwpStub, @"C:\Packages\Microsoft.Windows.SDK.NET\Microsoft.Windows.SDK.NET.dll")); + Compile("ReswPlusTests.NativeAotUwpStub", UwpStub, @"C:\Packages\Microsoft.Windows.SDK.NET\Microsoft.Windows.SDK.NET.dll", registerForRuntime: true)); /// /// Returns the references of a UWP project built for Native AOT: the UWP types are there, but nothing names @@ -586,10 +623,41 @@ public static ImmutableArray ReferencesFor(AppType appType) { AppType.WindowsAppSDK => RuntimeReferences.Value.Add(WindowsAppSdk.Value), AppType.UWP => RuntimeReferences.Value.Add(Uwp.Value), + AppType.UnoWinUI => RuntimeReferences.Value.Add(UnoWinUI.Value), _ => RuntimeReferences.Value, }; } + /// + /// Returns the APIs of an app model under an unrecognized or misleading reference path. + /// + /// The app model whose APIs are available. + /// The app model suggested by the path, or for no hint. + /// The references with no conclusive Uno type identity. + public static ImmutableArray ReferencesWithDetectionHint(AppType appType, AppType? hintedAppType = null) + { + var source = appType switch + { + AppType.WindowsAppSDK => WindowsAppSdkStub, + AppType.UWP => UwpStub, + AppType.UnoWinUI => UnoWinUIStub, + _ => throw new ArgumentOutOfRangeException(nameof(appType)), + }; + var path = hintedAppType switch + { + AppType.WindowsAppSDK => @"C:\Packages\Microsoft.WindowsAppSdk\Platform.dll", + AppType.UWP => @"C:\Packages\UAP\Windows.Foundation.UniversalApiContract.winmd", + null => @"C:\References\Platform.dll", + _ => throw new ArgumentOutOfRangeException(nameof(hintedAppType)), + }; + + return RuntimeReferences.Value.Add(Compile( + $"ReswPlusTests.Custom{appType}", + source, + path, + registerForRuntime: true)); + } + /// /// The images of the stubs compiled so far, so that generated code referencing one can be run and not only /// compiled. @@ -627,7 +695,15 @@ private static bool IsWindowsProjection(string path) || name.Equals("WinRT.Runtime", StringComparison.OrdinalIgnoreCase); } - private static MetadataReference Compile(string assemblyName, string source, string filePath) + /// + /// Compiles platform types into an in-memory reference with a caller-supplied identity and display path. + /// + /// The defining assembly identity. + /// The source of the platform types. + /// The reference path the generator sees. + /// Whether the reference is a shared platform stub that can be loaded at runtime. + /// The compiled reference. + public static MetadataReference Compile(string assemblyName, string source, string filePath, bool registerForRuntime = false) { var compilation = CSharpCompilation.Create( assemblyName, @@ -650,7 +726,10 @@ private static MetadataReference Compile(string assemblyName, string source, str // path the real framework would have. var image = peStream.ToArray(); - StubImages[assemblyName] = image; + if (registerForRuntime) + { + StubImages[assemblyName] = image; + } return AssemblyMetadata.CreateFromImage(image) .GetReference(filePath: filePath, display: filePath);