Skip to content

Commit e76382a

Browse files
authored
Allow modification of source generated documents (#77587)
Part of dotnet/razor#10693 Razor side of this is dotnet/razor#11619 which I think makes us ready to internally dogfood cohosting. This unblocks Razor completion and most of code actions in cohosting. It allows `WithText` and `WithSyntaxRoot` to work on source generated documents, creating a forked solution with frozen source generated documents. Solutions can be continually frozen without issue, and unfreezing puts them back to their original state. This also allows code actions to run on modified source generated documents, but only if they have been frozen. As discussed there aren't any flags for this to only be possible from/for Razor. Still investigating individual code actions that aren't working in Razor, but those will probably be a follow up. Enough of the Razor tests pass now that it proves the system generally works (see comment below).
2 parents c618d0d + 645611f commit e76382a

File tree

15 files changed

+643
-48
lines changed

15 files changed

+643
-48
lines changed

src/Features/Core/Portable/Completion/Providers/AbstractRecommendationServiceBasedCompletionProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ internal sealed override async Task<CompletionDescription> GetDescriptionWorkerA
226226

227227
async Task<CompletionDescription?> TryGetDescriptionAsync(DocumentId documentId)
228228
{
229-
var relatedDocument = document.Project.Solution.GetRequiredDocument(documentId);
229+
var relatedDocument = await document.Project.Solution.GetRequiredDocumentAsync(documentId, includeSourceGenerated: true, cancellationToken).ConfigureAwait(false);
230230
var context = await Utilities.CreateSyntaxContextWithExistingSpeculativeModelAsync(relatedDocument, position, cancellationToken).ConfigureAwait(false) as TSyntaxContext;
231231
Contract.ThrowIfNull(context);
232232
var symbols = await TryGetSymbolsForContextAsync(completionContext: null, context, options, cancellationToken).ConfigureAwait(false);

src/LanguageServer/Protocol/Handler/CodeActions/CodeActionResolveHelper.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,17 @@ await AddTextDocumentEditsAsync(
184184
newSolution.GetDocument,
185185
solution.GetDocument).ConfigureAwait(false);
186186

187+
// Razor calls through our code action handlers with documents that come from the Razor source generator
188+
// Those changes are not visible in project changes, because they happen in the compilation state, so we
189+
// make sure to pull changes out from that too. Changed source generated documents are "frozen" because
190+
// their content no longer comes from the source generator, so thats our cue to know when to handle.
191+
// Changes to non-frozen documents don't need to be included, because changes to the origin document would
192+
// cause the generator to re-generate the same changed content.
193+
await AddTextDocumentEditsAsync(
194+
changes.GetExplicitlyChangedSourceGeneratedDocuments(),
195+
newSolution.GetRequiredSourceGeneratedDocumentForAlreadyGeneratedId,
196+
solution.GetRequiredSourceGeneratedDocumentForAlreadyGeneratedId).ConfigureAwait(false);
197+
187198
// Changed analyzer config documents
188199
await AddTextDocumentEditsAsync(
189200
projectChanges.SelectMany(pc => pc.GetChangedAnalyzerConfigDocuments()),

src/Workspaces/Core/Portable/CodeActions/CodeAction_Cleanup.cs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,9 @@ internal static ImmutableArray<DocumentId> GetAllChangedOrAddedDocumentIds(
6565
.GetProjectChanges()
6666
.SelectMany(p => p.GetChangedDocuments(onlyGetDocumentsWithTextChanges: true).Concat(p.GetAddedDocuments()))
6767
.Concat(solutionChanges.GetAddedProjects().SelectMany(p => p.DocumentIds))
68-
.ToImmutableArray();
69-
return documentIds;
68+
.Concat(solutionChanges.GetExplicitlyChangedSourceGeneratedDocuments());
69+
70+
return documentIds.ToImmutableArray();
7071
}
7172

7273
internal static async Task<Solution> CleanSyntaxAndSemanticsAsync(
@@ -89,7 +90,10 @@ internal static async Task<Solution> CleanSyntaxAndSemanticsAsync(
8990
using var _ = ArrayBuilder<(DocumentId documentId, CodeCleanupOptions options)>.GetInstance(documentIds.Length, out var documentIdsAndOptions);
9091
foreach (var documentId in documentIds)
9192
{
92-
var document = changedSolution.GetRequiredDocument(documentId);
93+
// We include source generated documents here for Razor, which uses them. In that scenario the cleaned document is compared to the
94+
// original to create a set of changes for the LSP client, and part of that will include mapping the changes back to the Razor document,
95+
// so whilst it would seem like cleaning source generated documents is a waste of time, it's sometimes not.
96+
var document = await changedSolution.GetRequiredDocumentAsync(documentId, includeSourceGenerated: true, cancellationToken).ConfigureAwait(false);
9397

9498
// Only care about documents that support syntax. Non-C#/VB files can't be cleaned.
9599
if (document.SupportsSyntaxTree)
@@ -114,7 +118,7 @@ internal static async ValueTask<Document> CleanupDocumentAsync(Document document
114118
CodeAnalysisProgress.None,
115119
cancellationToken).ConfigureAwait(false);
116120

117-
return cleanedSolution.GetRequiredDocument(document.Id);
121+
return await cleanedSolution.GetRequiredDocumentAsync(document.Id, includeSourceGenerated: true, cancellationToken).ConfigureAwait(false);
118122
}
119123

120124
private static async Task<Solution> RunAllCleanupPassesInOrderAsync(
@@ -152,7 +156,7 @@ async Task<Solution> RunParallelCleanupPassAsync(
152156
var (documentId, options) = documentIdAndOptions;
153157

154158
// Fetch the current state of the document from this fork of the solution.
155-
var document = solution.GetRequiredDocument(documentId);
159+
var document = await solution.GetRequiredDocumentAsync(documentId, includeSourceGenerated: true, cancellationToken).ConfigureAwait(false);
156160
Contract.ThrowIfFalse(document.SupportsSyntaxTree, "GetDocumentIdsAndOptionsAsync should only be returning documents that support syntax");
157161

158162
// Now, perform the requested cleanup pass on it.

src/Workspaces/Core/Portable/Workspace/Solution/Document.cs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,13 +388,33 @@ public Document WithSourceCodeKind(SourceCodeKind kind)
388388
/// Creates a new instance of this document updated to have the text specified.
389389
/// </summary>
390390
public Document WithText(SourceText text)
391-
=> this.Project.Solution.WithDocumentText(this.Id, text, PreservationMode.PreserveIdentity).GetRequiredDocument(Id);
391+
{
392+
var solution = this.Project.Solution.WithDocumentText(this.Id, text, PreservationMode.PreserveIdentity);
393+
394+
if (Id.IsSourceGenerated)
395+
{
396+
// We just modified the text of the generated document, so it should be available synchronously, and throwing is appropriate if it isn't.
397+
return solution.GetRequiredSourceGeneratedDocumentForAlreadyGeneratedId(Id);
398+
}
399+
400+
return solution.GetRequiredDocument(Id);
401+
}
392402

393403
/// <summary>
394404
/// Creates a new instance of this document updated to have a syntax tree rooted by the specified syntax node.
395405
/// </summary>
396406
public Document WithSyntaxRoot(SyntaxNode root)
397-
=> this.Project.Solution.WithDocumentSyntaxRoot(this.Id, root, PreservationMode.PreserveIdentity).GetRequiredDocument(Id);
407+
{
408+
var solution = this.Project.Solution.WithDocumentSyntaxRoot(this.Id, root, PreservationMode.PreserveIdentity);
409+
410+
if (Id.IsSourceGenerated)
411+
{
412+
// We just modified the text of the generated document, so it should be available synchronously, and throwing is appropriate if it isn't.
413+
return solution.GetRequiredSourceGeneratedDocumentForAlreadyGeneratedId(Id);
414+
}
415+
416+
return solution.GetRequiredDocument(Id);
417+
}
398418

399419
/// <summary>
400420
/// Creates a new instance of this document updated to have the specified name.

src/Workspaces/Core/Portable/Workspace/Solution/DocumentState.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,7 @@ internal DocumentState UpdateTree(SyntaxNode newRoot, PreservationMode mode)
549549
}
550550
}
551551

552-
private VersionStamp GetNewTreeVersionForUpdatedTree(SyntaxNode newRoot, VersionStamp newTextVersion, PreservationMode mode)
552+
protected VersionStamp GetNewTreeVersionForUpdatedTree(SyntaxNode newRoot, VersionStamp newTextVersion, PreservationMode mode)
553553
{
554554
RoslynDebug.Assert(TreeSource != null);
555555

@@ -566,7 +566,7 @@ private VersionStamp GetNewTreeVersionForUpdatedTree(SyntaxNode newRoot, Version
566566
return oldRoot.IsEquivalentTo(newRoot, topLevel: true) ? oldTreeAndVersion.Version : newTextVersion;
567567
}
568568

569-
private VersionStamp GetNewerVersion()
569+
protected VersionStamp GetNewerVersion()
570570
{
571571
if (TextAndVersionSource.TryGetValue(LoadTextOptions, out var textAndVersion))
572572
{

src/Workspaces/Core/Portable/Workspace/Solution/Project.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,8 +332,7 @@ internal SourceGeneratedDocument GetOrCreateSourceGeneratedDocument(SourceGenera
332332
/// </summary>
333333
/// <remarks>
334334
/// This is only safe to call if you already have seen the SyntaxTree or equivalent that indicates the document state has already been
335-
/// generated. This method exists to implement <see cref="Solution.GetDocument(SyntaxTree?)"/> and is best avoided unless you're doing something
336-
/// similarly tricky like that.
335+
/// generated. This method is best avoided unless you manually ensure you realise the generated document before calling this method.
337336
/// </remarks>
338337
internal SourceGeneratedDocument? TryGetSourceGeneratedDocumentForAlreadyGeneratedId(DocumentId documentId)
339338
{

src/Workspaces/Core/Portable/Workspace/Solution/Solution.cs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1616,7 +1616,8 @@ public Solution WithDocumentText(IEnumerable<DocumentId?> documentIds, SourceTex
16161616
internal Document WithFrozenSourceGeneratedDocument(
16171617
SourceGeneratedDocumentIdentity documentIdentity, DateTime generationDateTime, SourceText text)
16181618
{
1619-
var newCompilationState = CompilationState.WithFrozenSourceGeneratedDocuments([(documentIdentity, generationDateTime, text)]);
1619+
// SyntaxNode is null here because it will be computed on demand. Other APIs, like Document.WithSyntaxRoot, specify it.
1620+
var newCompilationState = CompilationState.WithFrozenSourceGeneratedDocuments([(documentIdentity, generationDateTime, text, syntaxNode: null)]);
16201621
var newSolution = WithCompilationState(newCompilationState);
16211622

16221623
var newDocumentState = newCompilationState.TryGetSourceGeneratedDocumentStateForAlreadyGeneratedId(documentIdentity.DocumentId);
@@ -1627,7 +1628,7 @@ internal Document WithFrozenSourceGeneratedDocument(
16271628
}
16281629

16291630
internal Solution WithFrozenSourceGeneratedDocuments(ImmutableArray<(SourceGeneratedDocumentIdentity documentIdentity, DateTime generationDateTime, SourceText text)> documents)
1630-
=> WithCompilationState(CompilationState.WithFrozenSourceGeneratedDocuments(documents));
1631+
=> WithCompilationState(CompilationState.WithFrozenSourceGeneratedDocuments(documents.SelectAsArray(d => (d.documentIdentity, d.generationDateTime, d.text, (SyntaxNode?)null))));
16311632

16321633
/// <inheritdoc cref="SolutionCompilationState.UpdateSpecificSourceGeneratorExecutionVersions"/>
16331634
internal Solution UpdateSpecificSourceGeneratorExecutionVersions(SourceGeneratorExecutionVersionMap sourceGeneratorExecutionVersionMap)
@@ -1740,9 +1741,26 @@ private void CheckContainsDocument(DocumentId documentId)
17401741
throw new ArgumentNullException(nameof(documentId));
17411742
}
17421743

1743-
if (!ContainsDocument(documentId))
1744+
// For source generated documents we expect them to be already generated to use any of the APIs that call this
1745+
if (documentId.IsSourceGenerated && ContainsSourceGeneratedDocument(documentId))
17441746
{
1745-
throw new InvalidOperationException(WorkspaceExtensionsResources.The_solution_does_not_contain_the_specified_document);
1747+
return;
1748+
}
1749+
1750+
if (ContainsDocument(documentId))
1751+
{
1752+
return;
1753+
}
1754+
1755+
throw new InvalidOperationException(WorkspaceExtensionsResources.The_solution_does_not_contain_the_specified_document);
1756+
1757+
bool ContainsSourceGeneratedDocument(DocumentId documentId)
1758+
{
1759+
var project = this.GetProject(documentId.ProjectId);
1760+
if (project is null)
1761+
return false;
1762+
1763+
return project.TryGetSourceGeneratedDocumentForAlreadyGeneratedId(documentId) is not null;
17461764
}
17471765
}
17481766

src/Workspaces/Core/Portable/Workspace/Solution/SolutionChanges.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33
// See the LICENSE file in the project root for more information.
44

5+
using System;
56
using System.Collections.Generic;
67
using Microsoft.CodeAnalysis.Diagnostics;
8+
using Microsoft.CodeAnalysis.PooledObjects;
79
using Microsoft.CodeAnalysis.Shared.Extensions;
10+
using Roslyn.Utilities;
811

912
namespace Microsoft.CodeAnalysis;
1013

@@ -80,4 +83,31 @@ public IEnumerable<AnalyzerReference> GetRemovedAnalyzerReferences()
8083
}
8184
}
8285
}
86+
87+
/// <summary>
88+
/// Gets changed source generated document ids that were modified with <see cref="Solution.WithFrozenSourceGeneratedDocuments(System.Collections.Immutable.ImmutableArray{ValueTuple{SourceGeneratedDocumentIdentity, DateTime, Text.SourceText}})"/>
89+
/// </summary>
90+
/// <remarks>
91+
/// It is possible for a source generated document to be "frozen" without it existing in the solution, and in that case
92+
/// this method will not return that document. This only returns changes to source generated documents, hence they had
93+
/// to already be observed in the old solution.
94+
/// </remarks>
95+
internal IEnumerable<DocumentId> GetExplicitlyChangedSourceGeneratedDocuments()
96+
{
97+
if (_newSolution.CompilationState.FrozenSourceGeneratedDocumentStates.IsEmpty)
98+
return [];
99+
100+
using var _ = ArrayBuilder<SourceGeneratedDocumentState>.GetInstance(out var oldStateBuilder);
101+
foreach (var (id, _) in _newSolution.CompilationState.FrozenSourceGeneratedDocumentStates.States)
102+
{
103+
var oldState = _oldSolution.CompilationState.TryGetSourceGeneratedDocumentStateForAlreadyGeneratedId(id);
104+
oldStateBuilder.AddIfNotNull(oldState);
105+
}
106+
107+
var oldStates = new TextDocumentStates<SourceGeneratedDocumentState>(oldStateBuilder);
108+
return _newSolution.CompilationState.FrozenSourceGeneratedDocumentStates.GetChangedStateIds(
109+
oldStates,
110+
ignoreUnchangedContent: true,
111+
ignoreUnchangeableDocuments: false);
112+
}
83113
}

src/Workspaces/Core/Portable/Workspace/Solution/SolutionCompilationState.WithFrozenSourceGeneratedDocumentsCompilationTracker.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,18 @@ public ICompilationTracker WithDoNotCreateCreationPolicy()
9494
: new WithFrozenSourceGeneratedDocumentsCompilationTracker(underlyingTracker, _replacementDocumentStates);
9595
}
9696

97+
/// <summary>
98+
/// Updates the frozen source generated documents states being tracked
99+
/// </summary>
100+
/// <remarks>
101+
/// NOTE: This does not merge the states currently tracked, it simply replaces them. If merging is desired, it should be done
102+
/// by the caller.
103+
/// </remarks>
104+
public ICompilationTracker WithReplacementDocumentStates(TextDocumentStates<SourceGeneratedDocumentState> replacementDocumentStates)
105+
{
106+
return new WithFrozenSourceGeneratedDocumentsCompilationTracker(this.UnderlyingTracker, replacementDocumentStates);
107+
}
108+
97109
public async Task<Compilation> GetCompilationAsync(SolutionCompilationState compilationState, CancellationToken cancellationToken)
98110
{
99111
// Fast path if we've definitely already done this before

0 commit comments

Comments
 (0)