|
| 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 | +} |
0 commit comments