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

Add an analyzer to detect when a duplicate razor file is provided #11498

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 1 addition & 3 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,12 @@
<PackageVersion Include="Microsoft.Build.Framework" Version="$(_MicrosoftBuildPackageVersion)" />
<PackageVersion Include="Microsoft.Build.Locator" Version="1.4.1" />
<PackageVersion Include="Microsoft.Build.Utilities.Core" Version="$(_MicrosoftBuildPackageVersion)" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzer.Testing" Version="1.1.2-beta1.24121.1" NoWarn="NU1608" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.2" />
<!-- Temporarily force analyzers to match compiler version https://github.com/dotnet/razor-tooling/issues/6758 -->
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="$(_MicrosoftCodeAnalysisAnalyzersPackageVersion)" NoWarn="NU1608" />
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="$(MicrosoftCodeAnalysisCommonPackageVersion)" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="$(MicrosoftCodeAnalysisCSharpPackageVersion)" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Analyzer" Version="$(MicrosoftCodeAnalysisCSharpAnalyzerTestingPackageVersion)" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit" Version="1.1.2-beta1.24121.1" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.EditorFeatures" Version="$(MicrosoftCodeAnalysisCSharpEditorFeaturesPackageVersion)" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Features" Version="$(MicrosoftCodeAnalysisCSharpFeaturesPackageVersion)" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="$(MicrosoftCodeAnalysisCSharpWorkspacesPackageVersion)" />
Expand All @@ -50,7 +49,6 @@
<PackageVersion Include="Microsoft.CodeAnalysis.ExternalAccess.Razor" Version="$(MicrosoftCodeAnalysisExternalAccessRazorPackageVersion)" />
<PackageVersion Include="Microsoft.CodeAnalysis.Remote.ServiceHub" Version="$(MicrosoftCodeAnalysisRemoteServiceHubPackageVersion)" />
<PackageVersion Include="Microsoft.CodeAnalysis.Test.Utilities" Version="$(MicrosoftCodeAnalysisTestUtilitiesPackageVersion)"/>
<PackageVersion Include="Microsoft.CodeAnalysis.Testing.Verifiers.XUnit" Version="1.1.2-beta1.24121.1" />
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces" Version="$(MicrosoftCodeAnalysisWorkspacesPackageVersion)" />
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="$(MicrosoftCodeAnalysisWorkspacesCommonPackageVersion)" />
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="$(MicrosoftCodeAnalysisWorkspacesMSBuildPackageVersion)" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit" />
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<PackageReference Include="Microsoft.CodeAnalysis.Testing.Verifiers.XUnit" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@

using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Testing.Verifiers;
using Microsoft.CodeAnalysis.Testing;

namespace Razor.Diagnostics.Analyzers.Test;

