Skip to content
  •  
  •  
  •  
140 changes: 140 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# GitHub Copilot Instructions for Vogen

This document is aimed at AI agents working on the **Vogen** repository. The
codebase is a .NET source‑generator + analyzer that wraps primitives in
strongly‑typed value objects. Understanding the structure, build/test
workflows, and project‑specific conventions will make an agent productive
quickly.

---

## 🚀 High‑Level Architecture

* **Core library** lives in `src/Vogen`. It contains the Roslyn
source‑generator and analyzer logic. Look under `Generators` for the
template code that emits structs/classes/records, and `Conversions` for the
plumbing that handles primitive conversions.
* **Code fixers** are in `src/Vogen.CodeFixers`; they plug into the validator
diagnostics produced by the analyzer.
* **Packaging** happens in `src/Vogen.Pack`; this project bundles the
analyzer/generator dlls for multiple Roslyn versions (`roslyn4.4`,
`4.6`, `4.8`, `4.11`, `4.12`) so consumers can reference the correct one.
* **Shared types** used by tests live in `src/Vogen.SharedTypes`; these are
compiled as metadata references when snapshotting across frameworks.
* **Sample/consumer apps** under `samples/*` and the `Consumers.sln` show
real‑world usage (WebApplication, OrleansExample, etc.).

The generator is invoked during normal `dotnet build` on a project that
contains `[ValueObject]` attributes. The analyzer emits `VOG###` diagnostics to
prevent invalid construction. Codefixers offer automatic fixes.

---

## 🗂 Repository Structure (key folders)

```
src/ ← core projects (Vogen, CodeFixers, Pack, SharedTypes)
tests/ ← unit, snapshot, analyzer and consumer tests
samples/ ← lightweight example applications
RunSnapshots.ps1← PowerShell helper for resetting/running snapshot tests
Build.ps1 ← full build-pack-test script used by CI and maintainers
CONTRIBUTING.md ← developer guidance (tests, thorough mode, etc.)
```

Snapshots live alongside generated code under `tests/SnapshotTests/snapshots`.
AnalyzerTests generate code in‑memory and assert diagnostics/solutions.
ConsumerTests are end‑to‑end projects that reference Vogen via NuGet (see
`Build.ps1` for how the local package is built and consumed).

---

## 🛠 Developer Workflows

1. **Quick build** – `dotnet build Vogen.sln -c Release` builds the generator,
analyzer, codefixers and tests. `Directory.Build.props` sets
`LangVersion=preview`, `TreatWarningsAsErrors`, and common suppression
flags.
2. **Snapshot tests** – run `.\RunSnapshots.ps1` from repo root. this
script cleans snapshot folders, builds with `-p Thorough=true` and runs
`dotnet test tests/SnapshotTests/SnapshotTests.csproj`. Pass
`-reset` to delete existing snapshots first. Add `-p THOROUGH` to the
build/test invocation for the full permutation set (used by CI).
3. **Analyzer tests** – `dotnet test tests/AnalyzerTests/AnalyzerTests.csproj`.
These compile generated snippets and assert diagnostics / codefixes. They
depend on `SnapshotTests` for the generated output.
4. **Consumer / sample validation** – run `Build.ps1` which:
* builds the core projects for each Roslyn version;
* packs `Vogen.Pack` into a local folder with a unique 999.X version;
* restores `Consumers.sln` using `nuget.private.config` pointing at the
local package;
* builds/tests samples and consumer tests in both Debug and Release;
* optionally rebuilds with `DefineConstants="VOGEN_NO_VALIDATION"`.
5. **Publishing** – `Build.ps1` ends by packing a release NuGet into
`./artifacts`.

> **Note:** pull requests should include updated snapshots if the
> generator output changes; run the reset script and commit the diffs.

---

## 🧪 Testing Conventions

