Skip to content

Commit 9679373

Browse files
authored
perf: halve the generator's time and allocations per pass (#335)
1 parent 943f924 commit 9679373

564 files changed

Lines changed: 9505 additions & 37608 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.editorconfig‎

Lines changed: 2071 additions & 466 deletions
Large diffs are not rendered by default.

‎.gitattributes‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Auto-detect text files and normalise line endings to LF in the repository.
2+
# Working trees check out LF on every platform (auto, no forced CRLF).
3+
* text=auto eol=lf
4+
5+
# Source code
6+
*.cs text diff=csharp
7+
*.xaml text
8+
*.c text
9+
*.h text
10+
*.cpp text
11+
*.hpp text
12+
13+
# Projects and build
14+
*.slnx text
15+
*.sln text
16+
*.csproj text
17+
*.vcxproj text
18+
*.props text
19+
*.targets text
20+
*.msbuild text
21+
*.config text
22+
*.json text
23+
*.xml text
24+
*.yml text
25+
*.yaml text
26+
*.runsettings text
27+
*.editorconfig text
28+
29+
# Documentation and scripts
30+
*.md text
31+
*.markdown text
32+
*.txt text
33+
*.tt text
34+
*.sh text
35+
*.ps1 text
36+
*.cmd text
37+
*.bat text
38+
39+
# Binary files that should not be normalized or diffed
40+
*.png binary
41+
*.jpg binary
42+
*.bmp binary
43+
*.gif binary
44+
*.ico binary
45+
*.rc binary
46+
47+
*.pfx binary
48+
*.snk binary
49+
*.dll binary
50+
*.exe binary
51+
*.lib binary
52+
*.exp binary
53+
*.pdb binary
54+
*.sdf binary
55+
*.7z binary

‎.github/workflows/ci-build.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ permissions:
1616

1717
jobs:
1818
build:
19-
uses: reactiveui/actions-common/.github/workflows/workflow-common-setup-and-build.yml@main
19+
uses: reactiveui/actions-common/.github/workflows/workflow-common-setup-and-build.yml@b285525c4af878b0fe3fbb00d9f2ae862e402395 # main
2020
with:
2121
productNamespacePrefix: Splat.DependencyInjection
2222
secrets:

‎.github/workflows/codeql.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ permissions:
1616

1717
jobs:
1818
codeql:
19-
uses: reactiveui/actions-common/.github/workflows/workflow-common-codeql.yml@main
19+
uses: reactiveui/actions-common/.github/workflows/workflow-common-codeql.yml@b285525c4af878b0fe3fbb00d9f2ae862e402395 # main
2020
with:
2121
srcFolder: src
2222
solutionFile: Splat.DI.SourceGenerator.slnx

‎.github/workflows/release.yml‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ permissions:
2525

2626
jobs:
2727
release:
28-
uses: reactiveui/actions-common/.github/workflows/workflow-common-release.yml@main
28+
uses: reactiveui/actions-common/.github/workflows/workflow-common-release.yml@b285525c4af878b0fe3fbb00d9f2ae862e402395 # main
2929
with:
3030
solutionFile: Splat.DI.SourceGenerator.slnx
3131
minverMinimumMajorMinor: '2.3'
@@ -45,12 +45,12 @@ jobs:
4545
id-token: write
4646
steps:
4747
- name: Download signed packages
48-
uses: actions/download-artifact@v8
48+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
4949
with:
5050
name: signed-nuget
5151

5252
- name: Setup .NET
53-
uses: actions/setup-dotnet@v5
53+
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
5454

5555
- name: NuGet login (OIDC trusted publishing)
5656
id: nuget-login
@@ -67,7 +67,7 @@ jobs:
6767
6868
create-release:
6969
needs: [release, publish-nuget]
70-
uses: reactiveui/actions-common/.github/workflows/workflow-common-create-release.yml@main
70+
uses: reactiveui/actions-common/.github/workflows/workflow-common-create-release.yml@b285525c4af878b0fe3fbb00d9f2ae862e402395 # main
7171
with:
7272
version: ${{ needs.release.outputs.semver2 }}
7373
tag: ${{ needs.release.outputs.tag }}

‎CLAUDE.md‎

