Skip to content

Make Razor redirect code more robust #78039

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

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft

Conversation

jaredpar
Copy link
Member

@jaredpar jaredpar commented Apr 8, 2025

In an attempt to normalize the Razor experience the Workspaces layer will redirect Razor analyzer references to the ones checked into Visual Studio. This detection had a couple of corner cases that it didn't handle:

  1. When customers used Razor from anywhere other than the .NET SDK. This would impact customers that check in artifacts as part of their build.
  2. In a future where Razor changes the set of DLLs that are registered with /analyzer and customers use an older SDK, this logic would attempt to remap DLLs that did not exist. That is because it just assumes that DLLs coming from the SDK exist in VS.

This attempts to make the process more robust by identifying an analyzer as being part of the Razor closure if it exists in the same directory as the Razor compiler. Then for all such DLLs it will just return the set of registered Razor analyzers.

This does have one remaining corner case where a customer registers in an analyzer in a directory with a file named MS.CA.Razor.Compiler.dll but that file is not registered as an analyzer. That seems a bit out of bounds as they're shipping MS.CA.Razor.Compiler in a state that is not supported. Happy to hear if anyone has ideas on how to solve this though.

In an attempt to normalize the Razor experience the Workspaces layer
will redirect Razor analyzer references to the ones checked into
Visual Studio. This detection had a couple of corner cases that it
didn't handle:

1. When customers used Razor from anywhere other than the .NET SDK. This
   would impact customers that check in artifacts as part of their
   build.
2. In a future where Razor changes the set of DLLs that are registered
   with `/analyzer` and customers use an older SDK, this logic would
   attempt to remap DLLs that did not exist. That is because it just
   assumes that DLLs coming from the SDK exist in VS.

This attempts to make the process more robust by identifying an analyzer
as being part of the Razor closure if it exists in the same directory as
the Razor compiler. Then for all such DLLs it will just return the set
of registered Razor analyzers.

This does have one remaining corner case where a customer registers in an
analyzer in a directory with a file named MS.CA.Razor.Compiler.dll but
that file is not registered as an analyzer. That seems a bit out of
bounds as they're shipping MS.CA.Razor.Compiler in a state that is not
supported. Happy to hear if anyone has ideas on how to solve this
though.
@dotnet-issue-labeler dotnet-issue-labeler bot added Area-IDE untriaged Issues and PRs which have not yet been triaged by a lead labels Apr 8, 2025
@jaredpar jaredpar marked this pull request as ready for review April 8, 2025 16:21
@jaredpar jaredpar requested a review from a team as a code owner April 8, 2025 16:21
@jaredpar
Copy link
Member Author

jaredpar commented Apr 8, 2025

' This does not throw because within the razor generator only the actual generator dll triggers real
' add / removes. At a glance this may seem problematic but it's an unavoidable outcome once the
' project system begins mapping / mangling analyzer paths. There will be cases where existing names
' do not map to analyzers and it ends up with this behavior.
Copy link
Member Author

Choose a reason for hiding this comment

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

This could be fixed by putting a bit more logic into ProjectSystemProject where by we keep track of the original paths that were mapped to empty. Then we could have the Add / Remove calls manipulate that table and get back throwing behavior for double remove. Ended up deciding against that because it's a half measure. Yes we get back the Add / Remove semantics but it doesn't fix the fact that AnalyzerReferences in the project don't change.

I could be convinced to go the other direction.

Copy link
Member

Choose a reason for hiding this comment

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

I'm fine with this, since at some point I hope this gets properly refactored. 😄