* Tests target multiple TFMs; snapshot project is multi‑targeted
(`net461`, `netstandard2.0`, `net8.0` …) to ensure generated code works
everywhere. The code generating the tests lives in `tests/SnapshotTests`.
* The `THOROUGH` MSBuild property expands the permutation matrix
(struct/class/record/readonly, underlying types `int`, `string`, …,
conversions, etc.). It slows down local runs; CI always sets it.
* `AnalyzerTests` use XUnit attributes and in‑memory compilation helpers
(`CompilationHelper.cs`). They also check codefixers in
`Vogen.CodeFixers`.
* `ConsumerTests` are plain projects referencing the NuGet package; they are
rebuilt by `Build.ps1` to exercise packaging scenarios.
* `tests/Testbench` contains scratch code that developers use when
experimenting; not part of CI.

---

## 🔧 Project‑Specific Patterns

* **Generator templates:** strings assembled with `$@` inside classes like
`StructGenerator`, `ClassGenerator`, `RecordStructGenerator`. Helpers in
`Util.cs` and the `Conversions` namespace produce small fragments.
* **Attribute‑driven design:** `[ValueObject]` (in the `Vogen` namespace) is
the only public API for consumers. Additional configuration comes from
`[ValueObjectConverter]`, `[ValueObjectTypeAttribute]`, etc. `GenerationParameters`
and `VoWorkItem` carry the state through the generator.
* **Roslyn versioning:** the `RoslynVersion` MSBuild property is used in
`src/Vogen/Vogen.csproj` and `Vogen.CodeFixers.csproj` to produce assemblies
that target specific engine versions – this drives the packaging logic.
* **Compile-time switches:** `VOGEN_NO_VALIDATION` disables the runtime
validation code; used in consumer tests. `THOROUGH` and
`ResetSnapshots` control test behaviours.
* **`Nullable` handling:** the generator has explicit support for nullable
underlying types (`string?`, `int?`) using helpers like
`Nullable.QuestionMarkForUnderlying` and `WrapBlock`.
* **Performance-first:** generated types are designed to be as thin as
possible; tests under `tests/Vogen.Benchmarks` validate performance.
* **Style:** the codebase is very terse and uses C#8/9/10/11 features; maintain
consistency with existing files when adding or editing generators.

---

## 📌 Integration & External Dependencies

* The only external package references in core code are Roslyn (`Microsoft.CodeAnalysis`)
and test frameworks (xUnit, FluentAssertions, etc.).
* Consumer examples depend on ASP.NET, Orleans, Refit, ServiceStack, etc.
* CI pipeline (GitHub actions) invokes `Build.ps1` and `RunSnapshots.ps1`.

---

## 👀 Getting Help / Next Steps

If a section here is unclear or you'd like more detail (e.g. typical test
patterns, how conversions are added, build script flags), let me know and I
can elaborate or add examples.

---

*Last updated March 2026.*
5 changes: 5 additions & 0 deletions samples/Vogen.Examples/Types/PartialString.Json.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace Vogen.Examples.Types;

public partial class PartialString
{
}
7 changes: 7 additions & 0 deletions samples/Vogen.Examples/Types/PartialString.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Vogen.Examples.Types;

[ValueObject<string>]
public partial class PartialString
{

}
8 changes: 8 additions & 0 deletions samples/WebApplication/OrdersController.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
Expand Down Expand Up @@ -32,5 +33,12 @@ public IActionResult GetByOrderId(OrderId orderId)
return new NotFoundResult();
return Ok(order);
}

[HttpPost]
[Produces(typeof(Order))]
public IActionResult Post([FromBody]OrderId[] orderIds)
{
return Created();
}
}

14 changes: 7 additions & 7 deletions samples/WebApplication/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,19 @@


var builder = Microsoft.AspNetCore.Builder.WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();

#if USE_MICROSOFT_OPENAPI_AND_SCALAR
builder.Services.AddOpenApi((OpenApiOptions o) =>
{
o.MapVogenTypesInOpenApiMarkers();
});
#endif
#if USE_MICROSOFT_OPENAPI_AND_SCALAR
builder.Services.AddOpenApi((OpenApiOptions o) =>
{
o.MapVogenTypesInOpenApiMarkers();
});
#endif


