From cd6239bacbe2d49d1abc26be024171c1b05dda1c Mon Sep 17 00:00:00 2001 From: Michael Stonis <120685+michaelstonis@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:51:50 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20AOT-safe=20property=20access=20?= =?UTF-8?q?=E2=80=94=20[KumikoGridItem]=20generator,=20column=20delegates,?= =?UTF-8?q?=20providers,=20guarded=20reflection=20(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KumikoUI.Core.Data: KumikoPropertyAccessor, IKumikoAccessorProvider, KumikoAccessorRegistry (type and global providers, cached per type and path) and [KumikoGridItem]. - KumikoUI.SourceGenerators (shipped in the Core package under analyzers/): a typed provider per [KumikoGridItem] type, registered by a module initializer; KUMIKO001/002 for unsupported types. - KumikoColumn.ValueAccessor/ValueSetter and EditorDisplaySelector/EditorValueSelector; DrawnComboBox.DisplaySelector/ValueSelector. Delegates win over every other source. - Reflection fallback behind a feature switch (FeatureSwitchDefinition + FeatureGuard); the package's build targets turn it off for PublishAot and TrimMode=full. Compiled expressions only where dynamic code is supported. - Paths resolve per segment: a null intermediate value reads as null instead of throwing; resolved paths are cached on the column (3.2x faster reads than before, 80% less allocation). - IsAotCompatible on Core, SkiaSharp, Maui and Uno; IL2xxx/IL3xxx are errors (Directory.Build.targets). - Fix: the ComboBox editor applied member paths after building its items. - Tests for the accessors, the switch-off mode and the generator; bench/KumikoUI.Benchmarks. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 3 + .github/workflows/ci.yml | 5 + .github/workflows/publish.yml | 3 + .gitignore | 3 + CHANGELOG.md | 12 +- CONTRIBUTING.md | 3 + Directory.Build.targets | 15 + Directory.Packages.props | 12 + KumikoUI.sln | 30 ++ README.md | 46 ++- .../KumikoUI.Benchmarks.csproj | 25 ++ bench/KumikoUI.Benchmarks/Program.cs | 3 + .../PropertyAccessBenchmarks.cs | 119 +++++++ bench/README.md | 9 + src/KumikoUI.Core/Components/DrawnComboBox.cs | 44 +-- .../Data/IKumikoAccessorProvider.cs | 20 ++ .../Data/KumikoAccessorRegistry.cs | 147 ++++++++ .../Data/KumikoGridItemAttribute.cs | 17 + .../Data/KumikoPropertyAccessor.cs | 50 +++ src/KumikoUI.Core/Data/KumikoValuePath.cs | 115 ++++++ .../Data/ReflectionAccessorFactory.cs | 47 +++ .../Editing/CellEditorFactory.cs | 9 +- src/KumikoUI.Core/KumikoUI.Core.csproj | 21 ++ src/KumikoUI.Core/Models/KumikoColumn.cs | 45 ++- src/KumikoUI.Core/Models/KumikoSource.cs | 141 ++------ .../buildTransitive/KumikoUI.Core.targets | 23 ++ src/KumikoUI.Maui/KumikoUI.Maui.csproj | 2 + .../KumikoUI.SkiaSharp.csproj | 2 + .../AnalyzerReleases.Shipped.md | 2 + .../AnalyzerReleases.Unshipped.md | 9 + src/KumikoUI.SourceGenerators/Diagnostics.cs | 44 +++ .../EquatableArray.cs | 42 +++ .../IsExternalInit.cs | 6 + .../KumikoGridItemGenerator.cs | 193 +++++++++++ .../KumikoUI.SourceGenerators.csproj | 29 ++ src/KumikoUI.Uno/KumikoUI.Uno.csproj | 2 + .../Data/AccessorModels.cs | 126 +++++++ .../Data/KumikoAccessorTests.cs | 326 ++++++++++++++++++ .../Data/ReflectionDisabledTests.cs | 75 ++++ .../KumikoUI.Core.Tests.csproj | 4 + .../KumikoGridItemGeneratorTests.cs | 190 ++++++++++ .../KumikoUI.SourceGenerators.Tests.csproj | 30 ++ 42 files changed, 1910 insertions(+), 139 deletions(-) create mode 100644 Directory.Build.targets create mode 100644 bench/KumikoUI.Benchmarks/KumikoUI.Benchmarks.csproj create mode 100644 bench/KumikoUI.Benchmarks/Program.cs create mode 100644 bench/KumikoUI.Benchmarks/PropertyAccessBenchmarks.cs create mode 100644 bench/README.md create mode 100644 src/KumikoUI.Core/Data/IKumikoAccessorProvider.cs create mode 100644 src/KumikoUI.Core/Data/KumikoAccessorRegistry.cs create mode 100644 src/KumikoUI.Core/Data/KumikoGridItemAttribute.cs create mode 100644 src/KumikoUI.Core/Data/KumikoPropertyAccessor.cs create mode 100644 src/KumikoUI.Core/Data/KumikoValuePath.cs create mode 100644 src/KumikoUI.Core/Data/ReflectionAccessorFactory.cs create mode 100644 src/KumikoUI.Core/buildTransitive/KumikoUI.Core.targets create mode 100644 src/KumikoUI.SourceGenerators/AnalyzerReleases.Shipped.md create mode 100644 src/KumikoUI.SourceGenerators/AnalyzerReleases.Unshipped.md create mode 100644 src/KumikoUI.SourceGenerators/Diagnostics.cs create mode 100644 src/KumikoUI.SourceGenerators/EquatableArray.cs create mode 100644 src/KumikoUI.SourceGenerators/IsExternalInit.cs create mode 100644 src/KumikoUI.SourceGenerators/KumikoGridItemGenerator.cs create mode 100644 src/KumikoUI.SourceGenerators/KumikoUI.SourceGenerators.csproj create mode 100644 tests/KumikoUI.Core.Tests/Data/AccessorModels.cs create mode 100644 tests/KumikoUI.Core.Tests/Data/KumikoAccessorTests.cs create mode 100644 tests/KumikoUI.Core.Tests/Data/ReflectionDisabledTests.cs create mode 100644 tests/KumikoUI.SourceGenerators.Tests/KumikoGridItemGeneratorTests.cs create mode 100644 tests/KumikoUI.SourceGenerators.Tests/KumikoUI.SourceGenerators.Tests.csproj diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1213b6d..1dbbcd4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,6 +13,9 @@ updates: # MauiVersion (Directory.Packages.props) is the deliberate minimum KumikoUI.Maui supports, # not a version to track: raising it forces every consumer onto that MAUI. Raise it by hand. - dependency-name: "Microsoft.Maui.Controls" + # The [KumikoGridItem] generator is built against the oldest Roslyn it supports; a newer + # Microsoft.CodeAnalysis.CSharp would stop older compilers from loading it. Raise it by hand. + - dependency-name: "Microsoft.CodeAnalysis.CSharp" groups: # One major version across the whole repository (see Directory.Packages.props). skiasharp: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e349c6..f09e2d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,7 @@ jobs: run: dotnet restore src/KumikoUI.Core/KumikoUI.Core.csproj && dotnet restore src/KumikoUI.SkiaSharp/KumikoUI.SkiaSharp.csproj && dotnet restore tests/KumikoUI.Core.Tests/KumikoUI.Core.Tests.csproj + && dotnet restore tests/KumikoUI.SourceGenerators.Tests/KumikoUI.SourceGenerators.Tests.csproj - name: Build — Core run: dotnet build src/KumikoUI.Core/KumikoUI.Core.csproj @@ -55,6 +56,10 @@ jobs: run: dotnet test tests/KumikoUI.Core.Tests/KumikoUI.Core.Tests.csproj --no-restore -c Release --logger "trx" --results-directory TestResults + - name: Test — source generator + run: dotnet test tests/KumikoUI.SourceGenerators.Tests/KumikoUI.SourceGenerators.Tests.csproj + --no-restore -c Release --logger "trx" --results-directory TestResults + - name: Upload test results if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fd32e00..938ea3b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -262,6 +262,9 @@ jobs: dotnet restore tests/KumikoUI.Core.Tests/KumikoUI.Core.Tests.csproj dotnet test tests/KumikoUI.Core.Tests/KumikoUI.Core.Tests.csproj \ -c Release --logger "trx" --results-directory TestResults + dotnet restore tests/KumikoUI.SourceGenerators.Tests/KumikoUI.SourceGenerators.Tests.csproj + dotnet test tests/KumikoUI.SourceGenerators.Tests/KumikoUI.SourceGenerators.Tests.csproj \ + -c Release --logger "trx" --results-directory TestResults test-uno: name: Run Uno tests diff --git a/.gitignore b/.gitignore index 093afab..6663e0a 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ artifacts/ Thumbs.db *.lscache + +# BenchmarkDotNet output (bench/) +BenchmarkDotNet.Artifacts/ diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb76d8..0ae07e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] ### Added +- Native AOT and trimming support (#17). Item properties are read and written, in order, through: + - `KumikoColumn.ValueAccessor` / `ValueSetter` delegates (and `EditorDisplaySelector` / `EditorValueSelector` for ComboBox items, `DrawnComboBox.DisplaySelector` / `ValueSelector`); + - accessors generated for types marked `[KumikoGridItem]` (`KumikoUI.Core.Data`), by a source generator now included in the KumikoUI.Core package; + - providers registered with `KumikoAccessorRegistry.Register` / `AddProvider` (`IKumikoAccessorProvider`, `KumikoPropertyAccessor`); + - reflection, as before. + + Generated and delegate reads are 3–5× faster than the previous compiled expression trees, with 80% less allocation (`bench/KumikoUI.Benchmarks`). All four packages are marked `IsAotCompatible` and build with no trimming or AOT analyzer warnings; such warnings now fail the build. - **KumikoUI.Uno**: a new package hosting the grid on the Uno Platform (Windows App SDK, desktop on Windows/macOS/Linux, WebAssembly, Android, iOS) with the same engine, rendering and features as KumikoUI.Maui, plus `KumikoFonts` for registering custom fonts from app assets, and the `SampleApp.Uno` sample. -- `DataGridStyle.PinBottomSummaryRows` (default `true`): `Position=Bottom` table summary rows are pinned to the bottom of the viewport as a fixed footer instead of scrolling after the last row. **Behaviour change** for existing users of bottom summaries; set it to `false` to restore the previous layout. +- `KumikoStyle.PinBottomSummaryRows` (default `true`): `Position=Bottom` table summary rows are pinned to the bottom of the viewport as a fixed footer instead of scrolling after the last row. **Behaviour change** for existing users of bottom summaries; set it to `false` to restore the previous layout. ### Changed +- The reflection fallback for item properties is off for `PublishAot=true` and `TrimMode=full` publishes (KumikoUI.Core build targets set the `KumikoUI.Core.IsReflectionPropertyAccessSupported` switch). A property with no generated accessor, delegate or provider then reads as null and is traced once. Override with the `KumikoReflectionPropertyAccess` MSBuild property. Partial trimming, the .NET MAUI default, is unaffected. +- A nested property path with a `null` value part-way (`Address.City` when `Address` is null) now reads as an empty cell instead of throwing, and an edit to it is ignored. - **Breaking:** naming pass: every grid type now uses the `Kumiko` prefix (drawn components keep `Drawn`), and no public name collides with types of MAUI, CommunityToolkit.Maui, WinUI/Uno, the Windows Community Toolkit DataGrid, WPF, Windows Forms or Avalonia (#16). Namespaces are unchanged (`KumikoUI.*`); member names are unchanged (for example `KumikoColumn.SortDirection`, `KumikoDataGrid.GridSelectionMode` and `KumikoDataGrid.GridStyle` keep their names, only their types change). Update XAML and code as follows: - Host control: `DataGridView` → `KumikoDataGrid` in both `KumikoUI.Maui` and `KumikoUI.Uno`, including XAML (`` → ``, `` → ``, and the same for `TableSummaryRows` and `GroupSummaryRows`). - Models: `DataGridSource` → `KumikoSource`, `DataGridColumn` → `KumikoColumn` (including `` in XAML), `DataGridColumnType` → `KumikoColumnType`, `DataGridStyle` → `KumikoStyle`, `DataGridTheme` → `KumikoTheme`, `DataGridThemeMode` → `KumikoThemeMode`, `CellStyle` → `KumikoCellStyle`, `RowStyle` → `KumikoRowStyle`, `DataGridRenderer` → `KumikoRenderer` @@ -36,6 +45,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - KumikoUI.Uno is built with Uno.Sdk 6.7.30 (was 6.5.31). ### Fixed +- ComboBox editors now apply the column's `EditorDisplayMemberPath` and `EditorValueMemberPath`; they were set after the items were built, so items showed `ToString()` and the current value was not selected. - An unknown font family now falls back to the default family in the requested weight and slant instead of producing a null typeface. Styled typefaces are resolved through `SKFontManager` with an explicit family, which SkiaSharp 4 needs to keep bold and italic. - Taps on the pinned summary footer no longer select or edit the data row behind it; unpinned bottom summary rows are also reported as `HitRegion.TableSummaryRow`. - Keyboard navigation keeps the current row fully visible between the header/frozen rows and the pinned footer, and accounts for the grouping panel and top summary rows (previously the scroll offset was counted twice). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b97d25e..724fcc7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,8 +46,11 @@ Build what you are working on; the platform-agnostic projects need no workloads: dotnet build src/KumikoUI.Core dotnet build src/KumikoUI.SkiaSharp dotnet test tests/KumikoUI.Core.Tests +dotnet test tests/KumikoUI.SourceGenerators.Tests ``` +The libraries are `IsAotCompatible`: a trimming or Native AOT analyzer warning (IL2xxx/IL3xxx) fails the build (`Directory.Build.targets`). Read item properties through `KumikoAccessorRegistry` rather than reflection (see "Native AOT & Trimming" in the README). Property-access benchmarks live in `bench/` (see `bench/README.md`). + Check public type names after building (see [Public type names](#public-type-names)): ```bash diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..ce909ca --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,15 @@ + + + + true + true + true + $(WarningsAsErrors);IL2001;IL2002;IL2003;IL2004;IL2005;IL2006;IL2007;IL2008;IL2009;IL2010;IL2011;IL2012;IL2013;IL2014;IL2015;IL2016;IL2017;IL2018;IL2019;IL2020;IL2021;IL2022;IL2023;IL2024;IL2025;IL2026;IL2027;IL2028;IL2029;IL2030;IL2031;IL2032;IL2033;IL2034;IL2035;IL2036;IL2037;IL2038;IL2039;IL2040;IL2041;IL2042;IL2043;IL2044;IL2045;IL2046;IL2047;IL2048;IL2049;IL2050;IL2051;IL2052;IL2053;IL2054;IL2055;IL2056;IL2057;IL2058;IL2059;IL2060;IL2061;IL2062;IL2063;IL2064;IL2065;IL2066;IL2067;IL2068;IL2069;IL2070;IL2071;IL2072;IL2073;IL2074;IL2075;IL2076;IL2077;IL2078;IL2079;IL2080;IL2081;IL2082;IL2083;IL2084;IL2085;IL2086;IL2087;IL2088;IL2089;IL2090;IL2091;IL2092;IL2093;IL2094;IL2095;IL2096;IL2097;IL2098;IL2099;IL2100;IL2101;IL2102;IL2103;IL2104;IL2105;IL2106;IL2107;IL2108;IL2109;IL2110;IL2111;IL2112;IL2113;IL2114;IL2115;IL2116;IL2117;IL2118;IL2119;IL2120;IL2121;IL2122;IL2123;IL3000;IL3001;IL3002;IL3003;IL3004;IL3005;IL3050;IL3051;IL3052;IL3053;IL3054;IL3055;IL3056;IL3057;IL3058 + + diff --git a/Directory.Packages.props b/Directory.Packages.props index ef068d4..ebdab99 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -82,6 +82,18 @@ + + + + + + + + + diff --git a/KumikoUI.sln b/KumikoUI.sln index 33268b0..26cd210 100644 --- a/KumikoUI.sln +++ b/KumikoUI.sln @@ -38,6 +38,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Build", "Build", "{DFDD4E5D EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KumikoUI.Uno.Tests", "tests\KumikoUI.Uno.Tests\KumikoUI.Uno.Tests.csproj", "{57855B3A-D369-4D2D-8B43-FA09549FF955}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KumikoUI.SourceGenerators", "src\KumikoUI.SourceGenerators\KumikoUI.SourceGenerators.csproj", "{EA495C23-8110-4B63-A97D-A8C68483F9B4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KumikoUI.SourceGenerators.Tests", "tests\KumikoUI.SourceGenerators.Tests\KumikoUI.SourceGenerators.Tests.csproj", "{A0D6A010-D7CD-4DFE-A011-3413544A2C9A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -144,6 +148,30 @@ Global {57855B3A-D369-4D2D-8B43-FA09549FF955}.Release|x64.Build.0 = Release|Any CPU {57855B3A-D369-4D2D-8B43-FA09549FF955}.Release|x86.ActiveCfg = Release|Any CPU {57855B3A-D369-4D2D-8B43-FA09549FF955}.Release|x86.Build.0 = Release|Any CPU + {EA495C23-8110-4B63-A97D-A8C68483F9B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EA495C23-8110-4B63-A97D-A8C68483F9B4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EA495C23-8110-4B63-A97D-A8C68483F9B4}.Debug|x64.ActiveCfg = Debug|Any CPU + {EA495C23-8110-4B63-A97D-A8C68483F9B4}.Debug|x64.Build.0 = Debug|Any CPU + {EA495C23-8110-4B63-A97D-A8C68483F9B4}.Debug|x86.ActiveCfg = Debug|Any CPU + {EA495C23-8110-4B63-A97D-A8C68483F9B4}.Debug|x86.Build.0 = Debug|Any CPU + {EA495C23-8110-4B63-A97D-A8C68483F9B4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EA495C23-8110-4B63-A97D-A8C68483F9B4}.Release|Any CPU.Build.0 = Release|Any CPU + {EA495C23-8110-4B63-A97D-A8C68483F9B4}.Release|x64.ActiveCfg = Release|Any CPU + {EA495C23-8110-4B63-A97D-A8C68483F9B4}.Release|x64.Build.0 = Release|Any CPU + {EA495C23-8110-4B63-A97D-A8C68483F9B4}.Release|x86.ActiveCfg = Release|Any CPU + {EA495C23-8110-4B63-A97D-A8C68483F9B4}.Release|x86.Build.0 = Release|Any CPU + {A0D6A010-D7CD-4DFE-A011-3413544A2C9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A0D6A010-D7CD-4DFE-A011-3413544A2C9A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A0D6A010-D7CD-4DFE-A011-3413544A2C9A}.Debug|x64.ActiveCfg = Debug|Any CPU + {A0D6A010-D7CD-4DFE-A011-3413544A2C9A}.Debug|x64.Build.0 = Debug|Any CPU + {A0D6A010-D7CD-4DFE-A011-3413544A2C9A}.Debug|x86.ActiveCfg = Debug|Any CPU + {A0D6A010-D7CD-4DFE-A011-3413544A2C9A}.Debug|x86.Build.0 = Debug|Any CPU + {A0D6A010-D7CD-4DFE-A011-3413544A2C9A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A0D6A010-D7CD-4DFE-A011-3413544A2C9A}.Release|Any CPU.Build.0 = Release|Any CPU + {A0D6A010-D7CD-4DFE-A011-3413544A2C9A}.Release|x64.ActiveCfg = Release|Any CPU + {A0D6A010-D7CD-4DFE-A011-3413544A2C9A}.Release|x64.Build.0 = Release|Any CPU + {A0D6A010-D7CD-4DFE-A011-3413544A2C9A}.Release|x86.ActiveCfg = Release|Any CPU + {A0D6A010-D7CD-4DFE-A011-3413544A2C9A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -158,5 +186,7 @@ Global {5565DAEA-A170-462E-B2F5-0BDD6878319C} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {DFDD4E5D-1418-41C2-A6CE-2A6397026499} = {17B595E7-34C1-4B92-9FD3-316E5A57E051} {57855B3A-D369-4D2D-8B43-FA09549FF955} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {EA495C23-8110-4B63-A97D-A8C68483F9B4} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {A0D6A010-D7CD-4DFE-A011-3413544A2C9A} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 182b72c..53a10de 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Every visual element — cells, headers, editors, scrollbars, popups — is rend - [Custom Fonts](#-custom-fonts) - [Scrolling & Performance](#-scrolling--performance) - [Live Data](#-live-data) +- [Native AOT & Trimming](#-native-aot--trimming) - [Architecture](#-architecture) - [Sample Application](#-sample-application) - [Requirements](#-requirements) @@ -88,6 +89,7 @@ Every visual element — cells, headers, editors, scrollbars, popups — is rend | **Theming** | Built-in Light / Dark / HighContrast; fully customisable style object | | **Momentum scrolling** | Physics-based inertial scroll with configurable friction | | **Live data** | `INotifyPropertyChanged` and `ObservableCollection` O(1) updates | +| **Native AOT & trimming** | Warning-free under `PublishAot`; `[KumikoGridItem]` source-generated accessors or per-column delegates | | **Column resizing** | Drag column border | --- @@ -815,7 +817,49 @@ public class Employee : INotifyPropertyChanged ``` -Accessors are compiled once via `Expression.Property` chains and cached. +Each path segment is resolved once per item type and cached. A `null` value part-way along the path reads as an empty cell, and an edit to it is ignored. How the properties are read is covered in [Native AOT & Trimming](#-native-aot--trimming). + +--- + +## 🧩 Native AOT & Trimming + +All KumikoUI packages are marked `IsAotCompatible` and build with no trimming or Native AOT warnings. The grid reads and writes item properties named by `PropertyName` through, in order: + +1. **Column delegates**: `ValueAccessor` and `ValueSetter` on `KumikoColumn` (and `EditorDisplaySelector` / `EditorValueSelector` for ComboBox items). +2. **Generated accessors**: types marked `[KumikoGridItem]`. The `KumikoUI.Core` package includes a source generator that writes typed accessors for their public properties and registers them when the assembly loads. +3. **Registered providers**: `KumikoAccessorRegistry.Register(type, provider)` or `AddProvider(provider)` with your own `IKumikoAccessorProvider`, for example to read dictionary entries or data rows. +4. **Reflection**: everything else. This fallback is turned off automatically for `PublishAot=true` and `TrimMode=full`, because those publishes remove the metadata it reads. Partial trimming (the .NET MAUI default) keeps it on. + +With reflection off, a property that no tier can serve reads as `null`, edits to it are ignored, and a warning naming the type and property is traced once. + +```csharp +using KumikoUI.Core.Data; + +[KumikoGridItem] // generated accessors for Name, Age, Address, … +public class Employee +{ + public string Name { get; set; } = ""; + public int Age { get; set; } + public Address? Address { get; set; } +} + +[KumikoGridItem] // mark every type a path walks through ("Address.City") +public class Address +{ + public string City { get; set; } = ""; +} + +// Or per column, without attributes: +var bonus = new KumikoColumn +{ + Header = "Bonus", + PropertyName = "Bonus", // still names the column for summaries and grouping + ValueAccessor = item => ((Employee)item).Age * 10m, + ValueSetter = (item, value) => { /* write it back */ }, +}; +``` + +Generated accessors and delegates are faster than the expression trees the grid compiled before, and need no runtime code generation. `bench/KumikoUI.Benchmarks` measures them. The generator reports `KUMIKO001` for generic types and `KUMIKO002` for types it cannot reference (private, protected or file-local); use delegates or a provider for those. Set the `KumikoReflectionPropertyAccess` MSBuild property to `true` or `false` to override the automatic choice. --- diff --git a/bench/KumikoUI.Benchmarks/KumikoUI.Benchmarks.csproj b/bench/KumikoUI.Benchmarks/KumikoUI.Benchmarks.csproj new file mode 100644 index 0000000..804677e --- /dev/null +++ b/bench/KumikoUI.Benchmarks/KumikoUI.Benchmarks.csproj @@ -0,0 +1,25 @@ + + + + + Exe + net10.0 + enable + enable + false + false + + + + + + + + + + + + + diff --git a/bench/KumikoUI.Benchmarks/Program.cs b/bench/KumikoUI.Benchmarks/Program.cs new file mode 100644 index 0000000..c9a0467 --- /dev/null +++ b/bench/KumikoUI.Benchmarks/Program.cs @@ -0,0 +1,3 @@ +using BenchmarkDotNet.Running; + +BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); diff --git a/bench/KumikoUI.Benchmarks/PropertyAccessBenchmarks.cs b/bench/KumikoUI.Benchmarks/PropertyAccessBenchmarks.cs new file mode 100644 index 0000000..44b4aa9 --- /dev/null +++ b/bench/KumikoUI.Benchmarks/PropertyAccessBenchmarks.cs @@ -0,0 +1,119 @@ +using System.Linq.Expressions; +using System.Reflection; +using BenchmarkDotNet.Attributes; +using KumikoUI.Core.Data; +using KumikoUI.Core.Models; + +namespace KumikoUI.Benchmarks; + +[KumikoGridItem] +public class GeneratedRow +{ + public string Name { get; set; } = ""; + public int Age { get; set; } +} + +public class ReflectedRow +{ + public string Name { get; set; } = ""; + public int Age { get; set; } +} + +/// +/// Reads one boxed int column for every row through , the call the +/// renderer makes per visible cell, and sorts by it. "Pre17" replays the accessor lookup KumikoSource used +/// before #17 (a "{type}.{path}" string key into a dictionary of compiled expression trees). +/// +[MemoryDiagnoser] +public class PropertyAccessBenchmarks +{ + private const int Rows = 10_000; + + private KumikoSource _generated = null!; + private KumikoSource _delegate = null!; + private KumikoSource _reflection = null!; + private KumikoColumn _generatedAge = null!; + private KumikoColumn _delegateAge = null!; + private KumikoColumn _reflectionAge = null!; + private List _pre17Items = null!; + private readonly Dictionary> _pre17Accessors = new(); + + [GlobalSetup] + public void Setup() + { + var generated = Enumerable.Range(0, Rows).Select(i => new GeneratedRow { Name = $"R{i}", Age = Rows - i }).ToList(); + var reflected = Enumerable.Range(0, Rows).Select(i => new ReflectedRow { Name = $"R{i}", Age = Rows - i }).ToList(); + + _generatedAge = new KumikoColumn { PropertyName = "Age" }; + _generated = new KumikoSource(); + _generated.SetColumns(new[] { _generatedAge }); + _generated.SetItems(generated); + + _delegateAge = new KumikoColumn { PropertyName = "Age", ValueAccessor = static o => ((GeneratedRow)o).Age }; + _delegate = new KumikoSource(); + _delegate.SetColumns(new[] { _delegateAge }); + _delegate.SetItems(generated); + + _reflectionAge = new KumikoColumn { PropertyName = "Age" }; + _reflection = new KumikoSource(); + _reflection.SetColumns(new[] { _reflectionAge }); + _reflection.SetItems(reflected); + + _pre17Items = reflected.Cast().ToList(); + } + + [Benchmark(Baseline = true)] + public long Read_Pre17CompiledExpression() + { + long sum = 0; + foreach (var item in _pre17Items) + { + var key = $"{item.GetType().FullName}.Age"; + if (!_pre17Accessors.TryGetValue(key, out var accessor)) + _pre17Accessors[key] = accessor = Compile(item.GetType(), "Age"); + sum += (int)accessor(item)!; + } + return sum; + } + + [Benchmark] + public long Read_Generated() => Sum(_generated, _generatedAge); + + [Benchmark] + public long Read_ValueAccessor() => Sum(_delegate, _delegateAge); + + [Benchmark] + public long Read_ReflectionFallback() => Sum(_reflection, _reflectionAge); + + [Benchmark] + public void Sort_Generated() + { + _generated.ToggleSort(_generatedAge); + _generated.ToggleSort(_generatedAge); + } + + [Benchmark] + public void Sort_ReflectionFallback() + { + _reflection.ToggleSort(_reflectionAge); + _reflection.ToggleSort(_reflectionAge); + } + + private static long Sum(KumikoSource source, KumikoColumn column) + { + long sum = 0; + for (int row = 0; row < Rows; row++) + sum += (int)source.GetCellValue(row, column)!; + return sum; + } + + private static Func Compile(Type type, string path) + { + var param = Expression.Parameter(typeof(object), "item"); + Expression body = Expression.Convert(param, type); + foreach (var part in path.Split('.')) + body = Expression.Property(body, type.GetProperty(part, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)!); + if (body.Type.IsValueType) body = Expression.Convert(body, typeof(object)); + return Expression.Lambda>(body, param).Compile(); + } +} diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 0000000..5fe02ee --- /dev/null +++ b/bench/README.md @@ -0,0 +1,9 @@ +# Benchmarks + +[BenchmarkDotNet](https://benchmarkdotnet.org/) projects. They are not part of `KumikoUI.sln` and do not run in CI. + +```bash +dotnet run -c Release --project bench/KumikoUI.Benchmarks -- --filter '*' +``` + +- `PropertyAccessBenchmarks` (#17): reading a column for 10 000 rows through `KumikoSource.GetCellValue`, and sorting by it, with generated accessors (`[KumikoGridItem]`), `KumikoColumn.ValueAccessor`, the reflection fallback, and the compiled-expression lookup used before #17. diff --git a/src/KumikoUI.Core/Components/DrawnComboBox.cs b/src/KumikoUI.Core/Components/DrawnComboBox.cs index c04b50e..f8fdc83 100644 --- a/src/KumikoUI.Core/Components/DrawnComboBox.cs +++ b/src/KumikoUI.Core/Components/DrawnComboBox.cs @@ -121,6 +121,18 @@ public int SelectedIndex /// public string? ValueMemberPath { get; set; } + /// + /// Returns the display text of a source item. Takes precedence over ; + /// reflection-free. + /// + public Func? DisplaySelector { get; set; } + + /// + /// Returns the value of a source item. Takes precedence over ; + /// reflection-free. + /// + public Func? ValueSelector { get; set; } + // ── Events ────────────────────────────────────────────────── /// Raised when the selected item changes. @@ -182,13 +194,17 @@ public void SetItemsFromSource(System.Collections.IEnumerable source) { if (item == null) continue; - string displayText = DisplayMemberPath != null - ? GetPropertyValue(item, DisplayMemberPath)?.ToString() ?? string.Empty - : item.ToString() ?? string.Empty; + string displayText = DisplaySelector != null + ? DisplaySelector(item) ?? string.Empty + : DisplayMemberPath != null + ? GetPropertyValue(item, DisplayMemberPath)?.ToString() ?? string.Empty + : item.ToString() ?? string.Empty; - object? value = ValueMemberPath != null - ? GetPropertyValue(item, ValueMemberPath) - : item; + object? value = ValueSelector != null + ? ValueSelector(item) + : ValueMemberPath != null + ? GetPropertyValue(item, ValueMemberPath) + : item; _items.Add(new DrawnComboBoxItem(displayText, value)); } @@ -212,20 +228,8 @@ public void SetSelectedValue(object? value) SelectedIndex = -1; } - private static object? GetPropertyValue(object obj, string propertyPath) - { - object? current = obj; - foreach (var part in propertyPath.Split('.')) - { - if (current == null) return null; - var type = current.GetType(); - var prop = type.GetProperty(part, - System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.IgnoreCase); - if (prop == null) return null; - current = prop.GetValue(current); - } - return current; - } + private static object? GetPropertyValue(object obj, string propertyPath) => + Data.KumikoAccessorRegistry.GetPath(obj.GetType(), propertyPath).GetValue(obj); private List GetVisibleItems() { diff --git a/src/KumikoUI.Core/Data/IKumikoAccessorProvider.cs b/src/KumikoUI.Core/Data/IKumikoAccessorProvider.cs new file mode 100644 index 0000000..faeccfa --- /dev/null +++ b/src/KumikoUI.Core/Data/IKumikoAccessorProvider.cs @@ -0,0 +1,20 @@ +namespace KumikoUI.Core.Data; + +/// +/// Supplies property accessors for item types, so the grid reads and writes item properties without +/// reflection (Native AOT and trimming safe). Register providers with ; +/// the [KumikoGridItem] source generator registers one for every attributed type. +/// +/// +/// A provider can also serve items without CLR properties, for example dictionaries or data rows: +/// return an accessor whose getter reads the entry named propertyName. +/// +public interface IKumikoAccessorProvider +{ + /// + /// Returns the accessor for one property (a single path segment, never dotted) of + /// , or null when this provider does not know it. Property names + /// are matched case-insensitively. + /// + KumikoPropertyAccessor? GetAccessor(Type itemType, string propertyName); +} diff --git a/src/KumikoUI.Core/Data/KumikoAccessorRegistry.cs b/src/KumikoUI.Core/Data/KumikoAccessorRegistry.cs new file mode 100644 index 0000000..158f378 --- /dev/null +++ b/src/KumikoUI.Core/Data/KumikoAccessorRegistry.cs @@ -0,0 +1,147 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace KumikoUI.Core.Data; + +/// +/// Resolves how the grid reads and writes item properties named by a path such as +/// "Name" or "Address.City". +/// +/// +/// Each path segment is resolved, in order, from: +/// +/// providers registered for the item's type or one of its base types (), +/// including the ones the [KumikoGridItem] source generator registers; +/// providers added for every type (); +/// reflection, when is true. +/// +/// A column's and +/// take precedence over all of these. +/// +public static class KumikoAccessorRegistry +{ + /// + /// The switch (and RuntimeHostConfigurationOption) that turns the + /// reflection fallback off. The KumikoUI.Core package sets it to false for Native AOT and + /// TrimMode=full publishes; override it with the KumikoReflectionPropertyAccess + /// MSBuild property. + /// + public const string ReflectionSwitchName = "KumikoUI.Core.IsReflectionPropertyAccessSupported"; + + private static readonly object s_gate = new(); + private static readonly Dictionary s_typeProviders = new(); + private static readonly List s_globalProviders = new(); + private static readonly ConcurrentDictionary<(Type Type, string Name), KumikoPropertyAccessor?> s_segments = new(); + private static readonly ConcurrentDictionary<(Type Type, string Path), KumikoValuePath> s_paths = new(); + private static readonly ConcurrentDictionary<(Type Type, string Name), bool> s_reportedMissing = new(); + private static int s_version; + + /// + /// Whether properties without a registered accessor are read and written through reflection. + /// True unless the switch is false. When it is false, such a + /// property reads as null, cannot be written, and a warning is traced once per type and property. + /// + [FeatureSwitchDefinition(ReflectionSwitchName)] + [FeatureGuard(typeof(RequiresUnreferencedCodeAttribute))] + [UnconditionalSuppressMessage("Trimming", "IL4000", + Justification = "The KumikoUI.Core build targets set this switch to false (RuntimeHostConfigurationOption, " + + "Trim=true) for Native AOT and TrimMode=full, so the trimmer removes the reflection path; " + + "partial trimming keeps app types' metadata, which the reflection path reads.")] + public static bool IsReflectionPropertyAccessSupported => + AppContext.TryGetSwitch(ReflectionSwitchName, out bool enabled) ? enabled : true; + + /// + /// Registers the accessor provider for and the types derived from it. + /// The last registration for a type wins. + /// + public static void Register(Type itemType, IKumikoAccessorProvider provider) + { + ArgumentNullException.ThrowIfNull(itemType); + ArgumentNullException.ThrowIfNull(provider); + lock (s_gate) + { + s_typeProviders[itemType] = provider; + ClearCaches(); + } + } + + /// + /// Adds a provider consulted for every item type after the type-specific ones, for example one + /// that reads dictionary entries. + /// + public static void AddProvider(IKumikoAccessorProvider provider) + { + ArgumentNullException.ThrowIfNull(provider); + lock (s_gate) + { + s_globalProviders.Add(provider); + ClearCaches(); + } + } + + /// Changes whenever registrations change, invalidating paths cached outside the registry. + internal static int Version => Volatile.Read(ref s_version); + + /// Returns the resolved path for , cached. + internal static KumikoValuePath GetPath(Type itemType, string path) => + s_paths.GetOrAdd((itemType, path), static key => new KumikoValuePath(key.Type, key.Path)); + + /// Returns the accessor for one path segment of , or null; cached. + internal static KumikoPropertyAccessor? GetSegmentAccessor(Type type, string name) => + s_segments.GetOrAdd((type, name), static key => ResolveSegment(key.Type, key.Name)); + + /// Forgets every resolved accessor (after a registration, or in tests). + internal static void ClearCaches() + { + s_segments.Clear(); + s_paths.Clear(); + s_reportedMissing.Clear(); + Interlocked.Increment(ref s_version); + } + + private static KumikoPropertyAccessor? ResolveSegment(Type type, string name) + { + IKumikoAccessorProvider[] globals; + List? typed = null; + lock (s_gate) + { + for (var t = type; t != null; t = t.BaseType) + { + if (s_typeProviders.TryGetValue(t, out var provider)) + (typed ??= new()).Add(provider); + } + globals = s_globalProviders.ToArray(); + } + + if (typed != null) + { + foreach (var provider in typed) + { + if (provider.GetAccessor(type, name) is { } accessor) + return accessor; + } + } + + foreach (var provider in globals) + { + if (provider.GetAccessor(type, name) is { } accessor) + return accessor; + } + + if (IsReflectionPropertyAccessSupported) + return ReflectionAccessorFactory.Create(type, name); + + ReportMissing(type, name); + return null; + } + + private static void ReportMissing(Type type, string name) + { + if (!s_reportedMissing.TryAdd((type, name), true)) return; + Trace.TraceWarning( + $"KumikoUI: no accessor for property '{name}' of {type.FullName}, and reflection property access is " + + $"disabled ({ReflectionSwitchName}=false, the default for Native AOT and full trimming). Mark the type " + + "with [KumikoGridItem], set KumikoColumn.ValueAccessor/ValueSetter, or register an IKumikoAccessorProvider."); + } +} diff --git a/src/KumikoUI.Core/Data/KumikoGridItemAttribute.cs b/src/KumikoUI.Core/Data/KumikoGridItemAttribute.cs new file mode 100644 index 0000000..9b7aa2d --- /dev/null +++ b/src/KumikoUI.Core/Data/KumikoGridItemAttribute.cs @@ -0,0 +1,17 @@ +namespace KumikoUI.Core.Data; + +/// +/// Generates reflection-free accessors for the public properties of this type, so the grid can read +/// and write them under Native AOT and full trimming. The KumikoUI.Core package's source +/// generator emits the accessors and registers them with when the +/// assembly loads. +/// +/// +/// Apply it to every type a column path walks through: for PropertyName="Address.City", mark both +/// the row type and the type of Address. Generic, private and file-local types are not supported; +/// use or an for them. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false, AllowMultiple = false)] +public sealed class KumikoGridItemAttribute : Attribute +{ +} diff --git a/src/KumikoUI.Core/Data/KumikoPropertyAccessor.cs b/src/KumikoUI.Core/Data/KumikoPropertyAccessor.cs new file mode 100644 index 0000000..2312873 --- /dev/null +++ b/src/KumikoUI.Core/Data/KumikoPropertyAccessor.cs @@ -0,0 +1,50 @@ +namespace KumikoUI.Core.Data; + +/// +/// Reads and writes one property of an item without reflection. Created by the +/// [KumikoGridItem] source generator, by an , or by hand. +/// +public sealed class KumikoPropertyAccessor +{ + private readonly Func? _getter; + private readonly Action? _setter; + + /// Creates an accessor. + /// The property name. + /// The declared property type; values are converted to it before they are set. + /// Reads the property from an item, or null when the property is write-only. + /// Writes the property on an item, or null when the property is read-only. + public KumikoPropertyAccessor(string name, Type propertyType, + Func? getter, Action? setter) + { + ArgumentNullException.ThrowIfNull(name); + ArgumentNullException.ThrowIfNull(propertyType); + Name = name; + PropertyType = propertyType; + _getter = getter; + _setter = setter; + } + + /// The property name. + public string Name { get; } + + /// The declared property type. + public Type PropertyType { get; } + + /// Whether the property can be read. + public bool CanRead => _getter != null; + + /// Whether the property can be written. + public bool CanWrite => _setter != null; + + /// Reads the property, or returns null when it is write-only. + public object? GetValue(object item) => _getter?.Invoke(item); + + /// Writes the property. Returns false when it is read-only. + public bool SetValue(object item, object? value) + { + if (_setter == null) return false; + _setter(item, value); + return true; + } +} diff --git a/src/KumikoUI.Core/Data/KumikoValuePath.cs b/src/KumikoUI.Core/Data/KumikoValuePath.cs new file mode 100644 index 0000000..aaa5886 --- /dev/null +++ b/src/KumikoUI.Core/Data/KumikoValuePath.cs @@ -0,0 +1,115 @@ +namespace KumikoUI.Core.Data; + +/// +/// A property path ("Name", "Address.City") resolved for one root item type. Segments whose +/// declaring type is known up front are resolved once; the rest are resolved per call from the runtime +/// type of the intermediate value. A null intermediate value reads as null and cannot be written. +/// +internal sealed class KumikoValuePath +{ + private readonly string[] _names; + private readonly KumikoPropertyAccessor?[] _accessors; + + public KumikoValuePath(Type rootType, string path) + { + RootType = rootType; + Path = path; + Version = KumikoAccessorRegistry.Version; + _names = string.IsNullOrEmpty(path) ? Array.Empty() : path.Split('.'); + _accessors = new KumikoPropertyAccessor?[_names.Length]; + + Type? declaringType = rootType; + for (int i = 0; i < _names.Length && declaringType != null; i++) + { + var accessor = KumikoAccessorRegistry.GetSegmentAccessor(declaringType, _names[i]); + _accessors[i] = accessor; + // A value typed as object or an interface may be anything; resolve the next segment per call. + declaringType = accessor?.PropertyType is { } next && next != typeof(object) && !next.IsInterface + ? next + : null; + } + } + + /// The item type the path was resolved for. + public Type RootType { get; } + + /// The path this instance resolves. + public string Path { get; } + + /// The the path was resolved under. + public int Version { get; } + + /// Whether this resolution still applies to and . + public bool IsCurrentFor(Type itemType, string path) => + RootType == itemType && Version == KumikoAccessorRegistry.Version && string.Equals(Path, path, StringComparison.Ordinal); + + /// + /// The declared type of the last segment when it is known up front, otherwise null. An empty path + /// (the item itself) has no value type. + /// + public Type? ValueType => _names.Length > 0 ? _accessors[^1]?.PropertyType : null; + + /// Reads the value at the path; the item itself for an empty path. + public object? GetValue(object item) + { + object? current = item; + for (int i = 0; i < _names.Length; i++) + { + if (current is null) return null; + var accessor = Accessor(i, current); + if (accessor is null) return null; + current = accessor.GetValue(current); + } + return current; + } + + /// + /// Writes at the path, converting it to the property type when needed. + /// Returns false when the path cannot be written (unknown or read-only property, null intermediate + /// value, empty path, a value that does not convert, or a setter that throws). + /// + public bool TrySetValue(object item, object? value) + { + if (_names.Length == 0) return false; + + object? parent = item; + for (int i = 0; i < _names.Length - 1; i++) + { + if (parent is null) return false; + var accessor = Accessor(i, parent); + if (accessor is null) return false; + parent = accessor.GetValue(parent); + } + if (parent is null) return false; + + var last = Accessor(_names.Length - 1, parent); + if (last is null || !last.CanWrite) return false; + + if (value != null && !last.PropertyType.IsInstanceOfType(value)) + { + var target = Nullable.GetUnderlyingType(last.PropertyType) ?? last.PropertyType; + try + { + value = Convert.ChangeType(value, target); + } + catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException) + { + return false; + } + } + + try + { + return last.SetValue(parent, value); + } + catch (Exception ex) when (ex is not (OutOfMemoryException or InsufficientExecutionStackException)) + { + // A throwing setter (validation in the item, a wrong-typed value) leaves the item unchanged, + // as the reflection path always did. #32 surfaces such failures as validation errors. + return false; + } + } + + private KumikoPropertyAccessor? Accessor(int index, object instance) => + _accessors[index] ?? KumikoAccessorRegistry.GetSegmentAccessor(instance.GetType(), _names[index]); +} diff --git a/src/KumikoUI.Core/Data/ReflectionAccessorFactory.cs b/src/KumikoUI.Core/Data/ReflectionAccessorFactory.cs new file mode 100644 index 0000000..dd79fc9 --- /dev/null +++ b/src/KumikoUI.Core/Data/ReflectionAccessorFactory.cs @@ -0,0 +1,47 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace KumikoUI.Core.Data; + +/// +/// The reflection fallback: builds an accessor from . Getters are compiled +/// expression trees where dynamic code is supported, and +/// otherwise. Only reachable while . +/// +internal static class ReflectionAccessorFactory +{ + private const BindingFlags PropertyFlags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; + + [RequiresUnreferencedCode("Reads item properties through reflection; their metadata may be trimmed.")] + public static KumikoPropertyAccessor? Create(Type type, string name) + { + var property = type.GetProperty(name, PropertyFlags); + if (property == null || property.GetIndexParameters().Length > 0) + return null; + + Func? getter = null; + if (property.GetMethod != null) + { + getter = RuntimeFeature.IsDynamicCodeSupported + ? CompileGetter(type, property) + : property.GetValue; + } + + Action? setter = property.SetMethod != null ? property.SetValue : null; + + return new KumikoPropertyAccessor(property.Name, property.PropertyType, getter, setter); + } + + [RequiresUnreferencedCode("Reads item properties through reflection; their metadata may be trimmed.")] + [RequiresDynamicCode("Compiles an expression tree.")] + private static Func CompileGetter(Type type, PropertyInfo property) + { + var item = Expression.Parameter(typeof(object), "item"); + Expression body = Expression.Property(Expression.Convert(item, type), property); + if (body.Type.IsValueType) + body = Expression.Convert(body, typeof(object)); + return Expression.Lambda>(body, item).Compile(); + } +} diff --git a/src/KumikoUI.Core/Editing/CellEditorFactory.cs b/src/KumikoUI.Core/Editing/CellEditorFactory.cs index 76217ac..bd7cd36 100644 --- a/src/KumikoUI.Core/Editing/CellEditorFactory.cs +++ b/src/KumikoUI.Core/Editing/CellEditorFactory.cs @@ -158,11 +158,12 @@ private static DrawnComboBox CreateComboBoxEditor( if (column.EditorItems != null) { + // Paths and selectors first: SetItemsFromSource reads them while it builds the items. + comboBox.DisplayMemberPath = column.EditorDisplayMemberPath; + comboBox.ValueMemberPath = column.EditorValueMemberPath; + comboBox.DisplaySelector = column.EditorDisplaySelector; + comboBox.ValueSelector = column.EditorValueSelector; comboBox.SetItemsFromSource(column.EditorItems); - if (column.EditorDisplayMemberPath != null) - comboBox.DisplayMemberPath = column.EditorDisplayMemberPath; - if (column.EditorValueMemberPath != null) - comboBox.ValueMemberPath = column.EditorValueMemberPath; } if (value != null) diff --git a/src/KumikoUI.Core/KumikoUI.Core.csproj b/src/KumikoUI.Core/KumikoUI.Core.csproj index 5b69486..1eaefe9 100644 --- a/src/KumikoUI.Core/KumikoUI.Core.csproj +++ b/src/KumikoUI.Core/KumikoUI.Core.csproj @@ -2,6 +2,8 @@ net10.0 + + true enable enable true @@ -12,4 +14,23 @@ maui;datagrid;data;grid;table;core + + + + + + + + + + + diff --git a/src/KumikoUI.Core/Models/KumikoColumn.cs b/src/KumikoUI.Core/Models/KumikoColumn.cs index b4b0520..2a208e7 100644 --- a/src/KumikoUI.Core/Models/KumikoColumn.cs +++ b/src/KumikoUI.Core/Models/KumikoColumn.cs @@ -15,9 +15,40 @@ public class KumikoColumn /// Display header text. public string Header { get; set; } = string.Empty; - /// Property path on the data item (supports nested: "Address.City"). + /// + /// Property path on the data item (supports nested: "Address.City"; empty means the item itself). + /// Resolved through : accessors generated for types marked + /// , registered providers, then reflection where it is + /// supported. and take precedence. + /// public string PropertyName { get; set; } = string.Empty; + /// + /// Reads the cell value from a row item. When set, it is used instead of + /// for display, sorting, grouping, filtering and summaries. Reflection-free, so it works under + /// Native AOT and full trimming. + /// + public Func? ValueAccessor { get; set; } + + /// + /// Writes an edited value back to a row item. When set, it is used instead of + /// ; the editor's value is passed as is, without conversion. + /// Reflection-free, so it works under Native AOT and full trimming. + /// + public Action? ValueSetter { get; set; } + + // The path resolved for the last item type seen, so a cell read costs no registry lookup. + private Data.KumikoValuePath? _resolvedPath; + + /// Resolves for , caching the last result. + internal Data.KumikoValuePath ResolvePath(Type itemType) + { + var path = _resolvedPath; + if (path == null || !path.IsCurrentFor(itemType, PropertyName)) + _resolvedPath = path = Data.KumikoAccessorRegistry.GetPath(itemType, PropertyName); + return path; + } + /// Type of column for rendering dispatch. public KumikoColumnType ColumnType { get; set; } = KumikoColumnType.Text; @@ -146,6 +177,18 @@ public string? EditorItemsString /// Property path for value in ComboBox editor items. public string? EditorValueMemberPath { get; set; } + /// + /// Returns the display text of a ComboBox editor item. Takes precedence over + /// ; reflection-free. + /// + public Func? EditorDisplaySelector { get; set; } + + /// + /// Returns the value of a ComboBox editor item. Takes precedence over + /// ; reflection-free. + /// + public Func? EditorValueSelector { get; set; } + /// /// Custom editor factory for Template columns. /// Receives (currentValue, cellBounds) and returns a DrawnComponent editor. diff --git a/src/KumikoUI.Core/Models/KumikoSource.cs b/src/KumikoUI.Core/Models/KumikoSource.cs index 7c3a4f8..f3cc9f2 100644 --- a/src/KumikoUI.Core/Models/KumikoSource.cs +++ b/src/KumikoUI.Core/Models/KumikoSource.cs @@ -1,8 +1,7 @@ using System.Collections; using System.Collections.Specialized; using System.ComponentModel; -using System.Linq.Expressions; -using System.Reflection; +using KumikoUI.Core.Data; namespace KumikoUI.Core.Models; @@ -18,7 +17,6 @@ public class KumikoSource private List _viewIndices = new(); // filtered + sorted mapping private List? _flatView; // null when no grouping; otherwise group headers + data rows private List _columns = new(); - private readonly Dictionary> _accessors = new(); private Func? _filterPredicate; // ── Observable collection tracking ─────────────────────────── @@ -240,8 +238,6 @@ public void SetItems(IEnumerable items) else _sourceItems = items.Cast().ToList(); - _accessors.Clear(); - // Subscribe to INotifyCollectionChanged if supported if (items is INotifyCollectionChanged incc) { @@ -328,11 +324,6 @@ private void OnSourceCollectionChanged(object? sender, NotifyCollectionChangedEv break; } - // Note: we do NOT clear _accessors here. The compiled expression-tree - // delegates are keyed by "{type.FullName}.{propertyPath}" and remain - // valid across add/remove/replace operations (the item type doesn't change). - // Clearing them here would force expensive recompilation on every INCC event. - // For simple Add/Remove with no active sort, filter, or grouping we can // do an incremental update instead of a full O(n) rebuild. if (TryIncrementalUpdate(e)) @@ -432,8 +423,6 @@ private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) if (needsRebuild) { - // Note: we do NOT clear _accessors here. Property value changes - // don't alter the item type, so compiled accessors remain valid. RebuildView(); } else @@ -449,7 +438,6 @@ private void OnItemPropertyChanged(object? sender, PropertyChangedEventArgs e) public void SetColumns(IEnumerable columns) { _columns = columns.ToList(); - _accessors.Clear(); } /// @@ -667,9 +655,7 @@ public void ClearGroupDescriptions() public object? GetCellValue(int visibleRow, KumikoColumn column) { if (IsGroupHeaderRow(visibleRow)) return null; - var item = GetItem(visibleRow); - var accessor = GetOrCreateAccessor(item.GetType(), column.PropertyName); - return accessor(item); + return ReadColumnValue(GetItem(visibleRow), column); } /// @@ -867,55 +853,21 @@ public void Refresh() } /// - /// Set a cell value for a visible row and column via reflection. + /// Set a cell value for a visible row and column, through when + /// it is set and through the column's property path otherwise (converting the value to the property + /// type). A value that cannot be written (read-only or unknown property, null intermediate value, + /// failed conversion) is ignored. /// public void SetCellValue(int visibleRow, KumikoColumn column, object? value) { var item = GetItem(visibleRow); - SetPropertyValue(item, column.PropertyName, value); + if (column.ValueSetter != null) + column.ValueSetter(item, value); + else + column.ResolvePath(item.GetType()).TrySetValue(item, value); DataChanged?.Invoke(); } - /// - /// Set a property value on an object using reflection. Supports dotted paths. - /// - private static void SetPropertyValue(object target, string propertyPath, object? value) - { - var parts = propertyPath.Split('.'); - object current = target; - - // Navigate to the parent object for nested paths - for (int i = 0; i < parts.Length - 1; i++) - { - var prop = current.GetType().GetProperty(parts[i], - BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); - if (prop == null) return; - var next = prop.GetValue(current); - if (next == null) return; - current = next; - } - - // Set the final property - var finalProp = current.GetType().GetProperty(parts[^1], - BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); - if (finalProp == null || !finalProp.CanWrite) return; - - try - { - // Convert to the target type if needed - if (value != null && !finalProp.PropertyType.IsAssignableFrom(value.GetType())) - { - var targetType = Nullable.GetUnderlyingType(finalProp.PropertyType) ?? finalProp.PropertyType; - value = Convert.ChangeType(value, targetType); - } - finalProp.SetValue(current, value); - } - catch - { - // Graceful fallback — skip if conversion/set fails - } - } - // ── Internal ───────────────────────────────────────────────── /// @@ -971,11 +923,20 @@ private bool PassesOtherFilters(object item, KumikoColumn excludeColumn) /// /// Get a cell value directly from an item (bypasses view indices). /// - private object? GetCellValueDirect(object item, KumikoColumn column) - { - var accessor = GetOrCreateAccessor(item.GetType(), column.PropertyName); - return accessor(item); - } + private static object? GetCellValueDirect(object item, KumikoColumn column) => ReadColumnValue(item, column); + + /// + /// Read a column's value from an item: when it is set, + /// otherwise the column's property path (an empty path is the item itself). + /// + private static object? ReadColumnValue(object item, KumikoColumn column) => + column.ValueAccessor != null + ? column.ValueAccessor(item) + : column.ResolvePath(item.GetType()).GetValue(item); + + /// A reader for one column, for tight loops (sorting, aggregates). + private static Func CreateColumnReader(KumikoColumn column) => + column.ValueAccessor ?? (item => column.ResolvePath(item.GetType()).GetValue(item)); /// /// Format a cell value as display text. @@ -1088,12 +1049,10 @@ internal void RebuildView() if (sortedColumns.Count > 0 && _sourceItems.Count > 0) { - var itemType = _sourceItems[0]!.GetType(); - - // Pre-build accessors and comparers for each sort column + // Pre-build readers and comparers for each sort column var sortSpecs = sortedColumns.Select(col => new { - Accessor = GetOrCreateAccessor(itemType, col.PropertyName), + Accessor = CreateColumnReader(col), Comparer = col.CustomComparer ?? Comparer.Default, Direction = col.SortDirection == KumikoSortDirection.Ascending ? 1 : -1 }).ToList(); @@ -1167,8 +1126,7 @@ private void BuildGroupedFlatView(List sourceIndices, int level, string par object? rawValue; if (column != null) { - var accessor = GetOrCreateAccessor(item.GetType(), groupDesc.PropertyName); - rawValue = accessor(item); + rawValue = ReadColumnValue(item, column); } else { @@ -1372,10 +1330,10 @@ private ComputedSummaryRow ComputeSummaryRow( if (column == null || _sourceItems.Count == 0) return result; - var accessor = GetOrCreateAccessor(_sourceItems[0]!.GetType(), propertyName); + var reader = CreateColumnReader(column); foreach (var idx in sourceIndices) { - result.Add(accessor(_sourceItems[idx]!)); + result.Add(reader(_sourceItems[idx]!)); } return result; } @@ -1512,45 +1470,4 @@ private static string GetDefaultLabel(SummaryType type) _ => "" }; } - - private Func GetOrCreateAccessor(Type type, string propertyPath) - { - var key = $"{type.FullName}.{propertyPath}"; - if (_accessors.TryGetValue(key, out var existing)) - return existing; - - var accessor = BuildAccessor(type, propertyPath); - _accessors[key] = accessor; - return accessor; - } - - /// - /// Build a compiled expression tree accessor for fast property access. - /// Supports dotted paths: "Address.City". - /// - private static Func BuildAccessor(Type type, string propertyPath) - { - // Empty path: return the item itself. Useful for Template/action columns - // that need the whole row object as the cell value (e.g. CustomEditorFactory). - if (string.IsNullOrEmpty(propertyPath)) - return item => item; - - var param = Expression.Parameter(typeof(object), "item"); - Expression body = Expression.Convert(param, type); - - foreach (var part in propertyPath.Split('.')) - { - var prop = body.Type.GetProperty(part, - BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); - if (prop == null) - return _ => null; // Graceful fallback - body = Expression.Property(body, prop); - } - - // Box value types - if (body.Type.IsValueType) - body = Expression.Convert(body, typeof(object)); - - return Expression.Lambda>(body, param).Compile(); - } } diff --git a/src/KumikoUI.Core/buildTransitive/KumikoUI.Core.targets b/src/KumikoUI.Core/buildTransitive/KumikoUI.Core.targets new file mode 100644 index 0000000..c017714 --- /dev/null +++ b/src/KumikoUI.Core/buildTransitive/KumikoUI.Core.targets @@ -0,0 +1,23 @@ + + + + + false + + + + + + diff --git a/src/KumikoUI.Maui/KumikoUI.Maui.csproj b/src/KumikoUI.Maui/KumikoUI.Maui.csproj index 3e7efd7..eae342d 100644 --- a/src/KumikoUI.Maui/KumikoUI.Maui.csproj +++ b/src/KumikoUI.Maui/KumikoUI.Maui.csproj @@ -3,6 +3,8 @@ net10.0-android;net10.0-ios;net10.0-maccatalyst $(TargetFrameworks);net10.0-windows10.0.19041.0 + + true true true diff --git a/src/KumikoUI.SkiaSharp/KumikoUI.SkiaSharp.csproj b/src/KumikoUI.SkiaSharp/KumikoUI.SkiaSharp.csproj index 82af35f..0d8380a 100644 --- a/src/KumikoUI.SkiaSharp/KumikoUI.SkiaSharp.csproj +++ b/src/KumikoUI.SkiaSharp/KumikoUI.SkiaSharp.csproj @@ -10,6 +10,8 @@ net10.0 + + true enable enable true diff --git a/src/KumikoUI.SourceGenerators/AnalyzerReleases.Shipped.md b/src/KumikoUI.SourceGenerators/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..f50bb1f --- /dev/null +++ b/src/KumikoUI.SourceGenerators/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/src/KumikoUI.SourceGenerators/AnalyzerReleases.Unshipped.md b/src/KumikoUI.SourceGenerators/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..82ba9b4 --- /dev/null +++ b/src/KumikoUI.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -0,0 +1,9 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +KUMIKO001 | KumikoUI | Warning | [KumikoGridItem] does not support generic types +KUMIKO002 | KumikoUI | Warning | [KumikoGridItem] type is not accessible diff --git a/src/KumikoUI.SourceGenerators/Diagnostics.cs b/src/KumikoUI.SourceGenerators/Diagnostics.cs new file mode 100644 index 0000000..6ada739 --- /dev/null +++ b/src/KumikoUI.SourceGenerators/Diagnostics.cs @@ -0,0 +1,44 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace KumikoUI.SourceGenerators; + +/// Diagnostics reported by . +internal static class Diagnostics +{ + private const string Category = "KumikoUI"; + + public static readonly DiagnosticDescriptor GenericType = new( + id: "KUMIKO001", + title: "[KumikoGridItem] does not support generic types", + messageFormat: "'{0}' is generic, so no accessors are generated for it. Use KumikoColumn.ValueAccessor/ValueSetter or register an IKumikoAccessorProvider.", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor InaccessibleType = new( + id: "KUMIKO002", + title: "[KumikoGridItem] type is not accessible", + messageFormat: "'{0}' (or a type containing it) is private, protected or file-local, so generated accessors cannot reference it. Make it internal or public.", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); +} + +/// A diagnostic captured in an equatable form, so the incremental pipeline can cache it. +internal sealed record DiagnosticInfo(DiagnosticDescriptor Descriptor, DiagnosticLocation? Location, string Argument) +{ + public Diagnostic ToDiagnostic() => + Diagnostic.Create(Descriptor, Location?.ToLocation(), Argument); +} + +/// An equatable stand-in for . +internal sealed record DiagnosticLocation(string FilePath, TextSpan Span, LinePositionSpan LineSpan) +{ + public static DiagnosticLocation? From(Location location) => + location.SourceTree is null + ? null + : new DiagnosticLocation(location.SourceTree.FilePath, location.SourceSpan, location.GetLineSpan().Span); + + public Location ToLocation() => Location.Create(FilePath, Span, LineSpan); +} diff --git a/src/KumikoUI.SourceGenerators/EquatableArray.cs b/src/KumikoUI.SourceGenerators/EquatableArray.cs new file mode 100644 index 0000000..99484d3 --- /dev/null +++ b/src/KumikoUI.SourceGenerators/EquatableArray.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace KumikoUI.SourceGenerators; + +/// An immutable array compared by value, so incremental generator outputs cache correctly. +internal readonly struct EquatableArray : IEquatable>, IEnumerable + where T : IEquatable +{ + private readonly ImmutableArray _items; + + public EquatableArray(ImmutableArray items) => _items = items; + + public int Length => _items.IsDefault ? 0 : _items.Length; + + public bool Equals(EquatableArray other) + { + if (Length != other.Length) return false; + for (int i = 0; i < Length; i++) + { + if (!_items[i].Equals(other._items[i])) return false; + } + return true; + } + + public override bool Equals(object? obj) => obj is EquatableArray other && Equals(other); + + public override int GetHashCode() + { + int hash = 17; + for (int i = 0; i < Length; i++) + hash = unchecked(hash * 31 + _items[i].GetHashCode()); + return hash; + } + + public IEnumerator GetEnumerator() => + ((IEnumerable)(_items.IsDefault ? ImmutableArray.Empty : _items)).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/KumikoUI.SourceGenerators/IsExternalInit.cs b/src/KumikoUI.SourceGenerators/IsExternalInit.cs new file mode 100644 index 0000000..c427652 --- /dev/null +++ b/src/KumikoUI.SourceGenerators/IsExternalInit.cs @@ -0,0 +1,6 @@ +// Records and init accessors on netstandard2.0, which lacks this marker type. +namespace System.Runtime.CompilerServices; + +internal static class IsExternalInit +{ +} diff --git a/src/KumikoUI.SourceGenerators/KumikoGridItemGenerator.cs b/src/KumikoUI.SourceGenerators/KumikoGridItemGenerator.cs new file mode 100644 index 0000000..4a28510 --- /dev/null +++ b/src/KumikoUI.SourceGenerators/KumikoGridItemGenerator.cs @@ -0,0 +1,193 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace KumikoUI.SourceGenerators; + +/// +/// Emits a reflection-free IKumikoAccessorProvider for every type marked +/// [KumikoGridItem], and a module initializer that registers it with +/// KumikoAccessorRegistry. +/// +[Generator(LanguageNames.CSharp)] +public sealed class KumikoGridItemGenerator : IIncrementalGenerator +{ + private const string AttributeMetadataName = "KumikoUI.Core.Data.KumikoGridItemAttribute"; + + /// Fully qualified, without nullable annotations (they are not valid in typeof). + private static readonly SymbolDisplayFormat TypeFormat = SymbolDisplayFormat.FullyQualifiedFormat + .RemoveMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); + + /// Fully qualified, with nullable annotations, so setter casts match the property type exactly. + private static readonly SymbolDisplayFormat CastFormat = SymbolDisplayFormat.FullyQualifiedFormat + .AddMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier); + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var items = context.SyntaxProvider.ForAttributeWithMetadataName( + AttributeMetadataName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, ct) => Analyze((INamedTypeSymbol)ctx.TargetSymbol, ctx.TargetNode, ct)); + + context.RegisterSourceOutput(items, static (spc, item) => + { + if (item.Diagnostic is { } diagnostic) + { + spc.ReportDiagnostic(diagnostic.ToDiagnostic()); + return; + } + + spc.AddSource(item.HintName, SourceText.From(Emit(item), Encoding.UTF8)); + }); + } + + private static GridItemModel Analyze(INamedTypeSymbol type, SyntaxNode node, CancellationToken ct) + { + string displayName = type.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat); + var location = DiagnosticLocation.From(((TypeDeclarationSyntax)node).Identifier.GetLocation()); + + if (type.IsGenericType) + return GridItemModel.Fail(new DiagnosticInfo(Diagnostics.GenericType, location, displayName)); + + for (INamedTypeSymbol? t = type; t != null; t = t.ContainingType) + { + if (t.IsFileLocal || !IsAccessible(t.DeclaredAccessibility)) + return GridItemModel.Fail(new DiagnosticInfo(Diagnostics.InaccessibleType, location, displayName)); + } + + string typeName = type.ToDisplayString(TypeFormat); + var properties = new List(); + var seen = new HashSet(System.StringComparer.OrdinalIgnoreCase); + + // Most-derived declarations first, so overrides and 'new' members win over base members. + for (INamedTypeSymbol? t = type; t != null && t.SpecialType is not (SpecialType.System_Object or SpecialType.System_ValueType); t = t.BaseType) + { + ct.ThrowIfCancellationRequested(); + foreach (var member in t.GetMembers()) + { + if (member is not IPropertySymbol property || !seen.Add(property.Name) || !IsSupported(property)) + continue; + + bool canRead = property.GetMethod is { } get && IsAccessible(get.DeclaredAccessibility); + bool canWrite = property.SetMethod is { } set && !set.IsInitOnly && IsAccessible(set.DeclaredAccessibility); + if (!canRead && !canWrite) + continue; + + bool dynamic = property.Type.TypeKind == TypeKind.Dynamic; + string propertyType = dynamic ? "object" : property.Type.ToDisplayString(TypeFormat); + string castType = dynamic ? "object?" : property.Type.ToDisplayString(CastFormat); + properties.Add(new PropertyModel(property.Name, Identifier(property.Name), propertyType, castType, canRead, canWrite)); + } + } + + string sanitized = Sanitize(type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", "")); + return new GridItemModel( + HintName: $"KumikoAccessors.{sanitized}.g.cs", + ClassName: "__KumikoAccessors_" + sanitized, + TypeName: typeName, + IsValueType: type.IsValueType, + Properties: new EquatableArray(properties.ToImmutableArray()), + Diagnostic: null); + } + + private static bool IsSupported(IPropertySymbol property) => + !property.IsStatic + && !property.IsIndexer + && property.DeclaredAccessibility == Accessibility.Public + && property.ExplicitInterfaceImplementations.IsEmpty + && property.RefKind == RefKind.None + && !property.Type.IsRefLikeType + && property.Type.TypeKind is not (TypeKind.Pointer or TypeKind.FunctionPointer) + && !IsErrorObsolete(property); + + /// Using an [Obsolete(..., error: true)] member is a compile error, so it gets no accessor. + private static bool IsErrorObsolete(IPropertySymbol property) => + property.GetAttributes().Any(a => + a.AttributeClass?.ToDisplayString() == "System.ObsoleteAttribute" + && a.ConstructorArguments.Length == 2 + && a.ConstructorArguments[1].Value is true); + + private static bool IsAccessible(Accessibility accessibility) => + accessibility is Accessibility.Public or Accessibility.Internal or Accessibility.ProtectedOrInternal; + + private static string Identifier(string name) => + SyntaxFacts.GetKeywordKind(name) != SyntaxKind.None || SyntaxFacts.GetContextualKeywordKind(name) == SyntaxKind.FieldKeyword + ? "@" + name + : name; + + private static string Sanitize(string name) + { + var sb = new StringBuilder(name.Length); + foreach (char c in name) + sb.Append(char.IsLetterOrDigit(c) || c == '_' ? c : '_'); + return sb.ToString(); + } + + private static string Emit(GridItemModel item) + { + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("// Generated by KumikoUI.SourceGenerators for [KumikoGridItem]. Do not edit."); + sb.AppendLine("#nullable enable"); + sb.AppendLine("#pragma warning disable CS0612, CS0618 // obsolete members are still accessible"); + sb.AppendLine(); + sb.AppendLine("namespace KumikoUI.Generated"); + sb.AppendLine("{"); + sb.AppendLine(" [global::System.CodeDom.Compiler.GeneratedCode(\"KumikoUI.SourceGenerators\", \"1.0\")]"); + sb.AppendLine(" [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]"); + sb.AppendLine($" internal sealed class {item.ClassName} : global::KumikoUI.Core.Data.IKumikoAccessorProvider"); + sb.AppendLine(" {"); + sb.AppendLine(" private static readonly global::System.Collections.Generic.Dictionary s_accessors ="); + sb.AppendLine(" new global::System.Collections.Generic.Dictionary(global::System.StringComparer.OrdinalIgnoreCase)"); + sb.AppendLine(" {"); + foreach (var p in item.Properties) + { + string getter = p.CanRead + ? $"static item => (({item.TypeName})item).{p.Identifier}" + : "null"; + string target = item.IsValueType + ? $"global::System.Runtime.CompilerServices.Unsafe.Unbox<{item.TypeName}>(item)" + : $"(({item.TypeName})item)"; + string setter = p.CanWrite + ? $"static (item, value) => {target}.{p.Identifier} = value is null ? default! : ({p.CastType})value" + : "null"; + sb.AppendLine($" [\"{p.Name}\"] = new global::KumikoUI.Core.Data.KumikoPropertyAccessor(\"{p.Name}\", typeof({p.PropertyType}),"); + sb.AppendLine($" {getter},"); + sb.AppendLine($" {setter}),"); + } + sb.AppendLine(" };"); + sb.AppendLine(); + sb.AppendLine(" public global::KumikoUI.Core.Data.KumikoPropertyAccessor? GetAccessor(global::System.Type itemType, string propertyName) =>"); + sb.AppendLine(" s_accessors.TryGetValue(propertyName, out var accessor) ? accessor : null;"); + sb.AppendLine(); + sb.AppendLine("#pragma warning disable CA2255 // registering accessors at module load is the intent"); + sb.AppendLine(" [global::System.Runtime.CompilerServices.ModuleInitializer]"); + sb.AppendLine("#pragma warning restore CA2255"); + sb.AppendLine(" internal static void Register() =>"); + sb.AppendLine($" global::KumikoUI.Core.Data.KumikoAccessorRegistry.Register(typeof({item.TypeName}), new {item.ClassName}());"); + sb.AppendLine(" }"); + sb.AppendLine("}"); + return sb.ToString(); + } +} + +internal sealed record GridItemModel( + string HintName, + string ClassName, + string TypeName, + bool IsValueType, + EquatableArray Properties, + DiagnosticInfo? Diagnostic) +{ + public static GridItemModel Fail(DiagnosticInfo diagnostic) => + new("", "", "", false, default, diagnostic); +} + +internal sealed record PropertyModel(string Name, string Identifier, string PropertyType, string CastType, bool CanRead, bool CanWrite); diff --git a/src/KumikoUI.SourceGenerators/KumikoUI.SourceGenerators.csproj b/src/KumikoUI.SourceGenerators/KumikoUI.SourceGenerators.csproj new file mode 100644 index 0000000..26f1218 --- /dev/null +++ b/src/KumikoUI.SourceGenerators/KumikoUI.SourceGenerators.csproj @@ -0,0 +1,29 @@ + + + + + netstandard2.0 + latest + enable + disable + true + true + false + false + KumikoUI.SourceGenerators + + + + + + + + + + + + + diff --git a/src/KumikoUI.Uno/KumikoUI.Uno.csproj b/src/KumikoUI.Uno/KumikoUI.Uno.csproj index 303cc2c..8e82b29 100644 --- a/src/KumikoUI.Uno/KumikoUI.Uno.csproj +++ b/src/KumikoUI.Uno/KumikoUI.Uno.csproj @@ -11,6 +11,8 @@ --> net10.0;net10.0-ios;net10.0-android;net10.0-browserwasm;net10.0-desktop $(TargetFrameworks);net10.0-windows10.0.26100 + + true true Library diff --git a/tests/KumikoUI.Core.Tests/Data/AccessorModels.cs b/tests/KumikoUI.Core.Tests/Data/AccessorModels.cs new file mode 100644 index 0000000..d86f390 --- /dev/null +++ b/tests/KumikoUI.Core.Tests/Data/AccessorModels.cs @@ -0,0 +1,126 @@ +using KumikoUI.Core.Data; + +namespace KumikoUI.Core.Tests.Data; + +// Models for the accessor tests. Types marked [KumikoGridItem] get generated accessors (the test project +// runs the source generator, as the KumikoUI.Core package does in apps); the others resolve through +// reflection. Each test touches its own types, because the accessor registry is process-wide. + +[KumikoGridItem] +internal class GenEmployee +{ + public string Name { get; set; } = ""; + public int Age { get; set; } + public decimal Salary { get; set; } + public GenAddress? Address { get; set; } + public string ReadOnlyCode => "R-" + Name; + + // Write-only: no getter, so the cell reads as null, but edits still land. + private string _secret = ""; + public string Secret { set => _secret = value; } + public string SecretValue => _secret; +} + +[KumikoGridItem] +internal class GenAddress +{ + public string City { get; set; } = ""; +} + +[KumikoGridItem] +internal class GenBase +{ + public virtual string Label { get; set; } = "base"; + public int Shared { get; set; } +} + +// Not attributed: inherits the accessors registered for GenBase. +internal class GenDerivedUnmarked : GenBase +{ +} + +[KumikoGridItem] +internal class GenDerived : GenBase +{ + public override string Label { get; set; } = "derived"; + public string Extra { get; set; } = ""; +} + +[KumikoGridItem] +internal struct GenPoint +{ + public int X { get; set; } + public int Y { get; set; } +} + +[KumikoGridItem] +internal sealed record GenRecord(string Code, int Quantity) +{ + public string Note { get; set; } = ""; +} + +[KumikoGridItem] +internal class GenKeywords +{ + public string @class { get; set; } = ""; + public int @event { get; set; } +} + +[KumikoGridItem] +internal class GenOption +{ + public int Id { get; set; } + public string Label { get; set; } = ""; +} + +// Reflection-only twins of the generated models. +internal class RefEmployee +{ + public string Name { get; set; } = ""; + public int Age { get; set; } + public RefAddress? Address { get; set; } + public string ReadOnlyCode => "R-" + Name; + private string _secret = ""; + public string Secret { set => _secret = value; } + public string SecretValue => _secret; +} + +internal class RefAddress +{ + public string City { get; set; } = ""; +} + +// Used only by the reflection-disabled tests, so no other test has cached an accessor for them. +internal class NoAccessorItem +{ + public string Name { get; set; } = ""; +} + +[KumikoGridItem] +internal class AotItem +{ + public string Name { get; set; } = ""; + public AotChild? Child { get; set; } +} + +[KumikoGridItem] +internal class AotChild +{ + public int Value { get; set; } +} + +// Read through an IKumikoAccessorProvider registered for the type. +internal sealed class PropertyBag : Dictionary +{ +} + +[KumikoGridItem] +internal class GenValidated +{ + private int _percent; + public int Percent + { + get => _percent; + set => _percent = value is >= 0 and <= 100 ? value : throw new ArgumentOutOfRangeException(nameof(value)); + } +} diff --git a/tests/KumikoUI.Core.Tests/Data/KumikoAccessorTests.cs b/tests/KumikoUI.Core.Tests/Data/KumikoAccessorTests.cs new file mode 100644 index 0000000..4a68f75 --- /dev/null +++ b/tests/KumikoUI.Core.Tests/Data/KumikoAccessorTests.cs @@ -0,0 +1,326 @@ +using KumikoUI.Core.Components; +using KumikoUI.Core.Data; +using KumikoUI.Core.Editing; +using KumikoUI.Core.Models; +using KumikoUI.Core.Rendering; + +namespace KumikoUI.Core.Tests.Data; + +/// +/// Property access (#17): generated accessors, column delegates, providers and the reflection fallback, +/// through the public grid APIs that use them. +/// +public class KumikoAccessorTests +{ + private static KumikoSource Source(System.Collections.IList items, params KumikoColumn[] columns) + { + var source = new KumikoSource(); + source.SetColumns(columns); + source.SetItems(items); + return source; + } + + private static KumikoColumn Col(string path) => new() { Header = path, PropertyName = path }; + + // ── Generated accessors ─────────────────────────────────────── + + [Fact] + public void Generator_EmitsAndRegistersAProviderPerAttributedType() + { + var provider = typeof(GenEmployee).Assembly.GetType( + "KumikoUI.Generated.__KumikoAccessors_KumikoUI_Core_Tests_Data_GenEmployee"); + + Assert.NotNull(provider); + Assert.True(typeof(IKumikoAccessorProvider).IsAssignableFrom(provider)); + Assert.Null(typeof(RefEmployee).Assembly.GetType( + "KumikoUI.Generated.__KumikoAccessors_KumikoUI_Core_Tests_Data_RefEmployee")); + } + + [Fact] + public void Generated_ReadsAndWritesPropertiesCaseInsensitively() + { + var item = new GenEmployee { Name = "Ada", Age = 36 }; + var name = Col("name"); + var source = Source(new[] { item }, name, Col("AGE")); + + Assert.Equal("Ada", source.GetCellValue(0, name)); + Assert.Equal(36, source.GetCellValue(0, source.Columns[1])); + + source.SetCellValue(0, name, "Grace"); + Assert.Equal("Grace", item.Name); + } + + [Fact] + public void Generated_SetCellValue_ConvertsToThePropertyType() + { + var item = new GenEmployee(); + var age = Col("Age"); + var salary = Col("Salary"); + var source = Source(new[] { item }, age, salary); + + source.SetCellValue(0, age, 41.0); // double → int + source.SetCellValue(0, salary, "1250.5"); // string → decimal (current culture) + source.SetCellValue(0, age, "not a number"); + + Assert.Equal(41, item.Age); + Assert.Equal(1250.5m, item.Salary); + } + + [Fact] + public void Generated_ThrowingSetter_LeavesTheItemUnchanged() + { + var item = new GenValidated { Percent = 40 }; + var percent = Col("Percent"); + var source = Source(new[] { item }, percent); + + source.SetCellValue(0, percent, 250); + + Assert.Equal(40, item.Percent); + } + + [Fact] + public void Generated_ReadOnlyProperty_IgnoresEdits() + { + var item = new GenEmployee { Name = "Ada" }; + var code = Col("ReadOnlyCode"); + var source = Source(new[] { item }, code); + + source.SetCellValue(0, code, "X"); + + Assert.Equal("R-Ada", source.GetCellValue(0, code)); + } + + [Fact] + public void Generated_WriteOnlyProperty_ReadsNullButAcceptsEdits() + { + var item = new GenEmployee(); + var secret = Col("Secret"); + var source = Source(new[] { item }, secret); + + Assert.Null(source.GetCellValue(0, secret)); + source.SetCellValue(0, secret, "s3cret"); + Assert.Equal("s3cret", item.SecretValue); + } + + [Fact] + public void Generated_NestedPath_ReadsThroughAndNullIntermediateReadsNull() + { + var withAddress = new GenEmployee { Name = "A", Address = new GenAddress { City = "Oslo" } }; + var withoutAddress = new GenEmployee { Name = "B", Address = null }; + var city = Col("Address.City"); + var source = Source(new[] { withAddress, withoutAddress }, city); + + Assert.Equal("Oslo", source.GetCellValue(0, city)); + Assert.Null(source.GetCellValue(1, city)); + + source.SetCellValue(1, city, "Rome"); // no address to write into: ignored, no exception + source.SetCellValue(0, city, "Rome"); + Assert.Null(withoutAddress.Address); + Assert.Equal("Rome", withAddress.Address!.City); + } + + [Fact] + public void Generated_OverridesAndBaseProperties_UseTheMostDerivedType() + { + var derived = new GenDerived { Label = "d", Shared = 7, Extra = "x" }; + var unmarked = new GenDerivedUnmarked { Label = "u", Shared = 3 }; + var label = Col("Label"); + var shared = Col("Shared"); + var extra = Col("Extra"); + var source = Source(new GenBase[] { derived, unmarked }, label, shared, extra); + + Assert.Equal("d", source.GetCellValue(0, label)); + Assert.Equal(7, source.GetCellValue(0, shared)); + Assert.Equal("x", source.GetCellValue(0, extra)); + // An unmarked subclass resolves through its marked base type's accessors. + Assert.Equal("u", source.GetCellValue(1, label)); + Assert.Equal(3, source.GetCellValue(1, shared)); + } + + [Fact] + public void Generated_RecordsAndKeywordNamedProperties() + { + var record = new GenRecord("C-1", 4) { Note = "n" }; + var keywords = new GenKeywords { @class = "A", @event = 2 }; + + var source = Source(new object[] { record }, Col("Code"), Col("Quantity"), Col("Note")); + Assert.Equal("C-1", source.GetCellValue(0, source.Columns[0])); + Assert.Equal(4, source.GetCellValue(0, source.Columns[1])); + source.SetCellValue(0, source.Columns[1], 9); // init-only: not writable + Assert.Equal(4, record.Quantity); + + var kw = Source(new[] { keywords }, Col("class"), Col("event")); + Assert.Equal("A", kw.GetCellValue(0, kw.Columns[0])); + Assert.Equal(2, kw.GetCellValue(0, kw.Columns[1])); + } + + [Fact] + public void Generated_StructSetter_WritesIntoTheBoxedValue() + { + object boxed = new GenPoint { X = 1, Y = 2 }; + var source = Source(new[] { boxed }, Col("X"), Col("Y")); + + source.SetCellValue(0, source.Columns[0], 10); + + Assert.Equal(10, ((GenPoint)boxed).X); + Assert.Equal(2, source.GetCellValue(0, source.Columns[1])); + } + + // ── Column delegates ────────────────────────────────────────── + + [Fact] + public void ValueAccessor_TakesPrecedenceOverPropertyName() + { + var item = new GenEmployee { Name = "Ada", Age = 36 }; + var column = new KumikoColumn { PropertyName = "Name", ValueAccessor = o => ((GenEmployee)o).Age * 2 }; + var source = Source(new[] { item }, column); + + Assert.Equal(72, source.GetCellValue(0, column)); + Assert.Equal("72", source.GetCellDisplayText(0, column)); + } + + [Fact] + public void ValueSetter_TakesPrecedence_AndReceivesTheValueUnconverted() + { + var item = new GenEmployee(); + object? received = null; + var column = new KumikoColumn + { + PropertyName = "Age", + ValueSetter = (o, v) => { received = v; ((GenEmployee)o).Name = $"set:{v}"; }, + }; + var source = Source(new[] { item }, column); + + source.SetCellValue(0, column, 41.5); + + Assert.Equal(41.5, received); + Assert.Equal("set:41.5", item.Name); + Assert.Equal(0, item.Age); + } + + [Fact] + public void ValueAccessor_DrivesSortingGroupingAndSummaries() + { + var items = new[] + { + new GenEmployee { Name = "a", Age = 30 }, + new GenEmployee { Name = "b", Age = 10 }, + new GenEmployee { Name = "c", Age = 20 }, + }; + // "Computed" is not a property: every value comes from the delegate. + var computed = new KumikoColumn { Header = "Computed", PropertyName = "Computed", ValueAccessor = o => ((GenEmployee)o).Age / 10 }; + var name = Col("Name"); + var source = Source(items, name, computed); + source.AddTableSummaryRow(new TableSummaryRow("Totals") { Columns = { new SummaryColumnDescription("Computed", SummaryType.Sum) } }); + + source.ToggleSort(computed); + Assert.Equal(new[] { "b", "c", "a" }, Enumerable.Range(0, 3).Select(r => source.GetCellValue(r, name))); + Assert.Contains("6", source.ComputedBottomSummaries[0].Values["Computed"]); + + source.AddGroupDescription(new GroupDescription("Computed")); + Assert.Equal("1", source.GetGroupHeaderInfo(0)!.DisplayText); + } + + // ── Providers ───────────────────────────────────────────────── + + private sealed class BagProvider : IKumikoAccessorProvider + { + public KumikoPropertyAccessor? GetAccessor(Type itemType, string propertyName) => + new(propertyName, typeof(object), + item => ((PropertyBag)item).TryGetValue(propertyName, out var v) ? v : null, + (item, value) => ((PropertyBag)item)[propertyName] = value); + } + + [Fact] + public void RegisteredProvider_ServesItemsWithoutClrProperties() + { + KumikoAccessorRegistry.Register(typeof(PropertyBag), new BagProvider()); + var bag = new PropertyBag { ["Title"] = "Report", ["Pages"] = 12 }; + var title = Col("Title"); + var pages = Col("Pages"); + var source = Source(new[] { bag }, title, pages); + + Assert.Equal("Report", source.GetCellValue(0, title)); + Assert.Equal(12, source.GetCellValue(0, pages)); + + source.SetCellValue(0, title, "Summary"); + Assert.Equal("Summary", bag["Title"]); + } + + // ── Reflection fallback ─────────────────────────────────────── + + [Fact] + public void Reflection_ReadsNestedAndWriteOnlyProperties() + { + var item = new RefEmployee { Name = "Ada", Age = 36, Address = null }; + var city = Col("Address.City"); + var secret = Col("Secret"); + var source = Source(new[] { item }, Col("name"), city, secret); + + Assert.Equal("Ada", source.GetCellValue(0, source.Columns[0])); + Assert.Null(source.GetCellValue(0, city)); // null intermediate no longer throws + Assert.Null(source.GetCellValue(0, secret)); + + source.SetCellValue(0, secret, "s"); + source.SetCellValue(0, city, "Oslo"); + Assert.Equal("s", item.SecretValue); + Assert.Null(item.Address); + } + + [Fact] + public void EmptyPropertyName_IsTheItemItself() + { + var item = new RefEmployee(); + var whole = new KumikoColumn { PropertyName = "" }; + var source = Source(new[] { item }, whole); + + Assert.Same(item, source.GetCellValue(0, whole)); + } + + [Fact] + public void ReflectionIsSupportedByDefault() + { + Assert.True(KumikoAccessorRegistry.IsReflectionPropertyAccessSupported); + } + + // ── ComboBox editor ─────────────────────────────────────────── + + [Fact] + public void ComboBox_SelectorsTakePrecedenceOverMemberPaths() + { + var options = new[] { new GenOption { Id = 1, Label = "One" }, new GenOption { Id = 2, Label = "Two" } }; + var combo = new DrawnComboBox + { + DisplayMemberPath = "Label", + ValueMemberPath = "Id", + DisplaySelector = o => ((GenOption)o).Label.ToUpperInvariant(), + ValueSelector = o => ((GenOption)o).Id * 10, + }; + + combo.SetItemsFromSource(options); + combo.SetSelectedValue(20); + + Assert.Equal(1, combo.SelectedIndex); + Assert.Equal("TWO", combo.SelectedItem!.DisplayText); + } + + [Fact] + public void ComboBoxEditor_AppliesColumnMemberPathsToItsItems() + { + var options = new[] { new GenOption { Id = 1, Label = "One" }, new GenOption { Id = 2, Label = "Two" } }; + var column = new KumikoColumn + { + PropertyName = "Age", + ColumnType = KumikoColumnType.ComboBox, + EditorItems = options, + EditorDisplayMemberPath = "Label", + EditorValueMemberPath = "Id", + }; + + var combo = Assert.IsType(CellEditorFactory.CreateEditor(column, 2, new KumikoRect(0, 0, 100, 30))); + + // Before #17 the paths were applied after the items were built, so labels showed ToString(). + Assert.Equal(1, combo.SelectedIndex); + Assert.Equal("Two", combo.SelectedItem!.DisplayText); + } +} diff --git a/tests/KumikoUI.Core.Tests/Data/ReflectionDisabledTests.cs b/tests/KumikoUI.Core.Tests/Data/ReflectionDisabledTests.cs new file mode 100644 index 0000000..a36614a --- /dev/null +++ b/tests/KumikoUI.Core.Tests/Data/ReflectionDisabledTests.cs @@ -0,0 +1,75 @@ +using KumikoUI.Core.Data; +using KumikoUI.Core.Models; + +namespace KumikoUI.Core.Tests.Data; + +/// Runs alone: it flips the process-wide reflection switch that every other test relies on. +[CollectionDefinition(nameof(ReflectionSwitchCollection), DisableParallelization = true)] +public sealed class ReflectionSwitchCollection +{ +} + +/// +/// With the reflection fallback switched off (the default for Native AOT and full trimming), generated +/// accessors and column delegates keep working and anything else reads as null without throwing. +/// +[Collection(nameof(ReflectionSwitchCollection))] +public sealed class ReflectionDisabledTests : IDisposable +{ + public ReflectionDisabledTests() => + AppContext.SetSwitch(KumikoAccessorRegistry.ReflectionSwitchName, false); + + public void Dispose() => + AppContext.SetSwitch(KumikoAccessorRegistry.ReflectionSwitchName, true); + + private static KumikoSource Source(System.Collections.IList items, params KumikoColumn[] columns) + { + var source = new KumikoSource(); + source.SetColumns(columns); + source.SetItems(items); + return source; + } + + [Fact] + public void SwitchIsOff() + { + Assert.False(KumikoAccessorRegistry.IsReflectionPropertyAccessSupported); + } + + [Fact] + public void GeneratedAccessors_StillReadAndWriteNestedPaths() + { + var item = new AotItem { Name = "n", Child = new AotChild { Value = 3 } }; + var name = new KumikoColumn { PropertyName = "Name" }; + var value = new KumikoColumn { PropertyName = "Child.Value" }; + var source = Source(new[] { item }, name, value); + + Assert.Equal("n", source.GetCellValue(0, name)); + Assert.Equal(3, source.GetCellValue(0, value)); + + source.SetCellValue(0, value, 8); + Assert.Equal(8, item.Child!.Value); + } + + [Fact] + public void TypeWithoutAccessors_ReadsNullAndIgnoresEdits_ButDelegatesWork() + { + var item = new NoAccessorItem { Name = "kept" }; + var byPath = new KumikoColumn { PropertyName = "Name" }; + var byDelegate = new KumikoColumn + { + PropertyName = "Name", + ValueAccessor = o => ((NoAccessorItem)o).Name, + ValueSetter = (o, v) => ((NoAccessorItem)o).Name = (string)v!, + }; + var source = Source(new[] { item }, byPath, byDelegate); + + Assert.Null(source.GetCellValue(0, byPath)); + source.SetCellValue(0, byPath, "lost"); + Assert.Equal("kept", item.Name); + + Assert.Equal("kept", source.GetCellValue(0, byDelegate)); + source.SetCellValue(0, byDelegate, "written"); + Assert.Equal("written", item.Name); + } +} diff --git a/tests/KumikoUI.Core.Tests/KumikoUI.Core.Tests.csproj b/tests/KumikoUI.Core.Tests/KumikoUI.Core.Tests.csproj index a6714fa..8b74b0e 100644 --- a/tests/KumikoUI.Core.Tests/KumikoUI.Core.Tests.csproj +++ b/tests/KumikoUI.Core.Tests/KumikoUI.Core.Tests.csproj @@ -20,6 +20,10 @@ + + diff --git a/tests/KumikoUI.SourceGenerators.Tests/KumikoGridItemGeneratorTests.cs b/tests/KumikoUI.SourceGenerators.Tests/KumikoGridItemGeneratorTests.cs new file mode 100644 index 0000000..c377fba --- /dev/null +++ b/tests/KumikoUI.SourceGenerators.Tests/KumikoGridItemGeneratorTests.cs @@ -0,0 +1,190 @@ +using System.Collections.Immutable; +using KumikoUI.Core.Data; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace KumikoUI.SourceGenerators.Tests; + +public class KumikoGridItemGeneratorTests +{ + private static readonly ImmutableArray References = + ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Where(p => Path.GetFileName(p).StartsWith("System.", StringComparison.Ordinal) + || Path.GetFileName(p) is "mscorlib.dll" or "netstandard.dll" or "Microsoft.CSharp.dll") + .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)) + .Append(MetadataReference.CreateFromFile(typeof(KumikoAccessorRegistry).Assembly.Location)) + .ToImmutableArray(); + + private static (Compilation Output, ImmutableArray GeneratorDiagnostics, GeneratorDriverRunResult Run) Generate(string source) + { + var compilation = CSharpCompilation.Create("Tests", + new[] { CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.Latest)) }, + References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + new[] { new KumikoGridItemGenerator().AsSourceGenerator() }, + driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true)); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var output, out var diagnostics); + return (output, diagnostics, driver.GetRunResult()); + } + + private static string GeneratedFor(GeneratorDriverRunResult run, string typeName) => + run.Results.Single().GeneratedSources.Single(s => s.HintName.Contains(typeName, StringComparison.Ordinal)).SourceText.ToString(); + + private static void AssertCompilesCleanly(Compilation output) + { + var problems = output.GetDiagnostics().Where(d => d.Severity >= DiagnosticSeverity.Warning).ToList(); + Assert.True(problems.Count == 0, string.Join(Environment.NewLine, problems)); + } + + private const string Models = """ + using System; + using System.Collections.Generic; + using KumikoUI.Core.Data; + + namespace Demo; + + public class Base + { + public virtual string Label { get; set; } = ""; + public int Shared { get; set; } + public int Hidden { get; set; } + } + + [KumikoGridItem] + public class Item : Base + { + public override string Label { get; set; } = ""; + public new string Hidden { get; set; } = ""; + public string? Nullable { get; set; } + public int? MaybeNumber { get; set; } + public List Tags { get; set; } = new(); + public (int A, string B) Pair { get; set; } + public dynamic? Anything { get; set; } + public string InitOnly { get; init; } = ""; + public string PrivateSet { get; private set; } = ""; + public string ProtectedGet { protected get; set; } = ""; + public string InternalSet { get; internal set; } = ""; + public string @class { get; set; } = ""; + public static string Static { get; set; } = ""; + public string this[int i] => ""; + [Obsolete("old")] public string Old { get; set; } = ""; + [Obsolete("gone", true)] public string Gone { get; set; } = ""; + internal string InternalProperty { get; set; } = ""; + private string Private { get; set; } = ""; + } + + [KumikoGridItem] + public struct Point + { + public int X { get; set; } + public readonly int Length => X; + } + + [KumikoGridItem] + public sealed record Line(string Code, int Quantity); + + internal static class Outer + { + [KumikoGridItem] + internal class Nested + { + public string Value { get; set; } = ""; + } + } + """; + + [Fact] + public void GeneratedCode_CompilesWithoutWarnings() + { + var (output, diagnostics, run) = Generate(Models); + + Assert.Empty(diagnostics); + Assert.Equal(4, run.Results.Single().GeneratedSources.Length); + AssertCompilesCleanly(output); + } + + [Fact] + public void PublicInstanceProperties_GetAccessors_ByUsableAccessor() + { + var code = GeneratedFor(Generate(Models).Run, "Demo_Item"); + + foreach (var name in new[] { "Label", "Shared", "Hidden", "Nullable", "MaybeNumber", "Tags", "Pair", "Anything", + "InitOnly", "PrivateSet", "ProtectedGet", "InternalSet", "class", "Old" }) + Assert.Contains($"[\"{name}\"]", code); + + foreach (var name in new[] { "Static", "Gone", "InternalProperty", "Private", "Item" }) + Assert.DoesNotContain($"[\"{name}\"]", code); + + // Read-only for code outside the type: init-only and private setters. + Assert.Contains("[\"InitOnly\"] = new global::KumikoUI.Core.Data.KumikoPropertyAccessor(\"InitOnly\", typeof(string),\n static item => ((global::Demo.Item)item).InitOnly,\n null)", Normalize(code)); + Assert.Contains("(\"PrivateSet\", typeof(string),\n static item => ((global::Demo.Item)item).PrivateSet,\n null)", Normalize(code)); + // Write-only for code outside the type: a protected getter. + Assert.Contains("(\"ProtectedGet\", typeof(string),\n null,", Normalize(code)); + // Types are fully qualified, without nullable annotations; dynamic is object. + Assert.Contains("typeof(global::System.Collections.Generic.List)", code); + Assert.Contains("typeof(int?)", code); + Assert.Contains("(\"Anything\", typeof(object)", code); + Assert.Contains("((global::Demo.Item)item).@class", code); + // 'new' and override members resolve to the most-derived declaration. + Assert.Contains("(\"Hidden\", typeof(string)", code); + } + + [Fact] + public void StructSetters_WriteThroughTheBox() + { + var code = GeneratedFor(Generate(Models).Run, "Demo_Point"); + + Assert.Contains("global::System.Runtime.CompilerServices.Unsafe.Unbox(item).X = ", code); + Assert.Contains("(\"Length\", typeof(int),\n static item => ((global::Demo.Point)item).Length,\n null)", Normalize(code)); + } + + [Fact] + public void RegistersTheProviderAtModuleLoad() + { + var code = GeneratedFor(Generate(Models).Run, "Demo_Outer_Nested"); + + Assert.Contains("[global::System.Runtime.CompilerServices.ModuleInitializer]", code); + Assert.Contains("KumikoAccessorRegistry.Register(typeof(global::Demo.Outer.Nested), new __KumikoAccessors_Demo_Outer_Nested());", code); + } + + [Theory] + [InlineData("[KumikoGridItem] public class Box { public T? Value { get; set; } }", "KUMIKO001")] + [InlineData("public class Host { [KumikoGridItem] private class Secret { public int A { get; set; } } }", "KUMIKO002")] + [InlineData("public class Host { [KumikoGridItem] protected class Secret { public int A { get; set; } } }", "KUMIKO002")] + [InlineData("[KumikoGridItem] file class Local { public int A { get; set; } }", "KUMIKO002")] + public void UnsupportedTypes_ReportADiagnostic_AndGenerateNothing(string declaration, string id) + { + var (output, diagnostics, run) = Generate("using KumikoUI.Core.Data;\nnamespace Demo;\n" + declaration); + + var diagnostic = Assert.Single(diagnostics); + Assert.Equal(id, diagnostic.Id); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); + Assert.Empty(run.Results.Single().GeneratedSources); + AssertCompilesCleanly(output); + } + + [Fact] + public void UnrelatedEdits_ReuseCachedOutput() + { + var compilation = CSharpCompilation.Create("Tests", + new[] { CSharpSyntaxTree.ParseText(Models, new CSharpParseOptions(LanguageVersion.Latest)) }, + References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + new[] { new KumikoGridItemGenerator().AsSourceGenerator() }, + driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true)); + driver = driver.RunGenerators(compilation); + + var edited = compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText("namespace Demo; public class Unrelated { }", + new CSharpParseOptions(LanguageVersion.Latest))); + var outputs = driver.RunGenerators(edited).GetRunResult().Results.Single().TrackedOutputSteps + .SelectMany(s => s.Value).SelectMany(s => s.Outputs); + + Assert.All(outputs, o => Assert.True(o.Reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged, o.Reason.ToString())); + } + + private static string Normalize(string code) => code.Replace("\r\n", "\n"); +} diff --git a/tests/KumikoUI.SourceGenerators.Tests/KumikoUI.SourceGenerators.Tests.csproj b/tests/KumikoUI.SourceGenerators.Tests/KumikoUI.SourceGenerators.Tests.csproj new file mode 100644 index 0000000..76bdb3b --- /dev/null +++ b/tests/KumikoUI.SourceGenerators.Tests/KumikoUI.SourceGenerators.Tests.csproj @@ -0,0 +1,30 @@ + + + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + + + + From 017413c02dd533eedaf3966bdcc29589123e578a Mon Sep 17 00:00:00 2001 From: Michael Stonis <120685+michaelstonis@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:58:13 -0500 Subject: [PATCH 2/2] refactor: address CodeQL notes on #137 Co-Authored-By: Claude Opus 5.5 --- .../Data/KumikoAccessorRegistry.cs | 2 +- .../KumikoGridItemGenerator.cs | 22 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/KumikoUI.Core/Data/KumikoAccessorRegistry.cs b/src/KumikoUI.Core/Data/KumikoAccessorRegistry.cs index 158f378..628c2a0 100644 --- a/src/KumikoUI.Core/Data/KumikoAccessorRegistry.cs +++ b/src/KumikoUI.Core/Data/KumikoAccessorRegistry.cs @@ -49,7 +49,7 @@ public static class KumikoAccessorRegistry "Trim=true) for Native AOT and TrimMode=full, so the trimmer removes the reflection path; " + "partial trimming keeps app types' metadata, which the reflection path reads.")] public static bool IsReflectionPropertyAccessSupported => - AppContext.TryGetSwitch(ReflectionSwitchName, out bool enabled) ? enabled : true; + !AppContext.TryGetSwitch(ReflectionSwitchName, out bool enabled) || enabled; /// /// Registers the accessor provider for and the types derived from it. diff --git a/src/KumikoUI.SourceGenerators/KumikoGridItemGenerator.cs b/src/KumikoUI.SourceGenerators/KumikoGridItemGenerator.cs index 4a28510..4abd0ab 100644 --- a/src/KumikoUI.SourceGenerators/KumikoGridItemGenerator.cs +++ b/src/KumikoUI.SourceGenerators/KumikoGridItemGenerator.cs @@ -70,16 +70,14 @@ private static GridItemModel Analyze(INamedTypeSymbol type, SyntaxNode node, Can for (INamedTypeSymbol? t = type; t != null && t.SpecialType is not (SpecialType.System_Object or SpecialType.System_ValueType); t = t.BaseType) { ct.ThrowIfCancellationRequested(); - foreach (var member in t.GetMembers()) - { - if (member is not IPropertySymbol property || !seen.Add(property.Name) || !IsSupported(property)) - continue; - - bool canRead = property.GetMethod is { } get && IsAccessible(get.DeclaredAccessibility); - bool canWrite = property.SetMethod is { } set && !set.IsInitOnly && IsAccessible(set.DeclaredAccessibility); - if (!canRead && !canWrite) - continue; + var candidates = t.GetMembers() + .OfType() + .Where(p => seen.Add(p.Name) && IsSupported(p)) + .Select(p => (Property: p, CanRead: CanRead(p), CanWrite: CanWrite(p))) + .Where(c => c.CanRead || c.CanWrite); + foreach (var (property, canRead, canWrite) in candidates) + { bool dynamic = property.Type.TypeKind == TypeKind.Dynamic; string propertyType = dynamic ? "object" : property.Type.ToDisplayString(TypeFormat); string castType = dynamic ? "object?" : property.Type.ToDisplayString(CastFormat); @@ -97,6 +95,12 @@ private static GridItemModel Analyze(INamedTypeSymbol type, SyntaxNode node, Can Diagnostic: null); } + private static bool CanRead(IPropertySymbol property) => + property.GetMethod is { } get && IsAccessible(get.DeclaredAccessibility); + + private static bool CanWrite(IPropertySymbol property) => + property.SetMethod is { } set && !set.IsInitOnly && IsAccessible(set.DeclaredAccessibility); + private static bool IsSupported(IPropertySymbol property) => !property.IsStatic && !property.IsIndexer