/// </remarks>
internal static bool IsAnyRazorAnalyzerFilePath(string fullPath)
{
var dir = Path.GetDirectoryName(fullPath)!;
Copy link
Member

Choose a reason for hiding this comment

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

The suppression shouldn't be necessary thanks to the assert below

Suggested change
var dir = Path.GetDirectoryName(fullPath)!;
var dir = Path.GetDirectoryName(fullPath);

Comment on lines +1218 to +1223
return (razorGeneratorFullPaths.IsEmpty, IsFileName(fullPath, RazorSourceGenaratorDllName)) switch
{
if (s_razorSourceGeneratorAssemblyRootedFileNames.Any(
static (fileName, fullPath) => fullPath.EndsWith(fileName, StringComparison.OrdinalIgnoreCase), fullPath))
{
return OneOrMany.Create(vsixRazorAnalyzers);
}
(true, _) => OneOrMany.Create(fullPath),
(false, true) => OneOrMany.Create(razorGeneratorFullPaths),
(false, false) => OneOrMany<string>.Empty,
};
Copy link
Member

Choose a reason for hiding this comment

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

If we rewrote this as

Suggested change
return (razorGeneratorFullPaths.IsEmpty, IsFileName(fullPath, RazorSourceGenaratorDllName)) switch
{
if (s_razorSourceGeneratorAssemblyRootedFileNames.Any(
static (fileName, fullPath) => fullPath.EndsWith(fileName, StringComparison.OrdinalIgnoreCase), fullPath))
{
return OneOrMany.Create(vsixRazorAnalyzers);
}
(true, _) => OneOrMany.Create(fullPath),
(false, true) => OneOrMany.Create(razorGeneratorFullPaths),
(false, false) => OneOrMany<string>.Empty,
};
if (razorGeneratorFullPaths.IsEmpty)
{
return OneOrMany.Create(fullPath);
}
return IsFileName(fullPath, RazorSourceGenaratorDllName)
? OneOrMany.Create(razorGeneratorFullPaths)
: OneOrMany<string>.Empty;

I think the code might be more readable and wouldn't need to call IsFileName unnecessarily.

Copy link
Member

Choose a reason for hiding this comment

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

Oh this is much more readable. It also makes it possible to add comments in the logic too. 😉


return OneOrMany<string>.Empty;
static bool IsFileName(string filePath, 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.

I think the convention is using first letter lowercase for local function names? Might be just compiler layer convention though, not sure.


return OneOrMany<string>.Empty;
static bool IsFileName(string filePath, 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.

Perhaps "hasFileName" because it checks whether the path has the given file name.

/// </summary>
/// <remarks>
/// For <see cref="RazorSourceGenaratorDllName"/> this will return all registered analyzers in the
/// Razor extension. For everything else it will return an empty set.
Copy link
Member

@jjonescz jjonescz Apr 10, 2025

Choose a reason for hiding this comment

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

For everything else it will return an empty set.

It looks like the method also returns just the fullPath itself in case the Razor extension does not exist or have any analyzers registered (but the doc comment suggest it would return an empty set)

Consider also testing this case.


Assert.Empty(environment.Workspace.CurrentSolution.Projects.Single().AnalyzerReferences)
' Deliberately left Util.dll as we want to verify the behavior when the sdk and vsix disagree on total
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
' Deliberately left Util.dll as we want to verify the behavior when the sdk and vsix disagree on total
' Deliberately left out Util.dll as we want to verify the behavior when the sdk and vsix disagree on total

Using environment = New TestEnvironment()

Dim vsixDir = CreateAnalyzerDir(ProjectSystemProject.RazorSourceGenaratorDllName, "Analyzer.dll")
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't Util.dll be listed here?

Copy link
Member

Choose a reason for hiding this comment

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

This should really get refactored so the test setup is one helper for all tests to avoid this oops.


AssertEx.Equal(
Dim vsixDir = CreateAnalyzerDir(ProjectSystemProject.RazorSourceGenaratorDllName, "Analyzer.dll")
Copy link
Member

Choose a reason for hiding this comment

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

Should this be Util.dll to match the vsix setup below?

Applies to the tests below as well.

Dim filePath = DirectCast(a, AnalyzerFileReference).FullPath
list.Add(filePath)
Next
Return list
Copy link
Member

Choose a reason for hiding this comment

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

Consider moving the OrderBy that's used by all callers here

Copy link
Member

@jasonmalinowski jasonmalinowski left a comment

Choose a reason for hiding this comment

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

I approve in the sense I don't have much to add further, but @jjonescz has a lot of great feedback and you should fix all of his feedback. 😄

private static bool IsSdkRazorSourceGenerator(string fullPath) => DirectoryNameEndsWith(fullPath, s_razorSourceGeneratorSdkDirectory);

private OneOrMany<string> GetMappedRazorSourceGenerator(string fullPath)
internal const string RazorSourceGenaratorDllName = "Microsoft.CodeAnalysis.Razor.Compiler.dll";
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
internal const string RazorSourceGenaratorDllName = "Microsoft.CodeAnalysis.Razor.Compiler.dll";
internal const string RazorCompilerDllName = "Microsoft.CodeAnalysis.Razor.Compiler.dll";

Minimally fix the spelling of 'generator', but the field name doesn't seem to match the content anymore.

Comment on lines +1218 to +1223
return (razorGeneratorFullPaths.IsEmpty, IsFileName(fullPath, RazorSourceGenaratorDllName)) switch
{
if (s_razorSourceGeneratorAssemblyRootedFileNames.Any(
static (fileName, fullPath) => fullPath.EndsWith(fileName, StringComparison.OrdinalIgnoreCase), fullPath))
{
return OneOrMany.Create(vsixRazorAnalyzers);
}
(true, _) => OneOrMany.Create(fullPath),
(false, true) => OneOrMany.Create(razorGeneratorFullPaths),
(false, false) => OneOrMany<string>.Empty,
};
Copy link
Member

Choose a reason for hiding this comment

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

Oh this is much more readable. It also makes it possible to add comments in the logic too. 😉

Comment on lines +206 to +211
Assert.Equal(2, getAnalyzerRefs().Count)
Assert.Equal(
{
Path.Combine(vsixDir, ProjectSystemProject.RazorSourceGenaratorDllName),
Path.Combine(vsixDir, "Util.dll")
}, GetAnalyzerFilePaths(getAnalyzerRefs()).OrderBy(Function(x) Path.GetFileName(x), StringComparer.Ordinal))
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 just a "assert the file names are" helper; then you could just use that one helper everywhere, and also replace all the count assertions with that which makes it a bit clearer you expect things didn't change.

Copy link
Member

Choose a reason for hiding this comment

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

(I'm also not a fan of the count assertion before the set assertion: if it's got not-2, it'll fail, but the failure assertion won't make it obvious what happened.)

Comment on lines +179 to +187
Dim providerFactory = DirectCast(environment.ExportProvider.GetExportedValue(Of IVisualStudioDiagnosticAnalyzerProviderFactory), MockVisualStudioDiagnosticAnalyzerProviderFactory)
providerFactory.Extensions =
{
Path.Combine(TempRoot.Root, "RazorVsix", "Microsoft.CodeAnalysis.Razor.Compiler.SourceGenerators.dll"),
Path.Combine(TempRoot.Root, "RazorVsix", "VsixDependency1.dll"),
Path.Combine(TempRoot.Root, "RazorVsix", "VsixDependency2.dll"),
Path.Combine(TempRoot.Root, "Some other directory", "Microsoft.CodeAnalysis.Razor.Compiler.SourceGenerators.dll"),
Path.Combine(TempRoot.Root, "Dir", "File.dll")
}, environment.Workspace.CurrentSolution.Projects.Single().AnalyzerReferences.Select(Function(r) r.FullPath))
({
Path.Combine(vsixDir, ProjectSystemProject.RazorSourceGenaratorDllName),
Path.Combine(vsixDir, "Util.dll")
},
ProjectSystemProject.RazorVsixExtensionId)
}
Copy link
Member

Choose a reason for hiding this comment

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

Consider extracting this to a helper.

Comment on lines +144 to +147
Dim getAnalyzerRefs =
Function() As IReadOnlyList(Of AnalyzerReference)
Return environment.Workspace.CurrentSolution.Projects.Single().AnalyzerReferences
End Function
Copy link
Member

Choose a reason for hiding this comment

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

Just inline this logic into GetAnalyzerFilePaths since it's being repeated everywhere.

Using environment = New TestEnvironment()

Dim vsixDir = CreateAnalyzerDir(ProjectSystemProject.RazorSourceGenaratorDllName, "Analyzer.dll")
Copy link
Member

Choose a reason for hiding this comment

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

This should really get refactored so the test setup is one helper for all tests to avoid this oops.

' This does not throw because within the razor generator only the actual generator dll triggers real
' add / removes. At a glance this may seem problematic but it's an unavoidable outcome once the
' project system begins mapping / mangling analyzer paths. There will be cases where existing names
' do not map to analyzers and it ends up with this behavior.
Copy link
Member

Choose a reason for hiding this comment

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

I'm fine with this, since at some point I hope this gets properly refactored. 😄

@jaredpar
Copy link
Member Author

@jasonmalinowski, @jjonescz thanks for the reviews. @chsienki saw this and thinks he has a better way of approaching the problem. He's going to explore it once we get past our 17.14 QB issues. But for now I'm holding off on this until he has time to explore his idea.

@jaredpar jaredpar marked this pull request as draft April 17, 2025 17:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Area-IDE untriaged Issues and PRs which have not yet been triaged by a lead VSCode
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants