Analysers and Source Generators
Analysers warn about your code at compile time, source generators add code at compile time, and EditorConfig settles the house style.
[GeneratedRegex(pattern)] internal static partial Regex Name();Two kinds of component plug into the C# compiler. An analyser reads your code and reports diagnostics. A source generator reads your code and adds new source files to the compilation. Neither runs your program, and a generator can only add code; it cannot change or delete what you wrote.
Analysers and severity
The .NET code quality rules, the ones with CA numbers, ship in the SDK and are on by default for projects targeting .NET 5 or later. AnalysisMode chooses how many are enabled, from None through Default, Minimum and Recommended to All. AnalysisLevel pins the rule set to a release, so upgrading the SDK does not add warnings mid-sprint. Code style rules, the IDE numbers, do not run during a build unless you set EnforceCodeStyleInBuild to true. TreatWarningsAsErrors makes every warning stop the build; WarningsNotAsErrors exempts named codes, and NoWarn silences them.
EditorConfig
One .editorconfig at the repository root sets indentation, brace placement and per-rule severity, and the compiler, the IDE and dotnet format all read it. That is why formatting arguments stop happening.
Generators in the SDK
You will use generators before you write one. [GeneratedRegex] (.NET 7, and on a partial property since .NET 9) writes the matching code at compile time. [LoggerMessage] writes allocation-free logging methods. JsonSerializerContext writes serialisation metadata. Generated code is ordinary C# you can read and step into. A generator written today implements IIncrementalGenerator; the older interface is ISourceGenerator.
Example
using System.Text.RegularExpressions;
Console.WriteLine($"123-4567 is a ticket: {Patterns.Ticket().IsMatch("123-4567")}");
Console.WriteLine($"12-4567 is a ticket: {Patterns.Ticket().IsMatch("12-4567")}");
Console.WriteLine($"found: {Patterns.Ticket().Match("see 123-4567 for detail").Value}");
internal static partial class Patterns
{
// Partial, with no body. The source generator in the SDK writes
// the body at compile time from the pattern, and caches the
// Regex instance. Available since .NET 7.
// The attribute's full name is GeneratedRegexAttribute; the
// "Attribute" suffix is dropped at the use site, as always.
[GeneratedRegex(@"\d{3}-\d{4}")]
internal static partial Regex Ticket();
// The longhand this replaces, kept here for comparison:
// private static readonly Regex _ticket =
// new Regex(@"\d{3}-\d{4}", RegexOptions.Compiled);
}
// Output:
// 123-4567 is a ticket: True
// 12-4567 is a ticket: False
// found: 123-4567When to use it
- Stop a pull request that reintroduces a bug an analyser already knows about.
- End formatting arguments by committing one .editorconfig and running dotnet format in CI.
- Replace a hot-path regular expression or logging call with a generated, allocation-free version.
- Pin the analyser rule set so an SDK upgrade does not break the build on a release day.
More examples
.editorconfig at the repository root
Commit this file and formatting stops being a review topic: `dotnet format` applies it, the IDE follows it, and the build enforces whatever you marked as a warning or an error. Set severities per rule here rather than per project, so a rule means the same thing in every project in the repository.
root = true
[*.cs]
indent_style = space
indent_size = 4
insert_final_newline = true
# Allman braces: the opening brace goes on its own line.
csharp_new_line_before_open_brace = all
# Severity of individual rules: none, silent, suggestion,
# warning or error.
dotnet_diagnostic.CA1822.severity = warning
dotnet_diagnostic.IDE0055.severity = error
# Generated files should not be judged by hand-written rules.
[*.g.cs]
generated_code = trueBuild settings that decide which warnings matter
Put this in a Directory.Build.props at the root so every project inherits it. Raising AnalysisMode on an existing codebase produces a long list at once; raise it one level at a time, or pin AnalysisLevel and schedule the upgrade, rather than turning the whole thing off because the first attempt was noisy.
<PropertyGroup>
<!-- On by default for net5.0 and later; stated to be explicit. -->
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<!-- More rules than the default set. -->
<AnalysisMode>Recommended</AnalysisMode>
<!-- Pin the rule set so an SDK upgrade adds no new warnings. -->
<AnalysisLevel>10</AnalysisLevel>
<!-- Run the IDE code style rules during the build too. -->
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<!-- No warnings reach main. -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Except package advisories, which appear without a code change. -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);NU1901;NU1902;NU1903;NU1904</WarningsNotAsErrors>
</PropertyGroup>The logging source generator
Both forms produce the same structured log entry, with orderId and region as separate fields rather than baked into one string. The generated form costs nothing when the level is disabled, which matters on a hot path, and a typo in a placeholder becomes a compile-time error instead of a mangled log line. The method must be partial and return void. It does not have to be static: a static logging method must take the ILogger as a parameter, as this one does, while an instance method picks the logger up from an ILogger field on the containing class, or, since .NET 9, from an ILogger primary constructor parameter.
using Microsoft.Extensions.Logging;
internal static partial class Log
{
// The generator writes a method that checks whether the level
// is enabled before touching the arguments, and avoids boxing
// the int. Message placeholders match the parameter names.
[LoggerMessage(
Level = LogLevel.Information,
Message = "Order {orderId} shipped to {region}")]
public static partial void OrderShipped(ILogger logger, int orderId, string region);
}
// At the call site:
// Log.OrderShipped(logger, 7, "eu-west");
//
// The longhand it replaces:
// logger.LogInformation("Order {orderId} shipped to {region}", 7, "eu-west");
Discussion