Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow PublicAPI analyzers to merge from multiple sources #5422

Merged
merged 6 commits into from
Nov 2, 2021
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -257,12 +257,12 @@ private void OnSymbolActionCore(ISymbol symbol, Action<Diagnostic> reportDiagnos
}
else
{
reportAnnotateApi(symbol, isImplicitlyDeclaredConstructor, publicApiName, foundApiLine.IsShippedApi);
reportAnnotateApi(symbol, isImplicitlyDeclaredConstructor, publicApiName, foundApiLine.IsShippedApi, foundApiLine.Path);
}
}
else if (hasPublicApiEntryWithNullability && symbolUsesOblivious)
{
reportAnnotateApi(symbol, isImplicitlyDeclaredConstructor, publicApiName, foundApiLine.IsShippedApi);
reportAnnotateApi(symbol, isImplicitlyDeclaredConstructor, publicApiName, foundApiLine.IsShippedApi, foundApiLine.Path);
}
}
else
Expand Down Expand Up @@ -395,15 +395,16 @@ void reportDeclareNewApi(ISymbol symbol, bool isImplicitlyDeclaredConstructor, s
reportDiagnosticAtLocations(DeclareNewApiRule, propertyBag, errorMessageName);
}

void reportAnnotateApi(ISymbol symbol, bool isImplicitlyDeclaredConstructor, ApiName publicApiName, bool isShipped)
void reportAnnotateApi(ISymbol symbol, bool isImplicitlyDeclaredConstructor, ApiName publicApiName, bool isShipped, string filename)
{
// Public API missing annotations in public API file - report diagnostic.
string errorMessageName = GetErrorMessageName(symbol, isImplicitlyDeclaredConstructor);
ImmutableDictionary<string, string> propertyBag = ImmutableDictionary<string, string>.Empty
.Add(PublicApiNamePropertyBagKey, publicApiName.Name)
.Add(PublicApiNameWithNullabilityPropertyBagKey, withObliviousIfNeeded(publicApiName.NameWithNullability))
.Add(MinimalNamePropertyBagKey, errorMessageName)
.Add(PublicApiIsShippedPropertyBagKey, isShipped ? "true" : "false");
.Add(PublicApiIsShippedPropertyBagKey, isShipped ? "true" : "false")
.Add(FileName, filename);

reportDiagnosticAtLocations(AnnotateApiRule, propertyBag, errorMessageName);
}
Expand Down
94 changes: 53 additions & 41 deletions src/PublicApiAnalyzers/Core/Analyzers/DeclarePublicApiAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
Expand All @@ -21,8 +20,11 @@ namespace Microsoft.CodeAnalysis.PublicApiAnalyzers
[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)]
public sealed partial class DeclarePublicApiAnalyzer : DiagnosticAnalyzer
{
internal const string ShippedFileNamePrefix = "PublicAPI.Shipped";
internal const string ShippedFileName = "PublicAPI.Shipped.txt";
internal const string UnshippedFileName = "PublicAPI.Unshipped.txt";
internal const string UnshippedFileNamePrefix = "PublicAPI.Unshipped";
internal const string Extension = ".txt";
internal const string UnshippedFileName = UnshippedFileNamePrefix + Extension;
internal const string PublicApiNamePropertyBagKey = "PublicAPIName";
internal const string PublicApiNameWithNullabilityPropertyBagKey = "PublicAPINameWithNullability";
internal const string MinimalNamePropertyBagKey = "MinimalName";
Expand All @@ -33,6 +35,7 @@ public sealed partial class DeclarePublicApiAnalyzer : DiagnosticAnalyzer
internal const string InvalidReasonShippedCantHaveRemoved = "The shipped API file can't have removed members";
internal const string InvalidReasonMisplacedNullableEnable = "The '#nullable enable' marker can only appear as the first line in the shipped API file";
internal const string PublicApiIsShippedPropertyBagKey = "PublicAPIIsShipped";
internal const string FileName = "FileName";

private const char ObliviousMarker = '~';

Expand Down Expand Up @@ -258,38 +261,42 @@ private void OnCompilationStart(CompilationStartAnalysisContext compilationConte
compilationContext.RegisterCompilationEndAction(impl.OnCompilationEnd);
}

private static ApiData ReadApiData(string path, SourceText sourceText, bool isShippedApi)
private static ApiData ReadApiData(List<(string path, SourceText sourceText)> data, bool isShippedApi)
{
var apiBuilder = ImmutableArray.CreateBuilder<ApiLine>();
var removedBuilder = ImmutableArray.CreateBuilder<RemovedApiLine>();
var maxNullableRank = -1;

int rank = -1;
foreach (TextLine line in sourceText.Lines)
foreach (var (path, sourceText) in data)
{
string text = line.ToString();
if (string.IsNullOrWhiteSpace(text))
int rank = -1;

foreach (TextLine line in sourceText.Lines)
{
continue;
}
string text = line.ToString();
if (string.IsNullOrWhiteSpace(text))
{
continue;
}

rank++;
rank++;

if (text == NullableEnable)
{
maxNullableRank = rank;
continue;
}
if (text == NullableEnable)
{
maxNullableRank = Math.Max(rank, maxNullableRank);
continue;
}

var apiLine = new ApiLine(text, line.Span, sourceText, path, isShippedApi);
if (text.StartsWith(RemovedApiPrefix, StringComparison.Ordinal))
{
string removedtext = text[RemovedApiPrefix.Length..];
removedBuilder.Add(new RemovedApiLine(removedtext, apiLine));
}
else
{
apiBuilder.Add(apiLine);
var apiLine = new ApiLine(text, line.Span, sourceText, path, isShippedApi);
if (text.StartsWith(RemovedApiPrefix, StringComparison.Ordinal))
{
string removedtext = text[RemovedApiPrefix.Length..];
removedBuilder.Add(new RemovedApiLine(removedtext, apiLine));
}
else
{
apiBuilder.Add(apiLine);
}
}
}

Expand Down Expand Up @@ -322,8 +329,8 @@ private static bool TryGetApiData(AnalyzerOptions analyzerOptions, Compilation c
return false;
}

shippedData = ReadApiData(shippedText.Value.path, shippedText.Value.text, isShippedApi: true);
unshippedData = ReadApiData(unshippedText.Value.path, unshippedText.Value.text, isShippedApi: false);
shippedData = ReadApiData(shippedText, isShippedApi: true);
unshippedData = ReadApiData(unshippedText, isShippedApi: false);
return true;
}

Expand Down Expand Up @@ -380,42 +387,47 @@ private static bool TryGetEditorConfigOptionForMissingFiles(AnalyzerOptions anal
private static bool TryGetApiText(
ImmutableArray<AdditionalText> additionalTexts,
CancellationToken cancellationToken,
[NotNullWhen(returnValue: true)] out (string path, SourceText text)? shippedText,
[NotNullWhen(returnValue: true)] out (string path, SourceText text)? unshippedText)
[NotNullWhen(returnValue: true)] out List<(string path, SourceText text)>? shippedText,
[NotNullWhen(returnValue: true)] out List<(string path, SourceText text)>? unshippedText)
{
shippedText = null;
unshippedText = null;

StringComparer comparer = StringComparer.Ordinal;
foreach (AdditionalText additionalText in additionalTexts)
{
cancellationToken.ThrowIfCancellationRequested();

string fileName = Path.GetFileName(additionalText.Path);
var file = new PublicApiFile(additionalText.Path);

bool isShippedFile = comparer.Equals(fileName, ShippedFileName);
bool isUnshippedFile = comparer.Equals(fileName, UnshippedFileName);

if (isShippedFile || isUnshippedFile)
if (file.IsApiFile)
{
SourceText text = additionalText.GetText(cancellationToken);

if (text == null)
if (text is null)
{
continue;
}

var data = (additionalText.Path, text);
if (isShippedFile)

if (file.IsShipping)
{
shippedText = data;
}
if (shippedText is null)
{
shippedText = new();
}

if (isUnshippedFile)
shippedText.Add(data);
}
else
{
unshippedText = data;
if (unshippedText is null)
{
unshippedText = new();
}

unshippedText.Add(data);
}
continue;
}
}

Expand Down
30 changes: 30 additions & 0 deletions src/PublicApiAnalyzers/Core/Analyzers/PublicApiFile.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information.

using System;

namespace Microsoft.CodeAnalysis.PublicApiAnalyzers
{
public readonly struct PublicApiFile
{
public PublicApiFile(string path)
{
Path = path;

var fileName = System.IO.Path.GetFileName(path);
twsouthwick marked this conversation as resolved.
Show resolved Hide resolved

IsShipping = IsFile(fileName, DeclarePublicApiAnalyzer.ShippedFileNamePrefix);
var isUnshippedFile = IsFile(fileName, DeclarePublicApiAnalyzer.UnshippedFileNamePrefix);

IsApiFile = IsShipping || isUnshippedFile;
}

public string Path { get; }

public bool IsShipping { get; }

public bool IsApiFile { get; }

private static bool IsFile(string path, string prefix)
=> path.StartsWith(prefix, StringComparison.Ordinal) && path.EndsWith(DeclarePublicApiAnalyzer.Extension, StringComparison.Ordinal);
}
}
79 changes: 28 additions & 51 deletions src/PublicApiAnalyzers/Core/CodeFixes/AnnotatePublicApiFix.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Analyzer.Utilities.PooledObjects;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.Text;

using DiagnosticIds = Roslyn.Diagnostics.Analyzers.RoslynDiagnosticIds;

#nullable enable
Expand All @@ -38,15 +38,16 @@ public sealed override Task RegisterCodeFixesAsync(CodeFixContext context)
string minimalSymbolName = diagnostic.Properties[DeclarePublicApiAnalyzer.MinimalNamePropertyBagKey];
string publicSymbolName = diagnostic.Properties[DeclarePublicApiAnalyzer.PublicApiNamePropertyBagKey];
string publicSymbolNameWithNullability = diagnostic.Properties[DeclarePublicApiAnalyzer.PublicApiNameWithNullabilityPropertyBagKey];
bool isShippedDocument = diagnostic.Properties[DeclarePublicApiAnalyzer.PublicApiIsShippedPropertyBagKey] == "true";
string fileName = diagnostic.Properties[DeclarePublicApiAnalyzer.FileName];
jmarolf marked this conversation as resolved.
Show resolved Hide resolved

TextDocument? document = isShippedDocument ? PublicApiFixHelpers.GetShippedDocument(project) : PublicApiFixHelpers.GetUnshippedDocument(project);
TextDocument? document = project.GetPublicApiDocument(fileName);

if (document != null)
{
context.RegisterCodeFix(
new DeclarePublicApiFix.AdditionalDocumentChangeAction(
$"Annotate {minimalSymbolName} in public API",
document.Id,
c => GetFix(document, publicSymbolName, publicSymbolNameWithNullability, c)),
diagnostic);
}
Expand Down Expand Up @@ -81,8 +82,8 @@ private static SourceText AnnotateSymbolNamesInSourceText(SourceText sourceText,
}
}