#if USE_SWASHBUCKLE
// Add services to the container.
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(opt =>
{
// the following extension method is available if you specify `GenerateSwashbuckleMappingExtensionMethod` - as shown above
Expand Down
26 changes: 25 additions & 1 deletion samples/WebApplication/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,29 @@ You can switch by changing `<OpenApiMode>` in the `.csproj` file to `MicrosoftAn
or `Swashbuckle-net10`.
The launch settings for `MicrosoftAndScalar` is `https openapi and scalar`.

## Run from the command line

The `WebApplication` project uses conditional compilation constants to switch between OpenAPI setups.
Use the `OpenApiMode` MSBuild property from the command line to set those constants.

### Run with Swashbuckle (`USE_SWASHBUCKLE`)

```bash
dotnet run --project samples/WebApplication/WebApplication.csproj -p:OpenApiMode=Swashbuckle-net8
```

```bash
dotnet run --project samples/WebApplication/WebApplication.csproj -p:OpenApiMode=Swashbuckle-net10
```
This will generate the swagger page at `/swagger`, and the OpenApi spec at `/swagger/v1/swagger.json`

### Run with Microsoft OpenAPI + Scalar (`USE_MICROSOFT_OPENAPI_AND_SCALAR`)

```bash
dotnet run --project samples/WebApplication/WebApplication.csproj -p:OpenApiMode=MicrosoftAndScalar
```

This will generated the OpenApi spec at `/openapi/v1.json`

The companion project to this is `WebApplicationConsumer` which demonstrates how to consume an API that uses value
object as parameters.
object as parameters.
2 changes: 1 addition & 1 deletion samples/WebApplication/WebApplication.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseLocallyBuiltPackage>true</UseLocallyBuiltPackage>
<OpenApiMode>Swashbuckle-net8</OpenApiMode>
<OpenApiMode>MicrosoftAndScalar</OpenApiMode>
</PropertyGroup>

<PropertyGroup Condition=" '$(OpenApiMode)' == 'Swashbuckle-net8'">
Expand Down
32 changes: 32 additions & 0 deletions src/Vogen/GenerateCodeForAspNetCoreOpenApiSchema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,38 @@ private static void MapWorkItemsForOpenApi(IEnumerable<Item> workItems, StringBu

sb.Append(_indent, 3).AppendLine($"}}");
sb.AppendLine();

// array handling
sb.Append(_indent, 3).AppendLine($"if (context.JsonTypeInfo.Type.IsArray && context.JsonTypeInfo.Type.GetElementType() == typeof({typeExpression}))");
sb.Append(_indent, 3).AppendLine("{");
if (v is OpenApiVersionBeingUsed.One)
{
sb.Append(_indent, 4).AppendLine("schema.Type = \"array\";");
sb.Append(_indent, 4).AppendLine($"schema.Items = new Microsoft.OpenApi.Models.OpenApiSchema {{ Type = \"{typeAndPossibleFormat.Type}\"{(string.IsNullOrEmpty(typeAndPossibleFormat.Format) ? "" : $", Format = \"{typeAndPossibleFormat.Format}\"")} }};");
}
if (v is OpenApiVersionBeingUsed.TwoPlus)
{
sb.Append(_indent, 4).AppendLine("schema.Type = Microsoft.OpenApi.JsonSchemaType.Array;");
sb.Append(_indent, 4).AppendLine($"schema.Items = new Microsoft.OpenApi.OpenApiSchema {{ Type = Microsoft.OpenApi.JsonSchemaType.{typeAndPossibleFormat.JsonSchemaType}{(string.IsNullOrEmpty(typeAndPossibleFormat.Format) ? "" : $", Format = \"{typeAndPossibleFormat.Format}\"")} }};");
}
sb.Append(_indent, 3).AppendLine("}");
sb.AppendLine();

// generic collection handling (List<>, IEnumerable<>, etc.)
sb.Append(_indent, 3).AppendLine($"if (context.JsonTypeInfo.Type.IsGenericType && context.JsonTypeInfo.Type.GetGenericArguments().Length == 1 && context.JsonTypeInfo.Type.GetGenericArguments()[0] == typeof({typeExpression}))");
sb.Append(_indent, 3).AppendLine("{");
if (v is OpenApiVersionBeingUsed.One)
{
sb.Append(_indent, 4).AppendLine("schema.Type = \"array\";");
sb.Append(_indent, 4).AppendLine($"schema.Items = new Microsoft.OpenApi.Models.OpenApiSchema {{ Type = \"{typeAndPossibleFormat.Type}\"{(string.IsNullOrEmpty(typeAndPossibleFormat.Format) ? "" : $", Format = \"{typeAndPossibleFormat.Format}\"")} }};");
}
if (v is OpenApiVersionBeingUsed.TwoPlus)
{
sb.Append(_indent, 4).AppendLine("schema.Type = Microsoft.OpenApi.JsonSchemaType.Array;");
sb.Append(_indent, 4).AppendLine($"schema.Items = new Microsoft.OpenApi.OpenApiSchema {{ Type = Microsoft.OpenApi.JsonSchemaType.{typeAndPossibleFormat.JsonSchemaType}{(string.IsNullOrEmpty(typeAndPossibleFormat.Format) ? "" : $", Format = \"{typeAndPossibleFormat.Format}\"")} }};");
}
sb.Append(_indent, 3).AppendLine("}");
sb.AppendLine();
}
}

Expand Down
6 changes: 3 additions & 3 deletions src/Vogen/GenerateCodeForBsonSerializers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public static void GenerateForApplicableValueObjects(SourceProductionContext con

var filename = new Filename(eachWrapper.WrapperType.ToDisplayString() + "_bson.g.cs");

Util.TryWriteUsingUniqueFilename(filename, context, Util.FormatSource(eachGenerated));
Util.AddSourceToContext(filename, context, Util.FormatSource(eachGenerated));
}

WriteRegistration(applicableWrappers, compilation, context);
Expand Down Expand Up @@ -84,7 +84,7 @@ private static void GenerateForMarkerClass(SourceProductionContext context, Mark

string filename = Util.GetLegalFilenameForMarkerClass(markerClass.MarkerClassSymbol, ConversionMarkerKind.Bson);

Util.TryWriteUsingUniqueFilename(filename, context, Util.FormatSource(sourceCode));
Util.AddSourceToContext(filename, context, Util.FormatSource(sourceCode));

return;

Expand Down Expand Up @@ -163,7 +163,7 @@ public static void TryRegister() { }
}
""";

Util.TryWriteUsingUniqueFilename(classNameForRegistering, context, Util.FormatSource(source));
Util.AddSourceToContext(classNameForRegistering, context, Util.FormatSource(source));
return;

string ClassNameForRegistering()
Expand Down
4 changes: 2 additions & 2 deletions src/Vogen/GenerateCodeForEfCoreMarkers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ private static void WriteEachIfNeeded(SourceProductionContext context, Conversio

string filename = Util.GetLegalFilenameForMarkerClass(markerClass.MarkerClassSymbol, markerClass.VoSymbol, markerClass.Kind);

Util.TryWriteUsingUniqueFilename(filename, context, sourceText);
Util.AddSourceToContext(filename, context, sourceText);
}

private static void StoreExtensionMethodToRegisterAllInMarkerClass(
Expand Down Expand Up @@ -121,7 +121,7 @@ private static void StoreExtensionMethodToRegisterAllInMarkerClass(

var filename = Util.GetLegalFilenameForMarkerClass(markerSymbol, ConversionMarkerKind.EFCore);

Util.TryWriteUsingUniqueFilename(filename, context, sourceText);
Util.AddSourceToContext(filename, context, sourceText);

return;

Expand Down
4 changes: 2 additions & 2 deletions src/Vogen/GenerateCodeForMessagePack.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ private static void GenerateForAMarkerClass(SourceProductionContext context, Mar

string filename = Util.GetLegalFilenameForMarkerClass(markerClass.MarkerClassSymbol, ConversionMarkerKind.MessagePack);

Util.TryWriteUsingUniqueFilename(filename, context, sourceText);
Util.AddSourceToContext(filename, context, sourceText);

return;

Expand Down Expand Up @@ -124,7 +124,7 @@ public static void GenerateForApplicableValueObjects(SourceProductionContext con
{
SourceText sourceText = Util.FormatSource(eachToWrite.SourceCode);

Util.TryWriteUsingUniqueFilename(eachToWrite.Filename, context, sourceText);
Util.AddSourceToContext(eachToWrite.Filename, context, sourceText);
}
}

Expand Down
23 changes: 23 additions & 0 deletions src/Vogen/GenerateCodeForOpenApiSchemaCustomization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,18 @@ private static void MapWorkItems(IEnumerable<Item> workItems, StringBuilder sb,

sb.AppendLine(
$$"""global::Microsoft.Extensions.DependencyInjection.SwaggerGenOptionsExtensions.MapType<{{fqn}}>(o, () => new global::Microsoft.OpenApi.Models.OpenApiSchema { {{typeText}}{{formatText}}{{nullableText}} });""");
// also map arrays and generic collection wrappers; this keeps the
// item schema information instead of letting it default to an
// untyped array.
sb.AppendLine(
$$"""global::Microsoft.Extensions.DependencyInjection.SwaggerGenOptionsExtensions.MapType<{{fqn}}[]>(o, () => new global::Microsoft.OpenApi.Models.OpenApiSchema { Type = "array", Items = new global::Microsoft.OpenApi.Models.OpenApiSchema { {{typeText}}{{formatText}}{{nullableText}} } });"""
);
sb.AppendLine(
$$"""global::Microsoft.Extensions.DependencyInjection.SwaggerGenOptionsExtensions.MapType<global::System.Collections.Generic.List<{{fqn}}>>(o, () => new global::Microsoft.OpenApi.Models.OpenApiSchema { Type = "array", Items = new global::Microsoft.OpenApi.Models.OpenApiSchema { {{typeText}}{{formatText}}{{nullableText}} } });"""
);
sb.AppendLine(
$$"""global::Microsoft.Extensions.DependencyInjection.SwaggerGenOptionsExtensions.MapType<global::System.Collections.Generic.IEnumerable<{{fqn}}>>(o, () => new global::Microsoft.OpenApi.Models.OpenApiSchema { Type = "array", Items = new global::Microsoft.OpenApi.Models.OpenApiSchema { {{typeText}}{{formatText}}{{nullableText}} } });"""
);
break;
}
case OpenApiVersionBeingUsed.TwoPlus:
Expand All @@ -219,8 +231,19 @@ private static void MapWorkItems(IEnumerable<Item> workItems, StringBuilder sb,
typeText += " | global::Microsoft.OpenApi.JsonSchemaType.Null";
}

var array =
$$"""new global::Microsoft.OpenApi.OpenApiSchema { Type = global::Microsoft.OpenApi.JsonSchemaType.Array, Items = new global::Microsoft.OpenApi.OpenApiSchema { {{typeText}}{{formatText}} } }""";
sb.AppendLine(
$$"""global::Microsoft.Extensions.DependencyInjection.SwaggerGenOptionsExtensions.MapType<{{fqn}}>(o, () => new global::Microsoft.OpenApi.OpenApiSchema { {{typeText}}{{formatText}} });""");
sb.AppendLine(
$$"""global::Microsoft.Extensions.DependencyInjection.SwaggerGenOptionsExtensions.MapType<{{fqn}}[]>(o, () => {{array}});"""
);
sb.AppendLine(
$$"""global::Microsoft.Extensions.DependencyInjection.SwaggerGenOptionsExtensions.MapType<global::System.Collections.Generic.List<{{fqn}}>>(o, () => {{array}});"""
);
sb.AppendLine(
$$"""global::Microsoft.Extensions.DependencyInjection.SwaggerGenOptionsExtensions.MapType<global::System.Collections.Generic.IEnumerable<{{fqn}}>>(o, () => {{array}});"""
);
break;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/Vogen/GenerateCodeForOrleansSerializers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ private static void WriteSerializer(SerializerEntry entry, SourceProductionConte
{
SourceText sourceText = SourceText.From(entry.SourceCode, Encoding.UTF8);

Util.TryWriteUsingUniqueFilename(entry.Filename, context, sourceText);
Util.AddSourceToContext(entry.Filename, context, sourceText);
}

public record SerializerEntry(string Filename, string SourceCode);
Expand Down
Loading
Loading