|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CodeStyle;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Editing;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Roslyn.Utilities;
namespace Microsoft.CodeAnalysis.FileHeaders;
internal abstract class AbstractFileHeaderCodeFixProvider : CodeFixProvider
{
protected abstract AbstractFileHeaderHelper FileHeaderHelper { get; }
public override ImmutableArray<string> FixableDiagnosticIds { get; }
= [IDEDiagnosticIds.FileHeaderMismatch];
public override Task RegisterCodeFixesAsync(CodeFixContext context)
{
foreach (var diagnostic in context.Diagnostics)
{
context.RegisterCodeFix(
CodeAction.Create(
CodeFixesResources.Add_file_header,
cancellationToken => GetTransformedDocumentAsync(context.Document, cancellationToken),
nameof(AbstractFileHeaderCodeFixProvider)),
diagnostic);
}
return Task.CompletedTask;
}
private async Task<Document> GetTransformedDocumentAsync(Document document, CancellationToken cancellationToken)
=> document.WithSyntaxRoot(await GetTransformedSyntaxRootAsync(document, cancellationToken).ConfigureAwait(false));
private async Task<SyntaxNode> GetTransformedSyntaxRootAsync(Document document, CancellationToken cancellationToken)
{
var options = await document.GetLineFormattingOptionsAsync(cancellationToken).ConfigureAwait(false);
var generator = document.GetRequiredLanguageService<SyntaxGeneratorInternal>();
var newLineTrivia = generator.EndOfLine(options.NewLine);
return await GetTransformedSyntaxRootAsync(generator.SyntaxFacts, FileHeaderHelper, newLineTrivia, document, fileHeaderTemplate: null, cancellationToken).ConfigureAwait(false);
}
internal static async Task<SyntaxNode> GetTransformedSyntaxRootAsync(ISyntaxFacts syntaxFacts, AbstractFileHeaderHelper fileHeaderHelper, SyntaxTrivia newLineTrivia, Document document, string? fileHeaderTemplate, CancellationToken cancellationToken)
{
var tree = await document.GetRequiredSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);
var root = await tree.GetRootAsync(cancellationToken).ConfigureAwait(false);
// If we weren't given a header lets get the one from editorconfig
if (fileHeaderTemplate is null &&
!document.Project.AnalyzerOptions.AnalyzerConfigOptionsProvider.GetOptions(tree).TryGetEditorConfigOption(CodeStyleOptions2.FileHeaderTemplate, out fileHeaderTemplate))
{
// No header supplied, no editorconfig setting, nothing to do
return root;
}
if (RoslynString.IsNullOrEmpty(fileHeaderTemplate))
{
// Header template is empty, nothing to do. This shouldn't be possible if this method is called in
// reaction to a diagnostic, but this method is also used when creating new documents so lets be defensive.
return root;
}
var expectedFileHeader = fileHeaderTemplate.Replace("{fileName}", Path.GetFileName(document.FilePath));
var fileHeader = fileHeaderHelper.ParseFileHeader(root);
SyntaxNode newSyntaxRoot;
if (fileHeader.IsMissing)
{
newSyntaxRoot = AddHeader(syntaxFacts, fileHeaderHelper, newLineTrivia, root, expectedFileHeader);
}
else
{
newSyntaxRoot = ReplaceHeader(syntaxFacts, fileHeaderHelper, newLineTrivia, root, expectedFileHeader);
}
return newSyntaxRoot;
}
private static SyntaxNode ReplaceHeader(ISyntaxFacts syntaxFacts, AbstractFileHeaderHelper fileHeaderHelper, SyntaxTrivia newLineTrivia, SyntaxNode root, string expectedFileHeader)
{
// Skip single line comments, whitespace, and end of line trivia until a blank line is encountered.
var triviaList = root.GetLeadingTrivia();
// True if the current line is blank so far (empty or whitespace); otherwise, false. The first line is
// assumed to not be blank, which allows the analysis to detect a file header which follows a blank line at
// the top of the file.
var onBlankLine = false;
// The set of indexes to remove from 'triviaList'. After removing these indexes, the remaining trivia (if
// any) will be preserved in the document along with the replacement header.
var removalList = new List<int>();
// The number of spaces to indent the new header. This is expected to match the indentation of the header
// which is being replaced.
var leadingSpaces = string.Empty;
// The number of spaces found so far on the current line. This will become 'leadingSpaces' if the spaces are
// followed by a comment which is considered a header comment.
var possibleLeadingSpaces = string.Empty;
// Need to do this with index so we get the line endings correct.
for (var i = 0; i < triviaList.Count; i++)
{
var triviaLine = triviaList[i];
if (triviaLine.RawKind == syntaxFacts.SyntaxKinds.SingleLineCommentTrivia)
{
if (possibleLeadingSpaces != string.Empty)
{
// One or more spaces precedes the comment. Keep track of these spaces so we can indent the new
// header by the same amount.
leadingSpaces = possibleLeadingSpaces;
}
removalList.Add(i);
onBlankLine = false;
}
else if (triviaLine.RawKind == syntaxFacts.SyntaxKinds.WhitespaceTrivia)
{
if (leadingSpaces == string.Empty)
{
possibleLeadingSpaces = triviaLine.ToFullString();
}
removalList.Add(i);
}
else if (triviaLine.RawKind == syntaxFacts.SyntaxKinds.EndOfLineTrivia)
{
possibleLeadingSpaces = string.Empty;
removalList.Add(i);
if (onBlankLine)
{
break;
}
else
{
onBlankLine = true;
}
}
else
{
break;
}
}
// Remove copyright lines in reverse order.
for (var i = removalList.Count - 1; i >= 0; i--)
{
triviaList = triviaList.RemoveAt(removalList[i]);
}
var newHeaderTrivia = CreateNewHeader(syntaxFacts, leadingSpaces + fileHeaderHelper.CommentPrefix, expectedFileHeader, newLineTrivia.ToFullString());
// Add a blank line and any remaining preserved trivia after the header.
newHeaderTrivia = newHeaderTrivia.Add(newLineTrivia).Add(newLineTrivia).AddRange(triviaList);
// Insert header at top of the file.
return root.WithLeadingTrivia(newHeaderTrivia);
}
private static SyntaxNode AddHeader(ISyntaxFacts syntaxFacts, AbstractFileHeaderHelper fileHeaderHelper, SyntaxTrivia newLineTrivia, SyntaxNode root, string expectedFileHeader)
{
var newTrivia = CreateNewHeader(syntaxFacts, fileHeaderHelper.CommentPrefix, expectedFileHeader, newLineTrivia.ToFullString()).Add(newLineTrivia).Add(newLineTrivia);
// Skip blank lines already at the beginning of the document, since we add our own
var leadingTrivia = root.GetLeadingTrivia();
var skipCount = 0;
for (var i = 0; i < leadingTrivia.Count; i++)
{
if (leadingTrivia[i].RawKind == syntaxFacts.SyntaxKinds.EndOfLineTrivia)
{
skipCount = i + 1;
}
else if (leadingTrivia[i].RawKind != syntaxFacts.SyntaxKinds.WhitespaceTrivia)
{
break;
}
}
newTrivia = newTrivia.AddRange(leadingTrivia.Skip(skipCount));
return root.WithLeadingTrivia(newTrivia);
}
private static SyntaxTriviaList CreateNewHeader(ISyntaxFacts syntaxFacts, string prefixWithLeadingSpaces, string expectedFileHeader, string newLineText)
{
var copyrightText = GetCopyrightText(prefixWithLeadingSpaces, expectedFileHeader, newLineText);
var newHeader = copyrightText;
return syntaxFacts.ParseLeadingTrivia(newHeader);
}
private static string GetCopyrightText(string prefixWithLeadingSpaces, string copyrightText, string newLineText)
{
copyrightText = copyrightText.Replace("\r\n", "\n");
var lines = copyrightText.Split('\n');
return string.Join(newLineText, lines.Select(line =>
{
// Rewrite the lines of the header as comments without trailing whitespace.
if (string.IsNullOrEmpty(line))
{
// This is a blank line of the header. We want the prefix indicating the line is a comment, but no
// additional trailing whitespace.
return prefixWithLeadingSpaces;
}
else
{
// This is a normal line of the header. We want the prefix, followed by a single space, and then the
// text of the header line.
return prefixWithLeadingSpaces + " " + line;
}
}));
}
public override FixAllProvider GetFixAllProvider()
=> FixAllProvider.Create(async (context, document, diagnostics) =>
{
if (diagnostics.IsEmpty)
return null;
return await this.GetTransformedDocumentAsync(document, context.CancellationToken).ConfigureAwait(false);
});
}
|