Skip to content

Commit 79958ac

Browse files
Merge pull request #14 from purview-dev/feat/expanding-codewriter
Feat/expanding codewriter
2 parents ef396ac + 713305c commit 79958ac

49 files changed

Lines changed: 6534 additions & 1403 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.

‎AGENTS.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,7 @@ This repository contains custom build rules, analysers, and source generators. A
55
## Warnings and Suggestions
66

77
Do **not** suppress or mute compiler, analyser, or build warnings by adding `<NoWarn>` entries, `#pragma warning disable`, or similar directives in code or project files without explicit user direction. Warnings and suggestions are the responsibility of the developer/user to evaluate and mute. If a warning is raised, surface it to the user and let them decide whether to suppress it.
8+
9+
## Documentation and Samples
10+
11+
Any API change must update the corresponding documentation, including XML doc comments and `docs/` pages such as `docs/code-writer.md`. Samples (the `SourceGeneratorFramework.ExampleGenerator` reference implementation and benchmarks) must use the current best-practice APIs: the minimal-parameter `CodeWriter` overloads, structured statements (`MethodCall`, `Return`, `Assignment`, `Throw`, `Comment`, `NetConditionalReturn`) rather than raw text, and the current method names. Add or update a sample whenever an API addition or change warrants a demonstrable example, and cover it with unit tests.

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
A set of libraries for building and testing incremental C# source generators using Roslyn.
44

5+
## Documentation
6+
7+
- [Source generator & analyser best practices](docs/guide.md)
8+
- [CodeWriter structured API reference](docs/code-writer.md)
9+
510
## Packages
611

712
| Package | Description | Packable |
@@ -45,6 +50,7 @@ normal reference with `ReferenceOutputAssembly="true"`. The complete pattern is
4550
The framework package includes `AttributeDataModelGenerator` (implemented in `Purview.SourceGeneratorFramework.Generators`), which generates `readonly record struct` parser models for .NET attributes. It removes the repetitive boilerplate of hand-writing `FromAttributeData` methods for every attribute you want to inspect in a source generator.
4651

4752
Supported features:
53+
4854
- Manual mapping of named arguments, constructor arguments by index, and constructor arguments by name
4955
- Auto-discovery of all constructor parameters and public named properties
5056
- Nested generated models (e.g., a shared `ValidationAttributeData` model reused inside `RequiredAttributeData`)

