Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
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.*
10 changes: 10 additions & 0 deletions TempVogenTest/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System;
using Vogen;

[assembly: VogenDefaults(openApiSchemaCustomizations: OpenApiSchemaCustomizations.GenerateSwashbuckleMappingExtensionMethod | OpenApiSchemaCustomizations.GenerateOpenApiMappingExtensionMethod)]

// simple program
Console.WriteLine("Hello, World!");

[ValueObject<string>]
public partial class StringType;
19 changes: 19 additions & 0 deletions TempVogenTest/TempVogenTest.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">

<ItemGroup>
<ProjectReference Include="..\src\Vogen\Vogen.csproj" />
<ProjectReference Include="..\src\Vogen.SharedTypes\Vogen.SharedTypes.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="7.0.0" />
</ItemGroup>

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

</Project>
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
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
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ public static partial class VogenOpenApiExtensions
schema.Type = "string";
}

if (context.JsonTypeInfo.Type.IsArray && context.JsonTypeInfo.Type.GetElementType() == typeof(global::N.MyVo))
{
schema.Type = "array";
schema.Items = new Microsoft.OpenApi.Models.OpenApiSchema { Type = "string" };
}

if (context.JsonTypeInfo.Type.IsGenericType && context.JsonTypeInfo.Type.GetGenericArguments().Length == 1 && context.JsonTypeInfo.Type.GetGenericArguments()[0] == typeof(global::N.MyVo))
{
schema.Type = "array";
schema.Items = new Microsoft.OpenApi.Models.OpenApiSchema { Type = "string" };
}

return global::System.Threading.Tasks.Task.CompletedTask;
});

Expand Down
Loading
Loading