Lines changed: 92 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ dotnet restore Splat.DI.SourceGenerator.slnx
2929
# Build the solution
3030
dotnet build Splat.DI.SourceGenerator.slnx -c Release
3131
32-
# Build with warnings as errors (includes StyleCop violations)
32+
# Build with warnings as errors (includes StyleSharp/PerformanceSharp/SecuritySharp violations)
3333
dotnet build Splat.DI.SourceGenerator.slnx -c Release -warnaserror
3434
3535
# Clean the solution
@@ -149,14 +149,23 @@ See https://tunit.dev/docs/reference/command-line-flags for complete TUnit flag
149149
Splat.DI.SourceGenerator is a high-performance C# source generator that produces compile-time dependency injection registrations for Splat. It eliminates runtime reflection, provides full native AOT support, and includes intelligent analyzers with automatic code fixes.
150150

151151
**Generator Project (`Splat.DependencyInjection.SourceGenerator/`)**
152-
- `Generator.cs` - IIncrementalGenerator entry point with CreateSyntaxProvider pipeline
153-
- `Models/` - Value-equatable POCO records (no ISymbol/SyntaxNode references)
154-
- `RegistrationInfo.cs` - Base record for all registration types
155-
- `TransientRegistrationInfo.cs`, `LazySingletonRegistrationInfo.cs`, `ConstantRegistrationInfo.cs`
156-
- `ConstructorParameter.cs`, `PropertyInjection.cs`
157-
- `EquatableArray.cs` - Value-equatable array wrapper for pipeline caching
158-
- `CodeGeneration/CodeGenerator.cs` - String-based code generation (not SyntaxFactory)
159-
- `Constants.cs` - Attribute definitions with `[Embedded]` attribute
152+
- `Generator.cs` - IIncrementalGenerator entry point: one syntax provider for both marker methods, then a split
153+
into a code output (registrations only) and a diagnostics output (registrations with locations)
154+
- `RoslynHelpers.cs` - The syntax predicate and small symbol helpers
155+
- `MetadataExtractor.cs` - The transform: turns a bound marker call into a `RegistrationSite`
156+
- `WellKnownSymbols.cs` - The symbols extraction compares against, cached per compilation in a `ConditionalWeakTable`
157+
- `RegistrationValidator.cs` - SPLATDI005/006/007, which need the whole registration graph
158+
- `Models/` - Value-equatable pipeline models (no ISymbol, SyntaxNode or Location)
159+
- `RegistrationInfo.cs` - Everything the generated code needs for one registration (`RegistrationKind` says which)
160+
- `RegistrationSite.cs` - A registration plus its `LocationInfo`, for the diagnostics
161+
- `LocationInfo.cs` - A location held as values (path, span, line span)
162+
- `ConstructorParameter.cs`, `PropertyInjection.cs` - `readonly record struct`s
163+
- `EquatableArray.cs` - Value-equatable array wrapper with a cached hash, read by index
164+
- `CodeGeneration/` - Emission through `SourceWriter` (see Writing Generated Code)
165+
- `CodeGenerator.cs` - The registrations file
166+
- `MarkerSource.cs` - The fixed marker methods and attributes, added in post-initialization
167+
- `SourceWriter.cs`, `SourceWriterExtensions.cs`, `PooledBuilder.cs` - The writer and its pooled builder
168+
- `Constants.cs` - Names matched against and file names (linked into the analyzer, so constants only)
160169
- `DiagnosticWarnings.cs` - Shared diagnostic descriptors
161170

162171
**Analyzer Project (`Splat.DependencyInjection.Analyzer/`)**
@@ -166,31 +175,67 @@ Splat.DI.SourceGenerator is a high-performance C# source generator that produces
166175
- `CodeFixes/PropertyCodeFixProvider.cs` - Fixes property setter accessibility
167176

168177
**Test Projects**
169-
- `Splat.DependencyInjection.SourceGenerator.Tests/` - 342 snapshot tests using Verify.SourceGenerators
170-
- `Splat.DependencyInjection.Analyzer.Tests/` - 45 analyzer and code fix tests
178+
- `Splat.DependencyInjection.SourceGenerator.Tests/` - Snapshot tests (`GeneratorSnapshot`) plus unit tests of
179+
the pipeline, extraction, validation, emission and caching
180+
- `Splat.DependencyInjection.Analyzer.Tests/` - Analyzer and code fix tests
181+
182+
**Benchmarks (`benchmarks/Splat.DI.SourceGenerator.Benchmarks/`)** - see `benchmarks/README.md`
171183

172184
### Key Architectural Patterns
173185

174186
**Incremental Generator Pipeline (IIncrementalGenerator)**
175-
- **Predicate functions** - Fast syntax-only checks (e.g., `IsRegisterInvocation`)
176-
- **Transform functions** - Semantic analysis + POCO extraction (no ISymbol in output!)
177-
- **Generation functions** - String-based code output using StringBuilder
187+
- **Predicate** (`RoslynHelpers.IsRegistrationInvocation`) - syntax only, allocation free. Every marker is generic
188+
and cannot infer its type arguments, and none takes a lambda, so calls without a type argument list or with a
189+
lambda argument (Splat's own `resolver.Register<T>(() => ...)`) never reach the transform.
190+
- **Transform** (`MetadataExtractor.Extract`) - binds the call. A `CreateSyntaxProvider` transform re-runs for every
191+
matching node on every compilation change, so it turns calls away as early and cheaply as it can.
192+
- **Outputs** - the generated file is built from `RegistrationInfo` alone, so an edit that only moves a registration
193+
regenerates nothing. The graph diagnostics are reported from their own output, which also carries the locations.
194+
With no registrations, no registrations file is added (the partial method is removed by the compiler).
195+
- Steps carry tracking names (`Generator.SitesStep` and so on); `GeneratorTests` asserts what an edit re-runs.
178196

179197
**Value-Equatable Models (Critical for Caching)**
180-
- ALL pipeline models must implement `IEquatable<T>`
181-
- NEVER include ISymbol or SyntaxNode references in pipeline outputs
198+
- Pipeline models are `sealed record` or `readonly record struct` types with value equality
199+
- NEVER include ISymbol, SyntaxNode or `Location` in pipeline outputs; use `LocationInfo`
182200
- Use `EquatableArray<T>` for array equality in records
183201
- Extract strings from symbols using `ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)`
202+
- Records on netstandard2.0 need `IsExternalInit`, which is inlined in `src/Polyfills/` and linked into the
203+
generator and analyzer projects, as elsewhere in rxui
184204

185205
**Generic-First API (AOT Compatible)**
186206
- Generated code uses `resolver.Register<T>()` instead of `Register(factory, typeof(T))`
187207
- Eliminates boxing for value types
188208
- Full Native AOT and trimming support
189209

190-
**Code Generation Strategy**
191-
- Uses StringBuilder with raw string literals (`"""`)
192-
- 3-5x faster than SyntaxFactory approach
193-
- No dependency on ReactiveMarbles.RoslynHelpers or ILRepack
210+
**What the generated code allocates**
211+
- Resolving a service allocates the objects it constructs and nothing else from the generated code; Splat's
212+
`GetServices` collection is Splat's. A missing dependency throws through a `ThrowNotRegistered` helper written once
213+
per file, so factories stay small.
214+
- A factory that needs nothing from the resolver captures nothing, so its delegate is cached once by the compiler.
215+
- `SetupIOC` allocates one closure (for `resolver` and every lazy), one delegate per capturing registration, and for
216+
each lazy singleton its `Lazy<T>` and three delegates. Lazies are method-level locals (`lazy0`, `lazy1`, ...), not
217+
locals of a block per registration: a block adds a closure object each.
218+
219+
**Interceptors are not used.** The marker calls are empty methods that registration never runs through, and
220+
`SetupIOC` is a direct call to the generated method, so there is no call to redirect. Intercepting the marker calls
221+
would change when and on which resolver registration happens.
222+
223+
### Writing Generated Code
224+
225+
`SourceWriter` owns the layout of every generated file. An emitter says what it writes, and the writer decides the
226+
indentation.
227+
228+
- **The writer tracks the level.** A line is indented when its first character is written. A blank line carries no
229+
whitespace. Every line ends with `\n`. No string literal in an emitter starts with spaces.
230+
- **Blocks change the level.** `OpenBlock` and `CloseBlock` write the braces and move one level in and out.
231+
`OpenContinuation`/`CloseContinuation` put the arguments of a multi-line call one level deeper.
232+
- **Each emitter method writes at the level it is given** and leaves it as it found it; its doc comment says where
233+
it leaves the writer.
234+
- **C# constructs have names** in `SourceWriterExtensions` (namespaces, continuations, argument separators). Domain
235+
shapes live in `CodeGenerator` (`AppendFactoryCall`, `AppendFactory`, `AppendService`).
236+
- **Fixed text is written with `Lines`** from a raw string literal whose indentation is relative to column zero.
237+
- `SourceWriter.Rent` takes its `StringBuilder` from `PooledBuilder`'s per-thread slot, and `ToStringAndReturn`
238+
gives it back.
194239

195240
**Analyzer Separation (Roslyn Best Practice)**
196241
- Generator focuses on code generation only
@@ -213,8 +258,11 @@ Splat.DI.SourceGenerator is a high-performance C# source generator that produces
213258
### Style Enforcement
214259

215260
- EditorConfig rules (`.editorconfig`) - comprehensive C# formatting and naming conventions
216-
- StyleCop Analyzers - builds fail on violations
217-
- Roslynator Analyzers - additional code quality rules
261+
- StyleSharp.Analyzers (SST), PerformanceSharp.Analyzers (PSH) and SecuritySharp.Analyzers (SES) - builds fail on
262+
violations. Rule docs: https://github.com/glennawatson/RoslynCommonAnalyzers/tree/main/docs/rules
263+
- Fix the code. Do not suppress rules, do not disable them in `.editorconfig`, and do not use
264+
`#pragma warning disable` (the `#pragma` for obsolete members inside the generated output is part of what the
265+
generator emits, not our source)
218266
- Analysis level: latest with enhanced .NET analyzers
219267
- **All public APIs require XML documentation comments** (including protected methods of public classes)
220268

@@ -239,7 +287,15 @@ See `.github/COPILOT_INSTRUCTIONS.md` for complete style guide.
239287
- Test projects: `Splat.DependencyInjection.Analyzer.Tests` and `Splat.DependencyInjection.SourceGenerator.Tests`
240288
- Coverage configured in `src/testconfig.json` (Cobertura format)
241289
- Parallel test execution disabled (`"parallel": false` in testconfig.json)
242-
- Snapshot testing uses Verify.SourceGenerators with `*.verified.cs` files
290+
- Snapshot tests compare every generated registrations file with a `*.verified.cs` snapshot beside the test class,
291+
through `GeneratorSnapshot.cs`. A snapshot is named `{type}.{method}_{arguments}#{hint name}.verified.cs`; generator
292+
diagnostics go to `...#Diagnostics.verified.txt`. The fixed marker source and embedded attribute are stored once, by
293+
`SnapshotTests.WritesFixedSources`. An output that differs or has no snapshot is written beside it as `.received`
294+
and fails the test, as does a snapshot the run no longer produces
295+
- To accept new or changed snapshots, run the tests with `ACCEPT_SNAPSHOTS=1`, then run them again without it
296+
- Snapshot paths must fit the Windows path limit on a CI runner (`D:\a\{repo}\{repo}\...`, under 256 characters);
297+
`SnapshotTests.SnapshotPathsFitWindowsLimit` checks this, so keep test names and arguments short
298+
- Line endings are LF everywhere (`.gitattributes`); the analyzers fail the build on CRLF
243299
- Always write unit tests for new features or bug fixes
244300
- Follow existing test patterns in test projects
245301
- Use `TestUtilities.AreEquivalent()` for newline-agnostic source code comparison
@@ -259,9 +315,9 @@ See `.github/COPILOT_INSTRUCTIONS.md` for complete style guide.
259315
2. **Create value-equatable POCOs** - no ISymbol/SyntaxNode references
260316
3. Create failing tests first (snapshot tests in SourceGenerator.Tests)
261317
4. Implement minimal functionality in Generator.cs
262-
5. Update code generation in CodeGenerator.cs (use StringBuilder, not SyntaxFactory)
318+
5. Update code generation in CodeGenerator.cs through `SourceWriter` (not a raw StringBuilder, not SyntaxFactory)
263319
6. Ensure generic-first API usage: `resolver.Register<T>()` not `typeof(T)`
264-
7. Verify snapshots match expected output (C# 7.3 compatible)
320+
7. Verify snapshots match expected output; a passing snapshot test also requires the generated code to compile
265321
8. Add XML documentation to all public APIs
266322
9. Run formatting validation before committing
267323

@@ -277,27 +333,28 @@ See `.github/COPILOT_INSTRUCTIONS.md` for complete style guide.
277333

278334
### Fixing Bugs
279335

280-
1. Create reproduction test (use Verify snapshots)
336+
1. Create reproduction test (use snapshots)
281337
2. Fix with minimal changes
282338
3. Ensure pipeline still caches properly (POCOs value-equatable)
283339
4. Verify no regression in existing tests
284340
5. Accept snapshot changes if expected
285341

286342
### Updating Generated Code Format
287343

288-
1. Modify code generation in `CodeGeneration/CodeGenerator.cs`
289-
2. Use raw string literals (`"""`) for multi-line code
344+
1. Modify code generation in `CodeGeneration/CodeGenerator.cs` through `SourceWriter`
345+
2. Write fixed multi-line text as raw string literals (`"""`) passed to `Lines`
290346
3. Ensure generic-first API: `resolver.Register<T>()` not `typeof(T)`
291-
4. Run all snapshot tests - expect 342 failing tests
292-
5. Review each `.verified.cs` diff carefully
293-
6. Accept snapshots only if changes are correct
294-
7. Ensure C# 7.3 compatibility (no file-scoped namespaces, no init properties)
347+
4. Run all snapshot tests - expect every `Splat.DI.Reg.g` snapshot to fail
348+
5. Review each received file carefully and accept (`ACCEPT_SNAPSHOTS=1`) only if the changes are correct
349+
6. Keep file-scoped namespaces, init properties and similar newer syntax out of the generated code
350+
7. Measure with the benchmarks (see `benchmarks/README.md`)
295351

296352
## What to Avoid
297353

298354
- **ISymbol/SyntaxNode in pipeline outputs** - breaks incremental caching
299355
- **Runtime reflection** in generated code - breaks AOT compatibility
300-
- **SyntaxFactory for code generation** - 3-5x slower than StringBuilder
356+
- **SyntaxFactory or a raw StringBuilder for code generation** - write through `SourceWriter`
357+
- **LINQ in the generator** - use loops; the transform runs for every marker call on every compilation change
301358
- **Type-based API** - use `resolver.Register<T>()` not `Register(factory, typeof(T))`
302359
- **Diagnostics in generator** - use separate analyzer project instead
303360
- **Heavy dependencies** - keep generator lightweight (netstandard2.0 target)
@@ -308,9 +365,9 @@ See `.github/COPILOT_INSTRUCTIONS.md` for complete style guide.
308365

309366
- **Value-Equatable POCOs:** CRITICAL for incremental generator caching - never include ISymbol/SyntaxNode
310367
- **Generic-First API:** All generated code must use `resolver.Register<T>()` for AOT compatibility
311-
- **String-Based Generation:** Use StringBuilder with raw string literals, not SyntaxFactory
368+
- **Generation:** Write through `SourceWriter`, not SyntaxFactory
312369
- **Separate Analyzer:** Diagnostics in separate project following Roslyn best practices
313-
- **No shallow clones:** Repository requires full clone for git version information used by Nerdbank.GitVersioning
370+
- **No shallow clones:** Repository requires full clone for git version information used by MinVer
314371
- **Required .NET SDKs:** .NET 8.0, 9.0, and 10.0 (all three required for full build)
315372
- **Snapshot Testing:** Review `.verified.cs` diffs carefully before accepting
316373
- **Comprehensive Instructions:** `.github/COPILOT_INSTRUCTIONS.md` contains detailed development guidelines

‎src/Directory.Build.props‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
<LangVersion>latest</LangVersion>
66
<Platform>AnyCPU</Platform>
77
<IsTestProject>$(MSBuildProjectName.Contains('Tests'))</IsTestProject>
8+
<!-- Matched on the singular so BenchmarkDotNet's generated per-job projects are included: they are
9+
written under a benchmark's output folder and so inherit this file. -->
10+
<IsBenchmarkProject>$(MSBuildProjectName.Contains('Benchmark'))</IsBenchmarkProject>
811
<DebugType>embedded</DebugType>
912
<Authors>Glenn Watson</Authors>
1013
<Copyright>Copyright (c) $([System.DateTime]::Now.ToString(yyyy)) ReactiveUI Association Inc</Copyright>
@@ -45,7 +48,7 @@
4548
</None>
4649
</ItemGroup>
4750

48-
<ItemGroup Condition="'$(IsTestProject)' != 'true'">
51+
<ItemGroup Condition="'$(IsTestProject)' != 'true' and '$(IsBenchmarkProject)' != 'true'">
4952
<PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All" />
5053
</ItemGroup>
5154

@@ -71,10 +74,8 @@
7174

7275
<ItemGroup>
7376
<PackageReference Include="MinVer" PrivateAssets="all" />
74-
<PackageReference Include="stylecop.analyzers" PrivateAssets="all" />
75-
<PackageReference Include="Roslynator.Analyzers" PrivateAssets="All" />
76-
</ItemGroup>
77-
<ItemGroup>
78-
<AdditionalFiles Include="$(MSBuildThisFileDirectory)stylecop.json" Link="stylecop.json" />
77+
<PackageReference Include="StyleSharp.Analyzers" PrivateAssets="all" />
78+
<PackageReference Include="PerformanceSharp.Analyzers" PrivateAssets="all" />
79+
<PackageReference Include="SecuritySharp.Analyzers" PrivateAssets="all" />
7980
</ItemGroup>
8081
</Project>

0 commit comments

Comments
 (0)