Skip to content

Commit 49712bf

Browse files
authored
Add C# diagnostics support (#1688)
1 parent 6ba91bb commit 49712bf

26 files changed

+2676
-148
lines changed

.gitattributes

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
**/module_bindings/** linguist-generated=true eol=lf
2-
*.verified.cs linguist-generated=true eol=lf
2+
*.verified.* linguist-generated=true eol=lf
33
/crates/bindings-csharp/Runtime/Internal/Autogen/*.cs linguist-generated=true
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
namespace SpacetimeDB.Codegen;
2+
3+
using System.Collections.Immutable;
4+
using System.Linq.Expressions;
5+
using System.Runtime.CompilerServices;
6+
using Microsoft.CodeAnalysis;
7+
using Microsoft.CodeAnalysis.CSharp.Syntax;
8+
using static SpacetimeDB.Codegen.Utils;
9+
10+
public record ErrorDescriptorGroup(string Tag, string Name)
11+
{
12+
private int idCounter = 0;
13+
14+
public string NextId() => $"{Tag}{++idCounter:D4}";
15+
}
16+
17+
// Roslyn `Diagnostics` requires declaration using old-school string format ("foo {0} bar {1}")
18+
// which is not very type-safe when you want to pass arguments from arbitrary places in the codebase.
19+
//
20+
// This helper class allows us to define diagnostics in a type-safe way by using LINQ expressions
21+
// to extract the format and arguments from a normal-looking lambda.
22+
public class ErrorDescriptor<TContext>
23+
{
24+
private readonly DiagnosticDescriptor descriptor;
25+
private readonly Func<TContext, Location?> toLocation;
26+
private readonly Func<TContext, object[]> makeFormatArgs;
27+
28+
public ErrorDescriptor(
29+
ErrorDescriptorGroup group,
30+
string title,
31+
Expression<Func<TContext, FormattableString>> interpolate,
32+
Func<TContext, Location?> toLocation
33+
)
34+
{
35+
this.toLocation = toLocation;
36+
if (
37+
interpolate.Body
38+
is not MethodCallExpression
39+
{
40+
Method:
41+
{
42+
DeclaringType: var declaringType,
43+
Name: nameof(FormattableStringFactory.Create)
44+
},
45+
Arguments: [
46+
ConstantExpression { Value: string messageFormat },
47+
NewArrayExpression args,
48+
]
49+
}
50+
|| declaringType != typeof(FormattableStringFactory)
51+
)
52+
{
53+
throw new InvalidOperationException(
54+
$"Expected an interpolated string as a lambda body but got {interpolate.Body}."
55+
);
56+
}
57+
descriptor = new(
58+
id: group.NextId(),
59+
title: title,
60+
messageFormat: messageFormat,
61+
category: group.Name,
62+
defaultSeverity: DiagnosticSeverity.Error,
63+
isEnabledByDefault: true
64+
);
65+
makeFormatArgs = Expression
66+
.Lambda<Func<TContext, object[]>>(args, interpolate.Parameters)
67+
.Compile();
68+
}
69+
70+
public ErrorDescriptor(
71+
ErrorDescriptorGroup group,
72+
string title,
73+
Expression<Func<TContext, FormattableString>> interpolate,
74+
Func<TContext, SyntaxNode> toLocation
75+
)
76+
: this(group, title, interpolate, ctx => toLocation(ctx).GetLocation()) { }
77+
78+
public ErrorDescriptor(
79+
ErrorDescriptorGroup group,
80+
string title,
81+
Expression<Func<TContext, FormattableString>> interpolate,
82+
Func<TContext, ISymbol> toLocation
83+
)
84+
: this(group, title, interpolate, ctx => toLocation(ctx).Locations.FirstOrDefault()) { }
85+
86+
public Diagnostic ToDiag(TContext ctx) =>
87+
Diagnostic.Create(descriptor, toLocation(ctx), makeFormatArgs(ctx));
88+
}
89+
90+
internal static class ErrorDescriptor
91+
{
92+
private static readonly ErrorDescriptorGroup group = new("BSATN", "SpacetimeDB.BSATN");
93+
94+
public static readonly ErrorDescriptor<(
95+
ISymbol member,
96+
ITypeSymbol type,
97+
Exception e
98+
)> UnsupportedType =
99+
new(
100+
group,
101+
"Unsupported type",
102+
ctx => $"BSATN implementation for {ctx.type} is not found: {ctx.e.Message}",
103+
ctx => ctx.member
104+
);
105+
106+
public static readonly ErrorDescriptor<(
107+
EqualsValueClauseSyntax equalsValue,
108+
EnumMemberDeclarationSyntax enumMember,
109+
EnumDeclarationSyntax @enum
110+
)> EnumWithExplicitValues =
111+
new(
112+
group,
113+
"[SpacetimeDB.Type] enums cannot have explicit values",
114+
ctx =>
115+
$"{ctx.@enum.Identifier}.{ctx.enumMember.Identifier} has an explicit value {ctx.equalsValue.Value} which is not allowed in SpacetimeDB enums.",
116+
ctx => ctx.equalsValue
117+
);
118+
119+
public static readonly ErrorDescriptor<EnumDeclarationSyntax> EnumTooManyVariants =
120+
new(
121+
group,
122+
"[SpacetimeDB.Type] enums are limited to 256 variants",
123+
@enum =>
124+
$"{@enum.Identifier} has {@enum.Members.Count} variants which is more than the allowed 256 variants for SpacetimeDB enums.",
125+
@enum => @enum.Members[256]
126+
);
127+
128+
public static readonly ErrorDescriptor<INamedTypeSymbol> TaggedEnumInlineTuple =
129+
new(
130+
group,
131+
"Tagged enum variants must be declared with inline tuples",
132+
baseType =>
133+
$"{baseType} does not have the expected format SpacetimeDB.TaggedEnum<(TVariant1 v1, ..., TVariantN vN)>.",
134+
baseType => baseType
135+
);
136+
137+
public static readonly ErrorDescriptor<IFieldSymbol> TaggedEnumField =
138+
new(
139+
group,
140+
"Tagged enums cannot have instance fields",
141+
field =>
142+
$"{field.Name} is an instance field, which are not permitted inside SpacetimeDB tagged enums.",
143+
field => field
144+
);
145+
146+
public static readonly ErrorDescriptor<TypeParameterListSyntax> TypeParams =
147+
new(
148+
group,
149+
"Type parameters are not yet supported",
150+
typeParams => $"Type parameters {typeParams} are not supported in SpacetimeDB types.",
151+
typeParams => typeParams
152+
);
153+
}
154+
155+
// This class is used to collect diagnostics during parsing and return them as a combined result.
156+
//
157+
// It's necessary because Roslyn doesn't let incremental generators emit diagnostics while parsing,
158+
// only while emitting - which means we need to collect them during parsing and store in some data
159+
// structure (this one) and report them later, when we have the emitter context.
160+
//
161+
// Diagnostics are not kept inside the parsed data structure to make the main data cacheable even
162+
// when the diagnostic locations and details change between reruns.
163+
public record ParseResult<T>(T? Parsed, EquatableArray<Diagnostic> Diag)
164+
where T : IEquatable<T>;
165+
166+
public class DiagReporter
167+
{
168+
private readonly ImmutableArray<Diagnostic>.Builder builder =
169+
ImmutableArray.CreateBuilder<Diagnostic>();
170+
171+
public void Report<TContext>(ErrorDescriptor<TContext> descriptor, TContext ctx)
172+
{
173+
builder.Add(descriptor.ToDiag(ctx));
174+
}
175+
176+
private DiagReporter() { }
177+
178+
private static readonly ErrorDescriptor<(SyntaxNode node, Exception e)> InternalError =
179+
new(
180+
new("STDBINT", "SpacetimeDB.Internal"),
181+
"Internal SpacetimeDB codegen error",
182+
ctx => $"An internal error occurred during codegen: {ctx.e.Message}",
183+
ctx => ctx.node
184+
);
185+
186+
public static ParseResult<T> With<T>(SyntaxNode node, Func<DiagReporter, T> build)
187+
where T : IEquatable<T>
188+
{
189+
var reporter = new DiagReporter();
190+
T? parsed;
191+
try
192+
{
193+
parsed = build(reporter);
194+
}
195+
// Catch any unexpected exceptions not covered by proper diagnostics.
196+
// This is the last resort to prevent the generator from crashing and being skipped altogether.
197+
// Instead, it will limit the damage to skipping one particular syntax node.
198+
catch (Exception e)
199+
{
200+
reporter.Report(InternalError, (node, e));
201+
parsed = default;
202+
}
203+
return new(parsed, new(reporter.builder.ToImmutable()));
204+
}
205+
}
206+
207+
public static class DiagExtensions
208+
{
209+
public static ParseResult<T> ParseWithDiags<T>(
210+
this GeneratorAttributeSyntaxContext context,
211+
Func<DiagReporter, T> build
212+
)
213+
where T : IEquatable<T>
214+
{
215+
return DiagReporter.With(context.TargetNode, build);
216+
}
217+
218+
public static IncrementalValuesProvider<T> ReportDiagnostics<T>(
219+
this IncrementalValuesProvider<ParseResult<T>> diagnosticHolders,
220+
IncrementalGeneratorInitializationContext context
221+
)
222+
where T : IEquatable<T>
223+
{
224+
context.RegisterSourceOutput(
225+
diagnosticHolders.SelectMany((result, ct) => result.Diag),
226+
(context, diag) => context.ReportDiagnostic(diag)
227+
);
228+
return diagnosticHolders
229+
.Select((result, ct) => result.Parsed!)
230+
.Where(parsed => parsed is not null);
231+
}
232+
}

0 commit comments

Comments
 (0)