diff --git a/src/VisualStudio/Core/Def/Implementation/ProjectSystem/VisualStudioProject.BatchingDocumentCollection.cs b/src/VisualStudio/Core/Def/Implementation/ProjectSystem/VisualStudioProject.BatchingDocumentCollection.cs
new file mode 100644
index 0000000000000..fa83aa1b49ec3
--- /dev/null
+++ b/src/VisualStudio/Core/Def/Implementation/ProjectSystem/VisualStudioProject.BatchingDocumentCollection.cs
@@ -0,0 +1,623 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Host;
+using Microsoft.CodeAnalysis.PooledObjects;
+using Microsoft.CodeAnalysis.Shared.Extensions;
+using Microsoft.CodeAnalysis.Text;
+using Roslyn.Utilities;
+
+namespace Microsoft.VisualStudio.LanguageServices.Implementation.ProjectSystem
+{
+ internal sealed partial class VisualStudioProject
+ {
+ ///
+ /// Helper class to manage collections of source-file like things; this exists just to avoid duplicating all the logic for regular source files
+ /// and additional files.
+ ///
+ /// This class should be free-threaded, and any synchronization is done via .
+ /// This class is otherwise free to operate on private members of if needed.
+ private sealed class BatchingDocumentCollection
+ {
+ private readonly VisualStudioProject _project;
+
+ ///
+ /// The map of file paths to the underlying . This document may exist in or has been
+ /// pushed to the actual workspace.
+ ///
+ private readonly Dictionary _documentPathsToDocumentIds = new(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// A map of explicitly-added "always open" and their associated . This does not contain
+ /// any regular files that have been open.
+ ///
+ private IBidirectionalMap _sourceTextContainersToDocumentIds = BidirectionalMap.Empty;
+
+ ///
+ /// The map of to whose got added into
+ ///
+ private readonly Dictionary _documentIdToDynamicFileInfoProvider = new();
+
+ ///
+ /// The current list of documents that are to be added in this batch.
+ ///
+ private readonly ImmutableArray.Builder _documentsAddedInBatch = ImmutableArray.CreateBuilder();
+
+ ///
+ /// The current list of documents that are being removed in this batch. Once the document is in this list, it is no longer in .
+ ///
+ private readonly List _documentsRemovedInBatch = new();
+
+ ///
+ /// The current list of document file paths that will be ordered in a batch.
+ ///
+ private ImmutableList? _orderedDocumentsInBatch = null;
+
+ private readonly Func _documentAlreadyInWorkspace;
+ private readonly Action _documentAddAction;
+ private readonly Action _documentRemoveAction;
+ private readonly Func _documentTextLoaderChangedAction;
+ private readonly WorkspaceChangeKind _documentChangedWorkspaceKind;
+
+ public BatchingDocumentCollection(VisualStudioProject project,
+ Func documentAlreadyInWorkspace,
+ Action documentAddAction,
+ Action documentRemoveAction,
+ Func documentTextLoaderChangedAction,
+ WorkspaceChangeKind documentChangedWorkspaceKind)
+ {
+ _project = project;
+ _documentAlreadyInWorkspace = documentAlreadyInWorkspace;
+ _documentAddAction = documentAddAction;
+ _documentRemoveAction = documentRemoveAction;
+ _documentTextLoaderChangedAction = documentTextLoaderChangedAction;
+ _documentChangedWorkspaceKind = documentChangedWorkspaceKind;
+ }
+
+ public DocumentId AddFile(string fullPath, SourceCodeKind sourceCodeKind, ImmutableArray folders)
+ {
+ if (string.IsNullOrEmpty(fullPath))
+ {
+ throw new ArgumentException($"{nameof(fullPath)} isn't a valid path.", nameof(fullPath));
+ }
+
+ var documentId = DocumentId.CreateNewId(_project.Id, fullPath);
+ var textLoader = new FileTextLoader(fullPath, defaultEncoding: null);
+ var documentInfo = DocumentInfo.Create(
+ documentId,
+ FileNameUtilities.GetFileName(fullPath),
+ folders: folders.IsDefault ? null : folders,
+ sourceCodeKind: sourceCodeKind,
+ loader: textLoader,
+ filePath: fullPath,
+ isGenerated: false);
+
+ using (_project._gate.DisposableWait())
+ {
+ if (_documentPathsToDocumentIds.ContainsKey(fullPath))
+ {
+ throw new ArgumentException($"'{fullPath}' has already been added to this project.", nameof(fullPath));
+ }
+
+ // If we have an ordered document ids batch, we need to add the document id to the end of it as well.
+ _orderedDocumentsInBatch = _orderedDocumentsInBatch?.Add(documentId);
+
+ _documentPathsToDocumentIds.Add(fullPath, documentId);
+ _project._documentFileWatchingTokens.Add(documentId, _project._documentFileChangeContext.EnqueueWatchingFile(fullPath));
+
+ if (_project._activeBatchScopes > 0)
+ {
+ _documentsAddedInBatch.Add(documentInfo);
+ }
+ else
+ {
+ _project._workspace.ApplyChangeToWorkspace(w => _documentAddAction(w, documentInfo));
+ _project._workspace.QueueCheckForFilesBeingOpen(ImmutableArray.Create(fullPath));
+ }
+ }
+
+ return documentId;
+ }
+
+ public DocumentId AddTextContainer(SourceTextContainer textContainer, string fullPath, SourceCodeKind sourceCodeKind, ImmutableArray folders, bool designTimeOnly, IDocumentServiceProvider? documentServiceProvider)
+ {
+ if (textContainer == null)
+ {
+ throw new ArgumentNullException(nameof(textContainer));
+ }
+
+ var documentId = DocumentId.CreateNewId(_project.Id, fullPath);
+ var textLoader = new SourceTextLoader(textContainer, fullPath);
+ var documentInfo = DocumentInfo.Create(
+ documentId,
+ FileNameUtilities.GetFileName(fullPath),
+ folders: folders.NullToEmpty(),
+ sourceCodeKind: sourceCodeKind,
+ loader: textLoader,
+ filePath: fullPath,
+ isGenerated: false,
+ designTimeOnly: designTimeOnly,
+ documentServiceProvider: documentServiceProvider);
+
+ using (_project._gate.DisposableWait())
+ {
+ if (_sourceTextContainersToDocumentIds.ContainsKey(textContainer))
+ {
+ throw new ArgumentException($"{nameof(textContainer)} is already added to this project.", nameof(textContainer));
+ }
+
+ if (fullPath != null)
+ {
+ if (_documentPathsToDocumentIds.ContainsKey(fullPath))
+ {
+ throw new ArgumentException($"'{fullPath}' has already been added to this project.");
+ }
+
+ _documentPathsToDocumentIds.Add(fullPath, documentId);
+ }
+
+ _sourceTextContainersToDocumentIds = _sourceTextContainersToDocumentIds.Add(textContainer, documentInfo.Id);
+
+ if (_project._activeBatchScopes > 0)
+ {
+ _documentsAddedInBatch.Add(documentInfo);
+ }
+ else
+ {
+ _project._workspace.ApplyChangeToWorkspace(w =>
+ {
+ _project._workspace.AddDocumentToDocumentsNotFromFiles_NoLock(documentInfo.Id);
+ _documentAddAction(w, documentInfo);
+ w.OnDocumentOpened(documentInfo.Id, textContainer);
+ });
+ }
+ }
+
+ return documentId;
+ }
+
+ public void AddDynamicFile_NoLock(IDynamicFileInfoProvider fileInfoProvider, DynamicFileInfo fileInfo, ImmutableArray folders)
+ {
+ Debug.Assert(_project._gate.CurrentCount == 0);
+
+ var documentInfo = CreateDocumentInfoFromFileInfo(fileInfo, folders.NullToEmpty());
+
+ // Generally, DocumentInfo.FilePath can be null, but we always have file paths for dynamic files.
+ Contract.ThrowIfNull(documentInfo.FilePath);
+ var documentId = documentInfo.Id;
+
+ var filePath = documentInfo.FilePath;
+ if (_documentPathsToDocumentIds.ContainsKey(filePath))
+ {
+ throw new ArgumentException($"'{filePath}' has already been added to this project.", nameof(filePath));
+ }
+
+ // If we have an ordered document ids batch, we need to add the document id to the end of it as well.
+ _orderedDocumentsInBatch = _orderedDocumentsInBatch?.Add(documentId);
+
+ _documentPathsToDocumentIds.Add(filePath, documentId);
+
+ _documentIdToDynamicFileInfoProvider.Add(documentId, fileInfoProvider);
+
+ if (_project._eventSubscriptionTracker.Add(fileInfoProvider))
+ {
+ // subscribe to the event when we use this provider the first time
+ fileInfoProvider.Updated += _project.OnDynamicFileInfoUpdated;
+ }
+
+ if (_project._activeBatchScopes > 0)
+ {
+ _documentsAddedInBatch.Add(documentInfo);
+ }
+ else
+ {
+ // right now, assumption is dynamically generated file can never be opened in editor
+ _project._workspace.ApplyChangeToWorkspace(w => _documentAddAction(w, documentInfo));
+ }
+ }
+
+ public IDynamicFileInfoProvider RemoveDynamicFile_NoLock(string fullPath)
+ {
+ Debug.Assert(_project._gate.CurrentCount == 0);
+
+ if (string.IsNullOrEmpty(fullPath))
+ {
+ throw new ArgumentException($"{nameof(fullPath)} isn't a valid path.", nameof(fullPath));
+ }
+
+ if (!_documentPathsToDocumentIds.TryGetValue(fullPath, out var documentId) ||
+ !_documentIdToDynamicFileInfoProvider.TryGetValue(documentId, out var fileInfoProvider))
+ {
+ throw new ArgumentException($"'{fullPath}' is not a dynamic file of this project.");
+ }
+
+ _documentIdToDynamicFileInfoProvider.Remove(documentId);
+
+ RemoveFileInternal(documentId, fullPath);
+
+ return fileInfoProvider;
+ }
+
+ public void RemoveFile(string fullPath)
+ {
+ if (string.IsNullOrEmpty(fullPath))
+ {
+ throw new ArgumentException($"{nameof(fullPath)} isn't a valid path.", nameof(fullPath));
+ }
+
+ using (_project._gate.DisposableWait())
+ {
+ if (!_documentPathsToDocumentIds.TryGetValue(fullPath, out var documentId))
+ {
+ throw new ArgumentException($"'{fullPath}' is not a source file of this project.");
+ }
+
+ _project._documentFileChangeContext.StopWatchingFile(_project._documentFileWatchingTokens[documentId]);
+ _project._documentFileWatchingTokens.Remove(documentId);
+
+ RemoveFileInternal(documentId, fullPath);
+ }
+ }
+
+ private void RemoveFileInternal(DocumentId documentId, string fullPath)
+ {
+ _orderedDocumentsInBatch = _orderedDocumentsInBatch?.Remove(documentId);
+ _documentPathsToDocumentIds.Remove(fullPath);
+
+ // There are two cases:
+ //
+ // 1. This file is actually been pushed to the workspace, and we need to remove it (either
+ // as a part of the active batch or immediately)
+ // 2. It hasn't been pushed yet, but is contained in _documentsAddedInBatch
+ if (_documentAlreadyInWorkspace(_project._workspace.CurrentSolution, documentId))
+ {
+ if (_project._activeBatchScopes > 0)
+ {
+ _documentsRemovedInBatch.Add(documentId);
+ }
+ else
+ {
+ _project._workspace.ApplyChangeToWorkspace(w => _documentRemoveAction(w, documentId));
+ }
+ }
+ else
+ {
+ for (var i = 0; i < _documentsAddedInBatch.Count; i++)
+ {
+ if (_documentsAddedInBatch[i].Id == documentId)
+ {
+ _documentsAddedInBatch.RemoveAt(i);
+ break;
+ }
+ }
+ }
+ }
+
+ public void RemoveTextContainer(SourceTextContainer textContainer)
+ {
+ if (textContainer == null)
+ {
+ throw new ArgumentNullException(nameof(textContainer));
+ }
+
+ using (_project._gate.DisposableWait())
+ {
+ if (!_sourceTextContainersToDocumentIds.TryGetValue(textContainer, out var documentId))
+ {
+ throw new ArgumentException($"{nameof(textContainer)} is not a text container added to this project.");
+ }
+
+ _sourceTextContainersToDocumentIds = _sourceTextContainersToDocumentIds.RemoveKey(textContainer);
+
+ // if the TextContainer had a full path provided, remove it from the map.
+ var entry = _documentPathsToDocumentIds.Where(kv => kv.Value == documentId).FirstOrDefault();
+ if (entry.Key != null)
+ {
+ _documentPathsToDocumentIds.Remove(entry.Key);
+ }
+
+ // There are two cases:
+ //
+ // 1. This file is actually been pushed to the workspace, and we need to remove it (either
+ // as a part of the active batch or immediately)
+ // 2. It hasn't been pushed yet, but is contained in _documentsAddedInBatch
+ if (_project._workspace.CurrentSolution.GetDocument(documentId) != null)
+ {
+ if (_project._activeBatchScopes > 0)
+ {
+ _documentsRemovedInBatch.Add(documentId);
+ }
+ else
+ {
+ _project._workspace.ApplyChangeToWorkspace(w =>
+ {
+ // Just pass null for the filePath, since this document is immediately being removed
+ // anyways -- whatever we set won't really be read since the next change will
+ // come through.
+ // TODO: Can't we just remove the document without closing it?
+ w.OnDocumentClosed(documentId, new SourceTextLoader(textContainer, filePath: null));
+ _documentRemoveAction(w, documentId);
+ _project._workspace.RemoveDocumentToDocumentsNotFromFiles_NoLock(documentId);
+ });
+ }
+ }
+ else
+ {
+ for (var i = 0; i < _documentsAddedInBatch.Count; i++)
+ {
+ if (_documentsAddedInBatch[i].Id == documentId)
+ {
+ _documentsAddedInBatch.RemoveAt(i);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ public bool ContainsFile(string fullPath)
+ {
+ if (string.IsNullOrEmpty(fullPath))
+ {
+ throw new ArgumentException($"{nameof(fullPath)} isn't a valid path.", nameof(fullPath));
+ }
+
+ using (_project._gate.DisposableWait())
+ {
+ return _documentPathsToDocumentIds.ContainsKey(fullPath);
+ }
+ }
+
+ public async ValueTask ProcessRegularFileChangesAsync(ImmutableArray filePaths)
+ {
+ using (await _project._gate.DisposableWaitAsync().ConfigureAwait(false))
+ {
+ // If our project has already been removed, this is a stale notification, and we can disregard.
+ if (_project.HasBeenRemoved)
+ {
+ return;
+ }
+
+ var documentsToChange = ArrayBuilder<(DocumentId, TextLoader)>.GetInstance(filePaths.Length);
+
+ foreach (var filePath in filePaths)
+ {
+ if (_documentPathsToDocumentIds.TryGetValue(filePath, out var documentId))
+ {
+ // We create file watching prior to pushing the file to the workspace in batching, so it's
+ // possible we might see a file change notification early. In this case, toss it out. Since
+ // all adds/removals of documents for this project happen under our lock, it's safe to do this
+ // check without taking the main workspace lock. We don't have to check for documents removed in
+ // the batch, since those have already been removed out of _documentPathsToDocumentIds.
+ if (!_documentsAddedInBatch.Any(d => d.Id == documentId))
+ {
+ documentsToChange.Add((documentId, new FileTextLoader(filePath, defaultEncoding: null)));
+ }
+ }
+ }
+
+ // Nothing actually matched, so we're done
+ if (documentsToChange.Count == 0)
+ {
+ return;
+ }
+
+ await _project._workspace.ApplyBatchChangeToWorkspaceMaybeAsync(useAsync: true, s =>
+ {
+ var accumulator = new SolutionChangeAccumulator(s);
+
+ foreach (var (documentId, textLoader) in documentsToChange)
+ {
+ if (!s.Workspace.IsDocumentOpen(documentId))
+ {
+ accumulator.UpdateSolutionForDocumentAction(
+ _documentTextLoaderChangedAction(accumulator.Solution, documentId, textLoader),
+ _documentChangedWorkspaceKind,
+ SpecializedCollections.SingletonEnumerable(documentId));
+ }
+ }
+
+ return accumulator;
+ }).ConfigureAwait(false);
+
+ documentsToChange.Free();
+ }
+ }
+
+ ///
+ /// Process file content changes
+ ///
+ /// filepath given from project system
+ /// filepath used in workspace. it might be different than projectSystemFilePath
+ public void ProcessDynamicFileChange(string projectSystemFilePath, string workspaceFilePath)
+ {
+ using (_project._gate.DisposableWait())
+ {
+ // If our project has already been removed, this is a stale notification, and we can disregard.
+ if (_project.HasBeenRemoved)
+ {
+ return;
+ }
+
+ if (_documentPathsToDocumentIds.TryGetValue(workspaceFilePath, out var documentId))
+ {
+ // We create file watching prior to pushing the file to the workspace in batching, so it's
+ // possible we might see a file change notification early. In this case, toss it out. Since
+ // all adds/removals of documents for this project happen under our lock, it's safe to do this
+ // check without taking the main workspace lock. We don't have to check for documents removed in
+ // the batch, since those have already been removed out of _documentPathsToDocumentIds.
+ if (_documentsAddedInBatch.Any(d => d.Id == documentId))
+ {
+ return;
+ }
+
+ Contract.ThrowIfFalse(_documentIdToDynamicFileInfoProvider.TryGetValue(documentId, out var fileInfoProvider));
+
+ _project._workspace.ApplyChangeToWorkspace(w =>
+ {
+ if (w.IsDocumentOpen(documentId))
+ {
+ return;
+ }
+
+ // we do not expect JTF to be used around this code path. and contract of fileInfoProvider is it being real free-threaded
+ // meaning it can't use JTF to go back to UI thread.
+ // so, it is okay for us to call regular ".Result" on a task here.
+ var fileInfo = fileInfoProvider.GetDynamicFileInfoAsync(
+ _project.Id, _project._filePath, projectSystemFilePath, CancellationToken.None).WaitAndGetResult_CanCallOnBackground(CancellationToken.None);
+
+ // Right now we're only supporting dynamic files as actual source files, so it's OK to call GetDocument here
+ var document = w.CurrentSolution.GetRequiredDocument(documentId);
+
+ var documentInfo = DocumentInfo.Create(
+ document.Id,
+ document.Name,
+ document.Folders,
+ document.SourceCodeKind,
+ loader: fileInfo.TextLoader,
+ document.FilePath,
+ document.State.Attributes.IsGenerated,
+ document.State.Attributes.DesignTimeOnly,
+ documentServiceProvider: fileInfo.DocumentServiceProvider);
+
+ w.OnDocumentReloaded(documentInfo);
+ });
+ }
+ }
+ }
+
+ public void ReorderFiles(ImmutableArray filePaths)
+ {
+ if (filePaths.IsEmpty)
+ {
+ throw new ArgumentOutOfRangeException("The specified files are empty.", nameof(filePaths));
+ }
+
+ using (_project._gate.DisposableWait())
+ {
+ if (_documentPathsToDocumentIds.Count != filePaths.Length)
+ {
+ throw new ArgumentException("The specified files do not equal the project document count.", nameof(filePaths));
+ }
+
+ var documentIds = ImmutableList.CreateBuilder();
+
+ foreach (var filePath in filePaths)
+ {
+ if (_documentPathsToDocumentIds.TryGetValue(filePath, out var documentId))
+ {
+ documentIds.Add(documentId);
+ }
+ else
+ {
+ throw new InvalidOperationException($"The file '{filePath}' does not exist in the project.");
+ }
+ }
+
+ if (_project._activeBatchScopes > 0)
+ {
+ _orderedDocumentsInBatch = documentIds.ToImmutable();
+ }
+ else
+ {
+ _project._workspace.ApplyChangeToWorkspace(_project.Id, solution => solution.WithProjectDocumentsOrder(_project.Id, documentIds.ToImmutable()));
+ }
+ }
+ }
+
+ internal void UpdateSolutionForBatch(
+ SolutionChangeAccumulator solutionChanges,
+ ImmutableArray.Builder documentFileNamesAdded,
+ List<(DocumentId documentId, SourceTextContainer textContainer)> documentsToOpen,
+ Func, Solution> addDocuments,
+ WorkspaceChangeKind addDocumentChangeKind,
+ Func, Solution> removeDocuments,
+ WorkspaceChangeKind removeDocumentChangeKind)
+ {
+ // Document adding...
+ solutionChanges.UpdateSolutionForDocumentAction(
+ newSolution: addDocuments(solutionChanges.Solution, _documentsAddedInBatch.ToImmutable()),
+ changeKind: addDocumentChangeKind,
+ documentIds: _documentsAddedInBatch.Select(d => d.Id));
+
+ foreach (var documentInfo in _documentsAddedInBatch)
+ {
+ Contract.ThrowIfNull(documentInfo.FilePath, "We shouldn't be adding documents without file paths.");
+ documentFileNamesAdded.Add(documentInfo.FilePath);
+
+ if (_sourceTextContainersToDocumentIds.TryGetKey(documentInfo.Id, out var textContainer))
+ {
+ documentsToOpen.Add((documentInfo.Id, textContainer));
+ }
+ }
+
+ ClearAndZeroCapacity(_documentsAddedInBatch);
+
+ // Document removing...
+ solutionChanges.UpdateSolutionForRemovedDocumentAction(removeDocuments(solutionChanges.Solution, _documentsRemovedInBatch.ToImmutableArray()),
+ removeDocumentChangeKind,
+ _documentsRemovedInBatch);
+
+ ClearAndZeroCapacity(_documentsRemovedInBatch);
+
+ // Update project's order of documents.
+ if (_orderedDocumentsInBatch != null)
+ {
+ solutionChanges.UpdateSolutionForProjectAction(
+ _project.Id,
+ solutionChanges.Solution.WithProjectDocumentsOrder(_project.Id, _orderedDocumentsInBatch));
+ _orderedDocumentsInBatch = null;
+ }
+ }
+
+ private DocumentInfo CreateDocumentInfoFromFileInfo(DynamicFileInfo fileInfo, ImmutableArray folders)
+ {
+ Contract.ThrowIfTrue(folders.IsDefault);
+
+ // we use this file path for editorconfig.
+ var filePath = fileInfo.FilePath;
+
+ var name = FileNameUtilities.GetFileName(filePath);
+ var documentId = DocumentId.CreateNewId(_project.Id, filePath);
+
+ var textLoader = fileInfo.TextLoader;
+ var documentServiceProvider = fileInfo.DocumentServiceProvider;
+
+ return DocumentInfo.Create(
+ documentId,
+ name,
+ folders: folders,
+ sourceCodeKind: fileInfo.SourceCodeKind,
+ loader: textLoader,
+ filePath: filePath,
+ isGenerated: false,
+ designTimeOnly: true,
+ documentServiceProvider: documentServiceProvider);
+ }
+
+ private sealed class SourceTextLoader : TextLoader
+ {
+ private readonly SourceTextContainer _textContainer;
+ private readonly string? _filePath;
+
+ public SourceTextLoader(SourceTextContainer textContainer, string? filePath)
+ {
+ _textContainer = textContainer;
+ _filePath = filePath;
+ }
+
+ public override Task LoadTextAndVersionAsync(Workspace workspace, DocumentId documentId, CancellationToken cancellationToken)
+ => Task.FromResult(TextAndVersion.Create(_textContainer.CurrentText, VersionStamp.Create(), _filePath));
+ }
+ }
+ }
+}
diff --git a/src/VisualStudio/Core/Def/Implementation/ProjectSystem/VisualStudioProject.cs b/src/VisualStudio/Core/Def/Implementation/ProjectSystem/VisualStudioProject.cs
index a477703d61ba2..2f2373de209ce 100644
--- a/src/VisualStudio/Core/Def/Implementation/ProjectSystem/VisualStudioProject.cs
+++ b/src/VisualStudio/Core/Def/Implementation/ProjectSystem/VisualStudioProject.cs
@@ -15,7 +15,6 @@
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.Internal.Log;
-using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Telemetry;
using Microsoft.CodeAnalysis.Text;
@@ -24,7 +23,7 @@
namespace Microsoft.VisualStudio.LanguageServices.Implementation.ProjectSystem
{
- internal sealed class VisualStudioProject
+ internal sealed partial class VisualStudioProject
{
private static readonly ImmutableArray s_defaultMetadataReferenceProperties = ImmutableArray.Create(default(MetadataReferenceProperties));
@@ -1305,605 +1304,5 @@ private static void ClearAndZeroCapacity(ImmutableArray.Builder list)
list.Clear();
list.Capacity = 0;
}
-
- ///
- /// Helper class to manage collections of source-file like things; this exists just to avoid duplicating all the logic for regular source files
- /// and additional files.
- ///
- /// This class should be free-threaded, and any synchronization is done via .
- /// This class is otherwise free to operate on private members of if needed.
- private sealed class BatchingDocumentCollection
- {
- private readonly VisualStudioProject _project;
-
- ///
- /// The map of file paths to the underlying . This document may exist in or has been
- /// pushed to the actual workspace.
- ///
- private readonly Dictionary _documentPathsToDocumentIds = new(StringComparer.OrdinalIgnoreCase);
-
- ///
- /// A map of explicitly-added "always open" and their associated . This does not contain
- /// any regular files that have been open.
- ///
- private IBidirectionalMap _sourceTextContainersToDocumentIds = BidirectionalMap.Empty;
-
- ///
- /// The map of to whose got added into
- ///
- private readonly Dictionary _documentIdToDynamicFileInfoProvider = new();
-
- ///
- /// The current list of documents that are to be added in this batch.
- ///
- private readonly ImmutableArray.Builder _documentsAddedInBatch = ImmutableArray.CreateBuilder();
-
- ///
- /// The current list of documents that are being removed in this batch. Once the document is in this list, it is no longer in .
- ///
- private readonly List _documentsRemovedInBatch = new();
-
- ///
- /// The current list of document file paths that will be ordered in a batch.
- ///
- private ImmutableList? _orderedDocumentsInBatch = null;
-
- private readonly Func _documentAlreadyInWorkspace;
- private readonly Action _documentAddAction;
- private readonly Action _documentRemoveAction;
- private readonly Func _documentTextLoaderChangedAction;
- private readonly WorkspaceChangeKind _documentChangedWorkspaceKind;
-
- public BatchingDocumentCollection(VisualStudioProject project,
- Func documentAlreadyInWorkspace,
- Action documentAddAction,
- Action documentRemoveAction,
- Func documentTextLoaderChangedAction,
- WorkspaceChangeKind documentChangedWorkspaceKind)
- {
- _project = project;
- _documentAlreadyInWorkspace = documentAlreadyInWorkspace;
- _documentAddAction = documentAddAction;
- _documentRemoveAction = documentRemoveAction;
- _documentTextLoaderChangedAction = documentTextLoaderChangedAction;
- _documentChangedWorkspaceKind = documentChangedWorkspaceKind;
- }
-
- public DocumentId AddFile(string fullPath, SourceCodeKind sourceCodeKind, ImmutableArray folders)
- {
- if (string.IsNullOrEmpty(fullPath))
- {
- throw new ArgumentException($"{nameof(fullPath)} isn't a valid path.", nameof(fullPath));
- }
-
- var documentId = DocumentId.CreateNewId(_project.Id, fullPath);
- var textLoader = new FileTextLoader(fullPath, defaultEncoding: null);
- var documentInfo = DocumentInfo.Create(
- documentId,
- FileNameUtilities.GetFileName(fullPath),
- folders: folders.IsDefault ? null : folders,
- sourceCodeKind: sourceCodeKind,
- loader: textLoader,
- filePath: fullPath,
- isGenerated: false);
-
- using (_project._gate.DisposableWait())
- {
- if (_documentPathsToDocumentIds.ContainsKey(fullPath))
- {
- throw new ArgumentException($"'{fullPath}' has already been added to this project.", nameof(fullPath));
- }
-
- // If we have an ordered document ids batch, we need to add the document id to the end of it as well.
- _orderedDocumentsInBatch = _orderedDocumentsInBatch?.Add(documentId);
-
- _documentPathsToDocumentIds.Add(fullPath, documentId);
- _project._documentFileWatchingTokens.Add(documentId, _project._documentFileChangeContext.EnqueueWatchingFile(fullPath));
-
- if (_project._activeBatchScopes > 0)
- {
- _documentsAddedInBatch.Add(documentInfo);
- }
- else
- {
- _project._workspace.ApplyChangeToWorkspace(w => _documentAddAction(w, documentInfo));
- _project._workspace.QueueCheckForFilesBeingOpen(ImmutableArray.Create(fullPath));
- }
- }
-
- return documentId;
- }
-
- public DocumentId AddTextContainer(SourceTextContainer textContainer, string fullPath, SourceCodeKind sourceCodeKind, ImmutableArray folders, bool designTimeOnly, IDocumentServiceProvider? documentServiceProvider)
- {
- if (textContainer == null)
- {
- throw new ArgumentNullException(nameof(textContainer));
- }
-
- var documentId = DocumentId.CreateNewId(_project.Id, fullPath);
- var textLoader = new SourceTextLoader(textContainer, fullPath);
- var documentInfo = DocumentInfo.Create(
- documentId,
- FileNameUtilities.GetFileName(fullPath),
- folders: folders.NullToEmpty(),
- sourceCodeKind: sourceCodeKind,
- loader: textLoader,
- filePath: fullPath,
- isGenerated: false,
- designTimeOnly: designTimeOnly,
- documentServiceProvider: documentServiceProvider);
-
- using (_project._gate.DisposableWait())
- {
- if (_sourceTextContainersToDocumentIds.ContainsKey(textContainer))
- {
- throw new ArgumentException($"{nameof(textContainer)} is already added to this project.", nameof(textContainer));
- }
-
- if (fullPath != null)
- {
- if (_documentPathsToDocumentIds.ContainsKey(fullPath))
- {
- throw new ArgumentException($"'{fullPath}' has already been added to this project.");
- }
-
- _documentPathsToDocumentIds.Add(fullPath, documentId);
- }
-
- _sourceTextContainersToDocumentIds = _sourceTextContainersToDocumentIds.Add(textContainer, documentInfo.Id);
-
- if (_project._activeBatchScopes > 0)
- {
- _documentsAddedInBatch.Add(documentInfo);
- }
- else
- {
- _project._workspace.ApplyChangeToWorkspace(w =>
- {
- _project._workspace.AddDocumentToDocumentsNotFromFiles_NoLock(documentInfo.Id);
- _documentAddAction(w, documentInfo);
- w.OnDocumentOpened(documentInfo.Id, textContainer);
- });
- }
- }
-
- return documentId;
- }
-
- public void AddDynamicFile_NoLock(IDynamicFileInfoProvider fileInfoProvider, DynamicFileInfo fileInfo, ImmutableArray folders)
- {
- Debug.Assert(_project._gate.CurrentCount == 0);
-
- var documentInfo = CreateDocumentInfoFromFileInfo(fileInfo, folders.NullToEmpty());
-
- // Generally, DocumentInfo.FilePath can be null, but we always have file paths for dynamic files.
- Contract.ThrowIfNull(documentInfo.FilePath);
- var documentId = documentInfo.Id;
-
- var filePath = documentInfo.FilePath;
- if (_documentPathsToDocumentIds.ContainsKey(filePath))
- {
- throw new ArgumentException($"'{filePath}' has already been added to this project.", nameof(filePath));
- }
-
- // If we have an ordered document ids batch, we need to add the document id to the end of it as well.
- _orderedDocumentsInBatch = _orderedDocumentsInBatch?.Add(documentId);
-
- _documentPathsToDocumentIds.Add(filePath, documentId);
-
- _documentIdToDynamicFileInfoProvider.Add(documentId, fileInfoProvider);
-
- if (_project._eventSubscriptionTracker.Add(fileInfoProvider))
- {
- // subscribe to the event when we use this provider the first time
- fileInfoProvider.Updated += _project.OnDynamicFileInfoUpdated;
- }
-
- if (_project._activeBatchScopes > 0)
- {
- _documentsAddedInBatch.Add(documentInfo);
- }
- else
- {
- // right now, assumption is dynamically generated file can never be opened in editor
- _project._workspace.ApplyChangeToWorkspace(w => _documentAddAction(w, documentInfo));
- }
- }
-
- public IDynamicFileInfoProvider RemoveDynamicFile_NoLock(string fullPath)
- {
- Debug.Assert(_project._gate.CurrentCount == 0);
-
- if (string.IsNullOrEmpty(fullPath))
- {
- throw new ArgumentException($"{nameof(fullPath)} isn't a valid path.", nameof(fullPath));
- }
-
- if (!_documentPathsToDocumentIds.TryGetValue(fullPath, out var documentId) ||
- !_documentIdToDynamicFileInfoProvider.TryGetValue(documentId, out var fileInfoProvider))
- {
- throw new ArgumentException($"'{fullPath}' is not a dynamic file of this project.");
- }
-
- _documentIdToDynamicFileInfoProvider.Remove(documentId);
-
- RemoveFileInternal(documentId, fullPath);
-
- return fileInfoProvider;
- }
-
- public void RemoveFile(string fullPath)
- {
- if (string.IsNullOrEmpty(fullPath))
- {
- throw new ArgumentException($"{nameof(fullPath)} isn't a valid path.", nameof(fullPath));
- }
-
- using (_project._gate.DisposableWait())
- {
- if (!_documentPathsToDocumentIds.TryGetValue(fullPath, out var documentId))
- {
- throw new ArgumentException($"'{fullPath}' is not a source file of this project.");
- }
-
- _project._documentFileChangeContext.StopWatchingFile(_project._documentFileWatchingTokens[documentId]);
- _project._documentFileWatchingTokens.Remove(documentId);
-
- RemoveFileInternal(documentId, fullPath);
- }
- }
-
- private void RemoveFileInternal(DocumentId documentId, string fullPath)
- {
- _orderedDocumentsInBatch = _orderedDocumentsInBatch?.Remove(documentId);
- _documentPathsToDocumentIds.Remove(fullPath);
-
- // There are two cases:
- //
- // 1. This file is actually been pushed to the workspace, and we need to remove it (either
- // as a part of the active batch or immediately)
- // 2. It hasn't been pushed yet, but is contained in _documentsAddedInBatch
- if (_documentAlreadyInWorkspace(_project._workspace.CurrentSolution, documentId))
- {
- if (_project._activeBatchScopes > 0)
- {
- _documentsRemovedInBatch.Add(documentId);
- }
- else
- {
- _project._workspace.ApplyChangeToWorkspace(w => _documentRemoveAction(w, documentId));
- }
- }
- else
- {
- for (var i = 0; i < _documentsAddedInBatch.Count; i++)
- {
- if (_documentsAddedInBatch[i].Id == documentId)
- {
- _documentsAddedInBatch.RemoveAt(i);
- break;
- }
- }
- }
- }
-
- public void RemoveTextContainer(SourceTextContainer textContainer)
- {
- if (textContainer == null)
- {
- throw new ArgumentNullException(nameof(textContainer));
- }
-
- using (_project._gate.DisposableWait())
- {
- if (!_sourceTextContainersToDocumentIds.TryGetValue(textContainer, out var documentId))
- {
- throw new ArgumentException($"{nameof(textContainer)} is not a text container added to this project.");
- }
-
- _sourceTextContainersToDocumentIds = _sourceTextContainersToDocumentIds.RemoveKey(textContainer);
-
- // if the TextContainer had a full path provided, remove it from the map.
- var entry = _documentPathsToDocumentIds.Where(kv => kv.Value == documentId).FirstOrDefault();
- if (entry.Key != null)
- {
- _documentPathsToDocumentIds.Remove(entry.Key);
- }
-
- // There are two cases:
- //
- // 1. This file is actually been pushed to the workspace, and we need to remove it (either
- // as a part of the active batch or immediately)
- // 2. It hasn't been pushed yet, but is contained in _documentsAddedInBatch
- if (_project._workspace.CurrentSolution.GetDocument(documentId) != null)
- {
- if (_project._activeBatchScopes > 0)
- {
- _documentsRemovedInBatch.Add(documentId);
- }
- else
- {
- _project._workspace.ApplyChangeToWorkspace(w =>
- {
- // Just pass null for the filePath, since this document is immediately being removed
- // anyways -- whatever we set won't really be read since the next change will
- // come through.
- // TODO: Can't we just remove the document without closing it?
- w.OnDocumentClosed(documentId, new SourceTextLoader(textContainer, filePath: null));
- _documentRemoveAction(w, documentId);
- _project._workspace.RemoveDocumentToDocumentsNotFromFiles_NoLock(documentId);
- });
- }
- }
- else
- {
- for (var i = 0; i < _documentsAddedInBatch.Count; i++)
- {
- if (_documentsAddedInBatch[i].Id == documentId)
- {
- _documentsAddedInBatch.RemoveAt(i);
- break;
- }
- }
- }
- }
- }
-
- public bool ContainsFile(string fullPath)
- {
- if (string.IsNullOrEmpty(fullPath))
- {
- throw new ArgumentException($"{nameof(fullPath)} isn't a valid path.", nameof(fullPath));
- }
-
- using (_project._gate.DisposableWait())
- {
- return _documentPathsToDocumentIds.ContainsKey(fullPath);
- }
- }
-
- public async ValueTask ProcessRegularFileChangesAsync(ImmutableArray filePaths)
- {
- using (await _project._gate.DisposableWaitAsync().ConfigureAwait(false))
- {
- // If our project has already been removed, this is a stale notification, and we can disregard.
- if (_project.HasBeenRemoved)
- {
- return;
- }
-
- var documentsToChange = ArrayBuilder<(DocumentId, TextLoader)>.GetInstance(filePaths.Length);
-
- foreach (var filePath in filePaths)
- {
- if (_documentPathsToDocumentIds.TryGetValue(filePath, out var documentId))
- {
- // We create file watching prior to pushing the file to the workspace in batching, so it's
- // possible we might see a file change notification early. In this case, toss it out. Since
- // all adds/removals of documents for this project happen under our lock, it's safe to do this
- // check without taking the main workspace lock. We don't have to check for documents removed in
- // the batch, since those have already been removed out of _documentPathsToDocumentIds.
- if (!_documentsAddedInBatch.Any(d => d.Id == documentId))
- {
- documentsToChange.Add((documentId, new FileTextLoader(filePath, defaultEncoding: null)));
- }
- }
- }
-
- // Nothing actually matched, so we're done
- if (documentsToChange.Count == 0)
- {
- return;
- }
-
- await _project._workspace.ApplyBatchChangeToWorkspaceMaybeAsync(useAsync: true, s =>
- {
- var accumulator = new SolutionChangeAccumulator(s);
-
- foreach (var (documentId, textLoader) in documentsToChange)
- {
- if (!s.Workspace.IsDocumentOpen(documentId))
- {
- accumulator.UpdateSolutionForDocumentAction(
- _documentTextLoaderChangedAction(accumulator.Solution, documentId, textLoader),
- _documentChangedWorkspaceKind,
- SpecializedCollections.SingletonEnumerable(documentId));
- }
- }
-
- return accumulator;
- }).ConfigureAwait(false);
-
- documentsToChange.Free();
- }
- }
-
- ///
- /// Process file content changes
- ///
- /// filepath given from project system
- /// filepath used in workspace. it might be different than projectSystemFilePath
- public void ProcessDynamicFileChange(string projectSystemFilePath, string workspaceFilePath)
- {
- using (_project._gate.DisposableWait())
- {
- // If our project has already been removed, this is a stale notification, and we can disregard.
- if (_project.HasBeenRemoved)
- {
- return;
- }
-
- if (_documentPathsToDocumentIds.TryGetValue(workspaceFilePath, out var documentId))
- {
- // We create file watching prior to pushing the file to the workspace in batching, so it's
- // possible we might see a file change notification early. In this case, toss it out. Since
- // all adds/removals of documents for this project happen under our lock, it's safe to do this
- // check without taking the main workspace lock. We don't have to check for documents removed in
- // the batch, since those have already been removed out of _documentPathsToDocumentIds.
- if (_documentsAddedInBatch.Any(d => d.Id == documentId))
- {
- return;
- }
-
- Contract.ThrowIfFalse(_documentIdToDynamicFileInfoProvider.TryGetValue(documentId, out var fileInfoProvider));
-
- _project._workspace.ApplyChangeToWorkspace(w =>
- {
- if (w.IsDocumentOpen(documentId))
- {
- return;
- }
-
- // we do not expect JTF to be used around this code path. and contract of fileInfoProvider is it being real free-threaded
- // meaning it can't use JTF to go back to UI thread.
- // so, it is okay for us to call regular ".Result" on a task here.
- var fileInfo = fileInfoProvider.GetDynamicFileInfoAsync(
- _project.Id, _project._filePath, projectSystemFilePath, CancellationToken.None).WaitAndGetResult_CanCallOnBackground(CancellationToken.None);
-
- // Right now we're only supporting dynamic files as actual source files, so it's OK to call GetDocument here
- var document = w.CurrentSolution.GetRequiredDocument(documentId);
-
- var documentInfo = DocumentInfo.Create(
- document.Id,
- document.Name,
- document.Folders,
- document.SourceCodeKind,
- loader: fileInfo.TextLoader,
- document.FilePath,
- document.State.Attributes.IsGenerated,
- document.State.Attributes.DesignTimeOnly,
- documentServiceProvider: fileInfo.DocumentServiceProvider);
-
- w.OnDocumentReloaded(documentInfo);
- });
- }
- }
- }
-
- public void ReorderFiles(ImmutableArray filePaths)
- {
- if (filePaths.IsEmpty)
- {
- throw new ArgumentOutOfRangeException("The specified files are empty.", nameof(filePaths));
- }
-
- using (_project._gate.DisposableWait())
- {
- if (_documentPathsToDocumentIds.Count != filePaths.Length)
- {
- throw new ArgumentException("The specified files do not equal the project document count.", nameof(filePaths));
- }
-
- var documentIds = ImmutableList.CreateBuilder();
-
- foreach (var filePath in filePaths)
- {
- if (_documentPathsToDocumentIds.TryGetValue(filePath, out var documentId))
- {
- documentIds.Add(documentId);
- }
- else
- {
- throw new InvalidOperationException($"The file '{filePath}' does not exist in the project.");
- }
- }
-
- if (_project._activeBatchScopes > 0)
- {
- _orderedDocumentsInBatch = documentIds.ToImmutable();
- }
- else
- {
- _project._workspace.ApplyChangeToWorkspace(_project.Id, solution => solution.WithProjectDocumentsOrder(_project.Id, documentIds.ToImmutable()));
- }
- }
- }
-
- internal void UpdateSolutionForBatch(
- SolutionChangeAccumulator solutionChanges,
- ImmutableArray.Builder documentFileNamesAdded,
- List<(DocumentId documentId, SourceTextContainer textContainer)> documentsToOpen,
- Func, Solution> addDocuments,
- WorkspaceChangeKind addDocumentChangeKind,
- Func, Solution> removeDocuments,
- WorkspaceChangeKind removeDocumentChangeKind)
- {
- // Document adding...
- solutionChanges.UpdateSolutionForDocumentAction(
- newSolution: addDocuments(solutionChanges.Solution, _documentsAddedInBatch.ToImmutable()),
- changeKind: addDocumentChangeKind,
- documentIds: _documentsAddedInBatch.Select(d => d.Id));
-
- foreach (var documentInfo in _documentsAddedInBatch)
- {
- Contract.ThrowIfNull(documentInfo.FilePath, "We shouldn't be adding documents without file paths.");
- documentFileNamesAdded.Add(documentInfo.FilePath);
-
- if (_sourceTextContainersToDocumentIds.TryGetKey(documentInfo.Id, out var textContainer))
- {
- documentsToOpen.Add((documentInfo.Id, textContainer));
- }
- }
-
- ClearAndZeroCapacity(_documentsAddedInBatch);
-
- // Document removing...
- solutionChanges.UpdateSolutionForRemovedDocumentAction(removeDocuments(solutionChanges.Solution, _documentsRemovedInBatch.ToImmutableArray()),
- removeDocumentChangeKind,
- _documentsRemovedInBatch);
-
- ClearAndZeroCapacity(_documentsRemovedInBatch);
-
- // Update project's order of documents.
- if (_orderedDocumentsInBatch != null)
- {
- solutionChanges.UpdateSolutionForProjectAction(
- _project.Id,
- solutionChanges.Solution.WithProjectDocumentsOrder(_project.Id, _orderedDocumentsInBatch));
- _orderedDocumentsInBatch = null;
- }
- }
-
- private DocumentInfo CreateDocumentInfoFromFileInfo(DynamicFileInfo fileInfo, ImmutableArray folders)
- {
- Contract.ThrowIfTrue(folders.IsDefault);
-
- // we use this file path for editorconfig.
- var filePath = fileInfo.FilePath;
-
- var name = FileNameUtilities.GetFileName(filePath);
- var documentId = DocumentId.CreateNewId(_project.Id, filePath);
-
- var textLoader = fileInfo.TextLoader;
- var documentServiceProvider = fileInfo.DocumentServiceProvider;
-
- return DocumentInfo.Create(
- documentId,
- name,
- folders: folders,
- sourceCodeKind: fileInfo.SourceCodeKind,
- loader: textLoader,
- filePath: filePath,
- isGenerated: false,
- designTimeOnly: true,
- documentServiceProvider: documentServiceProvider);
- }
-
- private sealed class SourceTextLoader : TextLoader
- {
- private readonly SourceTextContainer _textContainer;
- private readonly string? _filePath;
-
- public SourceTextLoader(SourceTextContainer textContainer, string? filePath)
- {
- _textContainer = textContainer;
- _filePath = filePath;
- }
-
- public override Task LoadTextAndVersionAsync(Workspace workspace, DocumentId documentId, CancellationToken cancellationToken)
- => Task.FromResult(TextAndVersion.Create(_textContainer.CurrentText, VersionStamp.Create(), _filePath));
- }
- }
}
}