diff --git a/.github/workflows/build-nuget.yml b/.github/workflows/build-nuget.yml index fff42b3..f3475c9 100644 --- a/.github/workflows/build-nuget.yml +++ b/.github/workflows/build-nuget.yml @@ -21,7 +21,9 @@ jobs: - name: Set up .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 10.0.400 + dotnet-version: | + 8.0.x + 10.0.400 - name: Cache NuGet packages uses: actions/cache@v4 @@ -37,6 +39,9 @@ jobs: - name: Build solution run: dotnet build AlephMapper.slnx --configuration Release --no-restore + - name: Verify packing without rebuilding + run: dotnet pack source/AlephMapper.csproj --configuration Release --no-build --no-restore --output artifacts/package-no-build + - name: Run tests run: dotnet test AlephMapper.slnx --configuration Release --no-build --no-restore -- --report-trx --results-directory artifacts/test-results @@ -51,6 +56,9 @@ jobs: - name: Pack NuGet package run: dotnet pack source/AlephMapper.csproj --configuration Release --no-restore -p:BuildNumber=${{ github.run_number }} --output artifacts/nuget + - name: Verify Roslyn compiler compatibility + run: python scripts/verify_roslyn_compatibility.py --package-dir artifacts/nuget + - name: Verify package consumption shell: bash run: | diff --git a/AlephMapper.slnx b/AlephMapper.slnx index 205c115..084432e 100644 --- a/AlephMapper.slnx +++ b/AlephMapper.slnx @@ -1,5 +1,6 @@ + diff --git a/README.md b/README.md index b734136..a7f717f 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,75 @@ Use the original method in memory: var person = PersonMapper.MapPerson(employee); ``` +## C# 14 extension members + +C# 14 instance methods and expression-bodied getter-only properties in extension +blocks can generate ordinary static expression factories in the outer `static +partial` class. The receiver is the expression input; ordinary method arguments +become factory arguments. + +```csharp +public static partial class OrderExtensions +{ + // Classic extension method remains supported. + [Projectable] + public static decimal Twice(this Order order) => order.Amount * 2m; + + extension(Order order) + { + [Projectable] + public decimal AmountWithTax => order.Amount * 1.25m; + + [Projectable] + public string Label(int min) => order.Amount >= min ? "high" : "low"; + } +} + +// Expression> +var tax = OrderExtensions.AmountWithTaxExpression(); +// Expression> +var label = OrderExtensions.LabelExpression(100); +``` + +Nested source-defined extension accesses/calls are expanded by symbol identity, +including classic/C# 14 composition, switch bodies, and nested LINQ method calls. +Primitive, interface, struct, nullable-value and reference receivers are supported. +`[Projectable]` on the outer class includes supported extension members. +`[Updatable]` and `[Adapt]` do not automatically apply to computed properties; +direct requests on extension methods receive `AM0021`. + +The NuGet package selects a compatible analyzer automatically by the compiler +host version (independent of the application's target framework): + +| Compiler host | Selected analyzer | Features | +|---|---|---| +| Roslyn 4.8 through 4.x | `analyzers/dotnet/roslyn4.8/cs/AlephMapper.dll` | Classic mappings, helpers, switches, Update and Adapt | +| Roslyn 5.0 or newer | `analyzers/dotnet/roslyn5.0/cs/AlephMapper.dll` | Classic features plus C# 14 extension members | + +Both variants target `netstandard2.0` and share the mapping implementation. +The package contains no compiler DLLs and needs no consumer-side version setting. +Hosts below Roslyn 4.8 are outside the supported floor. IDE builds follow their +compiler host version too; individual IDE versions were not separately tested. +C# 14 source still requires a compiler/language version that supports C# 14. + +Package tests execute actual compilers **4.8.0, 4.14.0, 5.0.0**, and the installed +SDK **10.0.403 / Roslyn 5.9**, asserting that exactly one correct analyzer is +selected. The old single-5.0 package was reproduced failing on 4.14 with CS9057. +The new package runs classic net8 consumers under 4.x and extension projections +under 5.x. See the [multiversion plan and evidence](docs/roslyn-multiversion-plan.md). + +`AM0021` explains unsupported extension forms and skips the affected companion: +generic blocks/methods, block/accessor bodies, query syntax (use LINQ method +syntax), bound extension method groups (use an explicit lambda), static members, +optional/params/ref arguments, ref-like receivers, ref returns, generated factory +signature conflicts, and inaccessible expanded dependencies. Ordinary +`Select(Map)` method groups retain their existing support. + +Null policies and the repeated-evaluation tradeoff remain unchanged. Generated +expression correctness does not guarantee provider translation; the SQLite +integration tests execute the documented scalar projection subset. See the +[extension compatibility matrix](docs/extension-members-compatibility-matrix.md). + ## Switch expressions: evaluation semantics AlephMapper lowers supported C# `switch` expressions to nested conditional diff --git a/docs/extension-members-autopilot-progress.md b/docs/extension-members-autopilot-progress.md new file mode 100644 index 0000000..f73b1ab --- /dev/null +++ b/docs/extension-members-autopilot-progress.md @@ -0,0 +1,68 @@ +# Extension members autopilot progress + +- Starting main: `3fcc1a76b1bec0f4f6eacda49add93c8879e0350` (unchanged). +- PR #56 verified open/mergeable, CI run 41683392210 successful, all review threads resolved. +- Created dev_v0_8_0 from main, retargeted #56, squash-merged 2026-11-03 to `d154572081ba338c790f63f17605a98392dbe333`. +- Feature `feat/extension-members-parity-v0.8.0` created from that integration head in isolated worktree C:/Play/AlephMapper-extension-members. Owner untracked files untouched. +- Integration baseline: restore and Release build successful, 157 tests passed, 0 failed/skipped. Existing generated nullable warnings remain. +- SDK 10.0.403 hosts Roslyn 5.9.0. Independent net10.0 probe compiled against Roslyn 5.0.0 verifies CSharp14, ExtensionBlockDeclarationSyntax, IsExtension, ExtensionParameter, original symbols and consumer calls. Instance receiver is separate from ordinary method parameters. Generator minimum upgraded to 5.0.0 (netstandard2.0 retained). +- API: outer static class MemberExpression(extraArguments) returning Expression>. Properties use zero-argument factory. Extension properties receive Projectable support only. +- New PR MUST target dev_v0_8_0 and remain unmerged pending owner approval. No releases/tags/publishing. + +## Implementation and review + +- E00: 2ea9211, executable Roslyn probe; baseline158 including probe; package smoke passed. +- E01-E04: 7bebd06, discovery, separate receiver/member metadata, property catalog, transitive expansion and AM0021. Expanded receiver/cycle/determinism suite173 passed. +- E05-E07: b19767c, conversions, lambda alpha-renaming, static dependencies, escaped names and explicit unsupported forms. Initial bound-methodgroup negative fixture incorrectly used a value-type receiver (invalid CSharp delegate source); corrected to valid string receiver in f9f3f50, preserving assertions. +- E08: f9f3f50, nonfinite constants, qualified accessibility, query rejection and partial-class failure isolation. Full186 passed. +- Three bounded independent review cycles: all reported capture, static binding/accessibility, escaped name, conditional conversion, constants and query-capture findings fixed. Final cycle found nameof substitution; original INameOfOperation is now folded before substitution with runtime regression. No known findings remain; newest nameof fix has test evidence, not a fourth review. +- Only Projectable AttributeUsage changed in25 attribute snapshots. Existing generated mapper snapshots retained. +- Release/Debug builds and fresh packaged net10/CSharp14 and net8/classic consumers verified. Existing nullable warnings remain visible. +- New PR must target dev_v0_8_0 and remain OPEN pending owner approval; no auto-merge/release/tag/publication. + +## Final local verification + +- `dotnet restore AlephMapper.slnx`: pass. +- `dotnet build AlephMapper.slnx -c Release --no-restore`: pass, zero errors; existing nullable warnings visible. +- `dotnet build AlephMapper.slnx -c Debug --no-restore`: pass, zero errors; nullable warnings visible. +- `dotnet test AlephMapper.slnx -c Release --no-build --no-restore -- --report-trx --results-directory artifacts/test-results`: 191 passed, 0 failed, 0 skipped. +- `dotnet pack source/AlephMapper.csproj -c Release --no-restore -o artifacts/nuget`: pass, local0.7.4 artifact only (version unchanged, no publication). +- Fresh isolated package caches: CSharp14/net10 consumer restores and executes property/method/combined expression comparisons; classic CSharp12/net8 consumer restores and executes. No CS9057 or analyzer assembly-load failures. Both use the final packed analyzer without direct Roslyn references. +- `git diff --check`: pass. +- Final review fix commit: 0f7f516 (nameof). No known unresolved findings after three bounded review cycles. +- Main remains3fcc1a76b1bec0f4f6eacda49add93c8879e0350; integration remainsd154572081ba338c790f63f17605a98392dbe333. + +Owner gate: extension-members PR has NOT been merged. Await explicit approval to merge into dev_v0_8_0. CI and remote review status are reported in the PR and final handoff separately from local checks. + +## GitHub PR review follow-up + +PR57 targets dev_v0_8_0 and remains open. CI run41689329495 passed on713c68d. +The first remote review found two P2 issues beyond the three local review cycles: +classic-lambda nameof constants after alpha-renaming, and unqualified generic static +dependencies. Both reproduced in Classic_lambda_renaming_preserves_nameof_and_generic_static_binding; +fixed original nameof folding for any substitution scope and qualified generic +static methods by their bound containing type. Added qualified/unqualified private +generic dependency negatives. Full189 passed; Release/Debug, pack and fresh net10 +and net8 package-consumer programs rechecked successfully. Requested remote review +of the corrected head; final CI status is recorded on the PR. + +Remote review cycle2: preserved inferred anonymous-object member names from bound +IAnonymousObjectCreationOperation properties when alpha-renaming expressions; +added runtime compilation regression (result4). Root nameof folding now runs for +expression projections too, while retaining ordinary Update output formatting. +The review's nameof(extensionProperty) source is itself rejected by CSharp14 with +CS9316; a dedicated original-compilation regression records that restriction. +Valid root nameof(ordinaryProperty) also has runtime coverage. Full191 passed; +Release/Debug, pack and fresh net10/net8 consumers passed again. Final remote +review cycle3 requested; no further automatic cycles will be started. + +## Owner-requested Roslyn multiversion follow-up + +The earlier5.0-only host requirement is superseded by docs/roslyn-multiversion-plan.md. +The concern was reproduced as CS9057 on real compiler4.14. Shared-source analyzer +assets now cover Roslyn4.8 and5.0 in one NuGet, with automatic SDK selection. +Classic behavior executes on4.8/4.14; CSharp14 extensions execute on5.0/SDK5.9. +Package tests verify a single selected analyzer, actual compiler binary/version +and invocation, AM0019 retention, AM0020 absence, and runtime semantics. Full191 +existing tests pass without mapper snapshot changes. The package matrix is added +to CI; no merge, release or publication is authorized by this follow-up. diff --git a/docs/extension-members-compatibility-matrix.md b/docs/extension-members-compatibility-matrix.md new file mode 100644 index 0000000..0db540e --- /dev/null +++ b/docs/extension-members-compatibility-matrix.md @@ -0,0 +1,66 @@ +# Extension member compatibility matrix + +Scope: readable source-generated projection/inlining for C#14 instance extension +methods and getter-only expression properties. Factories are static methods in the +outer partial class. This is not full ExpressiveSharp parity. + +Evidence: [generator tests](../tests/AlephMapper.Tests/ExtensionMemberTests.cs), +[runtime and SQLite tests](../tests/AlephMapper.IntegrationTests/ExtensionMembersParityTests.cs). + +| Case / source example | Expected API | Compile | Runtime | SQLite | Status / evidence or reason | +|---|---|---|---|---|---| +| `extension(Order o) { decimal AmountWithTax => ...; }` | `AmountWithTaxExpression()` | Pass | Pass | Pass | CSharp14_properties_methods_and_classic_extensions_compose; CSharp14_projection_executes_in_SQLite | +| `Label(int min)` | `LabelExpression(int min)` | Pass | Pass | Pass | Same, below/equal/above threshold | +| Primitive `int.Squared` | `SquaredExpression()` | Pass | Pass | Not asserted | CSharp14_extension_factories_compile_and_match_source; runtime composition | +| Interface `IEntity.InterfaceLabel` | `InterfaceLabelExpression()` | Pass | Pass | Not asserted | Runtime composition | +| Struct `Point.Sum` | `SumExpression()` | Pass | Pass | Not asserted | Extension_members_preserve_nested_receivers_null_policies_and_method_groups | +| Nullable `int?.OrZero` | `OrZeroExpression()` | Pass | Pass | Not asserted | Same, null/non-null | +| Named/reordered/mixed arguments | Symbol/operation binding | Pass | Pass | Not asserted | Named mapper, including positional after named | +| Overloads with different formal types | `TimesExpression(int)` and `(long)` | Pass | Pass | Not asserted | Composition runtime | +| Same factory signature across receiver blocks | Skip conflicting factories | Pass | N/A | N/A | AM0021; Extension_factory_signature_conflicts_are_diagnosed | +| Separate namespaces / same property name | Separate property symbols | Pass | Not separately run | N/A | Extension_members_resolve_by_symbol_across_namespaces; no residual .Value | +| Property/method and classic/CSharp14 composition | Transitive expansion | Pass | Pass | Pass subset | Composed, Classic, ToDto | +| Nested receivers and composite return precedence | Correct path and grouping | Pass | Pass | Not asserted | Nested mapper, scalar fixtures | +| Switch bodies and guards | Existing switch lowering | Pass | Pass | Pass | Label/ToDto plus full PR56 regression suite | +| `Select(Map)` with extension properties in helper | Existing method-group expansion | Pass | Pass | Not asserted | Group mapper; classic generated mapper snapshots unchanged | +| Bound extension method group | Skip companion; use explicit lambda | Pass | N/A | N/A | AM0021; Bound_extension_method_groups_report_diagnostic | +| Ignore / Rewrite null strategy | Existing behavior plus receiver conversion | Pass | Pass | Not asserted | Nested receivers and Base/Derived hidden member regression | +| None null strategy | Skip expression | Pass | N/A | N/A | AM0017; NullConditional_None_keeps_its_diagnostic_for_extension_members | +| Lambda name collision / inferred anonymous member name | Symbol-based alpha-renaming | Pass | Pass | Not asserted | Capture returns14, not5; Renamed_lambda_preserves_anonymous_members_and_root_nameof | +| Static dependencies, nonfinite constants, nameof | Bound symbols / original constant value | Pass | Pass | Not asserted | StaticDependency, Nonfinite, Name and Classic_lambda_renaming_preserves_nameof_and_generic_static_binding runtime regressions | +| Inaccessible qualified/unqualified dependency | Skip affected companion | Pass | N/A | N/A | AM0021; Inaccessible_extension_dependencies_are_diagnosed | +| Escaped receiver/factory argument | Escaped identifiers | Pass | Not separately run | N/A | Escaped_receiver_and_factory_argument_names_compile | +| Partial files, class/member attributes, repeat run | One deterministic file | Pass | N/A | N/A | Extension_members_in_partial_files_generate_once_and_deterministically | +| Method/property cycles | Terminate safely | Pass | N/A | N/A | AM0002; Extension_member_cycles_terminate_with_diagnostic | +| Missing partial outer class | No invalid partial source | Pass | N/A | N/A | AM0021; Missing_partial_outer_class_has_actionable_diagnostic | +| Direct Updatable / Adapt extension request | Projection only, diagnostic for unsupported request | Pass | N/A | N/A | AM0021; direct Updatable regression, ordinary Update/Adapt suite retained | +| Generic blocks/methods | No assumed generic substitution | Pass method fixture | N/A | N/A | AM0021 | +| Optional/params/ref args, ref-like receiver, ref returns | Explicit unsupported reason | Tested optional | N/A | N/A | AM0021; remaining shapes symbol-validated, no exhaustive test claim | +| Block/accessor bodies, static extension members | Skip affected companion | Pass | N/A | N/A | AM0021; unsupported-member matrix | +| Query syntax / block or async lambda | Use LINQ method syntax / expression lambda | Pass query/block fixtures | N/A | N/A | AM0021; prevents range-variable capture or invalid expression trees | +| Cross-assembly source body expansion | Not attempted | N/A | N/A | N/A | Source body required; unexpandable dependency AM0021 | + +Matrix reports test evidence instead of a marketing parity percentage. Receiver +and argument evaluation may be repeated, skipped or reordered by the existing +conditional/substitution architecture; no AM0020 or purity gate was introduced. +Property catalog keys are IPropertySymbol identities; getter methods are stable +recursion tokens alongside the existing method catalog, not unrelated methods. + +Compiler/package support is now versioned: classic mappings use the Roslyn4.8 +asset on4.x hosts; CSharp14 extension features use the Roslyn5.0 asset on5.x hosts. +Both assets remain netstandard2.0. Real compiler processes4.8.0,4.14.0,5.0.0 and the +installed SDK10.0.403/Roslyn5.9 pass package selection and runtime checks. +A net8 target under SDK10 alone did not establish4.x host compatibility; the old +single5.0 package was reproduced failing with CS9057 on compiler4.14. +See [multiversion evidence](roslyn-multiversion-plan.md). Hosts below4.8 remain +unsupported. Individual IDE versions were not separately tested. + +Conceptual inventory: [ExpressiveSharp guide](https://github.com/EFNext/ExpressiveSharp/blob/main/docs/guide/extension-members.md) +and [reference tests](https://github.com/EFNext/ExpressiveSharp/blob/main/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/ExtensionMemberTests.cs). +Upstream test code was not copied or executed. Block-body lowering, interceptors, +ExpressiveFor and cross-assembly source reuse remain outside scope. + +CSharp14 rejects nameof(extensionMember) in original source (CS9316); this is a +compiler restriction, not a supported generator input. The executable +CSharp14_compiler_rejects_nameof_extension_member_in_original_source test records +it. Ordinary root nameof expressions preserve their original constant values. diff --git a/docs/roslyn-multiversion-plan.md b/docs/roslyn-multiversion-plan.md new file mode 100644 index 0000000..c34ae03 --- /dev/null +++ b/docs/roslyn-multiversion-plan.md @@ -0,0 +1,86 @@ +# Roslyn multiversion implementation plan + +## Finding + +The current package contains a single analyzer compiled against Roslyn5.0. Older +Roslyn4.x hosts reject it (CS9057). Targeting net8 while building with SDK10 only +proves target-framework compatibility, not older compiler-host compatibility. +The previous Roslyn4.14 baseline also excluded older4.x hosts; this change aims +to broaden classic support to4.8 rather than merely restoring4.14. + +## Design and sequence + +1. Reproduce the5.0-only package failure with a real4.x compiler toolset. +2. Isolate CSharp14 syntax/symbol APIs in a small compile-time compatibility shim; + keep mapping, diagnostics and switch/inlining code shared, with no reflection + or text-based recognition of extension syntax. +3. Build two netstandard2.0 analyzer assemblies from shared source: Roslyn4.8 and + Roslyn5.0. Keep separate project/intermediate/output directories to avoid + restore/build races and stale-asset reuse. +4. Pack exactly one assembly per version band at + analyzers/dotnet/roslyn4.8/cs/AlephMapper.dll and + analyzers/dotnet/roslyn5.0/cs/AlephMapper.dll. Remove the unversioned analyzer. + Use SDK analyzer selection; do not ship compiler assemblies or custom loader. +5. Verify real package selection and execution under compiler toolsets4.8,4.14, + 5.0 and the installed SDK host. Assert exactly one AlephMapper analyzer is + selected, the expected band, no CS9057/generator-loading failures, and runtime + equivalence for classic projections/switches/helpers/updates/adaptations. + Verify CSharp14 extension projections on5.0+. +6. Run the complete existing suite, Release/Debug build and pack; add the package + matrix to CI, update compatibility documentation, commit and push to PR57. + Keep PR57 unmerged pending owner approval. + +## Boundaries + +Classic mappings are supported on Roslyn4.8+. CSharp14 extension syntax inherently +requires a5.0+ compiler and uses the5.0 asset. Compiler hosts below4.8 are outside +this support floor. No target-framework/version-number bump or package publication. + +## Primary references + +- https://github.com/dotnet/sdk/pull/20793 (versioned analyzer selection) +- https://github.com/dotnet/sdk/blob/main/src/Tasks/Microsoft.NET.Build.Tasks/ResolvePackageAssets.cs +- https://github.com/dotnet/roslyn/discussions/81256 (CS9057 and analyzer bands) + +## Implemented and verified + +- Reproduced the old package failure with actual compiler4.14.0: + CS9057, analyzer references5.0.0.0 but host is4.14.0.0. +- Added RoslynCompatibility compile-time shim. Legacy CSharp14 branches return + false/empty/null; no reflection and no string-based syntax recognition. The + embedded-attribute definition API also has a4.8-compatible implementation. +- Shared source builds against4.8.0 and5.0.0 in separate projects/output/obj paths. + Both use the same assembly name/version and embedded attribute resource. +- NuGet ZIP verified to contain only the two expected analyzer DLLs. No compiler + DLLs or unversioned fallback; SDK selects exactly one compatible assembly. +- Package matrix passed for actual csc4.8.0,4.14.0,5.0.0 and SDK10.0.403/csc5.9. + Toolset processes are explicitly invoked via tiny platform-specific wrappers; + compiler version and an invocation marker are checked. CompilerApiVersion is + set to the verified toolset API for SDK asset selection. Simply adding an old + Toolset PackageReference under SDK10 did not replace its inbox compiler. +- Runtime fixture covers classic projection, helper and classic-extension inlining, + nullable/type/property/list-slice switch patterns, Update, Adapt, AM0019 retention + and AM0020 absence.5.0+ cases additionally execute CSharp14 property/method and + composed factories. All target net8 to separate framework from compiler version. +- Full existing suite:191 passed,0 failed,0 skipped. Release clean build and + Release pack --no-build --no-restore passed. Independent review caught a missing + legacy project entry in slnx (it could build the child as Debug); added it to the + solution and verified after cleaning Release outputs. CI covers no-build packing. +- CI installs net8 runtime/SDK plus the repository SDK10, then runs the package + compiler matrix after packing. No merge, release, tag or publication performed. + +Reproduce from repository root with SDK10 and net8 runtime installed: + +```sh +dotnet restore AlephMapper.slnx +dotnet build AlephMapper.slnx -c Release --no-restore +dotnet test AlephMapper.slnx -c Release --no-build --no-restore +dotnet pack source/AlephMapper.csproj -c Release --no-build --no-restore -o artifacts/nuget +python scripts/verify_roslyn_compatibility.py --package-dir artifacts/nuget +``` + +The script requires exactly one AlephMapper nupkg in that directory and uses fresh +isolated package caches, so previous installs of the same package version cannot +mask a stale analyzer. Dependencies/toolsets are restored from nuget.org; package +and compiler binaries are not committed. Individual IDE versions and hosts below +4.8 were not tested or claimed supported. diff --git a/docs/v0.8.0-release-notes-draft.md b/docs/v0.8.0-release-notes-draft.md new file mode 100644 index 0000000..621e6c4 --- /dev/null +++ b/docs/v0.8.0-release-notes-draft.md @@ -0,0 +1,16 @@ +# v0.8.0 release notes - unpublished draft + +- Add CSharp14 extension-block instance methods and expression-bodied getter + properties to static Projectable factories, with symbol-aware composition. +- Add AM0021 for unsupported requests, signature conflicts and inaccessible + dependencies; existing switch/null diagnostic contracts remain unchanged. +- Preserve receiver/argument conversions, nested lambda scopes, static dependencies, + original nameof results and classic extension method behavior. +- Versioned analyzer assets restore/broaden compiler-host compatibility: classic + mappings on Roslyn4.8+, CSharp14 extensions on Roslyn5.0+. NuGet automatically + selects one compatible asset. Both variants target netstandard2.0. Actual4.8, + 4.14,5.0 and SDK5.9 compiler processes are covered by package runtime tests. +- Incorporates PR56 switch and helper-inlining improvements in dev_v0_8_0. + +No version bump, release, tag, NuGet publish or main integration was performed. +The feature PR requires explicit owner approval before merge. diff --git a/scripts/verify_roslyn_compatibility.py b/scripts/verify_roslyn_compatibility.py new file mode 100644 index 0000000..d90705c --- /dev/null +++ b/scripts/verify_roslyn_compatibility.py @@ -0,0 +1,99 @@ +"""Verify NuGet analyzer band selection with actual isolated compiler processes.""" +import argparse +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import uuid +from xml.etree import ElementTree as ET +from zipfile import ZipFile + +ROOT = Path(__file__).resolve().parents[1] + + +def run(args, cwd=ROOT): + result = subprocess.run([str(arg) for arg in args], cwd=cwd, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + print(result.stdout, end="", flush=True) + if result.returncode: + raise RuntimeError(f"Command failed ({result.returncode}): {args}") + return result.stdout + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--package-dir", type=Path, default=ROOT / "artifacts/nuget") + parser.add_argument("--compiler", action="append", help="Defaults: 4.8.0, 4.14.0, 5.0.0, sdk") + args = parser.parse_args() + packages = list(args.package_dir.resolve().glob("AlephMapper.*.nupkg")) + if len(packages) != 1: + raise RuntimeError(f"Expected one AlephMapper package in {args.package_dir}, got {packages}") + with ZipFile(packages[0]) as archive: + dlls = sorted(name for name in archive.namelist() if name.endswith(".dll")) + expected = [f"analyzers/dotnet/roslyn{band}/cs/AlephMapper.dll" for band in ("4.8", "5.0")] + if dlls != expected: + raise RuntimeError(f"Unexpected package assemblies: {dlls}") + nuspec = ET.fromstring(archive.read(next(n for n in archive.namelist() if n.endswith(".nuspec")))) + version = nuspec.find("{*}metadata/{*}version").text + dotnet = shutil.which("dotnet") + if not dotnet: + raise RuntimeError("dotnet is required") + output = ROOT / "artifacts/roslyn-compatibility" / uuid.uuid4().hex + output.mkdir(parents=True) + results = [] + for compiler in args.compiler or ["4.8.0", "4.14.0", "5.0.0", "sdk"]: + if compiler not in ("4.8.0", "4.14.0", "5.0.0", "sdk"): + raise RuntimeError(f"Unrecognized compiler: {compiler}") + modern = compiler == "sdk" or compiler.startswith("5.") + band = "5.0" if modern else "4.8" + work = output / compiler + work.mkdir() + for name in ("Consumer.csproj", "Program.cs"): + shutil.copy2(ROOT / "tests/PackageCompatibility" / name, work / name) + config = ET.Element("configuration") + sources = ET.SubElement(config, "packageSources") + ET.SubElement(sources, "clear") + ET.SubElement(sources, "add", key="local", value=str(args.package_dir.resolve())) + ET.SubElement(sources, "add", key="nuget.org", value="https://api.nuget.org/v3/index.json") + ET.ElementTree(config).write(work / "NuGet.Config", encoding="utf-8", xml_declaration=True) + properties = [f"-p:PackageVersionUnderTest={version}", f"-p:ExpectedRoslynBand={band}", + f"-p:CompatibilityLanguageVersion={'14' if modern else '12'}"] + if compiler != "sdk": + properties.append(f"-p:CompatibilityCompilerVersion={compiler}") + run([dotnet, "restore", work / "Consumer.csproj", "--configfile", work / "NuGet.Config", + "--packages", work / "packages", *properties]) + if compiler != "sdk": + tasks = work / "packages/microsoft.net.compilers.toolset" / compiler / "tasks/netcore" + csc = tasks / "bincore/csc.dll" + actual_version = run([dotnet, csc, "-version"]).strip() + if not actual_version.startswith(compiler): + raise RuntimeError(f"Wrong compiler binary: {actual_version}") + wrapper = work / "compiler" + wrapper.mkdir() + stamp = work / "compiler-invoked.txt" + if os.name == "nt": + tool = wrapper / "csc.cmd" + tool.write_text(f'@echo off\n> "{stamp}" echo {compiler}\n"{dotnet}" exec "{csc}" %*\n', encoding="utf-8") + else: + tool = wrapper / "csc" + tool.write_text(f'#!/bin/sh\nprintf "%s\\n" {shlex.quote(compiler)} > {shlex.quote(str(stamp))}\nexec {shlex.quote(dotnet)} exec {shlex.quote(str(csc))} "$@"\n', encoding="utf-8") + tool.chmod(0o755) + # SDK10 otherwise still uses its inbox compiler despite an older Toolset + # PackageReference. Select and verify the actual compiler explicitly. + api = ".".join(compiler.split(".")[:2]) + properties += [f"-p:CompilerApiVersion=roslyn{api}", f"-p:CscToolPath={wrapper}", f"-p:CscToolExe={tool.name}"] + log = run([dotnet, "build", work / "Consumer.csproj", "-c", "Release", "--no-restore", *properties]) + if compiler != "sdk" and (not stamp.exists() or stamp.read_text().strip() != compiler): + raise RuntimeError("The requested compiler wrapper was not executed") + if "AM0019" not in log or "AM0020" in log: + raise RuntimeError("Expected AM0019 and absence of AM0020 in package regression fixture") + run([dotnet, work / "bin/Release/net8.0/Consumer.dll"]) + results.append({"compiler": compiler, "selected_band": band, "status": "passed"}) + (output / "results.json").write_text(json.dumps(results, indent=2), encoding="utf-8") + print(f"All compiler/package checks passed. Results: {output / 'results.json'}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/source/AlephMapper.csproj b/source/AlephMapper.csproj index 3e7ee18..d02d847 100644 --- a/source/AlephMapper.csproj +++ b/source/AlephMapper.csproj @@ -1,41 +1,33 @@ - + + Yevhen Cherkes Copyright 2025 - $([System.DateTime]::Now.Year) Yevhen Cherkes Your manual mapping companion. true - true false - true - latest + $(DefineConstants);ALEPHMAPPER_ROSLYN5 AlephMapper MIT https://github.com/Raffinert/AlephMapper README.md - 0.7.4 - 0 - $(PackageVersion) - $(PackageVersion).$(BuildNumber) - $(AssemblyVersion) Mapping Companion git https://github.com/Raffinert/AlephMapper.git true - netstandard2.0 Aleph Mapper - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + - + + + + diff --git a/source/AlephSourceGenerator.cs b/source/AlephSourceGenerator.cs index 81c1de5..5ec7c6d 100644 --- a/source/AlephSourceGenerator.cs +++ b/source/AlephSourceGenerator.cs @@ -1,4 +1,5 @@ using AlephMapper.Generation; +using AlephMapper.Helpers; using Microsoft.CodeAnalysis; namespace AlephMapper; @@ -14,7 +15,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { context.RegisterPostInitializationOutput(static postInitializationContext => { - postInitializationContext.AddEmbeddedAttributeDefinition(); + RoslynCompatibility.AddEmbeddedAttributeDefinition(postInitializationContext); AttributeSourceEmitter.AddAttributes(postInitializationContext); }); diff --git a/source/Analyzer.Shared.props b/source/Analyzer.Shared.props new file mode 100644 index 0000000..448a580 --- /dev/null +++ b/source/Analyzer.Shared.props @@ -0,0 +1,18 @@ + + + netstandard2.0 + latest + AlephMapper + AlephMapper + true + true + 0.7.4 + 0 + $(PackageVersion) + $(PackageVersion).$(BuildNumber) + $(AssemblyVersion) + + + + + diff --git a/source/AnalyzerReleases.Unshipped.md b/source/AnalyzerReleases.Unshipped.md index bc7d19a..8a31324 100644 --- a/source/AnalyzerReleases.Unshipped.md +++ b/source/AnalyzerReleases.Unshipped.md @@ -22,3 +22,5 @@ AM0015 | AlephMapper | Error | Adapted generated member fails Roslyn rebinding. AM0016 | AlephMapper | Warning | Null-conditional receiver cannot be safely rewritten. AM0017 | AlephMapper | Warning | Null-conditional access is unsupported in expression trees. AM0018 | AlephMapper | Warning | Adaptation of generic mapping methods is unsupported. + +AM0021 | AlephMapper | Warning | Extension member cannot be projected; diagnostic supplies the unsupported reason. diff --git a/source/Attributes.cs b/source/Attributes.cs index e43bd06..1284add 100644 --- a/source/Attributes.cs +++ b/source/Attributes.cs @@ -37,7 +37,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/source/Diagnostics/DiagnosticDescriptors.cs b/source/Diagnostics/DiagnosticDescriptors.cs index da13e62..5f16e2e 100644 --- a/source/Diagnostics/DiagnosticDescriptors.cs +++ b/source/Diagnostics/DiagnosticDescriptors.cs @@ -171,6 +171,11 @@ public static class DiagnosticDescriptors DiagnosticSeverity.Warning, isEnabledByDefault: true); + public static readonly DiagnosticDescriptor UnsupportedExtensionMember = new( + "AM0021", "Extension member cannot be projected", + "Expression generation for extension member '{0}' skipped: {1}", + "AlephMapper", DiagnosticSeverity.Warning, isEnabledByDefault: true); + public static DiagnosticDescriptor? GetById(string id) => id switch { "AM0001" => UpdatableValueTypeReturn, @@ -192,6 +197,7 @@ public static class DiagnosticDescriptors "AM0017" => UnsupportedNullConditionalExpression, "AM0018" => AdaptGenericMethodUnsupported, "AM0019" => UnsupportedSwitchPattern, + "AM0021" => UnsupportedExtensionMember, _ => null }; } diff --git a/source/Generation/Emitters/AdaptationMemberEmitter.cs b/source/Generation/Emitters/AdaptationMemberEmitter.cs index fe5f88c..489fff6 100644 --- a/source/Generation/Emitters/AdaptationMemberEmitter.cs +++ b/source/Generation/Emitters/AdaptationMemberEmitter.cs @@ -71,6 +71,13 @@ public static void Emit(MappingMethodDetails details, MapperGenerationContext co .WithoutLeadingTrivia() .WithoutTrailingTrivia(); context.AddUsings(inliner.UsingDirectives.Concat(mapping.UsingDirectives)); + if (inliner.UnsupportedExtensions.FirstOrDefault() is var unsupportedExtension && unsupportedExtension.Member is not null) + { + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.UnsupportedExtensionMember, + unsupportedExtension.Member.Locations.FirstOrDefault(), unsupportedExtension.Member.Name, unsupportedExtension.Reason)); + continue; + } + if (inliner.CircularReferences.Any()) { context.ReportDiagnostic(Diagnostic.Create( diff --git a/source/Generation/Emitters/ProjectableMemberEmitter.cs b/source/Generation/Emitters/ProjectableMemberEmitter.cs index f875f71..da28661 100644 --- a/source/Generation/Emitters/ProjectableMemberEmitter.cs +++ b/source/Generation/Emitters/ProjectableMemberEmitter.cs @@ -21,6 +21,13 @@ public static void Emit(MappingMethodDetails details, MapperGenerationContext co .WithoutTrailingTrivia(); context.AddUsings(inliner.UsingDirectives.Concat(mapping.UsingDirectives)); + if (inliner.UnsupportedExtensions.FirstOrDefault() is var unsupportedExtension && unsupportedExtension.Member is not null) + { + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.UnsupportedExtensionMember, + unsupportedExtension.Member.Locations.FirstOrDefault(), unsupportedExtension.Member.Name, unsupportedExtension.Reason)); + return; + } + if (inliner.CircularReferences.Any()) { context.ReportDiagnostic(Diagnostic.Create( @@ -59,6 +66,17 @@ public static void Emit(MappingMethodDetails details, MapperGenerationContext co } var expressionMethodName = mapping.Name + "Expression"; + if (mapping.ReceiverSymbol is not null && + (context.GeneratedMemberSignatures.Contains(MethodSignature.Build(expressionMethodName, + details.ExtraExpressionParameterTypeNames, details.MethodTypeParameterCount)) || + context.MapperType.GetMembers(expressionMethodName).OfType().Any(m => + m.Parameters.Select(p => p.Type.ToDisplayString()).SequenceEqual(mapping.Parameters.Skip(1).Select(p => p.Type.ToDisplayString()))))) + { + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.UnsupportedExtensionMember, + mapping.MemberSymbol.Locations.FirstOrDefault(), mapping.Name, "the generated factory signature conflicts with another member")); + return; + } + context.GeneratedMemberSignatures.Add(MethodSignature.Build( expressionMethodName, details.ExtraExpressionParameterTypeNames, diff --git a/source/Generation/MapperCandidate.cs b/source/Generation/MapperCandidate.cs index e7bde50..6e05327 100644 --- a/source/Generation/MapperCandidate.cs +++ b/source/Generation/MapperCandidate.cs @@ -1,10 +1,12 @@ #nullable enable +using AlephMapper.Helpers; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using System; using System.Threading; +using System.Linq; namespace AlephMapper.Generation; @@ -38,6 +40,7 @@ public static bool IsAttributeTarget(SyntaxNode node, CancellationToken _) { ClassDeclarationSyntax classDeclaration => classDeclaration, MethodDeclarationSyntax { Parent: ClassDeclarationSyntax classDeclaration } => classDeclaration, + MethodDeclarationSyntax or PropertyDeclarationSyntax when RoslynCompatibility.IsExtensionMember(node) => node.Ancestors().OfType().FirstOrDefault(), _ => null }; diff --git a/source/Generation/MapperGenerationContext.cs b/source/Generation/MapperGenerationContext.cs index c35d802..009fea7 100644 --- a/source/Generation/MapperGenerationContext.cs +++ b/source/Generation/MapperGenerationContext.cs @@ -25,7 +25,7 @@ internal sealed class MapperGenerationContext( public StringBuilder Members { get; } = new(); private List Diagnostics { get; } = new(); - public ImmutableArray GetDiagnostics() => [.. Diagnostics]; + public ImmutableArray GetDiagnostics() => Diagnostics.ToImmutableArray(); public void ReportDiagnostic(Diagnostic diagnostic) => Diagnostics.Add(GenerationDiagnostic.From(diagnostic)); diff --git a/source/Generation/MapperSourceOutput.cs b/source/Generation/MapperSourceOutput.cs index d88938a..227aa1b 100644 --- a/source/Generation/MapperSourceOutput.cs +++ b/source/Generation/MapperSourceOutput.cs @@ -7,12 +7,11 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Threading; -#if !DEBUG using AlephMapper.Diagnostics; -#endif namespace AlephMapper.Generation; @@ -29,12 +28,14 @@ public static MapperGenerationResult Create( { ClassDeclarationSyntax classDeclaration => classDeclaration, MethodDeclarationSyntax { Parent: ClassDeclarationSyntax classDeclaration } => classDeclaration, + MethodDeclarationSyntax or PropertyDeclarationSyntax when RoslynCompatibility.IsExtensionMember(attributeContext.TargetNode) => attributeContext.TargetNode.Ancestors().OfType().FirstOrDefault(), _ => null }; var mapperType = attributeContext.TargetSymbol switch { INamedTypeSymbol classSymbol => classSymbol, - IMethodSymbol methodSymbol => methodSymbol.ContainingType, + IMethodSymbol methodSymbol => RoslynCompatibility.IsExtensionType(methodSymbol.ContainingType) ? methodSymbol.ContainingType.ContainingType : methodSymbol.ContainingType, + IPropertySymbol propertySymbol when RoslynCompatibility.IsExtensionType(propertySymbol.ContainingType) => propertySymbol.ContainingType.ContainingType, _ => null }; if (mapperDeclaration is null || mapperType is null) @@ -50,8 +51,7 @@ public static MapperGenerationResult Create( } var mappings = CreateMapperAnalyses(compilation, mapperType, cancellationToken); - if (mappings.Count == 0 || !mappings.Any(static mapping => - (mapping.IsProjectable || mapping.IsUpdatable || mapping.Adaptations.Count > 0) && mapping.IsClassPartial)) + if (mappings.Count == 0 && !mapperType.GetTypeMembers().Any(RoslynCompatibility.IsExtensionType)) { return MapperGenerationResult.Empty; } @@ -78,7 +78,7 @@ public static MapperGenerationResult Create( return new MapperGenerationResult( null, null, - [CrashDiagnosticsReporter.CreateDiagnostic(exception)]); + ImmutableArray.Create(CrashDiagnosticsReporter.CreateDiagnostic(exception))); } #endif } @@ -120,6 +120,11 @@ private static IReadOnlyList CreateMapperAnalyses( } var semanticModel = compilation.GetSemanticModel(declaration.SyntaxTree); + foreach (var member in RoslynCompatibility.GetExtensionMembers(declaration)) + { + var extension = MappingAnalysisFactory.CreateExtension(semanticModel, member, cancellationToken); + if (extension is not null) mappings.Add(extension); + } foreach (var method in declaration.Members.OfType() .Where(static method => method.ExpressionBody is not null) .OrderBy(static method => method.SpanStart)) @@ -143,6 +148,16 @@ private static IReadOnlyList CreateMapperAnalyses( IMethodSymbol method, CancellationToken cancellationToken) { + if (RoslynCompatibility.IsExtensionType(method.ContainingType)) + { + var symbol = method.AssociatedSymbol ?? (ISymbol)method; + foreach (var reference in symbol.DeclaringSyntaxReferences) + { + var syntax = reference.GetSyntax(cancellationToken); + return MappingAnalysisFactory.CreateExtension(compilation.GetSemanticModel(syntax.SyntaxTree), syntax, cancellationToken); + } + return null; + } foreach (var declarationReference in method.DeclaringSyntaxReferences .OrderBy(static reference => reference.SyntaxTree.FilePath, StringComparer.Ordinal) .ThenBy(static reference => reference.Span.Start)) @@ -193,6 +208,12 @@ private static IEnumerable GetMapperCandidates( yield return CreateCandidate(mapperDeclaration, classKind); } + foreach (var member in RoslynCompatibility.GetExtensionMembers(mapperDeclaration)) + { + var attributes = member switch { MethodDeclarationSyntax m => m.AttributeLists, PropertyDeclarationSyntax p => p.AttributeLists, _ => default }; + if (ContainsAlephMapperAttribute(attributes, semanticModel, cancellationToken, out var kind)) + yield return CreateCandidate(member, kind); + } foreach (var method in mapperDeclaration.Members.OfType()) { if (ContainsAlephMapperAttribute(method.AttributeLists, semanticModel, cancellationToken, out var methodKind)) @@ -244,6 +265,18 @@ private static MapperGenerationResult GenerateMapper( MappingCatalog catalog) { var context = new MapperGenerationContext(mapperType, catalog); + foreach (var extensionType in mapperType.GetTypeMembers().Where(RoslynCompatibility.IsExtensionType)) + foreach (var member in extensionType.GetMembers().Where(s => s is IPropertySymbol || s is IMethodSymbol { MethodKind: MethodKind.Ordinary })) + { + var unsupportedRequest = SymbolHelpers.HasAttribute(member, typeof(UpdatableAttribute).FullName) || + SymbolHelpers.HasAttribute(member, typeof(AdaptAttribute).FullName); + if (!unsupportedRequest && !SymbolHelpers.HasAttribute(member, typeof(ProjectableAttribute).FullName) && + !SymbolHelpers.HasAttribute(mapperType, typeof(ProjectableAttribute).FullName)) continue; + var reason = unsupportedRequest ? "extension members support Projectable only; Updatable and Adapt requests are unsupported" : + MappingAnalysisFactory.ExtensionUnsupportedReason(member); + if (reason is not null) context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.UnsupportedExtensionMember, member.Locations.FirstOrDefault(), member.Name, reason)); + } foreach (var mapping in mappings) { if (!mapping.IsProjectable && !mapping.IsUpdatable && mapping.Adaptations.Count == 0) @@ -251,12 +284,23 @@ private static MapperGenerationResult GenerateMapper( continue; } + if (mapping.IsProjectable && mappings.Any(other => other != mapping && other.IsProjectable && + (mapping.ReceiverSymbol is not null || other.ReceiverSymbol is not null) && + mapping.Name == other.Name && mapping.MethodSymbol.TypeParameters.Length == other.MethodSymbol.TypeParameters.Length && + mapping.Parameters.Skip(1).Select(p => p.Type).SequenceEqual(other.Parameters.Skip(1).Select(p => p.Type), SymbolEqualityComparer.Default))) + { + context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.UnsupportedExtensionMember, + mapping.MemberSymbol.Locations.FirstOrDefault(), mapping.Name, "the generated factory signature conflicts with another mapping")); + continue; + } var details = new MappingMethodDetails(mapping); ProjectableMemberEmitter.Emit(details, context); AdaptationMemberEmitter.Emit(details, context); UpdatableMemberEmitter.Emit(details, context); } + if (context.Members.Length == 0 && mapperType.GetTypeMembers().Any(RoslynCompatibility.IsExtensionType)) + return new MapperGenerationResult(null, null, context.GetDiagnostics()); var generatedFile = MapperFileEmitter.Render(context); return new MapperGenerationResult(generatedFile.HintName, generatedFile.Source, context.GetDiagnostics()); } diff --git a/source/Generation/MappingAnalysisFactory.cs b/source/Generation/MappingAnalysisFactory.cs index 4e48b24..36dbd1a 100644 --- a/source/Generation/MappingAnalysisFactory.cs +++ b/source/Generation/MappingAnalysisFactory.cs @@ -54,6 +54,50 @@ internal static class MappingAnalysisFactory GetAdaptations(methodSymbol)); } + internal static string? ExtensionUnsupportedReason(ISymbol member, bool requirePartial = true) + { + var type = member.ContainingType; + var receiver = RoslynCompatibility.GetExtensionReceiver(type); + if (member.IsStatic || receiver is null) return "static members or unnamed receivers are unsupported"; + if (type.TypeParameters.Length != 0 || member is IMethodSymbol { IsGenericMethod: true }) + return "generic extension blocks and methods are not supported yet"; + if (receiver.RefKind != RefKind.None || receiver.Type.IsRefLikeType) + return "ref/in/out or ref-like receivers cannot be projected"; + if (requirePartial && !type.ContainingType.DeclaringSyntaxReferences.All(r => r.GetSyntax() is ClassDeclarationSyntax c && + c.Modifiers.Any(SyntaxKind.StaticKeyword) && c.Modifiers.Any(SyntaxKind.PartialKeyword))) + return "the outer class must be static partial"; + if (member is IMethodSymbol method && (method.ReturnsVoid || method.ReturnsByRef || method.ReturnsByRefReadonly || + method.Parameters.Any(p => p.RefKind != RefKind.None || p.IsOptional || p.IsParams))) + return "void/ref returns, ref parameters, optional parameters and params are unsupported"; + if (member is IPropertySymbol property && (property.ReturnsByRef || property.ReturnsByRefReadonly || property.SetMethod is not null)) + return "only value-returning getter-only properties are supported"; + var syntax = member.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax(); + if (syntax is not MethodDeclarationSyntax { ExpressionBody: not null } and + not PropertyDeclarationSyntax { ExpressionBody: not null }) + return "an expression body is required"; + if (syntax.DescendantNodes().OfType().Any()) + return "query syntax is unsupported in extension bodies; use LINQ method syntax"; + if (syntax.DescendantNodes().OfType().Any(lambda => lambda.Body is BlockSyntax || lambda.AsyncKeyword.IsKind(SyntaxKind.AsyncKeyword))) + return "block-bodied or async anonymous functions cannot be projected"; + return null; + } + + internal static MappingAnalysis? CreateExtension(SemanticModel model, SyntaxNode syntax, CancellationToken token) + { + if (!RoslynCompatibility.IsExtensionMember(syntax) || model.GetDeclaredSymbol(syntax, token) is not { } member || + ExtensionUnsupportedReason(member, SymbolHelpers.HasAttribute(member, typeof(ProjectableAttribute).FullName) || + SymbolHelpers.HasAttribute(member.ContainingType.ContainingType, typeof(ProjectableAttribute).FullName)) is not null) return null; + var receiver = RoslynCompatibility.GetExtensionReceiver(member.ContainingType)!; + var outer = member.ContainingType.ContainingType; + var method = member as IMethodSymbol ?? ((IPropertySymbol)member).GetMethod!; + var body = syntax is MethodDeclarationSyntax m ? m.ExpressionBody! : ((PropertyDeclarationSyntax)syntax).ExpressionBody!; + return new MappingAnalysis(outer, method, member.Name, + new[] { receiver }.Concat(method.Parameters).ToArray(), method.ReturnType, body, model, + SymbolHelpers.HasAttribute(member, typeof(ProjectableAttribute).FullName) || SymbolHelpers.HasAttribute(outer, typeof(ProjectableAttribute).FullName), + false, true, GetNullStrategy(member) ?? GetNullStrategy(outer) ?? NullConditionalRewrite.Ignore, + CollectionPropertiesPolicy.Skip, ExtractUsingDirectives(syntax), [], member, receiver); + } + private static bool HasAttribute(INamedTypeSymbol classSymbol, IMethodSymbol methodSymbol, string attributeName) { return SymbolHelpers.HasAttribute(classSymbol, attributeName) || diff --git a/source/Generation/MappingCatalog.cs b/source/Generation/MappingCatalog.cs index d1982fa..7128382 100644 --- a/source/Generation/MappingCatalog.cs +++ b/source/Generation/MappingCatalog.cs @@ -17,6 +17,21 @@ internal sealed class MappingCatalog( Dictionary mappings, Func createExternalMapping) { + private readonly Dictionary properties = new(SymbolEqualityComparer.Default); + + public bool TryGetProperty(IPropertySymbol property, out MappingAnalysis mapping) + { + property = property.OriginalDefinition; + if (properties.TryGetValue(property, out mapping)) return true; + if (property.GetMethod is not null && TryGetValue(property.GetMethod, out mapping)) + { + properties[property] = mapping; + return true; + } + mapping = null!; + return false; + } + public bool TryGetValue(IMethodSymbol method, out MappingAnalysis mapping) { var normalizedMethod = SymbolHelpers.Normalize(method); diff --git a/source/Generation/MappingMethodDetails.cs b/source/Generation/MappingMethodDetails.cs index 542cfbb..3950656 100644 --- a/source/Generation/MappingMethodDetails.cs +++ b/source/Generation/MappingMethodDetails.cs @@ -22,17 +22,17 @@ public MappingMethodDetails(MappingAnalysis mapping) .ToArray(); DestinationTypeName = TypeDisplay.ForSymbol(mapping.ReturnType, mapping.ReturnType.NullableAnnotation, nullablePolicy); SourceTypeName = TypeDisplay.ForSymbol(mapping.ParamType, mapping.ParamType.NullableAnnotation, nullablePolicy); - SourceName = mapping.Parameters[0].Name; + SourceName = Escape(mapping.Parameters[0].Name); MethodParameterList = string.Join(", ", ParameterTypeNames); MethodParameterListWithNames = string.Join(", ", mapping.Parameters.Select(parameter => - $"{TypeDisplay.ForSymbol(parameter.Type, parameter.NullableAnnotation, nullablePolicy)} {parameter.Name}")); + $"{TypeDisplay.ForSymbol(parameter.Type, parameter.NullableAnnotation, nullablePolicy)} {Escape(parameter.Name)}")); ExtraExpressionParameterTypeNames = ParameterTypeNames.Skip(1).ToArray(); ExtraExpressionParameterListWithNames = string.Join(", ", mapping.Parameters.Skip(1).Select(parameter => - $"{TypeDisplay.ForSymbol(parameter.Type, parameter.NullableAnnotation, nullablePolicy)} {parameter.Name}")); + $"{TypeDisplay.ForSymbol(parameter.Type, parameter.NullableAnnotation, nullablePolicy)} {Escape(parameter.Name)}")); LambdaParameters = mapping.Parameters.Count == 1 - ? mapping.Parameters[0].Name - : "(" + string.Join(", ", mapping.Parameters.Select(parameter => parameter.Name)) + ")"; - ProjectionLambdaParameter = mapping.Parameters[0].Name; + ? Escape(mapping.Parameters[0].Name) + : "(" + string.Join(", ", mapping.Parameters.Select(parameter => Escape(parameter.Name))) + ")"; + ProjectionLambdaParameter = Escape(mapping.Parameters[0].Name); MethodTypeParameterList = mapping.MethodSymbol.TypeParameters.Length == 0 ? string.Empty : "<" + string.Join(", ", mapping.MethodSymbol.TypeParameters.Select(static parameter => parameter.Name)) + ">"; @@ -57,6 +57,9 @@ public MappingMethodDetails(MappingAnalysis mapping) public IReadOnlyList MethodConstraintClauses { get; } public NullablePolicy NullablePolicy { get; } + private static string Escape(string name) => Microsoft.CodeAnalysis.CSharp.SyntaxFacts.GetKeywordKind(name) != + Microsoft.CodeAnalysis.CSharp.SyntaxKind.None ? "@" + name : name; + private static IReadOnlyList BuildConstraintClauses( ImmutableArray typeParameters, NullablePolicy nullablePolicy) diff --git a/source/Helpers/RoslynCompatibility.cs b/source/Helpers/RoslynCompatibility.cs new file mode 100644 index 0000000..ab59ff0 --- /dev/null +++ b/source/Helpers/RoslynCompatibility.cs @@ -0,0 +1,64 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace AlephMapper.Helpers; + +internal static class RoslynCompatibility +{ + public static void AddEmbeddedAttributeDefinition(IncrementalGeneratorPostInitializationContext context) + { +#if ALEPHMAPPER_ROSLYN5 + context.AddEmbeddedAttributeDefinition(); +#else + context.AddSource("Microsoft.CodeAnalysis.EmbeddedAttribute.g.cs", """ + // + namespace Microsoft.CodeAnalysis + { + [global::System.Runtime.CompilerServices.CompilerGenerated] + [Embedded] + [global::System.AttributeUsage(global::System.AttributeTargets.All, AllowMultiple = false, Inherited = false)] + internal sealed class EmbeddedAttribute : global::System.Attribute { } + } + """); +#endif + } + + public static bool IsExtensionType(INamedTypeSymbol? type) + { +#if ALEPHMAPPER_ROSLYN5 + return type?.IsExtension == true; +#else + return false; +#endif + } + + public static IParameterSymbol? GetExtensionReceiver(INamedTypeSymbol type) + { +#if ALEPHMAPPER_ROSLYN5 + return type.ExtensionParameter; +#else + return null; +#endif + } + + public static bool IsExtensionMember(SyntaxNode node) + { +#if ALEPHMAPPER_ROSLYN5 + return node.Parent is ExtensionBlockDeclarationSyntax; +#else + return false; +#endif + } + + public static IEnumerable GetExtensionMembers(ClassDeclarationSyntax declaration) + { +#if ALEPHMAPPER_ROSLYN5 + return declaration.Members.OfType().SelectMany(block => block.Members); +#else + return Enumerable.Empty(); +#endif + } +} diff --git a/source/Models/MappingAnalysis.cs b/source/Models/MappingAnalysis.cs index ea1bda7..cc223d1 100644 --- a/source/Models/MappingAnalysis.cs +++ b/source/Models/MappingAnalysis.cs @@ -22,10 +22,14 @@ internal sealed class MappingAnalysis( NullConditionalRewrite nullStrategy, CollectionPropertiesPolicy collectionPolicy, IReadOnlyList usingDirectives, - IReadOnlyList adaptations) + IReadOnlyList adaptations, + ISymbol memberSymbol = null, + IParameterSymbol receiverSymbol = null) { public readonly INamedTypeSymbol ContainingType = containingType; public readonly IMethodSymbol MethodSymbol = methodSymbol; + public readonly ISymbol MemberSymbol = memberSymbol ?? methodSymbol; + public readonly IParameterSymbol ReceiverSymbol = receiverSymbol; public readonly string Name = name; public readonly IReadOnlyList Parameters = parameters; public readonly ITypeSymbol ParamType = parameters[0].Type; diff --git a/source/Roslyn4/AlephMapper.Roslyn4.csproj b/source/Roslyn4/AlephMapper.Roslyn4.csproj new file mode 100644 index 0000000..a055e47 --- /dev/null +++ b/source/Roslyn4/AlephMapper.Roslyn4.csproj @@ -0,0 +1,14 @@ + + + + false + false + + + + + + + + + diff --git a/source/SyntaxRewriters/InliningResolver.ExtensionMemberRewriter.cs b/source/SyntaxRewriters/InliningResolver.ExtensionMemberRewriter.cs new file mode 100644 index 0000000..0dc8f08 --- /dev/null +++ b/source/SyntaxRewriters/InliningResolver.ExtensionMemberRewriter.cs @@ -0,0 +1,128 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using AlephMapper.Generation; +using AlephMapper.Helpers; +using AlephMapper.Models; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace AlephMapper.SyntaxRewriters; + +internal sealed partial class InliningResolver +{ + private static ExpressionSyntax ExtensionConstant(IFieldSymbol field) + { + ExpressionSyntax value = field.ConstantValue switch + { + null => LiteralExpression(SyntaxKind.NullLiteralExpression), + string text => LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(text)), + char character => LiteralExpression(SyntaxKind.CharacterLiteralExpression, Literal(character)), + bool boolean => LiteralExpression(boolean ? SyntaxKind.TrueLiteralExpression : SyntaxKind.FalseLiteralExpression), + float number when float.IsNaN(number) || float.IsInfinity(number) => ParseExpression( + "global::System.Single." + (float.IsNaN(number) ? "NaN" : number > 0 ? "PositiveInfinity" : "NegativeInfinity")), + double number when double.IsNaN(number) || double.IsInfinity(number) => ParseExpression( + "global::System.Double." + (double.IsNaN(number) ? "NaN" : number > 0 ? "PositiveInfinity" : "NegativeInfinity")), + float number => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal(number)), + double number => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal(number)), + decimal number => LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal(number)), + _ => ParseExpression(Microsoft.CodeAnalysis.CSharp.SymbolDisplay.FormatPrimitive( + field.ConstantValue, quoteStrings: true, useHexadecimalNumbers: false)!) + }; + return ParenthesizedExpression(CastExpression(ParseTypeName(field.Type.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat)), ParenthesizedExpression(value))); + } + + private SyntaxNode RewriteExtensionCall(InvocationExpressionSyntax node, IMethodSymbol method) + { + if (node.Parent is ConditionalAccessExpressionSyntax && rewriteSupport == NullConditionalRewrite.None) + return node; + if (!catalog.TryGetValue(method, out var mapping)) return UnsupportedExtension(node, method); + ExpressionSyntax? receiver = node.Expression is MemberAccessExpressionSyntax access ? access.Expression : null; + var alreadyVisited = false; + if (node.Expression is MemberBindingExpressionSyntax && _conditionalAccessExpressionsStack.Count > 0) + { + receiver = _conditionalAccessExpressionsStack.Peek(); + alreadyVisited = true; + } + if (receiver is null) return UnsupportedExtension(node, method); + var substitutions = BindExtensionReceiver(mapping, receiver, alreadyVisited); + foreach (var argument in node.ArgumentList.Arguments) + { + if (model.GetOperation(argument) is not IArgumentOperation { Parameter: { } boundParameter } || + boundParameter.Ordinal >= mapping.MethodSymbol.Parameters.Length) + return UnsupportedExtension(node, method); + var parameter = mapping.MethodSymbol.Parameters[boundParameter.Ordinal]; + substitutions[parameter] = PreserveArgumentConversion(argument.Expression, (ExpressionSyntax)Visit(argument.Expression)!); + } + if (substitutions.Count != mapping.Parameters.Count) return UnsupportedExtension(node, method); + return ExpandExtension(node, mapping, substitutions); + } + + private SyntaxNode RewriteExtensionProperty(ExpressionSyntax node, IPropertySymbol property, + ExpressionSyntax receiver, bool receiverAlreadyVisited = false) + { + if (!catalog.TryGetProperty(property, out var mapping)) return UnsupportedExtension(node, property); + return ExpandExtension(node, mapping, BindExtensionReceiver(mapping, receiver, receiverAlreadyVisited)); + } + + private Dictionary BindExtensionReceiver(MappingAnalysis mapping, + ExpressionSyntax receiver, bool alreadyVisited) + { + var rewritten = alreadyVisited ? receiver : (ExpressionSyntax)Visit(receiver)!; + if (alreadyVisited) + rewritten = ParenthesizedExpression(CastExpression(ParseTypeName(TypeDisplay.ForSymbol( + mapping.ReceiverSymbol.Type, mapping.ReceiverSymbol.NullableAnnotation, nullablePolicy)), + ParenthesizedExpression(rewritten))); + return new Dictionary(SymbolEqualityComparer.Default) + { + [mapping.ReceiverSymbol] = PreserveArgumentConversion(alreadyVisited ? null : receiver, rewritten, mapping.ReceiverSymbol.Type) + }; + } + + private SyntaxNode UnsupportedExtension(SyntaxNode original, ISymbol member) + { + _unsupportedExtensions.Add((member, MappingAnalysisFactory.ExtensionUnsupportedReason(member) ?? "the receiver or arguments could not be bound to a source expression body")); + return original; + } + + private SyntaxNode ExpandExtension(SyntaxNode original, MappingAnalysis mapping, + IReadOnlyDictionary substitutions) + { + var token = SymbolHelpers.Normalize(mapping.MethodSymbol); + if (IsCircularReference(token)) + { + RecordCircularReference(token); + return original; + } + _callStack.Add(token); + try + { + _inlinedMethods[token] = mapping; + var resolver = new InliningResolver(mapping.SemanticModel, catalog, forUpdateMethod, rewriteSupport, + nullablePolicy, mapping.ReturnType, substitutions) + { + _callStack = _callStack, + _circularReferences = _circularReferences, + _inlinedMethods = _inlinedMethods, + _unsafeConditionalReceivers = _unsafeConditionalReceivers, + _unsupportedNullConditionals = _unsupportedNullConditionals, + _unsupportedSwitchPatterns = _unsupportedSwitchPatterns, + _unsupportedExtensions = _unsupportedExtensions, + _extensionExpansion = true, + _outputType = _outputType + }; + var body = (ExpressionSyntax)resolver.Visit(mapping.BodySyntax.Expression)!; + var info = mapping.SemanticModel.GetTypeInfo(mapping.BodySyntax.Expression); + if (!SymbolEqualityComparer.Default.Equals(info.Type, mapping.ReturnType)) + body = CastExpression(ParseTypeName(TypeDisplay.ForSymbol(mapping.ReturnType, + mapping.ReturnType.NullableAnnotation, nullablePolicy)), ParenthesizedExpression(body)); + // Both methods and properties are primary expressions at the call site. + return ParenthesizedExpression(body.WithoutTrivia()).WithTriviaFrom(original); + } + finally { _callStack.Remove(token); } + } +} diff --git a/source/SyntaxRewriters/InliningResolver.InvocationRewriter.cs b/source/SyntaxRewriters/InliningResolver.InvocationRewriter.cs index af19922..a0b9fa1 100644 --- a/source/SyntaxRewriters/InliningResolver.InvocationRewriter.cs +++ b/source/SyntaxRewriters/InliningResolver.InvocationRewriter.cs @@ -26,12 +26,29 @@ internal sealed partial class InliningResolver( internal const string InlinedConditionalAnnotation = "AlephMapper.InlinedConditional"; internal const string InlinedReturnCreationAnnotation = "AlephMapper.InlinedReturnCreation"; + private readonly Stack<(ISymbol Symbol, ExpressionSyntax Replacement)> _lambdaVariables = new(); + private bool _extensionExpansion; + private INamedTypeSymbol? _outputType; + + public override SyntaxNode? Visit(SyntaxNode? node) + { + if (_outputType is null && node is not null && CanQuerySemanticModel(node)) + { + var symbol = model.GetEnclosingSymbol(node.SpanStart); + _outputType = symbol as INamedTypeSymbol ?? symbol?.ContainingType; + if (_outputType is not null && RoslynCompatibility.IsExtensionType(_outputType)) _outputType = _outputType.ContainingType; + } + return base.Visit(node); + } + private HashSet _callStack = new(SymbolEqualityComparer.Default); private List _circularReferences = []; private Dictionary _inlinedMethods = new(SymbolEqualityComparer.Default); private List _unsafeConditionalReceivers = []; private List _unsupportedNullConditionals = []; private List _unsupportedSwitchPatterns = []; + private List<(ISymbol Member, string Reason)> _unsupportedExtensions = []; + public IReadOnlyList<(ISymbol Member, string Reason)> UnsupportedExtensions => _unsupportedExtensions; // Only the currently visited switch arm owns these locals. Keep the original // Roslyn symbols, not their identifier text, to avoid collisions with callers. private readonly Stack<(ISymbol Symbol, ExpressionSyntax Replacement)> _switchPatternVariables = new(); @@ -85,6 +102,21 @@ private void RecordCircularReference(IMethodSymbol method) return base.VisitIdentifierName(node); var symbol = model.GetSymbolInfo(node).Symbol; + foreach (var variable in _lambdaVariables) + if (SymbolEqualityComparer.Default.Equals(symbol, variable.Symbol)) + return variable.Replacement.WithTriviaFrom(node); + if (_extensionExpansion && symbol is { IsStatic: true, ContainingType: { } owner } && + (symbol is IFieldSymbol or IPropertySymbol or IMethodSymbol) && + !(node.Parent is MemberAccessExpressionSyntax memberAccess && memberAccess.Name == node)) + { + if (symbol is IFieldSymbol { IsConst: true } field && field.Type.TypeKind != TypeKind.Enum) + return ExtensionConstant(field).WithTriviaFrom(node); + if (_outputType is not null && !model.Compilation.IsSymbolAccessibleWithin(symbol, _outputType)) + _unsupportedExtensions.Add((symbol, "an expanded body references an inaccessible member")); + return MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + ParseName(owner.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)), + IdentifierName(node.Identifier)).WithTriviaFrom(node); + } foreach (var patternVariable in _switchPatternVariables) { if (SymbolEqualityComparer.Default.Equals(symbol, patternVariable.Symbol)) @@ -176,6 +208,10 @@ SyntaxKind.GreaterThanExpression or SyntaxKind.GreaterThanOrEqualExpression or public override SyntaxNode? VisitInvocationExpression(InvocationExpressionSyntax node) { + if ((!forUpdateMethod || _extensionExpansion || parameterSubstitutions is { Count: > 0 } || _lambdaVariables.Count > 0) && + CanQuerySemanticModel(node) && model.GetOperation(node) is Microsoft.CodeAnalysis.Operations.INameOfOperation && + model.GetConstantValue(node) is { HasValue: true, Value: string name }) + return LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(name)).WithTriviaFrom(node); if (node.Parent == null || !CanQuerySemanticModel(node) || model.GetSymbolInfo(node.Expression).Symbol is not IMethodSymbol invokedMethod) @@ -183,6 +219,9 @@ SyntaxKind.GreaterThanExpression or SyntaxKind.GreaterThanOrEqualExpression or return base.VisitInvocationExpression(node); } + if (RoslynCompatibility.IsExtensionType(invokedMethod.ContainingType)) + return RewriteExtensionCall(node, invokedMethod); + var args = node.ArgumentList.Arguments; var firstArgExpression = args.Count > 0 ? args[0].Expression : null; var isConditionalAccess = node.Parent is ConditionalAccessExpressionSyntax; @@ -192,6 +231,11 @@ SyntaxKind.GreaterThanExpression or SyntaxKind.GreaterThanOrEqualExpression or or AliasQualifiedNameSyntax) { var methodGroup = ResolveMethodGroupSymbol(firstArgExpression); + if (methodGroup is not null && RoslynCompatibility.IsExtensionType(methodGroup.ContainingType)) + { + _unsupportedExtensions.Add((methodGroup, "bound extension method groups are not supported; use an explicit expression lambda")); + return node; + } if (methodGroup is { Parameters.Length: > 0 }) { @@ -232,7 +276,10 @@ SyntaxKind.GreaterThanExpression or SyntaxKind.GreaterThanOrEqualExpression or _inlinedMethods = _inlinedMethods, _unsafeConditionalReceivers = _unsafeConditionalReceivers, _unsupportedNullConditionals = _unsupportedNullConditionals, - _unsupportedSwitchPatterns = _unsupportedSwitchPatterns + _unsupportedSwitchPatterns = _unsupportedSwitchPatterns, + _unsupportedExtensions = _unsupportedExtensions, + _extensionExpansion = _extensionExpansion, + _outputType = _outputType }.Visit(callee.BodySyntax.Expression)!; // Lambda parameters intentionally keep the callee's parameter names. @@ -303,7 +350,10 @@ SyntaxKind.GreaterThanExpression or SyntaxKind.GreaterThanOrEqualExpression or _inlinedMethods = _inlinedMethods, _unsafeConditionalReceivers = _unsafeConditionalReceivers, _unsupportedNullConditionals = _unsupportedNullConditionals, - _unsupportedSwitchPatterns = _unsupportedSwitchPatterns + _unsupportedSwitchPatterns = _unsupportedSwitchPatterns, + _unsupportedExtensions = _unsupportedExtensions, + _extensionExpansion = _extensionExpansion, + _outputType = _outputType }.Visit(callee2.BodySyntax.Expression)!; // Keep existing generated formatting unless grouping is needed for diff --git a/source/SyntaxRewriters/InliningResolver.LambdaScopeRewriter.cs b/source/SyntaxRewriters/InliningResolver.LambdaScopeRewriter.cs new file mode 100644 index 0000000..8e6adb0 --- /dev/null +++ b/source/SyntaxRewriters/InliningResolver.LambdaScopeRewriter.cs @@ -0,0 +1,76 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace AlephMapper.SyntaxRewriters; + +internal sealed partial class InliningResolver +{ + public override SyntaxNode? VisitSimpleLambdaExpression(SimpleLambdaExpressionSyntax node) + { + var count = 0; + try + { + var parameter = RenameLambdaParameter(node.Parameter, ref count); + return node.WithParameter(parameter).WithBody((CSharpSyntaxNode)Visit(node.Body)!); + } + finally { while (count-- > 0) _lambdaVariables.Pop(); } + } + + public override SyntaxNode? VisitParenthesizedLambdaExpression(ParenthesizedLambdaExpressionSyntax node) + { + var count = 0; + try + { + var parameters = node.ParameterList.Parameters.Select(p => RenameLambdaParameter(p, ref count)).ToArray(); + return node.WithParameterList(node.ParameterList.WithParameters(SeparatedList(parameters))) + .WithBody((CSharpSyntaxNode)Visit(node.Body)!); + } + finally { while (count-- > 0) _lambdaVariables.Pop(); } + } + + public override SyntaxNode? VisitAnonymousObjectMemberDeclarator(AnonymousObjectMemberDeclaratorSyntax node) + { + var rewritten = (AnonymousObjectMemberDeclaratorSyntax)base.VisitAnonymousObjectMemberDeclarator(node)!; + if (node.NameEquals is not null || node.Parent is not AnonymousObjectCreationExpressionSyntax creation || + !CanQuerySemanticModel(creation) || + model.GetOperation(creation) is not Microsoft.CodeAnalysis.Operations.IAnonymousObjectCreationOperation operation) + return rewritten; + var index = creation.Initializers.IndexOf(node); + if (index < 0 || operation.Initializers[index] is not Microsoft.CodeAnalysis.Operations.ISimpleAssignmentOperation + { Target: Microsoft.CodeAnalysis.Operations.IPropertyReferenceOperation property }) return rewritten; + var inferredName = rewritten.Expression switch + { + IdentifierNameSyntax name => name.Identifier.ValueText, + MemberAccessExpressionSyntax access => access.Name.Identifier.ValueText, + _ => null + }; + if (inferredName == property.Property.Name) return rewritten; + return rewritten.WithNameEquals(NameEquals(IdentifierName(Identifier("@" + property.Property.Name)))); + } + + private ParameterSyntax RenameLambdaParameter(ParameterSyntax parameter, ref int count) + { + if (parameterSubstitutions is null || !CanQuerySemanticModel(parameter) || + model.GetDeclaredSymbol(parameter) is not IParameterSymbol symbol || + !parameterSubstitutions.Values.Any(v => v.DescendantNodesAndSelf().OfType() + .Any(n => n.Identifier.ValueText == symbol.Name))) + return (ParameterSyntax)base.VisitParameter(parameter)!; + var used = new HashSet(parameter.SyntaxTree.GetRoot().DescendantTokens() + .Where(t => t.IsKind(SyntaxKind.IdentifierToken)).Select(t => t.ValueText), StringComparer.Ordinal); + used.UnionWith(parameterSubstitutions.Values.SelectMany(v => v.DescendantTokens()) + .Where(t => t.IsKind(SyntaxKind.IdentifierToken)).Select(t => t.ValueText)); + used.UnionWith(_lambdaVariables.Select(v => ((IdentifierNameSyntax)v.Replacement).Identifier.ValueText)); + var index = 0; + string name; + do { name = "__amLambda" + index++; } while (used.Contains(name)); + _lambdaVariables.Push((symbol, IdentifierName(name))); + count++; + return ((ParameterSyntax)base.VisitParameter(parameter)!).WithIdentifier(Identifier(name).WithTriviaFrom(parameter.Identifier)); + } +} diff --git a/source/SyntaxRewriters/InliningResolver.NullConditionalRewriter.cs b/source/SyntaxRewriters/InliningResolver.NullConditionalRewriter.cs index 2f9edff..db27968 100644 --- a/source/SyntaxRewriters/InliningResolver.NullConditionalRewriter.cs +++ b/source/SyntaxRewriters/InliningResolver.NullConditionalRewriter.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using AlephMapper.Helpers; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -188,6 +188,8 @@ ExpressionSyntax Transform(ExpressionSyntax expr, ExpressionSyntax currentTarget } var targetExpression = _conditionalAccessExpressionsStack.Peek(); + if (CanQuerySemanticModel(node) && model.GetSymbolInfo(node).Symbol is IPropertySymbol property && RoslynCompatibility.IsExtensionType(property.ContainingType)) + return RewriteExtensionProperty(node, property, targetExpression, receiverAlreadyVisited: true); var memberAccess = MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, targetExpression, node.Name); return memberAccess.WithTriviaFrom(node); } diff --git a/source/SyntaxRewriters/InliningResolver.StaticMemberReferenceRewriter.cs b/source/SyntaxRewriters/InliningResolver.StaticMemberReferenceRewriter.cs index 030f68b..7366e21 100644 --- a/source/SyntaxRewriters/InliningResolver.StaticMemberReferenceRewriter.cs +++ b/source/SyntaxRewriters/InliningResolver.StaticMemberReferenceRewriter.cs @@ -12,6 +12,18 @@ internal sealed partial class InliningResolver { public override SyntaxNode? VisitMemberAccessExpression(MemberAccessExpressionSyntax node) { + if (CanQuerySemanticModel(node) && model.GetSymbolInfo(node).Symbol is IPropertySymbol property && RoslynCompatibility.IsExtensionType(property.ContainingType)) + return RewriteExtensionProperty(node, property, node.Expression); + if (CanQuerySemanticModel(node) && model.GetSymbolInfo(node).Symbol is IMethodSymbol method && + RoslynCompatibility.IsExtensionType(method.ContainingType) && !(node.Parent is InvocationExpressionSyntax invocation && invocation.Expression == node)) + return UnsupportedExtension(node, method); + if (_extensionExpansion && CanQuerySemanticModel(node) && model.GetSymbolInfo(node).Symbol is { } dependency) + { + if (dependency is IFieldSymbol { IsConst: true } field && field.Type.TypeKind != TypeKind.Enum) + return ExtensionConstant(field).WithTriviaFrom(node); + if (_outputType is not null && !model.Compilation.IsSymbolAccessibleWithin(dependency, _outputType)) + _unsupportedExtensions.Add((dependency, "an expanded body references an inaccessible member")); + } var rewritten = (MemberAccessExpressionSyntax?)base.VisitMemberAccessExpression(node); if (rewritten == null || !CanQuerySemanticModel(node) || diff --git a/source/SyntaxRewriters/InliningResolver.TypeReferenceRewriter.cs b/source/SyntaxRewriters/InliningResolver.TypeReferenceRewriter.cs index 31a6144..cb2802a 100644 --- a/source/SyntaxRewriters/InliningResolver.TypeReferenceRewriter.cs +++ b/source/SyntaxRewriters/InliningResolver.TypeReferenceRewriter.cs @@ -68,9 +68,19 @@ internal sealed partial class InliningResolver var typeArguments = node.TypeArgumentList.Arguments .Zip(rewritten.TypeArgumentList.Arguments, GetFullyQualifiedTypeSyntax) .ToList(); - return rewritten.WithTypeArgumentList( - rewritten.TypeArgumentList.WithArguments( - Microsoft.CodeAnalysis.CSharp.SyntaxFactory.SeparatedList(typeArguments))); + rewritten = rewritten.WithTypeArgumentList(rewritten.TypeArgumentList.WithArguments( + Microsoft.CodeAnalysis.CSharp.SyntaxFactory.SeparatedList(typeArguments))); + if (_extensionExpansion && CanQuerySemanticModel(node) && + model.GetSymbolInfo(node).Symbol is IMethodSymbol { IsStatic: true } method && + !(node.Parent is MemberAccessExpressionSyntax access && access.Name == node)) + { + if (_outputType is not null && !model.Compilation.IsSymbolAccessibleWithin(method, _outputType)) + _unsupportedExtensions.Add((method, "an expanded body references an inaccessible member")); + return Microsoft.CodeAnalysis.CSharp.SyntaxFactory.MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, + Microsoft.CodeAnalysis.CSharp.SyntaxFactory.ParseName(method.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)), + rewritten).WithTriviaFrom(node); + } + return rewritten; } private TypeSyntax GetFullyQualifiedTypeSyntax(TypeSyntax originalType, TypeSyntax rewrittenType) diff --git a/tests/AlephMapper.IntegrationTests/ExtensionMembersParityTests.cs b/tests/AlephMapper.IntegrationTests/ExtensionMembersParityTests.cs new file mode 100644 index 0000000..589fd08 --- /dev/null +++ b/tests/AlephMapper.IntegrationTests/ExtensionMembersParityTests.cs @@ -0,0 +1,177 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace AlephMapper.IntegrationTests; + +public class ExtensionMembersParityTests +{ + [Test] + public async Task CSharp14_properties_methods_and_classic_extensions_compose() + { + foreach (var value in new[] { -1, 0, 3, 100 }) + { + var order = new ExtensionOrder { Id = value, Amount = value }; + await Assert.That(OrderExtensions.AmountWithTaxExpression().Compile()(order)).IsEqualTo(order.AmountWithTax); + await Assert.That(OrderExtensions.LabelExpression(3).Compile()(order)).IsEqualTo(order.Label(3)); + await Assert.That(OrderExtensions.ToDtoExpression().Compile()(order)).IsEqualTo(OrderExtensions.ToDto(order)); + await Assert.That(OrderExtensions.ComposedExpression().Compile()(order)).IsEqualTo(order.Composed); + await Assert.That(OrderExtensions.ClassicExpression().Compile()(order)).IsEqualTo(order.Classic()); + await Assert.That(OrderExtensions.NamedExpression().Compile()(order)).IsEqualTo(OrderExtensions.Named(order)); + await Assert.That(OrderExtensions.TimesExpression(2L).Compile()(order)).IsEqualTo(order.Times(2L)); + await Assert.That(ScalarExtensions.SquaredExpression().Compile()(value)).IsEqualTo(value.Squared); + await Assert.That(ScalarExtensions.InterfaceLabelExpression().Compile()(order)).IsEqualTo(((IExtensionEntity)order).InterfaceLabel); + } + } + + [Test] + public async Task Extension_members_preserve_nested_receivers_null_policies_and_method_groups() + { + var order = new ExtensionOrder { Amount = 2, Other = new() { Amount = 7 } }; + await Assert.That(OrderExtensions.NestedExpression().Compile()(order)).IsEqualTo(OrderExtensions.Nested(order)); + await Assert.That(OrderExtensions.IgnoreExpression().Compile()(order)).IsEqualTo(OrderExtensions.Ignore(order)); + await Assert.That(OrderExtensions.RewriteExpression().Compile()(order)).IsEqualTo(OrderExtensions.Rewrite(order)); + order.Other = null; + await Assert.That(OrderExtensions.RewriteExpression().Compile()(order)).IsEqualTo(OrderExtensions.Rewrite(order)); + foreach (var sample in new int?[] { null, -1, 3 }) + await Assert.That(ScalarExtensions.OrZeroExpression().Compile()(sample)).IsEqualTo(sample.OrZero); + foreach (var sample in new[] { new ExtensionPoint(2, 3), new ExtensionPoint(-1, 4) }) + await Assert.That(ScalarExtensions.SumExpression().Compile()(sample)).IsEqualTo(sample.Sum); + var input = new[] { new ExtensionOrder { Amount = 3 }, new ExtensionOrder { Amount = 8 } }; + await Assert.That(OrderExtensions.GroupExpression().Compile()(input)).IsEquivalentTo(OrderExtensions.Group(input)); + } + + [Test] + public async Task Receiver_substitutions_do_not_capture_lambda_names_or_lose_static_binding() + { + await Assert.That(ExtensionReviewMapper.CaptureExpression().Compile()(10)).IsEqualTo(ExtensionReviewMapper.Capture(10)); + await Assert.That(ExtensionReviewMapper.Capture(10)).IsEqualTo(14); + await Assert.That(ExtensionReviewMapper.NameExpression().Compile()(10)).IsEqualTo(ExtensionReviewMapper.Name(10)); + await Assert.That(ExtensionReviewMapper.Name(10)).IsEqualTo("receiver"); + await Assert.That(ExtensionReviewMapper.StaticDependencyExpression().Compile()(10)).IsEqualTo(ExtensionReviewMapper.StaticDependency(10)); + await Assert.That(ExtensionReviewMapper.NonfiniteExpression().Compile()(10)).IsEqualTo(ExtensionReviewMapper.Nonfinite(10)); + await Assert.That(ExtensionReviewMapper.Nonfinite(10)).IsTrue(); + var derived = new ExtensionDerived { Value = 99 }; + ((ExtensionBase)derived).Value = 3; + await Assert.That(ExtensionReviewMapper.ConditionalExpression().Compile()(derived)).IsEqualTo(3); + await Assert.That(ExtensionReviewMapper.ConditionalExpression().Compile()(null)).IsNull(); + await Assert.That(ExtensionReviewMapper.ConditionalIgnoreExpression().Compile()(derived)).IsEqualTo(3); + } + + [Test] + public async Task Classic_lambda_renaming_preserves_nameof_and_generic_static_binding() + { + var values = new[] { 1, 2, 3 }; + await Assert.That(ExtensionReviewMapper.ClassicNameExpression().Compile()(values)).IsEqualTo(ExtensionReviewMapper.ClassicName(values)); + await Assert.That(ExtensionReviewMapper.ClassicName(values)).IsEqualTo(9); + await Assert.That(ExtensionReviewMapper.GenericDependencyExpression().Compile()(10)).IsEqualTo(ExtensionReviewMapper.GenericDependency(10)); + await Assert.That(ExtensionReviewMapper.GenericDependency(10)).IsEqualTo(13); + } + + [Test] + public async Task Renamed_lambda_preserves_anonymous_members_and_root_nameof() + { + var values = new[] { 1, 2, 3 }; + await Assert.That(ExtensionReviewMapper.AnonymousNameExpression().Compile()(values)).IsEqualTo(ExtensionReviewMapper.AnonymousName(values)); + await Assert.That(ExtensionReviewMapper.AnonymousName(values)).IsEqualTo(4); + var order = new ExtensionOrder(); + await Assert.That(ExtensionReviewMapper.RootNameExpression().Compile()(order)).IsEqualTo(ExtensionReviewMapper.RootName(order)); + await Assert.That(ExtensionReviewMapper.RootName(order)).IsEqualTo("Amount"); + } + + [Test] + public async Task CSharp14_projection_executes_in_SQLite() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + await using var context = new ExtensionContext(new DbContextOptionsBuilder().UseSqlite(connection).Options); + await context.Database.EnsureCreatedAsync(); + context.Orders.AddRange(new ExtensionOrder { Id = 1, Amount = 2 }, new ExtensionOrder { Id = 2, Amount = 120 }); + await context.SaveChangesAsync(); + var actual = await context.Orders.OrderBy(o => o.Id).Select(OrderExtensions.ToDtoExpression()).ToListAsync(); + var expected = (await context.Orders.OrderBy(o => o.Id).ToListAsync()).Select(OrderExtensions.ToDto); + await Assert.That(actual).IsEquivalentTo(expected); + } +} + +public interface IExtensionEntity { int Id { get; } } +public sealed class ExtensionOrder : IExtensionEntity +{ + public int Id { get; set; } + public decimal Amount { get; set; } + public ExtensionOrder? Other { get; set; } +} +public readonly record struct ExtensionPoint(int X, int Y); +public record ExtensionDto(int Id, decimal AmountWithTax, string Label); +public sealed class ExtensionContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Orders => Set(); + protected override void OnModelCreating(ModelBuilder model) => model.Entity().Ignore(o => o.Other); +} + +public static partial class OrderExtensions +{ + extension(ExtensionOrder order) + { + [Projectable] public decimal AmountWithTax => order.Amount * 1.25m; + [Projectable] public string Label(int min) => order.Amount switch { _ when order.Amount >= min => "high", _ => "low" }; + [Projectable] public decimal Composed => order.AmountWithTax + order.Times(2) + order.Classic(); + [Projectable] public decimal Times(int factor) => order.Amount * factor; + [Projectable] public decimal Times(long factor) => order.Amount * factor; + [Projectable] public decimal Subtract(int left, int right) => order.Amount + left - right; + } + [Projectable] public static decimal Nested(ExtensionOrder order) => order.Other!.Composed * 2; + [Projectable(NullConditionalRewrite = NullConditionalRewrite.Ignore)] + public static decimal? Ignore(ExtensionOrder order) => order.Other?.AmountWithTax; + [Projectable(NullConditionalRewrite = NullConditionalRewrite.Rewrite)] + public static decimal? Rewrite(ExtensionOrder order) => order.Other?.Times(2) + order.Other?.AmountWithTax; + [Projectable] public static decimal[] Group(ExtensionOrder[] orders) => orders.Select(ReadAmount).ToArray(); + private static decimal ReadAmount(ExtensionOrder order) => order.AmountWithTax; + [Projectable] public static ExtensionDto ToDto(ExtensionOrder order) => new(order.Id, order.AmountWithTax, order.Label(100)); + [Projectable] public static decimal Classic(this ExtensionOrder order) => order.AmountWithTax + 1; + [Projectable] public static decimal Named(ExtensionOrder order) => order.Subtract(right: 2, left: 7) + order.Subtract(left: 7, 2); +} +public static partial class ScalarExtensions +{ + extension(int? n) { [Projectable] public int OrZero => n ?? 0; } + extension(ExtensionPoint point) { [Projectable] public int Sum => point.X + point.Y; } + extension(int n) { [Projectable] public int Squared => n*n; } + extension(IExtensionEntity entity) { [Projectable] public string InterfaceLabel => "#" + entity.Id; } +} + +public class ExtensionBase { public int Value { get; set; } } +public class ExtensionDerived : ExtensionBase { public new int Value { get; set; } } +public static partial class ReviewExtensions +{ + private const int Offset = 3; + private const double NotANumber = double.NaN; + private const float Infinite = float.PositiveInfinity; + public static int PublicOffset = 4; + public static int GenericOffset() { return 3; } + extension(int receiver) + { + [Projectable] public int WithGenericOffset => receiver + GenericOffset(); + [Projectable] public string ReceiverName => nameof(receiver); + [Projectable] public int Sum(int add) => new[] { 1 }.Sum(x => x + receiver + add); + [Projectable] public bool WithNonfinite => double.IsNaN(receiver + NotANumber) && float.IsPositiveInfinity(receiver + Infinite); + [Projectable] public int WithOffset => receiver + Offset + PublicOffset; + } + extension(ExtensionBase receiver) { [Projectable] public int Score => receiver.Value; } +} +public static partial class ExtensionReviewMapper +{ + public static int GenericOffset() { return 99; } + [Projectable] public static int GenericDependency(int n) => n.WithGenericOffset; + [Projectable] public static int ClassicName(int[] values) => ClassicHelper(values.Length); + private static int ClassicHelper(int n) => new[] { 1 }.Sum(Length => n + nameof(Length).Length); + [Projectable] public static string RootName(ExtensionOrder order) => nameof(order.Amount); + [Projectable] public static int AnonymousName(int[] values) => AnonymousHelper(values.Length); + private static int AnonymousHelper(int n) => new[] { 1 }.Select(Length => new { Length, Value = n }).Sum(v => v.Length + v.Value); + [Projectable] public static string Name(int n) => n.ReceiverName; + [Projectable] public static bool Nonfinite(int x) => x.WithNonfinite; + [Projectable] public static int Capture(int x) => x.Sum(3); + [Projectable] public static int StaticDependency(int x) => x.WithOffset; + [Projectable(NullConditionalRewrite = NullConditionalRewrite.Rewrite)] + public static int? Conditional(ExtensionDerived? value) => value?.Score; + [Projectable(NullConditionalRewrite = NullConditionalRewrite.Ignore)] + public static int? ConditionalIgnore(ExtensionDerived? value) => value?.Score; +} diff --git a/tests/AlephMapper.Tests/ExtensionMemberTests.cs b/tests/AlephMapper.Tests/ExtensionMemberTests.cs new file mode 100644 index 0000000..d9c177e --- /dev/null +++ b/tests/AlephMapper.Tests/ExtensionMemberTests.cs @@ -0,0 +1,205 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Testing; + +namespace AlephMapper.Tests; + +public class ExtensionMemberTests +{ + [Test] + public async Task CSharp14_extension_receiver_is_separate_from_method_parameters() + { + var source = "public class Order { public int Total {get;set;} } public static class Ext { extension(Order order) { public int DoubleTotal => order.Total*2; public int Scale(int factor) => order.Total*factor; } public static int Use(Order o) => o.DoubleTotal + o.Scale(3); }"; + var compilation = await Compile(source); + await Assert.That(compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + var tree = compilation.SyntaxTrees.Single(); + var model = compilation.GetSemanticModel(tree); + var block = tree.GetRoot().DescendantNodes().OfType().Single(); + var type = (INamedTypeSymbol)model.GetDeclaredSymbol(block)!; + await Assert.That(type.IsExtension).IsTrue(); + await Assert.That(type.ExtensionParameter!.Name).IsEqualTo("order"); + var method = (IMethodSymbol)model.GetDeclaredSymbol(block.Members.OfType().Single())!; + await Assert.That(method.Parameters.Length).IsEqualTo(1); + await Assert.That(method.Parameters[0].Name).IsEqualTo("factor"); + var access = tree.GetRoot().DescendantNodes().OfType().First(n => n.Name.Identifier.ValueText == "DoubleTotal"); + await Assert.That(SymbolEqualityComparer.Default.Equals(model.GetSymbolInfo(access).Symbol, + model.GetDeclaredSymbol(block.Members.OfType().Single()))).IsTrue(); + } + + [Test] + [Arguments("public int Double => n*2;", "DoubleExpression().Compile()(3) == 6")] + [Arguments("public int Scale(int factor) => n*factor;", "ScaleExpression(4).Compile()(3) == 12")] + public async Task CSharp14_extension_factories_compile_and_match_source(string declaration, string check) + { + var source = "using AlephMapper; using System.Linq; public static partial class Ext { extension(int n) { [Projectable] " + declaration + " } public static bool Check() => " + check + "; }"; + var (result, output) = await Generate(source); + await Assert.That(result.Diagnostics).IsEmpty(); + await Assert.That(output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + using var stream = new MemoryStream(); + var emit = output.Emit(stream); + await Assert.That(emit.Success).IsTrue(); + var assembly = System.Reflection.Assembly.Load(stream.ToArray()); + await Assert.That((bool)assembly.GetType("Ext")!.GetMethod("Check")!.Invoke(null, null)!).IsTrue(); + } + + [Test] + [Arguments("public int Value { get { return n; } }")] + [Arguments("public int Value() { return n; }")] + [Arguments("public static int Value => 3;")] + [Arguments("public int Value(int x = 1) => n+x;")] + [Arguments("public T Value(T x) => x;")] + [Arguments("public int Value() => System.Linq.Enumerable.Sum(new[]{n}, x => { return x; });")] + [Arguments("public System.Collections.Generic.IEnumerable Value => from x in new[]{1} select x+n;")] + public async Task Unsupported_extension_members_report_AM0021_and_isolate_failure(string declaration) + { + var source = "using AlephMapper; using System.Linq; public static partial class Ext { extension(int n) { [Projectable] " + declaration + " [Projectable] public int Good => n+1; } }"; + var (result, output) = await Generate(source); + await Assert.That(result.Diagnostics.Any(d => d.Id == "AM0021")).IsTrue(); + await Assert.That(result.Diagnostics.Any(d => d.Id == "AM0004")).IsFalse(); + await Assert.That(output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + await Assert.That(result.GeneratedSources.Any(s => s.SourceText.ToString().Contains("GoodExpression"))).IsTrue(); + } + + [Test] + [Arguments("public int A => n.B; public int B => n.A;")] + [Arguments("public int A() => n.B; public int B => n.A();")] + public async Task Extension_member_cycles_terminate_with_diagnostic(string declarations) + { + var (result, output) = await Generate("using AlephMapper; [Projectable] public static partial class Ext { extension(int n) { " + declarations + " public int Good => n+1; } }"); + await Assert.That(result.Diagnostics.Any(d => d.Id == "AM0002")).IsTrue(); + await Assert.That(result.Diagnostics.Any(d => d.Id == "AM0004")).IsFalse(); + await Assert.That(output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + await Assert.That(result.GeneratedSources.Any(s => s.SourceText.ToString().Contains("GoodExpression"))).IsTrue(); + } + + [Test] + public async Task Extension_factory_signature_conflicts_are_diagnosed() + { + var (result, output) = await Generate("using AlephMapper; [Projectable] public static partial class Ext { extension(int n) { public int Value => n; } extension(string n) { public int Value => n.Length; } }"); + await Assert.That(result.Diagnostics.Count(d => d.Id == "AM0021")).IsEqualTo(2); + await Assert.That(output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + } + + [Test] + public async Task Extension_members_in_partial_files_generate_once_and_deterministically() + { + const string source = "using AlephMapper; [Projectable] public static partial class Ext { extension(int n) { public int Double => n*2; } } // file boundary\nusing AlephMapper; public static partial class Ext { extension(int n) { [Projectable] public int Triple => n*3; } }"; + var (first, output) = await Generate(source); + var (second, _) = await Generate(source); + await Assert.That(first.Diagnostics).IsEmpty(); + await Assert.That(output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + var generated = first.GeneratedSources.Where(s => s.HintName.EndsWith("GeneratedMappings.g.cs")).ToArray(); + await Assert.That(generated.Length).IsEqualTo(1); + await Assert.That(generated[0].SourceText.ToString()).IsEqualTo(second.GeneratedSources.Single(s => s.HintName == generated[0].HintName).SourceText.ToString()); + } + + [Test] + public async Task AM0021_is_registered_and_AM0020_remains_absent() + { + await Assert.That(AlephMapper.Diagnostics.DiagnosticDescriptors.GetById("AM0021")).IsNotNull(); + await Assert.That(AlephMapper.Diagnostics.DiagnosticDescriptors.GetById("AM0020")).IsNull(); + } + + [Test] + public async Task Escaped_receiver_and_factory_argument_names_compile() + { + var (result, output) = await Generate("using AlephMapper; public static partial class Ext { extension(int @event) { [Projectable] public int Value(int @class) => @event + @class; } }"); + await Assert.That(result.Diagnostics).IsEmpty(); + await Assert.That(output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + } + + [Test] + [Arguments("private static int Offset => 3;", "Offset")] + [Arguments("private static int Offset() { return 3; }", "Offset()")] + [Arguments("private static int Offset => 3;", "Ext.Offset")] + [Arguments("private static int Offset() { return 3; }", "Ext.Offset()")] + [Arguments("private static int Offset() { return 3; }", "Offset()")] + [Arguments("private static int Offset() { return 3; }", "Ext.Offset()")] + public async Task Inaccessible_extension_dependencies_are_diagnosed(string declaration, string use) + { + var (result, output) = await Generate("using AlephMapper; public static partial class Ext { " + declaration + " extension(int n) { [Projectable] public int Value => n + " + use + "; } } public static partial class Mapper { [Projectable] public static int Map(int n) => n.Value; }"); + await Assert.That(result.Diagnostics.Any(d => d.Id == "AM0021")).IsTrue(); + await Assert.That(output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + } + + [Test] + public async Task Unsupported_extension_update_request_is_not_silently_ignored() + { + var (result, _) = await Generate("using AlephMapper; public static partial class Ext { extension(int n) { [Updatable] public int Value() => n+1; } }"); + await Assert.That(result.Diagnostics.Any(d => d.Id == "AM0021")).IsTrue(); + } + + [Test] + public async Task Bound_extension_method_groups_report_diagnostic() + { + var (result, output) = await Generate("using AlephMapper; using System.Linq; public static partial class Ext { extension(string n) { public int Add(int x) => n.Length+x; } [Projectable] public static int[] Map(int[] input) => input.Select(string.Empty.Add).ToArray(); }"); + await Assert.That(output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + await Assert.That(result.Diagnostics.Any(d => d.Id == "AM0021")).IsTrue(); + } + + [Test] + public async Task Extension_members_resolve_by_symbol_across_namespaces() + { + const string source = """ + using AlephMapper; + using A; + using B; + namespace A { + public class Left { public int Number {get;set;} } + public static partial class Ext { extension(Left n) { [Projectable] public int Value => n.Number*2; } } + } + namespace B { + public class Right { public int Number {get;set;} } + public static partial class Ext { extension(Right n) { [Projectable] public int Value => n.Number*3; } } + } + public class Input { public Left Left {get;set;} public Right Right {get;set;} } + public static partial class Mapper { [Projectable] public static int Map(Input input) => input.Left.Value + input.Right.Value; } + """; + var (result, output) = await Generate(source); + await Assert.That(result.Diagnostics).IsEmpty(); + await Assert.That(output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + await Assert.That(result.GeneratedSources.Single(s => s.HintName == "Mapper_GeneratedMappings.g.cs").SourceText.ToString()) + .DoesNotContain(".Value"); + } + + [Test] + public async Task NullConditional_None_keeps_its_diagnostic_for_extension_members() + { + var (result, output) = await Generate("using AlephMapper; public class Node {} public static partial class Ext { extension(Node n) { public int Score => 1; } [Projectable(NullConditionalRewrite=NullConditionalRewrite.None)] public static int? Map(Node n) => n?.Score; }"); + await Assert.That(result.Diagnostics.Any(d => d.Id == "AM0017")).IsTrue(); + await Assert.That(output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + } + + [Test] + public async Task Missing_partial_outer_class_has_actionable_diagnostic() + { + var (result, output) = await Generate("using AlephMapper; public static class Ext { extension(int n) { [Projectable] public int Value => n+1; } }"); + await Assert.That(result.Diagnostics.Any(d => d.Id == "AM0021" && d.GetMessage().Contains("partial"))).IsTrue(); + await Assert.That(output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + } + + [Test] + public async Task CSharp14_compiler_rejects_nameof_extension_member_in_original_source() + { + var compilation = await Compile("public class Node {} public static class Ext { extension(Node n) { public int Value => 1; } public static string Name(Node n) => nameof(n.Value); }"); + await Assert.That(compilation.GetDiagnostics().Any(d => d.Id == "CS9316")).IsTrue(); + } + + internal static async Task<(GeneratorRunResult Result, Compilation Output)> Generate(string source) + { + var compilation = await Compile(source); + GeneratorDriver driver = CSharpGeneratorDriver.Create([new AlephSourceGenerator().AsSourceGenerator()], + parseOptions: new CSharpParseOptions(LanguageVersion.CSharp14)); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var output, out _); + return (driver.GetRunResult().Results.Single(), output); + } + + internal static async Task Compile(string source) + { + var references = await ReferenceAssemblies.Net.Net90.ResolveAsync(LanguageNames.CSharp, CancellationToken.None); + return CSharpCompilation.Create("ExtensionTests" + Guid.NewGuid().ToString("N"), + source.Split("// file boundary").Select((text, index) => CSharpSyntaxTree.ParseText(text, new CSharpParseOptions(LanguageVersion.CSharp14), path: "source" + index + ".cs")), references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } +} diff --git a/tests/AlephMapper.Tests/Files/AdaptBoth/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/AdaptBoth/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/AdaptBoth/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/AdaptBoth/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/AdaptNested/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/AdaptNested/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/AdaptNested/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/AdaptNested/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/AdaptUpdate/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/AdaptUpdate/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/AdaptUpdate/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/AdaptUpdate/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/AnySourcesGeneratesAttribute/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/AnySourcesGeneratesAttribute/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/AnySourcesGeneratesAttribute/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/AnySourcesGeneratesAttribute/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/CircularMapper/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/CircularMapper/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/CircularMapper/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/CircularMapper/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/CircularPropertyMapper/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/CircularPropertyMapper/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/CircularPropertyMapper/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/CircularPropertyMapper/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/CollectionMapper/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/CollectionMapper/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/CollectionMapper/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/CollectionMapper/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/ConditionalPatternMapper/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/ConditionalPatternMapper/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/ConditionalPatternMapper/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/ConditionalPatternMapper/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/EfCoreMapper/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/EfCoreMapper/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/EfCoreMapper/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/EfCoreMapper/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/Expressive/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/Expressive/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/Expressive/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/Expressive/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/ExtensionMethodInlining/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/ExtensionMethodInlining/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/ExtensionMethodInlining/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/ExtensionMethodInlining/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/Mapper/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/Mapper/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/Mapper/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/Mapper/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/MethodGroupSpec/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/MethodGroupSpec/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/MethodGroupSpec/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/MethodGroupSpec/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/MethodGroupToEntityList/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/MethodGroupToEntityList/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/MethodGroupToEntityList/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/MethodGroupToEntityList/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/MultiParamExtensionInlining/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/MultiParamExtensionInlining/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/MultiParamExtensionInlining/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/MultiParamExtensionInlining/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/MultiParameterInlining/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/MultiParameterInlining/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/MultiParameterInlining/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/MultiParameterInlining/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/NestedConditionalExtensionInlining/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/NestedConditionalExtensionInlining/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/NestedConditionalExtensionInlining/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/NestedConditionalExtensionInlining/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/NullCoalesceValueAssignment/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/NullCoalesceValueAssignment/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/NullCoalesceValueAssignment/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/NullCoalesceValueAssignment/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/NullConditionalRewrite/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/NullConditionalRewrite/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/NullConditionalRewrite/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/NullConditionalRewrite/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/NullableDisabled/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/NullableDisabled/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/NullableDisabled/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/NullableDisabled/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/NullableEnabled/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/NullableEnabled/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/NullableEnabled/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/NullableEnabled/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/TechDebtFix/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/TechDebtFix/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/TechDebtFix/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/TechDebtFix/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/TestModel1Mapper/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/TestModel1Mapper/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/TestModel1Mapper/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/TestModel1Mapper/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/Updatable/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/Updatable/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/Updatable/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/Updatable/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/AlephMapper.Tests/Files/ValueTypesAndReferenceTypes/Expected/AlephMapper.Attributes.g.cs b/tests/AlephMapper.Tests/Files/ValueTypesAndReferenceTypes/Expected/AlephMapper.Attributes.g.cs index b810543..477291f 100644 --- a/tests/AlephMapper.Tests/Files/ValueTypesAndReferenceTypes/Expected/AlephMapper.Attributes.g.cs +++ b/tests/AlephMapper.Tests/Files/ValueTypesAndReferenceTypes/Expected/AlephMapper.Attributes.g.cs @@ -40,7 +40,7 @@ internal enum NullConditionalRewrite /// Marks a class to generate projectable companion methods. /// [Embedded] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = false, Inherited = false)] internal sealed class ProjectableAttribute : Attribute { /// diff --git a/tests/PackageCompatibility/Consumer.csproj b/tests/PackageCompatibility/Consumer.csproj new file mode 100644 index 0000000..b886bd8 --- /dev/null +++ b/tests/PackageCompatibility/Consumer.csproj @@ -0,0 +1,29 @@ + + + Exe + net8.0 + $(CompatibilityLanguageVersion) + enable + enable + false + $(WarningsAsErrors);CS9057;CS8032;CS8784;CS8785;AD0001 + $(DefineConstants);EXTENSION_MEMBERS + + + + + + + + <_AlephMapperAnalyzer Include="@(Analyzer)" Condition="'%(Filename)' == 'AlephMapper'" /> + + + <_AlephMapperAnalyzerPath>@(_AlephMapperAnalyzer) + + + + + + diff --git a/tests/PackageCompatibility/Program.cs b/tests/PackageCompatibility/Program.cs new file mode 100644 index 0000000..4315324 --- /dev/null +++ b/tests/PackageCompatibility/Program.cs @@ -0,0 +1,91 @@ +using AlephMapper; + +public class Source +{ + public string Name { get; set; } = ""; + public int Number { get; set; } + public int[] Items { get; set; } = []; +} +public class OtherSource { public string Name { get; set; } = ""; public int Number { get; set; } } +public class Destination { public string Name { get; set; } = ""; public int Number { get; set; } } +public class OtherDestination { public string Name { get; set; } = ""; public int Number { get; set; } } +public class Animal { public int Score { get; set; } } +public class Dog : Animal { } + +public static partial class Mapper +{ + [Projectable, Updatable] + public static Destination Map(Source source) => new() { Name = source.Name, Number = source.Number }; + + [Adapt(typeof(OtherSource), typeof(OtherDestination), Name = "MapOther")] + public static Destination AdaptTemplate(Source source) => new() { Name = source.Name, Number = source.Number }; + + [Projectable] public static int Grade(int? value) => value switch + { null => -1, >= 3 and <= 5 => 2, > 0 => 1, _ => 0 }; + [Projectable] public static int TypeRank(Animal? value) => value switch + { Dog dog when dog.Score > 2 => dog.Score, Animal animal => animal.Score, _ => -1 }; + [Projectable] public static int Property(Source? value) => value switch + { { Number: > 0, Name: not null } => value.Number, _ => -1 }; + [Projectable] public static int Slice(int[]? values) => values switch + { [var first, .., var last] when first > 0 => first + last, [] => 0, _ => -1 }; + [Projectable] public static int Helper(Source value) => Twice(value.Number + 1) * 2; + private static int Twice(int n) => n + n; + [Projectable] public static int ClassicExtension(this int n) => n * 3; + [Projectable] public static int ThroughClassic(Source value) => value.Number.ClassicExtension(); + [Projectable] public static int NonExhaustive(int value) => value switch { 1 => 1 }; +} + +#if EXTENSION_MEMBERS +public static partial class Extensions +{ + extension(int value) + { + [Projectable] public int Squared => value * value; + [Projectable] public int Add(int amount) => value + amount; + } + [Projectable] public static int Composed(Source value) => value.Number.Squared + value.Number.Add(3); +} +#endif + +public static class Program +{ + private static void Equal(T actual, T expected) + { + if (!EqualityComparer.Default.Equals(actual, expected)) + throw new InvalidOperationException($"Expected {expected}, got {actual}"); + } + + public static void Main() + { + foreach (var n in new[] { -1, 0, 1, 3, 5, 6 }) + { + var source = new Source { Name = "Ada", Number = n, Items = [1, 2, 3] }; + var mapped = Mapper.MapExpression().Compile()(source); + Equal(mapped.Name, Mapper.Map(source).Name); + Equal(mapped.Number, n); + var destination = new Destination(); + Mapper.Map(source, destination); + Equal(destination.Number, n); + Equal(destination.Name, "Ada"); + var other = Mapper.MapOtherExpression().Compile()(new OtherSource { Name = "Grace", Number = n }); + Equal(other.Name, "Grace"); + Equal(other.Number, n); + Equal(Mapper.GradeExpression().Compile()(n), Mapper.Grade(n)); + Equal(Mapper.PropertyExpression().Compile()(source), Mapper.Property(source)); + Equal(Mapper.HelperExpression().Compile()(source), Mapper.Helper(source)); + Equal(Mapper.ThroughClassicExpression().Compile()(source), Mapper.ThroughClassic(source)); +#if EXTENSION_MEMBERS + Equal(Extensions.SquaredExpression().Compile()(n), n.Squared); + Equal(Extensions.AddExpression(3).Compile()(n), n.Add(3)); + Equal(Extensions.ComposedExpression().Compile()(source), Extensions.Composed(source)); +#endif + } + Equal(Mapper.GradeExpression().Compile()(null), Mapper.Grade(null)); + Equal(Mapper.PropertyExpression().Compile()(null), Mapper.Property(null)); + foreach (var value in new Animal?[] { null, new Animal { Score = 1 }, new Dog { Score = 4 } }) + Equal(Mapper.TypeRankExpression().Compile()(value), Mapper.TypeRank(value)); + foreach (var values in new int[]?[] { null, [], [1], [1, 3], [1, 2, 3], [-1, 3] }) + Equal(Mapper.SliceExpression().Compile()(values), Mapper.Slice(values)); + Console.WriteLine("Package compatibility runtime checks passed."); + } +}