Skip to content

Commit 51e662d

Browse files
Merge pull request #3970 from icsharpcode/settings-source-generator
Generate DecompilerSettings boilerplate from [DecompilerSetting] attributes
2 parents 9ea8d7c + 7a7cb44 commit 51e662d

7 files changed

Lines changed: 733 additions & 1738 deletions

File tree

ICSharpCode.Decompiler.Generators/AnalyzerReleases.Unshipped.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,7 @@
66
Rule ID | Category | Severity | Notes
77
--------|----------|----------|-------
88
DSTG001 | DecompilerSyntaxTreeGenerator | Error | Slot kind must map to a single child type
9+
DSTG002 | DecompilerSettingsGenerator | Error | [DecompilerSetting] target must be a partial instance bool property
10+
DSTG003 | DecompilerSettingsGenerator | Error | Version-gated setting must not declare [Category]
11+
DSTG004 | DecompilerSettingsGenerator | Error | Language version has no display category
12+
DSTG005 | DecompilerSettingsGenerator | Error | Setting must be declared in a non-nested partial class
Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
// Copyright (c) 2026 Siegfried Pammer
2+
//
3+
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
4+
// software and associated documentation files (the "Software"), to deal in the Software
5+
// without restriction, including without limitation the rights to use, copy, modify, merge,
6+
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
7+
// to whom the Software is furnished to do so, subject to the following conditions:
8+
//
9+
// The above copyright notice and this permission notice shall be included in all copies or
10+
// substantial portions of the Software.
11+
//
12+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
13+
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
14+
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
15+
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
16+
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
17+
// DEALINGS IN THE SOFTWARE.
18+
19+
using System.Collections.Immutable;
20+
using System.Text;
21+
22+
using Microsoft.CodeAnalysis;
23+
using Microsoft.CodeAnalysis.CSharp;
24+
using Microsoft.CodeAnalysis.CSharp.Syntax;
25+
using Microsoft.CodeAnalysis.Text;
26+
27+
namespace ICSharpCode.Decompiler.Generators;
28+
29+
/// <summary>
30+
/// Generates the boilerplate behind [DecompilerSetting] partial properties: the backing field,
31+
/// the accessors with change notification, and - from the per-setting language version - the
32+
/// [Category] attribute plus the SetLanguageVersion and GetMinimumRequiredVersion methods,
33+
/// so that a setting's version is declared in exactly one place.
34+
/// </summary>
35+
[Generator]
36+
internal class DecompilerSettingsGenerator : IIncrementalGenerator
37+
{
38+
static readonly DiagnosticDescriptor InvalidSettingProperty = new(
39+
id: "DSTG002",
40+
title: "[DecompilerSetting] target must be a partial instance bool property",
41+
messageFormat: "Setting property '{0}' must be a partial instance bool property with get and set accessors and an uppercase-start name (the generated backing field uses the camelCase form)",
42+
category: "DecompilerSettingsGenerator",
43+
defaultSeverity: DiagnosticSeverity.Error,
44+
isEnabledByDefault: true);
45+
46+
// The generated [Category] would join a handwritten one on the merged partial property, and
47+
// GetCustomAttribute<CategoryAttribute>() (used by the settings UI) throws on duplicates.
48+
static readonly DiagnosticDescriptor CategoryOnVersionedSetting = new(
49+
id: "DSTG003",
50+
title: "Version-gated setting must not declare [Category]",
51+
messageFormat: "Setting '{0}' derives its [Category] from the language version; remove the handwritten [Category] attribute",
52+
category: "DecompilerSettingsGenerator",
53+
defaultSeverity: DiagnosticSeverity.Error,
54+
isEnabledByDefault: true);
55+
56+
static readonly DiagnosticDescriptor UnsupportedLanguageVersion = new(
57+
id: "DSTG004",
58+
title: "Language version has no display category",
59+
messageFormat: "Language version '{0}' has no display category; gate settings on a released C# version, or add the new version to DecompilerSettingsGenerator.CategoryByVersion",
60+
category: "DecompilerSettingsGenerator",
61+
defaultSeverity: DiagnosticSeverity.Error,
62+
isEnabledByDefault: true);
63+
64+
// The generated implementation is emitted as a top-level partial class; a nested or
65+
// non-partial containing type would make it merge nowhere (or into a stray new type).
66+
static readonly DiagnosticDescriptor InvalidContainingType = new(
67+
id: "DSTG005",
68+
title: "Setting must be declared in a non-nested partial class",
69+
messageFormat: "Setting '{0}' must be declared in a partial, non-nested class so the generated implementation merges into it",
70+
category: "DecompilerSettingsGenerator",
71+
defaultSeverity: DiagnosticSeverity.Error,
72+
isEnabledByDefault: true);
73+
74+
static readonly Dictionary<string, DiagnosticDescriptor> DescriptorsById =
75+
new DiagnosticDescriptor[] { InvalidSettingProperty, CategoryOnVersionedSetting, UnsupportedLanguageVersion, InvalidContainingType }
76+
.ToDictionary(d => d.Id);
77+
78+
// Display category per released C# version; the settings UI groups options by these strings.
79+
static readonly Dictionary<string, string> CategoryByVersion = new() {
80+
["CSharp1"] = "C# 1.0 / VS .NET",
81+
["CSharp2"] = "C# 2.0 / VS 2005",
82+
["CSharp3"] = "C# 3.0 / VS 2008",
83+
["CSharp4"] = "C# 4.0 / VS 2010",
84+
["CSharp5"] = "C# 5.0 / VS 2012",
85+
["CSharp6"] = "C# 6.0 / VS 2015",
86+
["CSharp7"] = "C# 7.0 / VS 2017",
87+
["CSharp7_1"] = "C# 7.1 / VS 2017.3",
88+
["CSharp7_2"] = "C# 7.2 / VS 2017.4",
89+
["CSharp7_3"] = "C# 7.3 / VS 2017.7",
90+
["CSharp8_0"] = "C# 8.0 / VS 2019",
91+
["CSharp9_0"] = "C# 9.0 / VS 2019.8",
92+
["CSharp10_0"] = "C# 10.0 / VS 2022",
93+
["CSharp11_0"] = "C# 11.0 / VS 2022.4",
94+
["CSharp12_0"] = "C# 12.0 / VS 2022.8",
95+
["CSharp13_0"] = "C# 13.0 / VS 2022.12",
96+
["CSharp14_0"] = "C# 14.0 / VS 2026",
97+
};
98+
99+
readonly record struct SettingInfo(
100+
string Namespace, string ClassName, string Accessibility, string PropertyName, string FieldName,
101+
bool DefaultValue, int VersionValue, string? VersionName, string? Category,
102+
string FilePath, int SpanStart);
103+
104+
// A diagnostic captured during the transform; kept as plain values so the pipeline stays cacheable.
105+
readonly record struct DiagInfo(string Id, string MessageArg, string FilePath, int SpanStart, int SpanLength,
106+
int StartLine, int StartChar, int EndLine, int EndChar);
107+
108+
readonly record struct SettingResult(SettingInfo? Setting, EquatableArray<DiagInfo>? Diagnostics);
109+
110+
public void Initialize(IncrementalGeneratorInitializationContext context)
111+
{
112+
context.RegisterPostInitializationOutput(i => i.AddSource("DecompilerSettingsGeneratorAttributes.g.cs", RoslynHelpers.EmbeddedAttributeSource + @"
113+
namespace ICSharpCode.Decompiler
114+
{
115+
[global::Microsoft.CodeAnalysis.EmbeddedAttribute]
116+
[global::System.AttributeUsage(global::System.AttributeTargets.Property)]
117+
sealed class DecompilerSettingAttribute : global::System.Attribute
118+
{
119+
public DecompilerSettingAttribute() { }
120+
121+
public DecompilerSettingAttribute(global::ICSharpCode.Decompiler.CSharp.LanguageVersion introducedIn) { }
122+
123+
/// <summary>Initial value of the setting. Defaults to true.</summary>
124+
public bool DefaultValue { get; set; } = true;
125+
}
126+
}
127+
128+
"));
129+
130+
var settings = context.SyntaxProvider.ForAttributeWithMetadataName(
131+
"ICSharpCode.Decompiler.DecompilerSettingAttribute",
132+
(n, ct) => n is PropertyDeclarationSyntax,
133+
GetSetting);
134+
135+
context.RegisterSourceOutput(settings.Collect(), WriteSettingsClasses);
136+
}
137+
138+
static SettingResult GetSetting(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken)
139+
{
140+
var property = (IPropertySymbol)context.TargetSymbol;
141+
var node = (PropertyDeclarationSyntax)context.TargetNode;
142+
var diagnostics = new List<DiagInfo>();
143+
144+
if (property.Type.SpecialType != SpecialType.System_Boolean || property.IsStatic
145+
|| property.GetMethod == null || property.SetMethod == null || property.SetMethod.IsInitOnly
146+
|| !char.IsUpper(property.Name[0])
147+
|| !node.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
148+
{
149+
diagnostics.Add(MakeDiagInfo(InvalidSettingProperty.Id, property.Name, node));
150+
return new SettingResult(null, diagnostics.ToEquatableArray());
151+
}
152+
153+
if (property.ContainingType.ContainingType != null
154+
|| node.Parent is not ClassDeclarationSyntax containingClass
155+
|| !containingClass.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
156+
{
157+
diagnostics.Add(MakeDiagInfo(InvalidContainingType.Id, property.Name, node));
158+
return new SettingResult(null, diagnostics.ToEquatableArray());
159+
}
160+
161+
var attribute = context.Attributes[0];
162+
int versionValue = 0;
163+
string? versionName = null;
164+
string? category = null;
165+
if (attribute.ConstructorArguments.Length == 1)
166+
{
167+
var versionArgument = attribute.ConstructorArguments[0];
168+
if (versionArgument.Kind == TypedConstantKind.Error || versionArgument.Value is not int boundVersion || versionArgument.Type is null)
169+
{
170+
// The argument did not bind (e.g. a typo'd enum member); the compiler already
171+
// reports that error at the argument, so just skip the setting instead of
172+
// crashing the whole generator.
173+
return new SettingResult(null, null);
174+
}
175+
versionValue = boundVersion;
176+
versionName = VersionNameFromSyntax(attribute)
177+
?? versionArgument.Type.GetMembers()
178+
.OfType<IFieldSymbol>()
179+
.FirstOrDefault(f => f.HasConstantValue && Equals(f.ConstantValue, versionValue))?.Name
180+
?? versionValue.ToString();
181+
if (!CategoryByVersion.TryGetValue(versionName, out category))
182+
{
183+
diagnostics.Add(MakeDiagInfo(UnsupportedLanguageVersion.Id, versionName, node));
184+
return new SettingResult(null, diagnostics.ToEquatableArray());
185+
}
186+
if (property.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString() == "System.ComponentModel.CategoryAttribute"))
187+
{
188+
diagnostics.Add(MakeDiagInfo(CategoryOnVersionedSetting.Id, property.Name, node));
189+
// The compiler would otherwise also flag the generated [Category] as a duplicate;
190+
// suppress it so the mistake surfaces as the single DSTG003.
191+
category = null;
192+
}
193+
}
194+
195+
bool defaultValue = true;
196+
foreach (var named in attribute.NamedArguments)
197+
{
198+
// A named argument that failed to bind is already a compiler error; ignore it here.
199+
if (named.Value.Value is not bool namedValue)
200+
continue;
201+
if (named.Key == "DefaultValue")
202+
defaultValue = namedValue;
203+
}
204+
205+
string fieldName = char.ToLowerInvariant(property.Name[0]) + property.Name.Substring(1);
206+
if (SyntaxFacts.GetKeywordKind(fieldName) != SyntaxKind.None)
207+
fieldName = "@" + fieldName;
208+
209+
var setting = new SettingInfo(
210+
property.ContainingNamespace.IsGlobalNamespace ? "" : property.ContainingNamespace.ToDisplayString(),
211+
property.ContainingType.Name,
212+
SyntaxFacts.GetText(property.DeclaredAccessibility),
213+
property.Name,
214+
fieldName,
215+
defaultValue,
216+
versionValue,
217+
versionName,
218+
category,
219+
node.SyntaxTree.FilePath,
220+
node.SpanStart);
221+
return new SettingResult(setting, diagnostics.Count == 0 ? null : diagnostics.ToEquatableArray());
222+
}
223+
224+
// Prefer the enum member name as spelled at the use site: constant values are not unique in
225+
// LanguageVersion (CSharp15_0 and Preview share a value), so a value-based reverse lookup can
226+
// name an alias the user never wrote.
227+
static string? VersionNameFromSyntax(AttributeData attribute)
228+
{
229+
if (attribute.ApplicationSyntaxReference?.GetSyntax() is not AttributeSyntax { ArgumentList.Arguments: { Count: >= 1 } arguments })
230+
return null;
231+
if (arguments[0].NameEquals != null)
232+
return null;
233+
return arguments[0].Expression switch {
234+
MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.Text,
235+
IdentifierNameSyntax identifier => identifier.Identifier.Text,
236+
_ => null,
237+
};
238+
}
239+
240+
static DiagInfo MakeDiagInfo(string id, string messageArg, SyntaxNode node)
241+
{
242+
var lineSpan = node.GetLocation().GetLineSpan();
243+
return new DiagInfo(id, messageArg, node.SyntaxTree.FilePath, node.Span.Start, node.Span.Length,
244+
lineSpan.StartLinePosition.Line, lineSpan.StartLinePosition.Character,
245+
lineSpan.EndLinePosition.Line, lineSpan.EndLinePosition.Character);
246+
}
247+
248+
static void WriteSettingsClasses(SourceProductionContext context, ImmutableArray<SettingResult> results)
249+
{
250+
foreach (var result in results)
251+
{
252+
if (result.Diagnostics is not { } resultDiagnostics)
253+
continue;
254+
foreach (var diag in resultDiagnostics)
255+
{
256+
// Indexer lookup so a diagnostic id missing from the map fails loudly instead of
257+
// being reported under an unrelated descriptor.
258+
var descriptor = DescriptorsById[diag.Id];
259+
var location = Location.Create(diag.FilePath, new TextSpan(diag.SpanStart, diag.SpanLength),
260+
new LinePositionSpan(new LinePosition(diag.StartLine, diag.StartChar), new LinePosition(diag.EndLine, diag.EndChar)));
261+
context.ReportDiagnostic(Diagnostic.Create(descriptor, location, diag.MessageArg));
262+
}
263+
}
264+
265+
var settings = results
266+
.Where(r => r.Setting != null)
267+
.Select(r => r.Setting!.Value)
268+
.OrderBy(s => s.FilePath, StringComparer.Ordinal)
269+
.ThenBy(s => s.SpanStart);
270+
271+
foreach (var settingsClass in settings.GroupBy(s => (s.Namespace, s.ClassName)))
272+
{
273+
WriteSettingsClass(context, settingsClass.Key.Namespace, settingsClass.Key.ClassName, settingsClass.ToArray());
274+
}
275+
}
276+
277+
static void WriteSettingsClass(SourceProductionContext context, string ns, string className, SettingInfo[] settings)
278+
{
279+
var builder = new StringBuilder();
280+
builder.AppendLine("// <auto-generated/>");
281+
builder.AppendLine("#nullable enable");
282+
builder.AppendLine();
283+
if (ns.Length > 0)
284+
{
285+
builder.AppendLine($"namespace {ns}");
286+
builder.AppendLine("{");
287+
}
288+
builder.AppendLine($"\tpartial class {className}");
289+
builder.AppendLine("\t{");
290+
291+
foreach (var setting in settings)
292+
{
293+
builder.AppendLine($"\t\tbool {setting.FieldName} = {(setting.DefaultValue ? "true" : "false")};");
294+
builder.AppendLine();
295+
if (setting.Category != null)
296+
{
297+
builder.AppendLine($"\t\t[global::System.ComponentModel.Category(\"{setting.Category}\")]");
298+
}
299+
builder.AppendLine($"\t\t{setting.Accessibility} partial bool {setting.PropertyName} {{");
300+
builder.AppendLine($"\t\t\tget {{ return {setting.FieldName}; }}");
301+
builder.AppendLine("\t\t\tset {");
302+
builder.AppendLine($"\t\t\t\tif ({setting.FieldName} != value)");
303+
builder.AppendLine("\t\t\t\t{");
304+
builder.AppendLine($"\t\t\t\t\t{setting.FieldName} = value;");
305+
builder.AppendLine("\t\t\t\t\tOnPropertyChanged();");
306+
builder.AppendLine("\t\t\t\t}");
307+
builder.AppendLine("\t\t\t}");
308+
builder.AppendLine("\t\t}");
309+
builder.AppendLine();
310+
}
311+
312+
var versionBuckets = settings
313+
.Where(s => s.VersionName != null)
314+
.GroupBy(s => s.VersionValue)
315+
.OrderBy(g => g.Key)
316+
.ToArray();
317+
if (versionBuckets.Length > 0)
318+
{
319+
WriteSetLanguageVersion(builder, versionBuckets);
320+
builder.AppendLine();
321+
WriteGetMinimumRequiredVersion(builder, versionBuckets);
322+
}
323+
324+
builder.AppendLine("\t}");
325+
if (ns.Length > 0)
326+
{
327+
builder.AppendLine("}");
328+
}
329+
// The hint name must carry the full grouping key: two same-named settings classes in
330+
// different namespaces would otherwise collide in AddSource and kill the generator.
331+
string hintName = ns.Length == 0 ? $"{className}.Settings.g.cs" : $"{ns}.{className}.Settings.g.cs";
332+
context.AddSource(hintName, SourceText.From(builder.ToString().Replace("\r\n", "\n"), Encoding.UTF8));
333+
}
334+
335+
// Emitted as partial implementing declarations: the containing class supplies the defining
336+
// stubs, which is where the XML documentation lives (the docs on a partial method's defining
337+
// declaration apply as long as the implementation carries none).
338+
static void WriteSetLanguageVersion(StringBuilder builder, IGrouping<int, SettingInfo>[] versionBuckets)
339+
{
340+
builder.AppendLine("\t\tpublic partial void SetLanguageVersion(global::ICSharpCode.Decompiler.CSharp.LanguageVersion languageVersion)");
341+
builder.AppendLine("\t\t{");
342+
builder.AppendLine("\t\t\t// By default, all decompiler features are enabled.");
343+
builder.AppendLine("\t\t\t// Disable some of them based on language version:");
344+
foreach (var bucket in versionBuckets)
345+
{
346+
builder.AppendLine($"\t\t\tif (languageVersion < global::ICSharpCode.Decompiler.CSharp.LanguageVersion.{bucket.First().VersionName})");
347+
builder.AppendLine("\t\t\t{");
348+
foreach (var setting in bucket)
349+
{
350+
builder.AppendLine($"\t\t\t\t{setting.FieldName} = false;");
351+
}
352+
builder.AppendLine("\t\t\t}");
353+
}
354+
builder.AppendLine("\t\t}");
355+
}
356+
357+
static void WriteGetMinimumRequiredVersion(StringBuilder builder, IGrouping<int, SettingInfo>[] versionBuckets)
358+
{
359+
builder.AppendLine("\t\tpublic partial global::ICSharpCode.Decompiler.CSharp.LanguageVersion GetMinimumRequiredVersion()");
360+
builder.AppendLine("\t\t{");
361+
foreach (var bucket in versionBuckets.Reverse())
362+
{
363+
builder.AppendLine($"\t\t\tif ({string.Join(" || ", bucket.Select(s => s.FieldName))})");
364+
builder.AppendLine($"\t\t\t\treturn global::ICSharpCode.Decompiler.CSharp.LanguageVersion.{bucket.First().VersionName};");
365+
}
366+
builder.AppendLine("\t\t\treturn global::ICSharpCode.Decompiler.CSharp.LanguageVersion.CSharp1;");
367+
builder.AppendLine("\t\t}");
368+
}
369+
}

ICSharpCode.Decompiler.Generators/DecompilerSyntaxTreeGenerator.cs

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -843,17 +843,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
843843
var visitorMembers = astNodeAdditions.Collect();
844844

845845
context
846-
.RegisterPostInitializationOutput(i => i.AddSource("DecompilerSyntaxTreeGeneratorAttributes.g.cs", @"
847-
848-
using System;
849-
850-
namespace Microsoft.CodeAnalysis
851-
{
852-
internal sealed partial class EmbeddedAttribute : global::System.Attribute
853-
{
854-
}
855-
}
856-
846+
.RegisterPostInitializationOutput(i => i.AddSource("DecompilerSyntaxTreeGeneratorAttributes.g.cs", RoslynHelpers.EmbeddedAttributeSource + @"
857847
namespace ICSharpCode.Decompiler.CSharp.Syntax
858848
{
859849
[global::Microsoft.CodeAnalysis.EmbeddedAttribute]

0 commit comments

Comments
 (0)