Added an analyzer which keeps line breaks LF on every system (#1193)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-11 19:30:00 +01:00
1 parent b1c1f13993
commit 65d50de033
96 files changed
+741 -393

No files matched your search

@@ -21,3 +21,4 @@
MWAIS0015 | Usage | Error | JsonSourceGenerationAnalyzer
MWAIS0016 | Style | Error | ClassicExtensionMethodAnalyzer
MWAIS0017 | Usage | Error | IconCatalogAnalyzer
MWAIS0018 | Usage | Error | SystemLineBreakAnalyzer
@@ -19,4 +19,5 @@ public static class Identifier
public const string JSON_SOURCE_GENERATION_ANALYZER = $"{Tools.ID_PREFIX}0015";
public const string CLASSIC_EXTENSION_METHOD_ANALYZER = $"{Tools.ID_PREFIX}0016";
public const string ICON_CATALOG_ANALYZER = $"{Tools.ID_PREFIX}0017";
public const string SYSTEM_LINE_BREAK_ANALYZER = $"{Tools.ID_PREFIX}0018";
}
@@ -0,0 +1,85 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;
namespace SourceCodeRules.UsageAnalyzers;
/// <summary>
/// Refuses the line break of the system: StringBuilder.AppendLine and Environment.NewLine.
/// </summary>
/// <remarks>
/// Both write CR LF on Windows and LF everywhere else. A text built with them differs between the systems, it mixes
/// two kinds of line breaks as soon as one of its parts comes with LF, and a test which expects LF fails on Windows
/// only, where no pipeline runs the tests. AppendLineLF of SharedTools.Text and '\n' write LF on every system. Where a
/// format needs CR LF, such as a row of a CSV file, write it on purpose, as CsvWriter.ROW_END does.
/// </remarks>
#pragma warning disable RS1038
[DiagnosticAnalyzer(LanguageNames.CSharp)]
#pragma warning restore RS1038
public sealed class SystemLineBreakAnalyzer : DiagnosticAnalyzer
{
/// <summary>
/// The key of the properties of a diagnostic which tells the code fix what was found.
/// </summary>
public const string KIND_KEY = "Kind";
/// <summary>
/// The kind of a diagnostic about a call of StringBuilder.AppendLine.
/// </summary>
public const string KIND_APPEND_LINE = "AppendLine";
/// <summary>
/// The kind of a diagnostic about Environment.NewLine.
/// </summary>
public const string KIND_NEW_LINE = "NewLine";
private const string DIAGNOSTIC_ID = Identifier.SYSTEM_LINE_BREAK_ANALYZER;
private const string TITLE = "Line breaks are LF on every system";
private const string MESSAGE_FORMAT = "'{0}' writes the line break of the system, which is CR LF on Windows; use {1} instead";
private const string DESCRIPTION = "StringBuilder.AppendLine and Environment.NewLine write CR LF on Windows and LF elsewhere, so a text differs between the systems and mixes two kinds of line breaks. Use AppendLineLF of SharedTools.Text or '\\n'.";
private const string CATEGORY = "Usage";
private static readonly DiagnosticDescriptor RULE = new(DIAGNOSTIC_ID, TITLE, MESSAGE_FORMAT, CATEGORY, DiagnosticSeverity.Error, isEnabledByDefault: true, description: DESCRIPTION);
private static readonly ImmutableDictionary<string, string?> APPEND_LINE_PROPERTIES = ImmutableDictionary<string, string?>.Empty.Add(KIND_KEY, KIND_APPEND_LINE);
private static readonly ImmutableDictionary<string, string?> NEW_LINE_PROPERTIES = ImmutableDictionary<string, string?>.Empty.Add(KIND_KEY, KIND_NEW_LINE);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [RULE];
public override void Initialize(AnalysisContext context)
{
//
// Razor markup ends up in generated code, and so does the code block of a .razor file. We analyze generated
// code as well, and the Razor compiler maps the diagnostic back to the line of the .razor file:
//
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics);
context.EnableConcurrentExecution();
context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation);
context.RegisterOperationAction(AnalyzePropertyReference, OperationKind.PropertyReference);
}
private static void AnalyzeInvocation(OperationAnalysisContext context)
{
var invocation = (IInvocationOperation)context.Operation;
if (invocation.TargetMethod.Name is not "AppendLine" || invocation.TargetMethod.ContainingType.ToDisplayString() is not "System.Text.StringBuilder")
return;
context.ReportDiagnostic(Diagnostic.Create(RULE, invocation.Syntax.GetLocation(), APPEND_LINE_PROPERTIES, "StringBuilder.AppendLine", "AppendLineLF of SharedTools.Text"));
}
private static void AnalyzePropertyReference(OperationAnalysisContext context)
{
var reference = (IPropertyReferenceOperation)context.Operation;
if (reference.Property.Name is not "NewLine" || reference.Property.ContainingType.ToDisplayString() is not "System.Environment")
return;
context.ReportDiagnostic(Diagnostic.Create(RULE, reference.Syntax.GetLocation(), NEW_LINE_PROPERTIES, "Environment.NewLine", "'\\n'"));
}
}
@@ -0,0 +1,112 @@
using System.Collections.Immutable;
using System.Composition;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using SourceCodeRules.UsageAnalyzers;
namespace SourceCodeRules.UsageCodeFixes;
/// <summary>
/// Writes LF instead of the line break of the system: AppendLineLF instead of StringBuilder.AppendLine, and "\n"
/// instead of Environment.NewLine.
/// </summary>
/// <remarks>
/// The app imports SharedTools.Text everywhere. Elsewhere, such as in SharedTools or in the tests, the fix adds the
/// using directive itself when AppendLineLF would not be found without it.
/// </remarks>
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(SystemLineBreakCodeFixProvider)), Shared]
public sealed class SystemLineBreakCodeFixProvider : CodeFixProvider
{
private const string TITLE_APPEND_LINE = "Use AppendLineLF";
private const string TITLE_NEW_LINE = "Use a string with LF";
private const string NAMESPACE_OF_APPEND_LINE_LF = "SharedTools.Text";
public override ImmutableArray<string> FixableDiagnosticIds => [Identifier.SYSTEM_LINE_BREAK_ANALYZER];
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
if (root is null)
return;
var diagnostic = context.Diagnostics.First();
if (!diagnostic.Properties.TryGetValue(SystemLineBreakAnalyzer.KIND_KEY, out var kind))
return;
var node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true);
if (kind is SystemLineBreakAnalyzer.KIND_APPEND_LINE && NameOfCall(node.FirstAncestorOrSelf<InvocationExpressionSyntax>()) is { } name)
context.RegisterCodeFix(CodeAction.Create(TITLE_APPEND_LINE, token => UseAppendLineLFAsync(context.Document, name, token), TITLE_APPEND_LINE), diagnostic);
else if (kind is SystemLineBreakAnalyzer.KIND_NEW_LINE && node is ExpressionSyntax newLine)
context.RegisterCodeFix(CodeAction.Create(TITLE_NEW_LINE, token => UseLineFeedAsync(context.Document, newLine, token), TITLE_NEW_LINE), diagnostic);
}
/// <summary>
/// Gets the name of the method a call invokes, also behind a ?. of a conditional access.
/// </summary>
/// <param name="call">The call, or null.</param>
/// <returns>The name, or null when the call names no member.</returns>
private static SimpleNameSyntax? NameOfCall(InvocationExpressionSyntax? call) => call?.Expression switch
{
MemberAccessExpressionSyntax access => access.Name,
MemberBindingExpressionSyntax binding => binding.Name,
_ => null
};
private static async Task<Document> UseAppendLineLFAsync(Document document, SimpleNameSyntax name, CancellationToken token)
{
var root = await document.GetSyntaxRootAsync(token).ConfigureAwait(false);
if (root is null)
return document;
var annotation = new SyntaxAnnotation();
var renamed = document.WithSyntaxRoot(root.ReplaceNode(name, SyntaxFactory.IdentifierName("AppendLineLF").WithTriviaFrom(name).WithAdditionalAnnotations(annotation)));
var renamedRoot = await renamed.GetSyntaxRootAsync(token).ConfigureAwait(false);
var model = await renamed.GetSemanticModelAsync(token).ConfigureAwait(false);
if (renamedRoot is not CompilationUnitSyntax unit || model is null)
return renamed;
//
// Without the namespace of AppendLineLF in scope, the call binds to nothing, and only then is the using
// directive missing. It ends with the line break the file already uses:
//
var call = unit.GetAnnotatedNodes(annotation).First().FirstAncestorOrSelf<InvocationExpressionSyntax>();
if (call is null || model.GetSymbolInfo(call, token).Symbol is not null)
return renamed;
var directive = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(NAMESPACE_OF_APPEND_LINE_LF).WithLeadingTrivia(SyntaxFactory.Space)).WithTrailingTrivia(SyntaxFactory.EndOfLine(LineBreakOf(unit)));
return renamed.WithSyntaxRoot(unit.AddUsings(directive));
}
private static async Task<Document> UseLineFeedAsync(Document document, ExpressionSyntax newLine, CancellationToken token)
{
var root = await document.GetSyntaxRootAsync(token).ConfigureAwait(false);
if (root is null)
return document;
var lineFeed = SyntaxFactory.LiteralExpression(SyntaxKind.StringLiteralExpression, SyntaxFactory.Literal("\n")).WithTriviaFrom(newLine);
return document.WithSyntaxRoot(root.ReplaceNode(newLine, lineFeed));
}
/// <summary>
/// Gets the line break a file uses, so that an added line does not bring a second kind into it.
/// </summary>
/// <param name="root">The root of the file.</param>
/// <returns>The first line break of the file, or LF when it has none.</returns>
private static string LineBreakOf(SyntaxNode root)
{
var lineBreak = root.DescendantTrivia().FirstOrDefault(trivia => trivia.IsKind(SyntaxKind.EndOfLineTrivia));
return lineBreak.IsKind(SyntaxKind.EndOfLineTrivia) ? lineBreak.ToString() : "\n";
}
}