|
// 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.Linq;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.Editor.UnitTests;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.Text.Shared.Extensions;
using Microsoft.VisualStudio.Composition;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Editor;
using Microsoft.VisualStudio.Text.Projection;
using Microsoft.VisualStudio.Utilities;
using Roslyn.Test.EditorUtilities;
using Roslyn.Test.Utilities;
using Roslyn.Utilities;
namespace Microsoft.CodeAnalysis.Test.Utilities;
public partial class EditorTestWorkspace : TestWorkspace<EditorTestHostDocument, EditorTestHostProject, EditorTestHostSolution>
{
private readonly Dictionary<string, ITextBuffer2> _createdTextBuffers = [];
internal EditorTestWorkspace(
TestComposition? composition = null,
string? workspaceKind = WorkspaceKind.Host,
Guid solutionTelemetryId = default,
bool disablePartialSolutions = true,
bool ignoreUnchangeableDocumentsWhenApplyingChanges = true,
WorkspaceConfigurationOptions? configurationOptions = null,
bool supportsLspMutation = false)
: base(composition ?? EditorTestCompositions.EditorFeatures,
workspaceKind,
solutionTelemetryId,
disablePartialSolutions,
ignoreUnchangeableDocumentsWhenApplyingChanges,
configurationOptions,
supportsLspMutation)
{
}
private protected override EditorTestHostDocument CreateDocument(
string text = "",
string displayName = "",
SourceCodeKind sourceCodeKind = SourceCodeKind.Regular,
DocumentId? id = null,
string? filePath = null,
IReadOnlyList<string>? folders = null,
ExportProvider? exportProvider = null,
IDocumentServiceProvider? documentServiceProvider = null)
=> new(text, displayName, sourceCodeKind, id, filePath, folders, exportProvider, documentServiceProvider);
private protected override EditorTestHostDocument CreateDocument(
ExportProvider exportProvider,
HostLanguageServices? languageServiceProvider,
string code,
string name,
string filePath,
int? cursorPosition,
IDictionary<string, ImmutableArray<TextSpan>> spans,
SourceCodeKind sourceCodeKind = SourceCodeKind.Regular,
IReadOnlyList<string>? folders = null,
bool isLinkFile = false,
IDocumentServiceProvider? documentServiceProvider = null,
ISourceGenerator? generator = null)
=> new(exportProvider, languageServiceProvider, code, name, filePath, cursorPosition, spans,
sourceCodeKind, folders, isLinkFile, documentServiceProvider, roles: default, textBuffer: null, generator);
private protected override EditorTestHostProject CreateProject(
HostLanguageServices languageServices,
CompilationOptions? compilationOptions,
ParseOptions? parseOptions,
string assemblyName,
string projectName,
IList<MetadataReference>? references,
IList<EditorTestHostDocument> documents,
IList<EditorTestHostDocument>? additionalDocuments = null,
IList<EditorTestHostDocument>? analyzerConfigDocuments = null,
Type? hostObjectType = null,
bool isSubmission = false,
string? filePath = null,
IList<AnalyzerReference>? analyzerReferences = null,
string? defaultNamespace = null)
=> new(
languageServices,
compilationOptions,
parseOptions,
assemblyName,
projectName,
references,
documents,
additionalDocuments,
analyzerConfigDocuments,
hostObjectType,
isSubmission,
filePath,
analyzerReferences,
defaultNamespace);
private protected override EditorTestHostSolution CreateSolution(EditorTestHostProject[] projects)
=> new(projects);
protected override void Dispose(bool finalize)
{
foreach (var document in Documents)
{
document.CloseTextView();
}
foreach (var document in AdditionalDocuments)
{
document.CloseTextView();
}
foreach (var document in AnalyzerConfigDocuments)
{
document.CloseTextView();
}
foreach (var document in ProjectionDocuments)
{
document.CloseTextView();
}
base.Dispose(finalize);
}
protected override void ApplyDocumentTextChanged(DocumentId document, SourceText newText)
{
var testDocument = GetTestDocument(document);
Contract.ThrowIfNull(testDocument);
testDocument.Update(newText);
}
protected override void ApplyAdditionalDocumentTextChanged(DocumentId document, SourceText newText)
{
var testDocument = GetTestAdditionalDocument(document);
Contract.ThrowIfNull(testDocument);
testDocument.Update(newText);
}
protected override void ApplyAnalyzerConfigDocumentTextChanged(DocumentId document, SourceText newText)
{
var testDocument = this.GetTestAnalyzerConfigDocument(document);
Contract.ThrowIfNull(testDocument);
testDocument.Update(newText);
}
public override void OpenDocument(DocumentId documentId, bool activate = true)
{
// Fetching the open SourceTextContainer implicitly opens the document.
var testDocument = GetTestDocument(documentId);
Contract.ThrowIfNull(testDocument);
Contract.ThrowIfTrue(testDocument.IsSourceGenerated);
testDocument.GetOpenTextContainer();
}
public override void OpenAdditionalDocument(DocumentId documentId, bool activate = true)
{
// Fetching the open SourceTextContainer implicitly opens the document.
var testDocument = GetTestAdditionalDocument(documentId);
Contract.ThrowIfNull(testDocument);
Contract.ThrowIfTrue(testDocument.IsSourceGenerated);
testDocument.GetOpenTextContainer();
}
public override void OpenAnalyzerConfigDocument(DocumentId documentId, bool activate = true)
{
// Fetching the open SourceTextContainer implicitly opens the document.
var testDocument = GetTestAnalyzerConfigDocument(documentId);
Contract.ThrowIfNull(testDocument);
Contract.ThrowIfTrue(testDocument.IsSourceGenerated);
testDocument.GetOpenTextContainer();
}
public void OpenSourceGeneratedDocument(DocumentId documentId)
{
// Fetching the open SourceTextContainer implicitly opens the document.
var testDocument = GetTestDocument(documentId);
Contract.ThrowIfNull(testDocument);
Contract.ThrowIfFalse(testDocument.IsSourceGenerated);
testDocument.GetOpenTextContainer();
}
/// <summary>
/// Creates a TestHostDocument backed by a projection buffer. The surface buffer is
/// described by a markup string with {|name:|} style pointers to annotated spans that can
/// be found in one of a set of provided documents. Unnamed spans in the documents (which
/// must have both endpoints inside an annotated spans) and in the surface buffer markup are
/// mapped and included in the resulting document.
///
/// If the markup string has the caret indicator "$$", then the caret will be placed at the
/// corresponding position. If it does not, then the first span mapped into the projection
/// buffer that contains the caret from its document is used.
///
/// The result is a new TestHostDocument backed by a projection buffer including tracking
/// spans from any number of documents and inert text from the markup itself.
///
/// As an example, consider surface buffer markup
/// ABC [|DEF|] [|GHI[|JKL|]|]{|S1:|} [|MNO{|S2:|}PQR S$$TU|] {|S4:|}{|S5:|}{|S3:|}
///
/// This contains 4 unnamed spans and references to 5 spans that should be found and
/// included. Consider an included base document created from the following markup:
///
/// public class C
/// {
/// public void M1()
/// {
/// {|S1:int [|abc[|d$$ef|]|] = goo;|}
/// int y = goo;
/// {|S2:int [|def|] = goo;|}
/// int z = {|S3:123|} + {|S4:456|} + {|S5:789|};
/// }
/// }
///
/// The resulting projection buffer (with unnamed span markup preserved) would look like:
/// ABC [|DEF|] [|GHI[|JKL|]|]int [|abc[|d$$ef|]|] = goo; [|MNOint [|def|] = goo;PQR S$$TU|] 456789123
///
/// The union of unnamed spans from the surface buffer markup and each of the projected
/// spans is sorted as it would have been sorted by MarkupTestFile had it parsed the entire
/// projection buffer as one file, which it would do in a stack-based manner. In our example,
/// the order of the unnamed spans would be as follows:
///
/// ABC [|DEF|] [|GHI[|JKL|]|]int [|abc[|d$$ef|]|] = goo; [|MNOint [|def|] = goo;PQR S$$TU|] 456789123
/// -----1 -----2 -------4 -----6
/// ------------3 --------------5 --------------------------------7
/// </summary>
/// <param name="markup">Describes the surface buffer, and contains a mix of inert text,
/// named spans and unnamed spans. Any named spans must contain only the name portion
/// (e.g. {|Span1:|} which must match the name of a span in one of the baseDocuments.
/// Annotated spans cannot be nested but they can be adjacent, in which case order will be
/// preserved. The markup may also contain the caret indicator.</param>
/// <param name="baseDocuments">The set of documents from which the projection buffer
/// document will be composed.</param>
/// <returns></returns>
public EditorTestHostDocument CreateProjectionBufferDocument(
string markup,
IList<EditorTestHostDocument> baseDocuments,
string path = "projectionbufferdocumentpath",
ProjectionBufferOptions options = ProjectionBufferOptions.None,
IProjectionEditResolver? editResolver = null)
{
GetSpansAndCaretFromSurfaceBufferMarkup(markup, baseDocuments,
out var projectionBufferSpans, out var mappedSpans, out var mappedCaretLocation);
var projectionBufferFactory = this.GetService<IProjectionBufferFactoryService>();
var projectionBuffer = projectionBufferFactory.CreateProjectionBuffer(editResolver, projectionBufferSpans, options);
// Add in mapped spans from each of the base documents
foreach (var document in baseDocuments)
{
mappedSpans[string.Empty] = mappedSpans.TryGetValue(string.Empty, out var emptyTextSpans)
? emptyTextSpans
: ImmutableArray<TextSpan>.Empty;
foreach (var span in document.SelectedSpans)
{
var snapshotSpan = span.ToSnapshotSpan(document.GetTextBuffer().CurrentSnapshot);
var mappedSpan = projectionBuffer.CurrentSnapshot.MapFromSourceSnapshot(snapshotSpan).Single();
mappedSpans[string.Empty] = mappedSpans[string.Empty].Add(mappedSpan.ToTextSpan());
}
// Order unnamed spans as they would be ordered by the normal span finding
// algorithm in MarkupTestFile
mappedSpans[string.Empty] = mappedSpans[string.Empty].OrderBy(s => s.End).ThenBy(s => -s.Start).ToImmutableArray();
foreach (var (key, spans) in document.AnnotatedSpans)
{
mappedSpans[key] = mappedSpans.TryGetValue(key, out var textSpans) ? textSpans : ImmutableArray<TextSpan>.Empty;
foreach (var span in spans)
{
var snapshotSpan = span.ToSnapshotSpan(document.GetTextBuffer().CurrentSnapshot);
var mappedSpan = projectionBuffer.CurrentSnapshot.MapFromSourceSnapshot(snapshotSpan).Cast<Span?>().SingleOrDefault();
if (mappedSpan == null)
{
// not all span on subject buffer needs to exist on surface buffer
continue;
}
// but if they do, it must be only 1
mappedSpans[key] = mappedSpans[key].Add(mappedSpan.Value.ToTextSpan());
}
}
}
var projectionDocument = new EditorTestHostDocument(
ExportProvider,
languageServiceProvider: null,
projectionBuffer.CurrentSnapshot.GetText(),
path,
path,
mappedCaretLocation,
mappedSpans,
textBuffer: (ITextBuffer2)projectionBuffer);
this.ProjectionDocuments.Add(projectionDocument);
return projectionDocument;
}
private static void GetSpansAndCaretFromSurfaceBufferMarkup(
string markup, IList<EditorTestHostDocument> baseDocuments,
out IList<object> projectionBufferSpans,
out Dictionary<string, ImmutableArray<TextSpan>> mappedMarkupSpans, out int? mappedCaretLocation)
{
projectionBufferSpans = new List<object>();
var projectionBufferSpanStartingPositions = new List<int>();
mappedCaretLocation = null;
MarkupTestFile.GetPositionAndSpans(markup,
out var inertText, out int? markupCaretLocation, out var markupSpans);
var namedSpans = markupSpans.Where(kvp => kvp.Key != string.Empty);
var sortedAndNamedSpans = namedSpans.OrderBy(kvp => kvp.Value.Single().Start)
.ThenBy(kvp => markup.IndexOf("{|" + kvp.Key + ":", StringComparison.Ordinal));
var currentPositionInInertText = 0;
var currentPositionInProjectionBuffer = 0;
// If the markup points to k spans, these k spans divide the inert text into k + 1
// possibly empty substrings. When handling each span, also handle the inert text that
// immediately precedes it. At the end, handle the trailing inert text
foreach (var spanNameToListMap in sortedAndNamedSpans)
{
var spanName = spanNameToListMap.Key;
var spanLocation = spanNameToListMap.Value.Single().Start;
// Get any inert text between this and the previous span
if (currentPositionInInertText < spanLocation)
{
var textToAdd = inertText[currentPositionInInertText..spanLocation];
projectionBufferSpans.Add(textToAdd);
projectionBufferSpanStartingPositions.Add(currentPositionInProjectionBuffer);
// If the caret is in the markup and in this substring, calculate the final
// caret location
if (mappedCaretLocation == null &&
markupCaretLocation != null &&
currentPositionInInertText + textToAdd.Length >= markupCaretLocation)
{
var caretOffsetInCurrentText = markupCaretLocation.Value - currentPositionInInertText;
mappedCaretLocation = currentPositionInProjectionBuffer + caretOffsetInCurrentText;
}
currentPositionInInertText += textToAdd.Length;
currentPositionInProjectionBuffer += textToAdd.Length;
}
// Find and insert the span from the corresponding document
var documentWithSpan = baseDocuments.FirstOrDefault(d => d.AnnotatedSpans.ContainsKey(spanName));
if (documentWithSpan == null)
{
continue;
}
markupSpans.Remove(spanName);
var matchingSpan = documentWithSpan.AnnotatedSpans[spanName].Single();
var span = new Span(matchingSpan.Start, matchingSpan.Length);
var trackingSpan = documentWithSpan.GetTextBuffer().CurrentSnapshot.CreateTrackingSpan(span, SpanTrackingMode.EdgeExclusive);
projectionBufferSpans.Add(trackingSpan);
projectionBufferSpanStartingPositions.Add(currentPositionInProjectionBuffer);
// If the caret is not in markup but is in this span, then calculate the final
// caret location. Note - if we find the caret marker in this document, then
// we DO want to map it up, even if it's at the end of the span for this document.
// This is not ambiguous for us, since we have explicit delimiters between the buffer
// so it's clear which document the caret is in.
if (mappedCaretLocation == null &&
markupCaretLocation == null &&
documentWithSpan.CursorPosition.HasValue &&
(matchingSpan.Contains(documentWithSpan.CursorPosition.Value) || matchingSpan.End == documentWithSpan.CursorPosition.Value))
{
var caretOffsetInSpan = documentWithSpan.CursorPosition.Value - matchingSpan.Start;
mappedCaretLocation = currentPositionInProjectionBuffer + caretOffsetInSpan;
}
currentPositionInProjectionBuffer += matchingSpan.Length;
}
// Handle any inert text after the final projected span
if (currentPositionInInertText < inertText.Length - 1)
{
projectionBufferSpans.Add(inertText[currentPositionInInertText..]);
projectionBufferSpanStartingPositions.Add(currentPositionInProjectionBuffer);
if (mappedCaretLocation == null && markupCaretLocation != null && markupCaretLocation >= currentPositionInInertText)
{
var caretOffsetInCurrentText = markupCaretLocation.Value - currentPositionInInertText;
mappedCaretLocation = currentPositionInProjectionBuffer + caretOffsetInCurrentText;
}
}
MapMarkupSpans(markupSpans, out mappedMarkupSpans, projectionBufferSpans, projectionBufferSpanStartingPositions);
}
private static void MapMarkupSpans(
IDictionary<string, ImmutableArray<TextSpan>> markupSpans,
out Dictionary<string, ImmutableArray<TextSpan>> mappedMarkupSpans,
IList<object> projectionBufferSpans, IList<int> projectionBufferSpanStartingPositions)
{
var tempMappedMarkupSpans = new Dictionary<string, PooledObjects.ArrayBuilder<TextSpan>>();
foreach (var key in markupSpans.Keys)
{
tempMappedMarkupSpans[key] = PooledObjects.ArrayBuilder<TextSpan>.GetInstance();
foreach (var markupSpan in markupSpans[key])
{
var positionInMarkup = 0;
var spanIndex = 0;
var markupSpanStart = markupSpan.Start;
var markupSpanEndExclusive = markupSpan.Start + markupSpan.Length;
int? spanStartLocation = null;
int? spanEndLocationExclusive = null;
foreach (var projectionSpan in projectionBufferSpans)
{
if (projectionSpan is string text)
{
// this currently has a bug where it can't distinguish a markup of {|ProjectionMarkup:|}{|Markup1:|} and {|Markup1:{|ProjectionMarkup:|}|}
// it always map markup1 span as the later one.
// tracking issue - {|ProjectionMarkup:|}{|Markup1:|} and {|Markup1:{|ProjectionMarkup:|}|}
if (spanStartLocation == null && positionInMarkup <= markupSpanStart && markupSpanStart <= positionInMarkup + text.Length)
{
var offsetInText = markupSpanStart - positionInMarkup;
spanStartLocation = projectionBufferSpanStartingPositions[spanIndex] + offsetInText;
}
if (spanEndLocationExclusive == null && positionInMarkup <= markupSpanEndExclusive && markupSpanEndExclusive <= positionInMarkup + text.Length)
{
var offsetInText = markupSpanEndExclusive - positionInMarkup;
spanEndLocationExclusive = projectionBufferSpanStartingPositions[spanIndex] + offsetInText;
break;
}
positionInMarkup += text.Length;
}
spanIndex++;
}
tempMappedMarkupSpans[key].Add(new TextSpan(spanStartLocation!.Value, spanEndLocationExclusive!.Value - spanStartLocation.Value));
}
}
mappedMarkupSpans = tempMappedMarkupSpans.ToDictionary(
kvp => kvp.Key, kvp => kvp.Value.ToImmutableAndFree());
}
internal ITextBuffer2 GetOrCreateBufferForPath(string? filePath, IContentType contentType, string languageName, string initialText)
{
// If we don't have a file path we'll just make something up for the purpose of this dictionary so all
// buffers are still held onto. This isn't a file name used in the workspace itself so it's unobservable.
if (RoslynString.IsNullOrEmpty(filePath))
{
filePath = Guid.NewGuid().ToString();
}
return _createdTextBuffers.GetOrAdd(filePath, _ =>
{
var textBuffer = EditorFactory.CreateBuffer(ExportProvider, contentType, initialText);
// Ensure that the editor options on the text buffer matches that of the options that can be directly set in the workspace
var editorOptions = ExportProvider.GetExportedValue<IEditorOptionsFactoryService>().GetOptions(textBuffer);
var globalOptions = GlobalOptions;
editorOptions.SetOptionValue(DefaultOptions.ConvertTabsToSpacesOptionId, !globalOptions.GetOption(FormattingOptions2.UseTabs, languageName));
editorOptions.SetOptionValue(DefaultOptions.TabSizeOptionId, globalOptions.GetOption(FormattingOptions2.TabSize, languageName));
editorOptions.SetOptionValue(DefaultOptions.IndentSizeOptionId, globalOptions.GetOption(FormattingOptions2.IndentationSize, languageName));
return textBuffer;
});
}
}
|