-
Notifications
You must be signed in to change notification settings - Fork 4.1k
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
base: main
Are you sure you want to change the base?
Conversation
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.
' 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)!; |
There was a problem hiding this comment.
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
var dir = Path.GetDirectoryName(fullPath)!; | |
var dir = Path.GetDirectoryName(fullPath); |
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, | ||
}; |
There was a problem hiding this comment.
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
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
' 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") |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
There was a problem hiding this 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"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
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, | ||
}; |
There was a problem hiding this comment.
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. 😉
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)) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.)
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) | ||
} |
There was a problem hiding this comment.
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.
Dim getAnalyzerRefs = | ||
Function() As IReadOnlyList(Of AnalyzerReference) | ||
Return environment.Workspace.CurrentSolution.Projects.Single().AnalyzerReferences | ||
End Function |
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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. 😄
@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. |
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:
/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.