var endOfLine = PublicApiFixHelpers.GetEndOfLine(sourceText);
SourceText newSourceText = sourceText.Replace(new TextSpan(0, sourceText.Length), string.Join(endOfLine, lines) + PublicApiFixHelpers.GetEndOfFileText(sourceText, endOfLine));
var endOfLine = sourceText.GetEndOfLine();
SourceText newSourceText = sourceText.Replace(new TextSpan(0, sourceText.Length), string.Join(endOfLine, lines) + sourceText.GetEndOfFileText(endOfLine));
return newSourceText;
}

Expand All @@ -102,53 +103,22 @@ public FixAllAdditionalDocumentChangeAction(string title, Solution solution, Lis

protected override async Task<Solution> GetChangedSolutionAsync(CancellationToken cancellationToken)
{
var updatedPublicSurfaceAreaText = new List<KeyValuePair<DocumentId, SourceText>>();

using var uniqueShippedDocuments = PooledHashSet<string>.GetInstance();
using var uniqueUnshippedDocuments = PooledHashSet<string>.GetInstance();
var updatedPublicSurfaceAreaText = new List<(DocumentId, SourceText)>();

foreach (KeyValuePair<Project, ImmutableArray<Diagnostic>> pair in _diagnosticsToFix)
foreach (var (project, diagnostics) in _diagnosticsToFix)
{
Project project = pair.Key;
ImmutableArray<Diagnostic> diagnostics = pair.Value;

TextDocument? unshippedDocument = PublicApiFixHelpers.GetUnshippedDocument(project);
if (unshippedDocument?.FilePath != null && !uniqueUnshippedDocuments.Add(unshippedDocument.FilePath))
{
// Skip past duplicate unshipped documents.
// Multi-tfm projects can likely share the same api files, and we want to avoid duplicate code fix application.
unshippedDocument = null;
}

TextDocument? shippedDocument = PublicApiFixHelpers.GetShippedDocument(project);
if (shippedDocument?.FilePath != null && !uniqueShippedDocuments.Add(shippedDocument.FilePath))
{
// Skip past duplicate shipped documents.
// Multi-tfm projects can likely share the same api files, and we want to avoid duplicate code fix application.
shippedDocument = null;
}

if (unshippedDocument == null && shippedDocument == null)
{
continue;
}

SourceText? unshippedSourceText = unshippedDocument is null ? null : await unshippedDocument.GetTextAsync(cancellationToken).ConfigureAwait(false);
SourceText? shippedSourceText = shippedDocument is null ? null : await shippedDocument.GetTextAsync(cancellationToken).ConfigureAwait(false);

IEnumerable<IGrouping<SyntaxTree, Diagnostic>> groupedDiagnostics =
diagnostics
.Where(d => d.Location.IsInSource)
.GroupBy(d => d.Location.SourceTree);

var shippedChanges = new Dictionary<string, string>();
var unshippedChanges = new Dictionary<string, string>();
var allChanges = new Dictionary<string, Dictionary<string, string>>();

foreach (IGrouping<SyntaxTree, Diagnostic> grouping in groupedDiagnostics)
{
Document document = project.GetDocument(grouping.Key);

if (document == null)
if (document is null)
{
continue;
}
Expand All @@ -166,29 +136,36 @@ protected override async Task<Solution> GetChangedSolutionAsync(CancellationToke
string oldName = diagnostic.Properties[DeclarePublicApiAnalyzer.PublicApiNamePropertyBagKey];
string newName = diagnostic.Properties[DeclarePublicApiAnalyzer.PublicApiNameWithNullabilityPropertyBagKey];
bool isShipped = diagnostic.Properties[DeclarePublicApiAnalyzer.PublicApiIsShippedPropertyBagKey] == "true";
var mapToUpdate = isShipped ? shippedChanges : unshippedChanges;
string fileName = diagnostic.Properties[DeclarePublicApiAnalyzer.FileName];

if (!allChanges.TryGetValue(fileName, out var mapToUpdate))
{
mapToUpdate = new();
allChanges.Add(fileName, mapToUpdate);
}

mapToUpdate[oldName] = newName;
}
}

if (shippedSourceText is object)
foreach (var (path, changes) in allChanges)
{
SourceText newShippedSourceText = AnnotateSymbolNamesInSourceText(shippedSourceText, shippedChanges);
updatedPublicSurfaceAreaText.Add(new KeyValuePair<DocumentId, SourceText>(shippedDocument!.Id, newShippedSourceText));
}
var doc = project.GetPublicApiDocument(path);

if (unshippedSourceText is object)
{
SourceText newUnshippedSourceText = AnnotateSymbolNamesInSourceText(unshippedSourceText, unshippedChanges);
updatedPublicSurfaceAreaText.Add(new KeyValuePair<DocumentId, SourceText>(unshippedDocument!.Id, newUnshippedSourceText));
if (doc is not null)
{
var text = await doc.GetTextAsync(cancellationToken).ConfigureAwait(false);
SourceText newShippedSourceText = AnnotateSymbolNamesInSourceText(text, changes);
updatedPublicSurfaceAreaText.Add((doc.Id, newShippedSourceText));
}
}
}

Solution newSolution = _solution;

foreach (KeyValuePair<DocumentId, SourceText> pair in updatedPublicSurfaceAreaText)
foreach (var (docId, text) in updatedPublicSurfaceAreaText)
{
newSolution = newSolution.WithAdditionalDocumentText(pair.Key, pair.Value);
newSolution = newSolution.WithAdditionalDocumentText(docId, text);
}

return newSolution;
Expand Down
Loading