-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathLspWorkspaceScopeManager.cs
More file actions
400 lines (341 loc) · 19.8 KB
/
Copy pathLspWorkspaceScopeManager.cs
File metadata and controls
400 lines (341 loc) · 19.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
using MediatR;
using OmniSharp.Extensions.LanguageServer.Protocol;
using Reqnroll.IdeSupport.Common;
using Reqnroll.IdeSupport.Common.Logging;
using Reqnroll.IdeSupport.Common.ProjectSystem;
using Reqnroll.IdeSupport.Common.ProjectSystem.Configuration;
using Reqnroll.IdeSupport.LSP.Server.Discovery.Connector;
using Reqnroll.IdeSupport.LSP.Server.Pipeline;
using Reqnroll.IdeSupport.LSP.Server.Protocol;
using Reqnroll.IdeSupport.LSP.Server.Registry;
namespace Reqnroll.IdeSupport.LSP.Server.Workspace;
/// <summary>
/// Thread-safe implementation of <see cref="ILspWorkspaceScopeManager"/>.
/// </summary>
public sealed class LspWorkspaceScopeManager : ILspWorkspaceScopeManager, IDisposable
{
private readonly IIdeScope _ideScope;
private readonly IIdeSupportLogger _logger;
private readonly IMediator _mediator;
private readonly ConcurrentDictionary<string, LspProjectScope> _scopes
= new(StringComparer.OrdinalIgnoreCase);
// The Q17 project-membership index is a genuinely separate concern from workspace/project
// lifecycle tracking above; see MembershipIndex's own remarks for why it isn't fully
// independent (it needs FindProjectByKey, which only _scopes above can answer).
private readonly MembershipIndex _membershipIndex;
/// <summary>Initializes a new instance of the <see cref="LspWorkspaceScopeManager"/> class.</summary>
public LspWorkspaceScopeManager(IIdeScope ideScope, IIdeSupportLogger logger, IMediator mediator)
{
_ideScope = ideScope;
_logger = logger;
_mediator = mediator;
_membershipIndex = new MembershipIndex(logger, mediator, FindProjectByKey);
}
// ── Folder lifecycle ──────────────────────────────────────────────────────
/// <summary>Raised when a new workspace folder scope is opened.</summary>
public event Action<LspProjectScope>? ScopeOpened;
/// <summary>Raised when a workspace folder scope is closed.</summary>
public event Action<LspProjectScope>? ScopeClosed;
/// <summary>Creates the workspace scope for <paramref name="rootPath"/> if it does not already exist, raising <see cref="ScopeOpened"/>.</summary>
public void OpenWorkspace(string rootPath)
{
var key = Normalise(rootPath);
LspProjectScope? added = null;
_scopes.GetOrAdd(key, k =>
{
_logger.LogInfo($"Opening workspace scope: {k}");
added = new LspProjectScope(k, _ideScope);
return added;
});
if (added is not null)
ScopeOpened?.Invoke(added);
}
/// <summary>Removes the workspace scope for <paramref name="rootPath"/>, raising <see cref="ProjectRemoved"/> for each of its projects and then <see cref="ScopeClosed"/>, and disposes the scope.</summary>
public void CloseWorkspace(string rootPath)
{
var key = Normalise(rootPath);
if (!_scopes.TryRemove(key, out var scope))
return;
_logger.LogInfo($"Closing workspace scope: {key}");
// Raise ProjectRemoved for every project still inside the scope.
foreach (var project in scope.Projects)
{
ProjectRemoved?.Invoke(project);
}
ScopeClosed?.Invoke(scope);
scope.Dispose();
}
// ── Project lifecycle ─────────────────────────────────────────────────────
/// <summary>Raised when a Reqnroll project is discovered (loaded) in the workspace.</summary>
public event Action<LspReqnrollProject>? ProjectDiscovered;
/// <summary>Raised when a Reqnroll project is removed (unloaded) from the workspace.</summary>
public event Action<LspReqnrollProject>? ProjectRemoved;
/// <summary>Handles a <c>reqnroll/projectLoaded</c> notification: ensures the workspace folder and project scope exist, updates or creates the project, and raises <see cref="ProjectDiscovered"/>.</summary>
public Task HandleProjectLoadedAsync(
ReqnrollProjectLoadedParams parameters,
CancellationToken cancellationToken)
{
// Ensure the workspace folder exists (create it if the IDE sends the project
// notification before the LSP initialize workspace-folders arrive).
var folderKey = Normalise(parameters.WorkspaceFolder);
var scope = _scopes.GetOrAdd(folderKey, k =>
{
_logger.LogInfo($"Auto-creating workspace scope for project notification: {k}");
var newScope = new LspProjectScope(k, _ideScope);
ScopeOpened?.Invoke(newScope);
return newScope;
});
var (project, isNew, _) = scope.AddOrUpdateProject(parameters);
if (isNew)
{
_logger.LogInfo(
$"Project discovered: {project.ProjectName} " +
$"[{project.TargetFrameworkMoniker}] → {project.OutputAssemblyPath}");
// ProjectDiscovered subscribers (BindingRegistryProviderRouter) create the
// per-project provider and trigger the initial discovery, so no explicit
// refresh is needed here for a brand-new project.
ProjectDiscovered?.Invoke(project);
}
else
{
_logger.LogInfo(
$"Project updated: {project.ProjectName} " +
$"[{project.TargetFrameworkMoniker}] → {project.OutputAssemblyPath}");
// Always re-run binding discovery for an existing project's re-send, not just when
// OutputAssemblyPath/TFM changed. Visual Studio re-sends projectLoaded after every
// successful build (VsProjectEventMonitor.OnBuildDone) as its only rebuild signal,
// because it does not advertise dynamicRegistration for didChangeWatchedFiles, so the
// output-assembly file watcher (WatchedFilesHandler) never gets registered there and
// a plain rebuild — same output path, same TFM — would otherwise trigger nothing at
// all. The assembly-hash guard in ConnectorDiscoveryService.ComputeHash already makes
// a redundant trigger a cheap no-op, so being unconditional here costs nothing when
// the output path/TFM didn't change and the assembly is actually unchanged (issue #542).
TriggerBindingDiscovery(project);
}
// The project's baseline may have already arrived (see HandleProjectFilesAsync) before
// this registration — that full re-scan was deferred since no project existed yet to
// attribute it to. Fire it now: any .cs buffers synced during the race window were
// evaluated with zero known owners and would otherwise never be re-evaluated, silently
// gating live Roslyn re-discovery for them until the next full build (issue #48).
if (_membershipIndex.TryConsumePendingFullRescan(project))
{
_logger.LogInfo(
$"[Membership] Firing deferred full re-scan for '{project.ProjectName}' now that the project has loaded.");
// See BindingRegistryProviderRouter.OnProviderChanged (issue #477): discarding
// the Publish Task would not actually defer it off this call stack. CancellationToken.None,
// not the incoming `cancellationToken`: that token is scoped to this notification's
// own request lifetime, which the background continuation deliberately outlives --
// forwarding it would let the publish get silently cancelled before it even runs.
FireAndForgetExtensions.FireAndForget(
() => _mediator.Publish(new BindingRegistryChangedNotification(project, true), CancellationToken.None),
_logger, nameof(HandleProjectLoadedAsync));
}
return Task.CompletedTask;
}
/// <summary>
/// Triggers a debounced binding re-discovery on the per-project
/// <see cref="ConnectorBindingRegistryProvider"/> stored in the project's property bag,
/// if one has been registered by <see cref="BindingRegistryProviderRouter"/>.
/// </summary>
private void TriggerBindingDiscovery(LspReqnrollProject project)
{
if (project.Properties.TryGetValue(
typeof(ConnectorBindingRegistryProvider), out var obj)
&& obj is ConnectorBindingRegistryProvider provider)
{
_logger.LogVerbose(
$"[{project.ProjectName}] projectLoaded re-send; triggering re-discovery.");
provider.TriggerRefresh();
}
else
{
_logger.LogVerbose(
$"[{project.ProjectName}] projectLoaded re-send but no binding provider " +
$"registered yet; skipping refresh.");
}
}
/// <summary>Handles a <c>reqnroll/projectUnloaded</c> notification: removes the matching project from its scope, raises <see cref="ProjectRemoved"/>, and disposes it.</summary>
public Task HandleProjectUnloadedAsync(
ReqnrollProjectUnloadedParams parameters,
CancellationToken cancellationToken)
{
foreach (var scope in _scopes.Values)
{
var removed = scope.RemoveProject(parameters.ProjectFile);
if (removed is null)
continue;
_logger.LogInfo($"Project removed: {removed.ProjectName}");
ProjectRemoved?.Invoke(removed);
removed.Dispose();
return Task.CompletedTask;
}
_logger.LogVerbose(
$"HandleProjectUnloadedAsync: no project found for {parameters.ProjectFile}");
return Task.CompletedTask;
}
// ── Lookup ────────────────────────────────────────────────────────────────
/// <summary>Finds the workspace scope whose root folder most closely contains <paramref name="uri"/>'s file path, if any.</summary>
public LspProjectScope? GetScopeForUri(DocumentUri uri)
{
var filePath = uri.GetFileSystemPath();
if (string.IsNullOrEmpty(filePath))
return null;
return _scopes.Values
.Where(s => PathUtils.IsUnderFolder(filePath, s.RootFolder))
.OrderByDescending(s => s.RootFolder.Length)
.FirstOrDefault();
}
/// <summary>Finds the single project whose folder most closely contains <paramref name="uri"/>'s file path, without consulting the membership index (see <see cref="ResolveOwners"/> for index-aware ownership).</summary>
public LspReqnrollProject? GetProjectForUri(DocumentUri uri)
{
var filePath = uri.GetFileSystemPath();
if (string.IsNullOrEmpty(filePath))
return null;
return _scopes.Values
.SelectMany(s => s.Projects)
.Where(p => PathUtils.IsUnderFolder(filePath, p.ProjectFolder))
.OrderByDescending(p => p.ProjectFolder.Length)
.FirstOrDefault();
}
/// <summary>Finds the project whose compiled output assembly path matches <paramref name="assemblyPath"/>, if any.</summary>
public LspReqnrollProject? GetProjectByOutputPath(string assemblyPath)
{
if (string.IsNullOrEmpty(assemblyPath))
return null;
// PathUtils.IsSamePath rather than a raw string compare: the watcher reports the path the
// client observed, which need not be byte-identical to the OutputAssemblyPath MSBuild gave
// us for the same DLL (separators, a "bin\Debug\..\Debug" segment, casing). A miss here
// silently drops the rebuild trigger -- see issue #542, where this is the second, latent
// break in that chain (issue #540 F4).
return _scopes.Values
.SelectMany(s => s.Projects)
.FirstOrDefault(p => PathUtils.IsSamePath(p.OutputAssemblyPath, assemblyPath));
}
/// <summary>Returns the owning project's configuration provider for <paramref name="uri"/>, or a default configuration provider when no project covers it.</summary>
public IIdeSupportConfigurationProvider GetConfigurationProviderForUri(DocumentUri uri)
{
var project = GetProjectForUri(uri);
if (project is not null)
return project.GetIdeSupportConfigurationProvider();
// Fallback: default configuration when no project covers the URI.
return new ProjectSystemIdeSupportConfigurationProvider(_ideScope);
}
// ── Membership index (workspace scope / project membership tracking) ────
// Delegates to MembershipIndex (see its own remarks) for everything that doesn't also need
// _scopes-based folder-prefix fallback logic.
/// <summary>Handles a <c>reqnroll/projectFiles</c> notification, applying it as a full baseline replacement or an incremental delta to the membership index.</summary>
public Task HandleProjectFilesAsync(
ReqnrollProjectFilesParams parameters,
CancellationToken cancellationToken)
=> _membershipIndex.HandleProjectFilesAsync(parameters, cancellationToken);
/// <summary>Looks up every project that claims <paramref name="uri"/> via the membership index (does not fall back to folder-prefix matching).</summary>
public IReadOnlyCollection<LspReqnrollProject> GetProjectsForUri(DocumentUri uri)
=> _membershipIndex.GetProjectsForUri(uri);
/// <summary>Resolves the projects that own <paramref name="uri"/>, preferring the membership index and falling back to folder-prefix matching only while the covering project's baseline is still pending.</summary>
public IReadOnlyCollection<LspReqnrollProject> ResolveOwners(DocumentUri uri)
{
var indexOwners = GetProjectsForUri(uri);
if (indexOwners.Count > 0)
return indexOwners;
// Fall back to folder-prefix for files whose covering project hasn't sent a baseline.
if (GetMembershipState(uri) == MembershipState.Pending)
{
var fallback = GetProjectForUri(uri);
return fallback is not null ? [fallback] : [];
}
return []; // Unowned
}
/// <summary>Picks a single "primary" owning project for <paramref name="uri"/> when multiple projects claim it, preferring the project whose folder contains the file (longest match), else falling back to an ordinal tiebreak for stability.</summary>
public LspReqnrollProject? ResolvePrimaryOwner(DocumentUri uri)
{
var owners = ResolveOwners(uri);
if (owners.Count == 0)
return null;
if (owners.Count == 1)
return owners.First();
var filePath = uri.GetFileSystemPath() ?? string.Empty;
// Prefer the owner whose ProjectFolder is a prefix of the file path (home project).
// If several qualify, pick the longest prefix (most specific containing project).
var homeOwners = owners
.Where(p => PathUtils.IsUnderFolder(filePath, p.ProjectFolder))
.OrderByDescending(p => p.ProjectFolder.Length)
.ToList();
if (homeOwners.Count > 0)
return homeOwners[0];
// File is outside every owner's folder (genuinely external/linked). Use ordinal tiebreak
// on ProjectFullName so the result is stable regardless of baseline-arrival order.
return owners
.OrderBy(p => p.ProjectFullName, StringComparer.Ordinal)
.First();
}
/// <summary>Classifies whether <paramref name="uri"/> is <see cref="MembershipState.Owned"/> in the index, still <see cref="MembershipState.Pending"/> a covering project's baseline, or <see cref="MembershipState.Unowned"/>.</summary>
public MembershipState GetMembershipState(DocumentUri uri)
{
// Normalised once here (not just inside IsPathOwned) because filePath is also used
// below for the PathUtils.IsUnderFolder folder-prefix checks, which do no
// normalisation of their own -- unlike the membership-index lookup, which normalises
// internally regardless.
var filePath = MembershipIndex.NormaliseFilePath(uri.GetFileSystemPath() ?? string.Empty);
if (string.IsNullOrEmpty(filePath))
return MembershipState.Unowned;
if (_membershipIndex.IsPathOwned(filePath))
return MembershipState.Owned;
// Any project that would cover this path via folder-prefix?
var covering = _scopes.Values
.SelectMany(s => s.Projects)
.Where(p => PathUtils.IsUnderFolder(filePath, p.ProjectFolder))
.ToList();
if (covering.Count == 0)
{
// No *registered* project covers this path yet. That is not the same as the
// path being permanently excluded: at startup, a workspace folder can be open
// (or about to open) well before its `reqnroll/projectLoaded` notification
// arrives, and file sync (didOpen/didChange) can race ahead of it. As long as
// the path falls inside a known workspace-folder scope, a covering project may
// still register momentarily, so treat this as Pending rather than a definitive
// Unowned — Unowned must only fire once we can be sure nothing will ever claim
// the file (see invariant I2 in CSharpBindingDiscoveryService).
var insideKnownScope = _scopes.Values.Any(
s => PathUtils.IsUnderFolder(filePath, s.RootFolder));
return insideKnownScope ? MembershipState.Pending : MembershipState.Unowned;
}
// Pending if any covering project has not yet sent a baseline.
foreach (var project in covering)
{
if (!_membershipIndex.HasBaselineForProject(project))
return MembershipState.Pending;
}
return MembershipState.Unowned;
}
/// <summary>Returns every file path in the membership index that <paramref name="project"/> owns with the <see cref="ProjectFileRole.Feature"/> role.</summary>
public IReadOnlyCollection<string> GetIndexedFeatureFiles(LspReqnrollProject project)
=> _membershipIndex.GetIndexedFeatureFiles(project);
/// <summary>Returns every file path in the membership index that <paramref name="project"/> owns with the <see cref="ProjectFileRole.Binding"/> role.</summary>
public IReadOnlyCollection<string> GetBindingFilePathsForProject(LspReqnrollProject project)
=> _membershipIndex.GetBindingFilePathsForProject(project);
/// <summary>Returns whether <paramref name="project"/> has received its initial full membership baseline yet.</summary>
public bool HasBaselineForProject(LspReqnrollProject project)
=> _membershipIndex.HasBaselineForProject(project);
private LspReqnrollProject? FindProjectByKey(ProjectKey key)
{
// Phase 1: match by ProjectFile only (TFM keying is a planned follow-up).
return _scopes.Values
.SelectMany(s => s.Projects)
.FirstOrDefault(p => string.Equals(
MembershipIndex.NormaliseFilePath(p.ProjectFullName),
key.ProjectFile,
StringComparison.OrdinalIgnoreCase));
}
// ── IDisposable ───────────────────────────────────────────────────────────
/// <summary>Closes every open workspace scope, disposing each one and raising <see cref="ProjectRemoved"/>/<see cref="ScopeClosed"/> as needed.</summary>
public void Dispose()
{
foreach (var key in _scopes.Keys.ToArray())
CloseWorkspace(key);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static string Normalise(string path)
=> Path.GetFullPath(path).TrimEnd(
Path.DirectorySeparatorChar,
Path.AltDirectorySeparatorChar);
}