Skip to content

Commit a92ccae

Browse files
authored
Create a fallback project manager to allow projects to work without using the Razor or Web SDK (#9486)
VS 17.8 port of #9470 Fixes #9459 Fixes https://devdiv.visualstudio.com/DevDiv/_workitems/edit/1902648 Fixes https://devdiv.visualstudio.com/DevDiv/_workitems/edit/1906971
2 parents 94fc3bd + ab35c41 commit a92ccae

File tree

5 files changed

+320
-2
lines changed

5 files changed

+320
-2
lines changed

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultRazorDynamicFileInfoProvider.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,21 @@ internal class DefaultRazorDynamicFileInfoProvider : RazorDynamicFileInfoProvide
3131
private readonly LSPEditorFeatureDetector _lspEditorFeatureDetector;
3232
private readonly FilePathService _filePathService;
3333
private readonly ProjectSnapshotManagerAccessor _projectSnapshotManagerAccessor;
34+
private readonly FallbackProjectManager _fallbackProjectManager;
3435

3536
[ImportingConstructor]
3637
public DefaultRazorDynamicFileInfoProvider(
3738
RazorDocumentServiceProviderFactory factory,
3839
LSPEditorFeatureDetector lspEditorFeatureDetector,
3940
FilePathService filePathService,
40-
ProjectSnapshotManagerAccessor projectSnapshotManagerAccessor)
41+
ProjectSnapshotManagerAccessor projectSnapshotManagerAccessor,
42+
FallbackProjectManager fallbackProjectManager)
4143
{
4244
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
4345
_lspEditorFeatureDetector = lspEditorFeatureDetector ?? throw new ArgumentNullException(nameof(lspEditorFeatureDetector));
4446
_filePathService = filePathService ?? throw new ArgumentNullException(nameof(filePathService));
4547
_projectSnapshotManagerAccessor = projectSnapshotManagerAccessor ?? throw new ArgumentNullException(nameof(projectSnapshotManagerAccessor));
48+
_fallbackProjectManager = fallbackProjectManager ?? throw new ArgumentNullException(nameof(fallbackProjectManager));
4649

4750
_entries = new ConcurrentDictionary<Key, Entry>();
4851
_createEmptyEntry = (key) => new Entry(CreateEmptyInfo(key));
@@ -243,6 +246,12 @@ public Task<RazorDynamicFileInfo> GetDynamicFileInfoAsync(ProjectId projectId, s
243246
throw new ArgumentNullException(nameof(filePath));
244247
}
245248

249+
var projectKey = TryFindProjectKeyForProjectId(projectId);
250+
if (projectKey is { } razorProjectKey)
251+
{
252+
_fallbackProjectManager.DynamicFileAdded(projectId, razorProjectKey, projectFilePath, filePath);
253+
}
254+
246255
var key = new Key(projectId, filePath);
247256
var entry = _entries.GetOrAdd(key, _createEmptyEntry);
248257
return Task.FromResult(entry.Current);
@@ -260,6 +269,8 @@ public Task RemoveDynamicFileInfoAsync(ProjectId projectId, string projectFilePa
260269
throw new ArgumentNullException(nameof(filePath));
261270
}
262271

272+
_fallbackProjectManager.DynamicFileRemoved(projectId, projectFilePath, filePath);
273+
263274
// ---------------------------------------------------------- NOTE & CAUTION --------------------------------------------------------------
264275
//
265276
// For all intents and purposes this method should not exist. When projects get torn down we do not get told to remove any documents
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT license. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Immutable;
6+
using System.Composition;
7+
using System.IO;
8+
using Microsoft.CodeAnalysis.Razor.Workspaces;
9+
10+
namespace Microsoft.CodeAnalysis.Razor.ProjectSystem;
11+
12+
/// <summary>
13+
/// This class is responsible for maintaining project information for projects that don't
14+
/// use the Razor or Web SDK, or otherwise don't get picked up by our CPS bits, but have
15+
/// .razor or .cshtml files regardless.
16+
/// </summary>
17+
[Shared]
18+
[Export(typeof(FallbackProjectManager))]
19+
internal sealed class FallbackProjectManager
20+
{
21+
private readonly ProjectConfigurationFilePathStore _projectConfigurationFilePathStore;
22+
private readonly LanguageServerFeatureOptions _languageServerFeatureOptions;
23+
private readonly ProjectSnapshotManagerAccessor _projectSnapshotManagerAccessor;
24+
25+
private ImmutableHashSet<ProjectId> _fallbackProjectIds = ImmutableHashSet<ProjectId>.Empty;
26+
27+
[ImportingConstructor]
28+
public FallbackProjectManager(
29+
ProjectConfigurationFilePathStore projectConfigurationFilePathStore,
30+
LanguageServerFeatureOptions languageServerFeatureOptions,
31+
ProjectSnapshotManagerAccessor projectSnapshotManagerAccessor)
32+
{
33+
_projectConfigurationFilePathStore = projectConfigurationFilePathStore;
34+
_languageServerFeatureOptions = languageServerFeatureOptions;
35+
_projectSnapshotManagerAccessor = projectSnapshotManagerAccessor;
36+
}
37+
38+
internal void DynamicFileAdded(ProjectId projectId, ProjectKey razorProjectKey, string projectFilePath, string filePath)
39+
{
40+
if (_fallbackProjectIds.Contains(projectId))
41+
{
42+
// If this is a fallback project, then Roslyn may not track documents in the project, so these dynamic file notifications
43+
// are the only way to know about files in the project.
44+
AddFallbackDocument(razorProjectKey, filePath, projectFilePath);
45+
}
46+
else if (_projectSnapshotManagerAccessor.Instance.GetLoadedProject(razorProjectKey) is null)
47+
{
48+
// We have been asked to provide dynamic file info, which means there is a .razor or .cshtml file in the project
49+
// but for some reason our project system doesn't know about the project. In these cases (often when people don't
50+
// use the Razor or Web SDK) we spin up a fallback experience for them
51+
AddFallbackProject(projectId, filePath);
52+
}
53+
}
54+
55+
internal void DynamicFileRemoved(ProjectId projectId, string projectFilePath, string filePath)
56+
{
57+
if (_fallbackProjectIds.Contains(projectId))
58+
{
59+
// If this is a fallback project, then Roslyn may not track documents in the project, so these dynamic file notifications
60+
// are the only way to know about files in the project.
61+
RemoveFallbackDocument(projectId, filePath, projectFilePath);
62+
}
63+
}
64+
65+
private void AddFallbackProject(ProjectId projectId, string filePath)
66+
{
67+
var project = TryFindProjectForProjectId(projectId);
68+
if (project?.FilePath is null)
69+
{
70+
return;
71+
}
72+
73+
var intermediateOutputPath = Path.GetDirectoryName(project.CompilationOutputInfo.AssemblyPath);
74+
if (intermediateOutputPath is null)
75+
{
76+
return;
77+
}
78+
79+
if (!ImmutableInterlocked.Update(ref _fallbackProjectIds, (set, id) => set.Add(id), project.Id))
80+
{
81+
return;
82+
}
83+
84+
var rootNamespace = project.DefaultNamespace ?? "ASP";
85+
var hostProject = new HostProject(project.FilePath, intermediateOutputPath, FallbackRazorConfiguration.Latest, rootNamespace, project.Name);
86+
87+
_projectSnapshotManagerAccessor.Instance.ProjectAdded(hostProject);
88+
89+
AddFallbackDocument(hostProject.Key, filePath, project.FilePath);
90+
91+
var configurationFilePath = Path.Combine(intermediateOutputPath, _languageServerFeatureOptions.ProjectConfigurationFileName);
92+
93+
_projectConfigurationFilePathStore.Set(hostProject.Key, configurationFilePath);
94+
}
95+
96+
private void AddFallbackDocument(ProjectKey projectKey, string filePath, string projectFilePath)
97+
{
98+
var hostDocument = CreateHostDocument(filePath, projectFilePath);
99+
var textLoader = new FileTextLoader(filePath, defaultEncoding: null);
100+
_projectSnapshotManagerAccessor.Instance.DocumentAdded(projectKey, hostDocument, textLoader);
101+
}
102+
103+
private static HostDocument CreateHostDocument(string filePath, string projectFilePath)
104+
{
105+
var targetPath = filePath.StartsWith(projectFilePath, FilePathComparison.Instance)
106+
? filePath[projectFilePath.Length..]
107+
: filePath;
108+
var hostDocument = new HostDocument(filePath, targetPath);
109+
return hostDocument;
110+
}
111+
112+
private void RemoveFallbackDocument(ProjectId projectId, string filePath, string projectFilePath)
113+
{
114+
var project = TryFindProjectForProjectId(projectId);
115+
if (project is null)
116+
{
117+
return;
118+
}
119+
120+
var projectKey = ProjectKey.From(project);
121+
if (projectKey is not { } razorProjectKey)
122+
{
123+
return;
124+
}
125+
126+
var hostDocument = CreateHostDocument(filePath, projectFilePath);
127+
_projectSnapshotManagerAccessor.Instance.DocumentRemoved(razorProjectKey, hostDocument);
128+
}
129+
130+
private Project? TryFindProjectForProjectId(ProjectId projectId)
131+
{
132+
if (_projectSnapshotManagerAccessor.Instance.Workspace is not { } workspace)
133+
{
134+
throw new InvalidOperationException("Can not map a ProjectId to a ProjectKey before the project is initialized");
135+
}
136+
137+
var project = workspace.CurrentSolution.GetProject(projectId);
138+
if (project is null ||
139+
project.Language != LanguageNames.CSharp)
140+
{
141+
return null;
142+
}
143+
144+
return project;
145+
}
146+
147+
internal TestAccessor GetTestAccessor()
148+
{
149+
return new TestAccessor(this);
150+
}
151+
152+
internal readonly struct TestAccessor
153+
{
154+
private readonly FallbackProjectManager _instance;
155+
156+
internal TestAccessor(FallbackProjectManager instance)
157+
{
158+
_instance = instance;
159+
}
160+
161+
internal ImmutableHashSet<ProjectId> ProjectIds => _instance._fallbackProjectIds;
162+
}
163+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT license. See License.txt in the project root for license information.
3+
4+
using System.IO;
5+
using System.Linq;
6+
using Microsoft.AspNetCore.Razor.Language;
7+
using Microsoft.AspNetCore.Razor.LanguageServer;
8+
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
9+
using Moq;
10+
using Xunit;
11+
using Xunit.Abstractions;
12+
using static Microsoft.CodeAnalysis.Razor.TestProjectData;
13+
14+
namespace Microsoft.CodeAnalysis.Razor.Workspaces.Test.ProjectSystem;
15+
16+
public class FallbackProjectManagerTest : WorkspaceTestBase
17+
{
18+
private FallbackProjectManager _fallbackProjectManger;
19+
private TestProjectSnapshotManager _projectSnapshotManager;
20+
private TestProjectConfigurationFilePathStore _projectConfigurationFilePathStore;
21+
22+
public FallbackProjectManagerTest(ITestOutputHelper testOutputHelper)
23+
: base(testOutputHelper)
24+
{
25+
var languageServerFeatureOptions = TestLanguageServerFeatureOptions.Instance;
26+
_projectConfigurationFilePathStore = new TestProjectConfigurationFilePathStore();
27+
28+
var dispatcher = Mock.Of<ProjectSnapshotManagerDispatcher>(MockBehavior.Strict);
29+
_projectSnapshotManager = new TestProjectSnapshotManager(Workspace, dispatcher);
30+
31+
var projectSnapshotManagerAccessor = Mock.Of<ProjectSnapshotManagerAccessor>(a => a.Instance == _projectSnapshotManager, MockBehavior.Strict);
32+
33+
_fallbackProjectManger = new FallbackProjectManager(_projectConfigurationFilePathStore, languageServerFeatureOptions, projectSnapshotManagerAccessor);
34+
}
35+
36+
[Fact]
37+
public void DynamicFileAdded_KnownProject_DoesNothing()
38+
{
39+
var hostProject = new HostProject(SomeProject.FilePath, SomeProject.IntermediateOutputPath, RazorConfiguration.Default, "RootNamespace", "DisplayName");
40+
_projectSnapshotManager.ProjectAdded(hostProject);
41+
42+
var projectId = ProjectId.CreateNewId();
43+
var projectInfo = ProjectInfo.Create(projectId, VersionStamp.Default, "DisplayName", "AssemblyName", LanguageNames.CSharp, filePath: SomeProject.FilePath)
44+
.WithCompilationOutputInfo(new CompilationOutputInfo().WithAssemblyPath(Path.Combine(SomeProject.IntermediateOutputPath, "SomeProject.dll")));
45+
Workspace.TryApplyChanges(Workspace.CurrentSolution.AddProject(projectInfo));
46+
47+
_fallbackProjectManger.DynamicFileAdded(projectId, hostProject.Key, SomeProject.FilePath, SomeProjectFile1.FilePath);
48+
49+
Assert.Empty(_fallbackProjectManger.GetTestAccessor().ProjectIds);
50+
}
51+
52+
[Fact]
53+
public void DynamicFileAdded_UnknownProject_Adds()
54+
{
55+
var projectId = ProjectId.CreateNewId();
56+
var projectInfo = ProjectInfo.Create(projectId, VersionStamp.Default, "DisplayName", "AssemblyName", LanguageNames.CSharp, filePath: SomeProject.FilePath)
57+
.WithCompilationOutputInfo(new CompilationOutputInfo().WithAssemblyPath(Path.Combine(SomeProject.IntermediateOutputPath, "SomeProject.dll")))
58+
.WithDefaultNamespace("RootNamespace");
59+
60+
Workspace.TryApplyChanges(Workspace.CurrentSolution.AddProject(projectInfo));
61+
62+
_fallbackProjectManger.DynamicFileAdded(projectId, SomeProject.Key, SomeProject.FilePath, SomeProjectFile1.FilePath);
63+
64+
var actualId = Assert.Single(_fallbackProjectManger.GetTestAccessor().ProjectIds);
65+
Assert.Equal(projectId, actualId);
66+
67+
var project = Assert.Single(_projectSnapshotManager.GetProjects());
68+
Assert.Equal("RootNamespace", project.RootNamespace);
69+
70+
var documentFilePath = Assert.Single(project.DocumentFilePaths);
71+
Assert.Equal(SomeProjectFile1.FilePath, documentFilePath);
72+
}
73+
74+
[Fact]
75+
public void DynamicFileAdded_TrackedProject_AddsDocuments()
76+
{
77+
var projectId = ProjectId.CreateNewId();
78+
var projectInfo = ProjectInfo.Create(projectId, VersionStamp.Default, "DisplayName", "AssemblyName", LanguageNames.CSharp, filePath: SomeProject.FilePath)
79+
.WithCompilationOutputInfo(new CompilationOutputInfo().WithAssemblyPath(Path.Combine(SomeProject.IntermediateOutputPath, "SomeProject.dll")))
80+
.WithDefaultNamespace("RootNamespace");
81+
82+
Workspace.TryApplyChanges(Workspace.CurrentSolution.AddProject(projectInfo));
83+
84+
_fallbackProjectManger.DynamicFileAdded(projectId, SomeProject.Key, SomeProject.FilePath, SomeProjectFile1.FilePath);
85+
86+
_fallbackProjectManger.DynamicFileAdded(projectId, SomeProject.Key, SomeProject.FilePath, SomeProjectFile2.FilePath);
87+
88+
_fallbackProjectManger.DynamicFileAdded(projectId, SomeProject.Key, SomeProject.FilePath, SomeProjectComponentFile1.FilePath);
89+
90+
var project = Assert.Single(_projectSnapshotManager.GetProjects());
91+
92+
Assert.Collection(project.DocumentFilePaths.OrderBy(f => f), // DocumentFilePaths comes from a dictionary, so no sort guarantee
93+
f => Assert.Equal(SomeProjectFile1.FilePath, f),
94+
f => Assert.Equal(SomeProjectComponentFile1.FilePath, f),
95+
f => Assert.Equal(SomeProjectFile2.FilePath, f));
96+
}
97+
98+
[Fact]
99+
public void DynamicFileAdded_UnknownProject_SetsConfigurationFileStore()
100+
{
101+
var projectId = ProjectId.CreateNewId();
102+
var projectInfo = ProjectInfo.Create(projectId, VersionStamp.Default, "DisplayName", "AssemblyName", LanguageNames.CSharp, filePath: SomeProject.FilePath)
103+
.WithCompilationOutputInfo(new CompilationOutputInfo().WithAssemblyPath(Path.Combine(SomeProject.IntermediateOutputPath, "SomeProject.dll")))
104+
.WithDefaultNamespace("RootNamespace");
105+
106+
Workspace.TryApplyChanges(Workspace.CurrentSolution.AddProject(projectInfo));
107+
108+
_fallbackProjectManger.DynamicFileAdded(projectId, SomeProject.Key, SomeProject.FilePath, SomeProjectFile1.FilePath);
109+
110+
var kvp = Assert.Single(_projectConfigurationFilePathStore.GetMappings());
111+
Assert.Equal(SomeProject.Key, kvp.Key);
112+
Assert.Equal(Path.Combine(SomeProject.IntermediateOutputPath, "project.razor.bin"), kvp.Value);
113+
}
114+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT license. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Diagnostics.CodeAnalysis;
7+
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
8+
9+
namespace Microsoft.CodeAnalysis.Razor.Workspaces.Test.ProjectSystem;
10+
11+
internal class TestProjectConfigurationFilePathStore : ProjectConfigurationFilePathStore
12+
{
13+
private Dictionary<ProjectKey, string> _mappings = new();
14+
15+
public override event EventHandler<ProjectConfigurationFilePathChangedEventArgs>? Changed { add { } remove { } }
16+
17+
public override IReadOnlyDictionary<ProjectKey, string> GetMappings()
18+
=> _mappings;
19+
20+
public override void Remove(ProjectKey projectKey)
21+
=> _mappings.Remove(projectKey);
22+
23+
public override void Set(ProjectKey projectKey, string configurationFilePath)
24+
=> _mappings[projectKey] = configurationFilePath;
25+
26+
public override bool TryGet(ProjectKey projectKey, [NotNullWhen(true)] out string? configurationFilePath)
27+
=> _mappings.TryGetValue(projectKey, out configurationFilePath);
28+
}

src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/DefaultRazorDynamicFileInfoProviderTest.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,10 @@ public DefaultRazorDynamicFileInfoProviderTest(ITestOutputHelper testOutput)
5555
var languageServerFeatureOptions = new TestLanguageServerFeatureOptions(includeProjectKeyInGeneratedFilePath: true);
5656
var filePathService = new FilePathService(languageServerFeatureOptions);
5757
var projectSnapshotManagerAccessor = Mock.Of<ProjectSnapshotManagerAccessor>(a => a.Instance == _projectSnapshotManager, MockBehavior.Strict);
58+
var projectConfigurationFilePathStore = Mock.Of<ProjectConfigurationFilePathStore>(MockBehavior.Strict);
59+
var fallbackProjectManager = new FallbackProjectManager(projectConfigurationFilePathStore, languageServerFeatureOptions, projectSnapshotManagerAccessor);
5860

59-
_provider = new DefaultRazorDynamicFileInfoProvider(_documentServiceFactory, _editorFeatureDetector, filePathService, projectSnapshotManagerAccessor);
61+
_provider = new DefaultRazorDynamicFileInfoProvider(_documentServiceFactory, _editorFeatureDetector, filePathService, projectSnapshotManagerAccessor, fallbackProjectManager);
6062
_testAccessor = _provider.GetTestAccessor();
6163
_provider.Initialize(_projectSnapshotManager);
6264

0 commit comments

Comments
 (0)