public static partial class CSharpAnalyzerVerifier<TAnalyzer>
where TAnalyzer : DiagnosticAnalyzer, new()
{
public class Test : CSharpAnalyzerTest<TAnalyzer, XUnitVerifier>
public class Test : CSharpAnalyzerTest<TAnalyzer, DefaultVerifier>
{
public Test()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Testing;
using Microsoft.CodeAnalysis.Testing.Verifiers;

namespace Razor.Diagnostics.Analyzers.Test;

Expand All @@ -16,15 +15,15 @@ public static partial class CSharpAnalyzerVerifier<TAnalyzer>
{
/// <inheritdoc cref="AnalyzerVerifier{TAnalyzer, TTest, TVerifier}.Diagnostic()"/>
public static DiagnosticResult Diagnostic()
=> CSharpAnalyzerVerifier<TAnalyzer, XUnitVerifier>.Diagnostic();
=> CSharpAnalyzerVerifier<TAnalyzer, DefaultVerifier>.Diagnostic();

/// <inheritdoc cref="AnalyzerVerifier{TAnalyzer, TTest, TVerifier}.Diagnostic(string)"/>
public static DiagnosticResult Diagnostic(string diagnosticId)
=> CSharpAnalyzerVerifier<TAnalyzer, XUnitVerifier>.Diagnostic(diagnosticId);
=> CSharpAnalyzerVerifier<TAnalyzer, DefaultVerifier>.Diagnostic(diagnosticId);

/// <inheritdoc cref="AnalyzerVerifier{TAnalyzer, TTest, TVerifier}.Diagnostic(DiagnosticDescriptor)"/>
public static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor)
=> CSharpAnalyzerVerifier<TAnalyzer, XUnitVerifier>.Diagnostic(descriptor);
=> CSharpAnalyzerVerifier<TAnalyzer, DefaultVerifier>.Diagnostic(descriptor);

/// <inheritdoc cref="AnalyzerVerifier{TAnalyzer, TTest, TVerifier}.VerifyAnalyzerAsync(string, DiagnosticResult[])"/>
public static async Task VerifyAnalyzerAsync(string source, params DiagnosticResult[] expected)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Text;
using Microsoft.NET.Sdk.Razor.SourceGenerators;

namespace Microsoft.AspNetCore.Razor.SourceGenerators;

#pragma warning disable RS1041 // Compiler extensions should be implemented in assemblies targeting netstandard2.0
[DiagnosticAnalyzer(LanguageNames.CSharp)]
#pragma warning restore RS1041 // Compiler extensions should be implemented in assemblies targeting netstandard2.0
public class DuplicateRazorFileIncludedAnalyzer : DiagnosticAnalyzer
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sealed?

{
private static readonly ImmutableArray<DiagnosticDescriptor> s_supportedDiagnostics = ImmutableArray.Create(RazorDiagnostics.DuplicateRazorFileIncludedDescriptor);

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => s_supportedDiagnostics;

public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterCompilationStartAction(static compStartAction =>
{
var includedFiles = new HashSet<string>(StringComparer.Ordinal);

compStartAction.RegisterAdditionalFileAction(additionalFilesContext =>
{
var additionalFile = additionalFilesContext.AdditionalFile;

if (additionalFile.Path.EndsWith(".cshtml", StringComparison.Ordinal) ||
additionalFile.Path.EndsWith(".razor", StringComparison.Ordinal))
{
if (!includedFiles.Add(additionalFile.Path))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can it happen that the paths are equivalent but not normalized at this point? For example:

Components\Pages\Counter.razor
Components/Pages/Counter.razor

This check would not catch such duplicates.

{
var diagnostic = Diagnostic.Create(
RazorDiagnostics.DuplicateRazorFileIncludedDescriptor,
Location.Create(additionalFile.Path, new TextSpan(0, 0), new LinePositionSpan(LinePosition.Zero, LinePosition.Zero)),
additionalFile.Path);

additionalFilesContext.ReportDiagnostic(diagnostic);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will cause errors in source generation

The source generator will still run and report errors though, right? Wouldn't it be better if this was part of the SG pipeline then?

What's the advantage of a separate analyzer anyway?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a general feeling that we should be moving towards analyzers producing diagnostics over generators as they play better in the incremental space.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps the source generator could also detect this situation and bail early to avoid producing superfluous diagnostics.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a general feeling that we should be moving towards analyzers producing diagnostics over generators as they play better in the incremental space.

Thinking about this recommendation in general, it's hard to imagine all the diagnostics that are produced by the razor SG now would instead be produced by some analyzer, that sounds like a lot of duplicate (and hence error-prone) logic.

}
}
});
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ internal static class DiagnosticIds
public const string UnexpectedProjectItemReadCallId = "RSG007";
public const string InvalidRazorContextComputedId = "RSG008";
public const string MetadataReferenceNotProvidedId = "RSG009";
public const string DuplicateRazorFileFound = "RSG010";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@ internal static class RazorDiagnostics
isEnabledByDefault: true
);

public static readonly DiagnosticDescriptor DuplicateRazorFileIncludedDescriptor = new DiagnosticDescriptor(
DiagnosticIds.DuplicateRazorFileFound,
new LocalizableResourceString(nameof(RazorSourceGeneratorResources.DuplicateRazorFileIncludedTitle), RazorSourceGeneratorResources.ResourceManager, typeof(RazorSourceGeneratorResources)),
new LocalizableResourceString(nameof(RazorSourceGeneratorResources.DuplicateRazorFileIncludedMessage), RazorSourceGeneratorResources.ResourceManager, typeof(RazorSourceGeneratorResources)),
"RazorSourceGenerator",
DiagnosticSeverity.Error,
isEnabledByDefault: true
);

public static Diagnostic AsDiagnostic(this RazorDiagnostic razorDiagnostic)
{
var descriptor = new DiagnosticDescriptor(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
<!--
Microsoft ResX Schema

Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes

The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.

Example:

... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
Expand All @@ -26,36 +26,36 @@
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple

There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the

Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not

The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can

Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.

mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.

mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.

mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
Expand Down Expand Up @@ -177,4 +177,10 @@
<data name="MetadataReferenceNotProvidedMessage" xml:space="preserve">
<value>Expected a valid MetadataReference, but found none.</value>
</data>
</root>
<data name="DuplicateRazorFileIncludedTitle" xml:space="preserve">
<value>Razor file was included twice</value>
</data>
<data name="DuplicateRazorFileIncludedMessage" xml:space="preserve">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Should this be more specific and say "was included as an additional file" rather than just "in the build"? Including it in the build twice isn't actually a problem if the items have different item types, eg EmbeddedResource and AdditionalFiles, right?

Also "more than once" rather than "twice" because it could actually be three times 😛

<value>File '{0}' was included in the build twice. This will cause generator build issues.</value>
</data>
</root>
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.SourceGenerators;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Testing;
using Xunit;

namespace Microsoft.NET.Sdk.Razor.SourceGenerators.Tests;

public class DuplicateRazorFileIncludedAnalyzerTest
{
[Theory]
[InlineData("Duplicate.cshtml")]
[InlineData("Duplicate.razor")]
public async Task Analyzer_ReportsDiagnostic_WhenDuplicateRazorFileIsIncluded(string fileName)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a test for the razor source generator as well to see what the errors would be.

I actually cannot reproduce any issues there. I've created a project which passes duplicate AdditionalFiles to Csc but don't see any errors from the razor source generator. What is needed to hit an error?

{
// Arrange
var test = new CSharpAnalyzerTest<DuplicateRazorFileIncludedAnalyzer, DefaultVerifier>
{
TestState =
{
Sources =
{
// Need a non-empty source file to make the test helper happy
("Test.cs", "public class Test {}"),
},
AdditionalFiles =
{
(fileName, "<h1>Duplicate</h1>"),
(fileName, "<h1>Duplicate</h1>"),
},
},
ExpectedDiagnostics =
{
new DiagnosticResult(RazorDiagnostics.DuplicateRazorFileIncludedDescriptor.Id, DiagnosticSeverity.Error)
.WithLocation(fileName, 1, 1)
.WithMessageFormat(RazorDiagnostics.DuplicateRazorFileIncludedDescriptor.MessageFormat.ToString())
.WithArguments(fileName),
},
};

// Act & Assert
await test.RunAsync();
}

[Theory]
[InlineData("Duplicate.cshtml", "duplicate.cshtml")]
[InlineData("Duplicate.razor", "duplicate.razor")]
public async Task Analyzer_NoDiagnostic_WhenDuplicateRazorFileIsIncluded_DifferentCase(string fileName1, string fileName2)
{
// Arrange
var test = new CSharpAnalyzerTest<DuplicateRazorFileIncludedAnalyzer, DefaultVerifier>
{
TestState =
{
Sources =
{
// Need a non-empty source file to make the test helper happy
("Test.cs", "public class Test {}"),
},
AdditionalFiles =
{
(fileName1, "<h1>Duplicate</h1>"),
(fileName2, "<h1>Duplicate</h1>"),
},
},
ExpectedDiagnostics =
{
},
};

// Act & Assert
await test.RunAsync();
}

[Theory]
[InlineData("Duplicate.cshtml")]
[InlineData("Duplicate.razor")]
public async Task Analyzer_ReportsDiagnostic_WhenThreeDuplicateRazorFilesAreIncluded(string fileName)
{
// Arrange
var test = new CSharpAnalyzerTest<DuplicateRazorFileIncludedAnalyzer, DefaultVerifier>
{
TestState =
{
Sources =
{
// Need a non-empty source file to make the test helper happy
("Test.cs", "public class Test {}"),
},
AdditionalFiles =
{
(fileName, "<h1>Duplicate</h1>"),
(fileName, "<h1>Duplicate</h1>"),
(fileName, "<h1>Duplicate</h1>"),
},
},
ExpectedDiagnostics =
{
new DiagnosticResult(RazorDiagnostics.DuplicateRazorFileIncludedDescriptor.Id, DiagnosticSeverity.Error)
.WithLocation(fileName, 1, 1)
.WithMessageFormat(RazorDiagnostics.DuplicateRazorFileIncludedDescriptor.MessageFormat.ToString())
.WithArguments(fileName),
new DiagnosticResult(RazorDiagnostics.DuplicateRazorFileIncludedDescriptor.Id, DiagnosticSeverity.Error)
.WithLocation(fileName, 1, 1)
.WithMessageFormat(RazorDiagnostics.DuplicateRazorFileIncludedDescriptor.MessageFormat.ToString())
.WithArguments(fileName),
},
};

// Act & Assert
await test.RunAsync();
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<PackageReference Include="Microsoft.Build.Framework" />
<PackageReference Include="Microsoft.Build.Utilities.Core" />
<PackageReference Include="Moq" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
<PackageReference Include="Microsoft.CodeAnalysis.Common" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@

<ItemGroup>
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzer.Testing" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.EditorFeatures" />
<PackageReference Include="Microsoft.CodeAnalysis.EditorFeatures.Common" />
Expand Down
Loading