‎docs/code-writer.md‎

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
# CodeWriter
2+
3+
`CodeWriter` is the structured, allocation-conscious writer used to build generated C# source. Instead
4+
of concatenating strings or writing raw text, generators describe *what* to emit — declarations,
5+
statements, scopes — and the writer handles indentation, blank-line separation, generated attributes,
6+
and deterministic layout.
7+
8+
This page uses the current best-practice API: bare semantic names (`Class`, `Method`, `Property`), the
9+
minimal-parameter overloads with an optional `configure` callback, and structured statements
10+
(`Return`, `MethodCall`, `Assignment`) instead of raw text.
11+
12+
## Primitives
13+
14+
Use the raw primitives for low-level text that has no structured equivalent:
15+
16+
```csharp
17+
writer.Write("partial"); // no trailing line feed
18+
writer.Line("// generated"); // line feed appended
19+
writer.Append("text"); // Write alias
20+
writer.AppendLine("text"); // Line alias
21+
writer.Comment("Explains the next member.");
22+
writer.Indent(); // increase indentation
23+
writer.NewLine();
24+
```
25+
26+
`Write`/`Line`/`Append`/`AppendLine` are the only methods that retain a verb prefix: everything
27+
semantic drops it because the receiver is already a writer.
28+
29+
## Declarations
30+
31+
Each declaration writer has:
32+
33+
- a **minimal overload** taking name/type/accessibility plus an optional `configure` callback
34+
(`options => options with { ... }`); and
35+
- a **scope form** (`...Scope`) returning a `BlockScope` for `using` when you need fine-grained control.
36+
37+
```csharp
38+
writer.Class(
39+
"OrderService",
40+
TypeDeclarationAccessibility.Public,
41+
options => options with { IsSealed = true, IsPartial = false },
42+
body =>
43+
{
44+
body.Field("_total", TypeIdentity.Create<decimal>().AsTypeReference(), TypeDeclarationAccessibility.Private);
45+
46+
body.Constructor(
47+
"OrderService",
48+
TypeDeclarationAccessibility.Public,
49+
options => options with
50+
{
51+
Parameters = [new("total", TypeIdentity.Create<decimal>().AsTypeReference())],
52+
},
53+
constructorBody => constructorBody.Assignment("_total", "total")
54+
);
55+
56+
body.Property(
57+
"Total",
58+
TypeIdentity.Create<decimal>().AsTypeReference(),
59+
TypeDeclarationAccessibility.Public
60+
);
61+
}
62+
);
63+
```
64+
65+
The same pattern applies to `Struct`, `RecordClass`, `RecordStruct`, `Interface`, `Enum` (+ `EnumField`),
66+
`Type` (kind-driven), `Delegate`, `AttributeClass`, `Method`/`PartialMethod`/`MethodExpression`,
67+
`Property`/`PropertyExpression`, `Indexer`, `Field`, and `Operator`.
68+
69+
### Scope forms
70+
71+
```csharp
72+
using (writer.ClassScope("OrderService", TypeDeclarationAccessibility.Public))
73+
using (writer.MethodScope("Apply", PurviewTypeLibrary.System.Void, TypeDeclarationAccessibility.Public))
74+
{
75+
writer.MethodCall("Validate");
76+
}
77+
```
78+
79+
Scope forms are ideal when a declaration spans multiple calls, loops, or conditional content. The
80+
`using` statement is mandatory — the closing token and indentation are written on dispose, and the
81+
`DiscardedCodeWriterScopeAnalyzer` (PSGFR17) flags scope returns that are dropped.
82+
83+
## Statements
84+
85+
Emit executable statements through the structured statement methods rather than raw `Line`:
86+
87+
```csharp
88+
writer.MethodCall("Process", "item"); // Process(item);
89+
writer.AwaitedMethodCall("SaveAsync", "cancellationToken"); // await SaveAsync(cancellationToken);
90+
writer.MethodCallOn("variable", "Process", "item"); // variable.Process(item);
91+
writer.AwaitedMethodCallOn("service", "LoadAsync", "token"); // await service.LoadAsync(token);
92+
writer.Return("value"); // return value;
93+
writer.Throw(TypeIdentity.Create<InvalidOperationException>(), "Failed."); // throw new ...;
94+
writer.Assignment("_total", "value"); // _total = value;
95+
writer.IfBlock("value is null", body => body.Return("null"));
96+
writer.Foreach("var item in items", body => body.MethodCallOn("item", "Process"));
97+
```
98+
99+
`MethodCall`/`AwaitedMethodCall` write a call without a receiver — `Process(item);` or
100+
`await SaveAsync(token);`. Use `MethodCallOn`/`AwaitedMethodCallOn` (or the `receiver` parameter on the
101+
`IEnumerable` overloads) for a call on a variable, including generic arguments:
102+
103+
```csharp
104+
writer.MethodCall("Create", ["x"], receiver: "factory", genericArguments: [TypeReference.Create<string>()]);
105+
// factory.Create<string>(x);
106+
```
107+
108+
### Conditional compilation blocks
109+
110+
`HashDefines`/`HashDefinesScope` write a `#if`/`#endif` block with both directives at **column zero**.
111+
The body keeps the surrounding indentation — file-level directives and their content stay at column
112+
zero, while class members inside the block stay at the same indent as their siblings:
113+
114+
```csharp
115+
using (writer.HashDefinesScope("!EXCLUDE_PURVIEW_TELEMETRY_LOGGING"))
116+
{
117+
writer.FileScopedNamespace("Example");
118+
writer.Enum("Mode", TypeDeclarationAccessibility.Public, fields: [new("Default", 0)]);
119+
}
120+
121+
// Equivalent action form:
122+
writer.HashDefines("NET", body => body.Line("// NET only"));
123+
```
124+
125+
Emits:
126+
127+
```csharp
128+
#if !EXCLUDE_PURVIEW_TELEMETRY_LOGGING
129+
namespace Example;
130+
...
131+
#endif
132+
```
133+
134+
At file level these blocks are self-spacing: a blank line is ensured before the `#if` and after the
135+
`#endif`, so directive sections remain separated without explicit `NewLine()` calls.
136+
137+
`HashElse()` writes the `#else` directive at column zero between the two bodies:
138+
139+
```csharp
140+
using (writer.HashDefinesScope("NET48_OR_GREATER || PURVIEW_TELEMETRY_NON_NULLABLE"))
141+
{
142+
writer.Property("name", TypeIdentity.Create<string>().AsTypeReference(), TypeDeclarationAccessibility.Public,
143+
options => options with { HasSetter = true, IncludeGeneratedAttributes = false });
144+
writer.HashElse();
145+
writer.Property("name", TypeIdentity.Create<string>().MakeNullable(writer), TypeDeclarationAccessibility.Public,
146+
options => options with { HasSetter = true, IncludeGeneratedAttributes = false });
147+
}
148+
```
149+
150+
Emits:
151+
152+
```csharp
153+
#if NET48_OR_GREATER || PURVIEW_TELEMETRY_NON_NULLABLE
154+
public string name { get; set; }
155+
#else
156+
public string? name { get; set; }
157+
#endif
158+
```
159+
160+
`EmptyScope()` returns a no-op scope so a block can be wrapped only when a guard requires it:
161+
162+
```csharp
163+
using var scope = wrapInExcludeLoggingGuard
164+
? writer.EmptyScope()
165+
: writer.HashDefinesScope("EXCLUDE_PURVIEW_TELEMETRY_LOGGING");
166+
```
167+
168+
### Pragma warning suppression
169+
170+
`PragmaDisable` writes a single `#pragma warning disable` directive at column zero for one or more
171+
warning codes. At file level it is self-spacing (blank lines are ensured around the directive):
172+
173+
```csharp
174+
writer.PragmaDisable("CS8625", "CS0618");
175+
// #pragma warning disable CS8625 CS0618
176+
```
177+
178+
For a scoped disable that restores the warnings when the scope is disposed, use `OpenPragmasScope`:
179+
180+
```csharp
181+
using (writer.OpenPragmasScope("CS0618"))
182+
{
183+
writer.Line("ObsoleteCall();");
184+
}
185+
// #pragma warning disable CS0618
186+
// ObsoleteCall();
187+
// #pragma warning restore CS0618
188+
```
189+
190+
The full header pattern — nullable directive, conditional `#nullable enable`, and a disabled warning —
191+
can be expressed entirely through the structured APIs (the file-level directives are self-spacing, so
192+
no explicit `NewLine()` calls are needed):
193+
194+
```csharp
195+
writer.AutoGeneratedHeader(nullableDirective: NullableDirectiveMode.Disable);
196+
writer.HashDefines("!NET48_OR_GREATER && !PURVIEW_TELEMETRY_NON_NULLABLE", hashWriter => hashWriter.Line("#nullable enable"));
197+
writer.PragmaDisable("CS8625");
198+
writer.FileScopedNamespace("Purview.Telemetry");
199+
```
200+
201+
Emits:
202+
203+
```csharp
204+
// <auto-generated />
205+
// This code was generated by ExampleGenerator (version 1.0.0).
206+
// Changes to this file will be lost when the source generator runs again.
207+
208+
#if !NET48_OR_GREATER && !PURVIEW_TELEMETRY_NON_NULLABLE
209+
#nullable enable
210+
#endif
211+
212+
#pragma warning disable CS8625
213+
214+
namespace Purview.Telemetry;
215+
```
216+
217+
### Conditional compilation returns
218+
219+
`NetConditionalReturn` writes a `return` for an interpolated string using the best invariant-culture
220+
API on each target framework, guarded by `#if NET`:
221+
222+
```csharp
223+
writer.Method(
224+
"Format",
225+
TypeIdentity.Create<string>().AsTypeReference(),
226+
TypeDeclarationAccessibility.Public,
227+
null,
228+
body => body.NetConditionalReturn("Value: {_value}")
229+
);
230+
```
231+
232+
Emits:
233+
234+
```csharp
235+
#if NET
236+
return string.Create(global::System.Globalization.CultureInfo.InvariantCulture, $"Value: {_value}");
237+
#else
238+
return global::System.FormattableString.Invariant($"Value: {_value}");
239+
#endif
240+
```
241+
242+
## Default accessibility
243+
244+
`CodeWriter` applies a default accessibility for each member kind when a declaration does not specify
245+
one. Set the defaults on `GenerationSettings` (to apply across a generation) or on the writer itself
246+
(to override per writer). Each value is `null`-able, so setting a kind back to `null` omits the
247+
modifier entirely.
248+
249+
| Setting | Default |
250+
|---|---|
251+
| `DefaultTypeAccessibility` | `Public` |
252+
| `DefaultPropertyAccessibility` | `Public` |
253+
| `DefaultPropertyGetterAccessibility` | `Public` |
254+
| `DefaultPropertySetterAccessibility` | `Public` |
255+
| `DefaultFieldAccessibility` | `Private` |
256+
| `DefaultMethodAccessibility` | `Public` |
257+
| `DefaultConstructorAccessibility` | `Public` |
258+
| `DefaultIndexerAccessibility` | `Public` |
259+
| `DefaultOperatorAccessibility` | `Public` |
260+
261+
```csharp
262+
var writer = generationContext.CreateCodeWriter();
263+
writer.Field("_total", TypeReference.Create<decimal>()); // private int _total; (DefaultFieldAccessibility)
264+
writer.Property("Total", TypeReference.Create<decimal>()); // public decimal Total { get; }
265+
```
266+
267+
An explicit accessibility always wins over the default:
268+
269+
```csharp
270+
writer.Property("Total", TypeReference.Create<decimal>(), TypeDeclarationAccessibility.Internal);
271+
// internal decimal Total { get; }
272+
```
273+
274+
Accessor (getter/setter) defaults are emitted only when they are **more restrictive** than the
275+
property's own accessibility — C# forbids an accessor modifier that is equal to or more permissive
276+
than the property (CS0273). With the public defaults, a public property keeps bare `{ get; set; }`:
277+
278+
```csharp
279+
writer.DefaultPropertySetterAccessibility = TypeDeclarationAccessibility.Private;
280+
writer.Property("Name", TypeReference.Create<string>(), TypeDeclarationAccessibility.Public,
281+
options => options with { HasSetter = true });
282+
// public string Name { get; private set; }
283+
```
284+
285+
## Guidance
286+
287+
- Prefer the minimal overloads with a `configure` callback over constructing `*DeclarationOptions`
288+
values manually — the `PreferMinimalCodeWriterOverloadAnalyzer` (PSGFR20) flags the verbose form.
289+
- Prefer structured declarations and statements over raw text — `PreferStructuredCodeWriterApiAnalyzer`
290+
(PSGFR18) and `PreferStructuredCodeWriterStatementAnalyzer` (PSGFR19) flag raw emission.
291+
- Always consume scope-returning methods with `using` (PSGFR17).
292+
- Keep every value emitted through the structured API so layout stays deterministic and the analyzers
293+
can guide callers back to the best practice.
294+
295+
## Samples
296+
297+
The [`SourceGeneratorFramework.ExampleGenerator`](../src/src/SourceGeneratorFramework.ExampleGenerator)
298+
reference implementation demonstrates these APIs end-to-end, including the `CodeWriterSampleGenerator`,
299+
which compiles a best-practice sample class for every `[GenerateCodeWriterSample]` target.

‎src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ PSGFR15 | Purview.SourceGeneratorFramework | Warning | Pipeline model collection
1010
PSGFR16 | Purview.SourceGeneratorFramework | Info | Prefer the nullable-context overload
1111
PSGFR17 | Purview.SourceGeneratorFramework | Warning | Consume CodeWriter scopes with a using statement
1212
PSGFR18 | Purview.SourceGeneratorFramework | Info | Prefer a structured CodeWriter declaration API
13+
PSGFR19 | Purview.SourceGeneratorFramework | Info | Prefer a structured CodeWriter statement API
14+
PSGFR20 | Purview.SourceGeneratorFramework | Info | Prefer the minimal CodeWriter overload
15+
PSGFR21 | Purview.SourceGeneratorFramework | Info | Prefer HashDefines for conditional compilation
16+
PSGFR22 | Purview.SourceGeneratorFramework | Info | Prefer PragmaDisable for warning suppression
1317
ADM0001 | Target | Error | Target attribute type cannot be resolved
1418
ADM0002 | Property | Error | Property type is not supported for attribute extraction
1519
ADM0003 | Source | Error | Specified constructor index/name does not exist on the target attribute

0 commit comments

Comments
 (0)