Skip to content

Commit d6eea46

Browse files
dmytroettDmytro.Ett
andauthored
Generate interfaces for RegisterAsGeneratedInterface (#14)
* Making some items visible in solution * First pass * Small simplifications where makes sense * Refactoring * Move between folders * cleanup comments * Added a bit more of the tests * Adding code for testing purposes * finishing with integration tests * Auto implement interfaces * Updating one other test * Small refactoring * Added generic method calls * Apparently my tests were not working properly this whole time! * Fixing test errors * Rerunning tests * fixes * I assume all is fixed. * Apparently interface generation is not that simple as I thought it would be. * Tightening public contract * Getting progress * Finished with the tests actually * Make tests tfm dependand * trying out codex * Adding file name to snapshot output * Improving generic naming * Rerun the tests after adjustments to the way they are generated. * Refactoring * Added couple more tests * Excluded some more well known interfaces * Fixing the bug with events * Adding some more test cases * Adding more tests to cover some of the edge cases * Adding tests for known interfaces * Added more coverage around known interfaces. --------- Co-authored-by: Dmytro.Ett <dmytro.ett@outlook.com>
1 parent 6e593fc commit d6eea46

File tree

158 files changed

+6543
-1185
lines changed

Some content is hidden

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

158 files changed

+6543
-1185
lines changed

.editorconfig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ root = true
33

44
[*.*]
55
# New line preferences
6-
end_of_line = crlf
6+
end_of_line = lf
77
insert_final_newline = false
88
indent_style = space
99

.gitattributes

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
###############################################################################
22
# Set default behavior to automatically normalize line endings.
33
###############################################################################
4-
* text=auto
4+
* text=auto eol=lf
55

66
###############################################################################
77
# Set default behavior for command prompt diff.

AGENTS.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Codex Instructions for AttributedDI
2+
3+
## General Guidelines
4+
5+
- When uncertain about APIs, best practices, or modern implementation patterns, consult Microsoft documentation.
6+
- When usage is unclear, search GitHub source code for referenced open source projects.
7+
- Prefer the configured MCP servers (GitHub, Microsoft Docs) as your first reference sources.
8+
9+
## Code Quality Guidelines
10+
11+
- Make sure code is maintainable and easy to understand. Suggest refactoring when beneficial.
12+
- If refactoring is challenging, complicated, or the user explicitly declined, add strategic comments to improve maintainability instead.
13+
- Do not overuse comments; place them only where they add real value.
14+
- Minimize public surface area: Use `private` or `internal` access modifiers by default unless the API is intentionally designed to be public. A smaller public API is easier to maintain and reduces breaking change concerns in future versions.
15+
16+
## Code Style & Formatting
17+
18+
When done with code generation or modification, you must:
19+
20+
1. Run `dotnet format --include <list-of-changed-files>`
21+
2. Ensure no formatting issues remain
22+
23+
Example:
24+
```
25+
dotnet format --include src/AttributedDI/MyClass.cs
26+
```
27+
28+
For multiple files:
29+
```
30+
dotnet format --include src/AttributedDI/File1.cs src/AttributedDI/File2.cs
31+
```
32+
33+
## Public API Documentation
34+
35+
This is a library. **All** public methods, properties, classes, and interfaces in library code **must** have XML documentation (///).
36+
37+
**Scope**: This requirement applies to library code only (`src/AttributedDI/`, `src/AttributedDI.SourceGenerator/`). Test projects are exempt.
38+
39+
### Required XML Tags
40+
41+
- `<summary>`: What it does
42+
- `<param>`: Each parameter's purpose and constraints
43+
- `<returns>`: Return value description
44+
- `<exception>`: Any exceptions thrown
45+
- `<example>`: Usage examples (for complex/frequently-used APIs)
46+
- `<remarks>`: Edge cases or important notes (when needed)
47+
48+
### Example
49+
50+
```csharp
51+
/// <summary>Registers a service with the DI container.</summary>
52+
/// <param name="serviceType">The service type to register.</param>
53+
/// <param name="implementationType">The implementation type.</param>
54+
/// <exception cref="ArgumentNullException">Thrown when parameters are null.</exception>
55+
public void RegisterService(Type serviceType, Type implementationType)
56+
```
57+
58+
### Documentation Rules
59+
60+
- Document only public APIs in library projects
61+
- Update documentation when signatures change

AttributedDI.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
<Folder Name="/Solution Items/">
88
<File Path=".editorconfig" />
99
<File Path=".github/copilot-instructions.md" />
10+
<File Path=".github/instructions/source-generators.instructions.md" />
1011
<File Path=".github/workflows/pr.yml" />
1112
<File Path=".github/workflows/release.yml" />
1213
<File Path="README.md" />
@@ -22,5 +23,6 @@
2223
<Folder Name="/test/assets/">
2324
<Project Path="test/assets/Company.TeamName.Project.API/Company.TeamName.Project.API.csproj" />
2425
<Project Path="test/assets/CustomModuleName/CustomModuleName.csproj" />
26+
<Project Path="test/assets/GeneratedInterfacesSut/GeneratedInterfacesSut.csproj" />
2527
</Folder>
2628
</Solution>
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Source Generator Instructions
2+
3+
Best practices for developing incremental source generators, based on official Roslyn documentation.
4+
5+
References:
6+
- Incremental Generators Design: https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.md
7+
- Incremental Generators Cookbook: https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.cookbook.md
8+
9+
## Core Principles
10+
11+
### Pipeline Model Design
12+
13+
Source generators must build value-equatable pipelines to enable incremental compilation caching.
14+
15+
- Use `record` types for data models (value equality is generated automatically)
16+
- Never put `ISymbol` instances in pipeline models
17+
- Never put `SyntaxNode` instances in models
18+
- Never put `Location` instances in models
19+
- Extract information from symbols/syntax as early as possible in the pipeline
20+
- Use `ImmutableArray<T>` instead of `List<T>` or `T[]` in models
21+
22+
Example anti-pattern to avoid:
23+
```csharp
24+
// DON'T do this - ISymbol prevents garbage collection
25+
private record ServiceInfo(INamedTypeSymbol Symbol, string Name);
26+
```
27+
28+
Correct pattern:
29+
```csharp
30+
// DO this - extract what you need as strings
31+
private record ServiceInfo(string Namespace, string Name, string FullyQualifiedName);
32+
```
33+
34+
### Use `ForAttributeWithMetadataName`
35+
36+
This is much more efficient than `CreateSyntaxProvider`.
37+
38+
- Always use `SyntaxProvider.ForAttributeWithMetadataName` when targeting attributes
39+
- Fully-qualified metadata names format: `Namespace.ClassName` (with backticks for generic parameters, e.g., `My.Namespace.MyAttribute`1`)
40+
- The built-in heuristic can eliminate most syntax nodes before any user code runs
41+
- Provides two lambdas:
42+
- `predicate`: Runs only on files that might contain the attribute (syntactic check)
43+
- `transform`: Runs on all matching nodes to capture semantic information
44+
45+
Example:
46+
```csharp
47+
var pipeline = context.SyntaxProvider.ForAttributeWithMetadataName(
48+
fullyQualifiedMetadataName: "AttributedDI.RegisterAsAttribute",
49+
predicate: static (node, _) => node is ClassDeclarationSyntax,
50+
transform: static (ctx, ct) =>
51+
{
52+
// Extract information from symbols here, not in the predicate
53+
var symbol = (INamedTypeSymbol)ctx.TargetSymbol;
54+
return new ServiceModel(symbol.Name, symbol.ContainingNamespace?.ToDisplayString());
55+
});
56+
```
57+
58+
## Performance Best Practices
59+
60+
### Combine Order Matters
61+
62+
Extract information from expensive sources (like `CompilationProvider`) before combining.
63+
64+
Inefficient:
65+
```csharp
66+
var combined = texts.Combine(context.CompilationProvider);
67+
```
68+
69+
Efficient:
70+
```csharp
71+
var assemblyName = context.CompilationProvider
72+
.Select(static (c, _) => c.AssemblyName);
73+
var combined = texts.Combine(assemblyName);
74+
```
75+
76+
### Minimize Collection Types in Models
77+
78+
- Wrap `ImmutableArray<T>` in your models with custom equality if needed
79+
- Built-in collection types (`List<T>`, `T[]`) use reference equality
80+
- Consider creating wrapper types for better caching
81+
82+
### Custom Comparers
83+
84+
Use `WithComparer()` when default equality isn't sufficient:
85+
86+
```csharp
87+
var pipeline = context.AdditionalTextsProvider
88+
.Select(static (text, _) => text.Path)
89+
.WithComparer(StringComparer.OrdinalIgnoreCase);
90+
```
91+
92+
## Incremental Caching Strategy
93+
94+
### Design for Caching
95+
96+
Break operations into small transformation steps to maximize cache hit opportunities.
97+
98+
- Each transformation is a checkpoint
99+
- If a checkpoint produces the same value as before, everything downstream is skipped
100+
- More transformations = more opportunities to cache
101+
102+
Example:
103+
```csharp
104+
var names = items.Select(static i => i.Name);
105+
var prefixed = names.Select(static n => "prefix_" + n);
106+
var collected = prefixed.Collect();
107+
```
108+
109+
### Determinism is Required
110+
111+
- All transformations must be deterministic
112+
- Same input must always produce identical output
113+
- Avoid `Guid.NewGuid()`, `DateTime.Now`, `Random`, etc.
114+
- Be careful with `Dictionary` iteration order (consider `ImmutableSortedDictionary`)
115+
116+
## Cancellation Handling
117+
118+
- Always forward `CancellationToken` to Roslyn APIs that accept it
119+
- For expensive operations, call `cancellationToken.ThrowIfCancellationRequested()` at regular intervals
120+
- Never save partially generated results to work around cancellation
121+
122+
```csharp
123+
var expensive = txtFilesArray.Select(static (files, cancellationToken) =>
124+
{
125+
foreach (var file in files)
126+
{
127+
cancellationToken.ThrowIfCancellationRequested();
128+
// expensive operation...
129+
}
130+
});
131+
```
132+
133+
## Patterns to Avoid
134+
135+
- Scanning for indirectly implemented interfaces
136+
- Scanning for indirectly inherited types
137+
- Scanning for marker attributes on base types
138+
- Non-sealed marker attributes that expect inheritance
139+
140+
These cause massive performance degradation in IDEs.

src/AttributedDI.SourceGenerator/AttributedDI.SourceGenerator.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
</PropertyGroup>
1111

1212
<ItemGroup>
13-
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" PrivateAssets="all"/>
13+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" PrivateAssets="all" />
1414
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" PrivateAssets="all" />
1515
<PackageReference Include="PolySharp" Version="1.15.0" PrivateAssets="all" />
1616
</ItemGroup>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
global using static AttributedDI.SourceGenerator.Utils.IncrementalValuesProviderHelper;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using System.Collections.Immutable;
2+
3+
namespace AttributedDI.SourceGenerator.InterfacesGeneration;
4+
5+
/// <summary>
6+
/// Value object describing an interface to be generated.
7+
/// </summary>
8+
/// <param name="InterfaceName">The interface name without namespace.</param>
9+
/// <param name="InterfaceNamespace">The namespace for the generated interface.</param>
10+
/// <param name="Accessibility">The accessibility keyword (public/internal) to apply.</param>
11+
/// <param name="MemberSignatures">Members to emit into the generated interface.</param>
12+
/// <param name="ClassName">The implementing class name without namespace.</param>
13+
/// <param name="ClassNamespace">The namespace for the implementing class.</param>
14+
/// <param name="ClassTypeParameters">Type parameters of the implementing class (e.g., "&lt;T, U&gt;" or empty string).</param>
15+
/// <param name="TypeParameterConstraints">Constraint clauses for the type parameters (e.g., "where T : class").</param>
16+
internal sealed record GeneratedInterfaceInfo(
17+
string InterfaceName,
18+
string InterfaceNamespace,
19+
string Accessibility,
20+
ImmutableArray<string> MemberSignatures,
21+
string ClassName,
22+
string ClassNamespace,
23+
string ClassTypeParameters,
24+
int TypeParameterCount,
25+
string TypeParameterConstraints);
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
using Microsoft.CodeAnalysis;
2+
using System;
3+
4+
namespace AttributedDI.SourceGenerator.InterfacesGeneration;
5+
6+
internal static class GeneratedInterfaceNamingResolver
7+
{
8+
internal static bool TryResolve(
9+
INamedTypeSymbol typeSymbol,
10+
AttributeData attribute,
11+
out GeneratedInterfaceNaming? naming)
12+
{
13+
var interfaceNameArgument = GetOptionalStringArgument(attribute, position: 0, name: "InterfaceName");
14+
var interfaceNamespaceArgument = GetOptionalStringArgument(attribute, position: 1, name: "InterfaceNamespace");
15+
16+
var defaultInterfaceName = $"I{typeSymbol.Name}";
17+
var defaultNamespace = typeSymbol.ContainingNamespace?.ToDisplayString() ?? string.Empty;
18+
19+
if (!string.IsNullOrWhiteSpace(interfaceNameArgument))
20+
{
21+
var parsed = ParseInterfaceName(interfaceNameArgument!);
22+
if (!string.IsNullOrEmpty(interfaceNamespaceArgument) && !string.IsNullOrEmpty(parsed.Namespace))
23+
{
24+
// TODO: emit diagnostic for conflicting interface name and namespace inputs.
25+
naming = null;
26+
return false;
27+
}
28+
29+
naming = new GeneratedInterfaceNaming(
30+
parsed.Name,
31+
string.IsNullOrEmpty(parsed.Namespace) ? interfaceNamespaceArgument ?? defaultNamespace : parsed.Namespace);
32+
return true;
33+
}
34+
35+
if (!string.IsNullOrWhiteSpace(interfaceNamespaceArgument))
36+
{
37+
naming = new GeneratedInterfaceNaming(defaultInterfaceName, interfaceNamespaceArgument!);
38+
return true;
39+
}
40+
41+
naming = new GeneratedInterfaceNaming(defaultInterfaceName, defaultNamespace);
42+
return true;
43+
}
44+
45+
private static string? GetOptionalStringArgument(AttributeData attribute, int position, string name)
46+
{
47+
if (attribute.ConstructorArguments.Length > position)
48+
{
49+
var ctorArg = attribute.ConstructorArguments[position];
50+
if (ctorArg.Value is string fromCtor && !string.IsNullOrWhiteSpace(fromCtor))
51+
{
52+
return fromCtor;
53+
}
54+
}
55+
56+
foreach (var namedArgument in attribute.NamedArguments)
57+
{
58+
var key = namedArgument.Key;
59+
var typedConstant = namedArgument.Value;
60+
61+
if (!string.Equals(key, name, StringComparison.Ordinal))
62+
{
63+
continue;
64+
}
65+
66+
if (typedConstant.Value is string fromNamed && !string.IsNullOrWhiteSpace(fromNamed))
67+
{
68+
return fromNamed;
69+
}
70+
}
71+
72+
return null;
73+
}
74+
75+
private static (string Name, string Namespace) ParseInterfaceName(string rawName)
76+
{
77+
var nameToParse = rawName.StartsWith("global::", StringComparison.Ordinal)
78+
? rawName.Substring("global::".Length)
79+
: rawName;
80+
81+
var lastDot = nameToParse.LastIndexOf('.');
82+
if (lastDot < 0)
83+
{
84+
return (nameToParse, string.Empty);
85+
}
86+
87+
var parsedNamespace = nameToParse[..lastDot];
88+
var parsedName = nameToParse[(lastDot + 1)..];
89+
return (parsedName, parsedNamespace);
90+
}
91+
}
92+
93+
internal sealed record GeneratedInterfaceNaming(string InterfaceName, string InterfaceNamespace)
94+
{
95+
internal string FullyQualifiedName => string.IsNullOrWhiteSpace(InterfaceNamespace)
96+
? InterfaceName
97+
: $"{InterfaceNamespace}.{InterfaceName}";
98+
}

0 commit comments

Comments
 (0)