File: Workspaces\TestHostDocument.cs
Web Access
Project: ..\..\..\src\EditorFeatures\TestUtilities\Microsoft.CodeAnalysis.EditorFeatures.Test.Utilities.csproj (Microsoft.CodeAnalysis.EditorFeatures.Test.Utilities)
// 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.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Microsoft.VisualStudio.Composition;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Editor;
using Roslyn.Test.Utilities;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.Editor.UnitTests.Workspaces
{
    public class TestHostDocument
    {
        private static readonly ImmutableArray<string> s_defaultRoles = ImmutableArray.Create<string>
            (PredefinedTextViewRoles.Analyzable,
            PredefinedTextViewRoles.Document,
            PredefinedTextViewRoles.Editable,
            PredefinedTextViewRoles.Interactive,
            PredefinedTextViewRoles.Zoomable);
 
        private readonly ExportProvider? _exportProvider;
        private HostLanguageServices? _languageServiceProvider;
        private readonly string _initialText;
        private IWpfTextView? _textView;
 
        private DocumentId? _id;
        private TestHostProject? _project;
 
        /// <summary>
        /// The <see cref="ITextBuffer2"/> for this document. Null if not yet created.
        /// </summary>
        private ITextBuffer2? _textBuffer;
 
        /// <summary>
        /// The <see cref="ITextSnapshot"/> when the buffer was first created, which can be used for tracking changes to the current buffer.
        /// </summary>
        private ITextSnapshot? _initialTextSnapshot;
        private readonly IReadOnlyList<string>? _folders;
        private readonly IDocumentServiceProvider? _documentServiceProvider;
        private readonly ImmutableArray<string> _roles;
        private readonly TestDocumentLoader _loader;
 
        public DocumentId Id
        {
            get
            {
                // For source generated documents, the workspace generates the ID. Thus we won't
                // know it until we have a workspace we can go and get the ID from. We of course could
                // duplicate the algorithm but this lets us keep this code oblivious to the internals
                // of the workspace implementation.
                if (IsSourceGenerated && _id is null)
                {
                    var workspace = _languageServiceProvider!.WorkspaceServices.Workspace;
                    var project = workspace.CurrentSolution.GetRequiredProject(_project!.Id);
                    var sourceGeneratedDocuments = project.GetSourceGeneratedDocumentsAsync(CancellationToken.None).AsTask().Result;
                    _id = sourceGeneratedDocuments.Single(d => d.FilePath == this.FilePath).Id;
                }
 
                Contract.ThrowIfNull(_id);
                return _id;
            }
        }
 
        public TestHostProject Project
        {
            get
            {
                Contract.ThrowIfNull(_project);
                return _project;
            }
        }
 
        public string Name { get; }
        public SourceCodeKind SourceCodeKind { get; }
        public string? FilePath { get; }
        public SourceHashAlgorithm ChecksumAlgorithm { get; } = SourceHashAlgorithms.Default;
 
        public int? CursorPosition { get; }
        public IList<TextSpan> SelectedSpans { get; } = new List<TextSpan>();
        public IDictionary<string, ImmutableArray<TextSpan>> AnnotatedSpans { get; } = new Dictionary<string, ImmutableArray<TextSpan>>();
 
        /// <summary>
        /// If a file exists in ProjectA and is added to ProjectB as a link, then this returns
        /// false for the document in ProjectA and true for the document in ProjectB.
        /// </summary>
        public bool IsLinkFile { get; }
 
        /// <summary>
        /// If this is a source generated file, the source generator that produced this document.
        /// </summary>
        public ISourceGenerator? Generator;
 
        /// <summary>
        /// Returns true if this will be a source generated file instead of a regular one.
        /// </summary>
        [MemberNotNullWhen(true, nameof(Generator))]
        public bool IsSourceGenerated => Generator is not null;
 
        internal TestHostDocument(
            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,
            ImmutableArray<string> roles = default,
            ITextBuffer2? textBuffer = null,
            ISourceGenerator? generator = null)
        {
            Contract.ThrowIfNull(filePath);
 
            _exportProvider = exportProvider;
            _languageServiceProvider = languageServiceProvider;
            _initialText = code;
            Name = name;
            FilePath = filePath;
            _folders = folders;
            this.CursorPosition = cursorPosition;
            SourceCodeKind = sourceCodeKind;
            this.IsLinkFile = isLinkFile;
            Generator = generator;
            _documentServiceProvider = documentServiceProvider;
            _roles = roles.IsDefault ? s_defaultRoles : roles;
 
            if (spans.ContainsKey(string.Empty))
            {
                this.SelectedSpans = spans[string.Empty];
            }
 
            foreach (var namedSpanList in spans.Where(s => s.Key != string.Empty))
            {
                this.AnnotatedSpans.Add(namedSpanList);
            }
 
            _loader = new TestDocumentLoader(this, _initialText);
 
            if (textBuffer != null)
            {
                _textBuffer = textBuffer;
                _initialTextSnapshot = textBuffer.CurrentSnapshot;
            }
        }
 
        internal TestHostDocument(
            string text = "",
            string displayName = "",
            SourceCodeKind sourceCodeKind = SourceCodeKind.Regular,
            DocumentId? id = null,
            string? filePath = null,
            IReadOnlyList<string>? folders = null,
            ExportProvider? exportProvider = null,
            IDocumentServiceProvider? documentServiceProvider = null)
        {
            _exportProvider = exportProvider;
            _id = id;
            _initialText = text;
            Name = displayName;
            SourceCodeKind = sourceCodeKind;
            _loader = new TestDocumentLoader(this, text);
            FilePath = filePath;
            _folders = folders;
            _roles = s_defaultRoles;
            _documentServiceProvider = documentServiceProvider;
        }
 
        internal void SetProject(TestHostProject project)
        {
            _project = project;
 
            // For generated documents, we need to fetch the IDs from the workspace later
            if (!IsSourceGenerated)
            {
                if (_id == null)
                {
                    _id = DocumentId.CreateNewId(project.Id, this.Name);
                }
                else
                {
                    Contract.ThrowIfFalse(project.Id == this.Id.ProjectId);
                }
            }
 
            _languageServiceProvider ??= project.LanguageServiceProvider;
        }
 
        private sealed class TestDocumentLoader : TextLoader
        {
            private readonly TestHostDocument _hostDocument;
            private readonly string _text;
 
            internal TestDocumentLoader(TestHostDocument hostDocument, string text)
            {
                _hostDocument = hostDocument;
                _text = text;
            }
 
            internal override string? FilePath
                => _hostDocument.FilePath;
 
            public override Task<TextAndVersion> LoadTextAndVersionAsync(LoadTextOptions options, CancellationToken cancellationToken)
                => Task.FromResult(TextAndVersion.Create(SourceText.From(_text, encoding: null, options.ChecksumAlgorithm), VersionStamp.Create(), _hostDocument.FilePath));
        }
 
        public TextLoader Loader => _loader;
 
        public IWpfTextView GetTextView()
        {
            if (_textView == null)
            {
                Contract.ThrowIfNull(_exportProvider, $"Can only create text view for {nameof(TestHostDocument)} created with {nameof(ExportProvider)}");
                WpfTestRunner.RequireWpfFact($"Creates an {nameof(IWpfTextView)} through {nameof(TestHostDocument)}.{nameof(GetTextView)}");
 
                var factory = _exportProvider.GetExportedValue<ITextEditorFactoryService>();
 
                // Every default role but outlining. Starting in 15.2, the editor
                // OutliningManager imports JoinableTaskContext in a way that's 
                // difficult to satisfy in our unit tests. Since we don't directly
                // depend on it, just disable it
                var roles = factory.CreateTextViewRoleSet(_roles);
                _textView = factory.CreateTextView(this.GetTextBuffer(), roles);
                if (this.CursorPosition.HasValue)
                {
                    _textView.Caret.MoveTo(new SnapshotPoint(_textView.TextSnapshot, CursorPosition.Value));
                }
                else if (this.SelectedSpans.IsSingle())
                {
                    var span = this.SelectedSpans.Single();
                    _textView.Selection.Select(new SnapshotSpan(_textView.TextSnapshot, new Span(span.Start, span.Length)), false);
                }
            }
 
            return _textView;
        }
 
        public ITextBuffer2 GetTextBuffer()
        {
            var workspace = (TestWorkspace?)_languageServiceProvider?.WorkspaceServices.Workspace;
 
            if (_textBuffer == null)
            {
                Contract.ThrowIfNull(_languageServiceProvider, $"To get a text buffer for a {nameof(TestHostDocument)}, it must have been parented in a project.");
                var contentType = _languageServiceProvider.GetRequiredService<IContentTypeLanguageService>().GetDefaultContentType();
 
                _textBuffer = workspace!.GetOrCreateBufferForPath(FilePath, contentType, _languageServiceProvider.Language, _initialText);
                _initialTextSnapshot = _textBuffer.CurrentSnapshot;
            }
 
            if (workspace != null)
            {
                // Open (or reopen) any files that were closed in this call. We do this for all linked copies at once.
                foreach (var linkedId in workspace.CurrentSolution.GetDocumentIdsWithFilePath(FilePath).Concat(this.Id))
                {
                    if (workspace.IsDocumentOpen(linkedId))
                        continue;
 
                    if (workspace.GetTestDocument(linkedId) is { } testDocument)
                    {
                        if (testDocument.IsSourceGenerated)
                        {
                            var threadingContext = workspace.GetService<IThreadingContext>();
                            var document = threadingContext.JoinableTaskFactory.Run(() => workspace.CurrentSolution.GetSourceGeneratedDocumentAsync(testDocument.Id, CancellationToken.None).AsTask());
                            Contract.ThrowIfNull(document);
 
                            workspace.OnSourceGeneratedDocumentOpened(_textBuffer.AsTextContainer(), document);
                        }
                        else
                        {
                            // If there is a linked file, we'll start the non-linked one as being the primary context, which some tests depend on.
                            workspace.OnDocumentOpened(linkedId, _textBuffer.AsTextContainer(), isCurrentContext: !testDocument.IsLinkFile);
                        }
                    }
                    else if (workspace.GetTestAdditionalDocument(linkedId) is { } testAdditionalDocument)
                    {
                        workspace.OnAdditionalDocumentOpened(linkedId, _textBuffer.AsTextContainer());
                    }
                    else if (workspace.GetTestAnalyzerConfigDocument(linkedId) is { } testAnalyzerConfigDocument)
                    {
                        workspace.OnAnalyzerConfigDocumentOpened(linkedId, _textBuffer.AsTextContainer());
                    }
                }
            }
 
            return _textBuffer;
        }
 
        public SourceTextContainer GetOpenTextContainer()
            => this.GetTextBuffer().AsTextContainer();
 
        public IReadOnlyList<string> Folders
        {
            get
            {
                return _folders ?? ImmutableArray.Create<string>();
            }
        }
 
        // TODO: delete this
        public ITextSnapshot InitialTextSnapshot
        {
            get
            {
                Contract.ThrowIfNull(_initialTextSnapshot);
                return _initialTextSnapshot;
            }
        }
 
        internal void Update(SourceText newText)
        {
            var buffer = GetTextBuffer();
            using (var edit = buffer.CreateEdit(EditOptions.DefaultMinimalChange, reiteratedVersionNumber: null, editTag: null))
            {
                var oldText = buffer.CurrentSnapshot.AsText();
                var changes = newText.GetTextChanges(oldText);
 
                foreach (var change in changes)
                {
                    edit.Replace(change.Span.Start, change.Span.Length, change.NewText);
                }
 
                edit.Apply();
            }
        }
 
        private void Update(string newText)
        {
            using (var edit = this.GetTextBuffer().CreateEdit(EditOptions.DefaultMinimalChange, reiteratedVersionNumber: null, editTag: null))
            {
                edit.Replace(new Span(0, this.GetTextBuffer().CurrentSnapshot.Length), newText);
                edit.Apply();
            }
        }
 
        internal void CloseTextView()
        {
            if (_textView != null && !_textView.IsClosed)
            {
                _textView.Close();
                _textView = null;
            }
        }
 
        public DocumentInfo ToDocumentInfo()
        {
            Contract.ThrowIfTrue(IsSourceGenerated, "We shouldn't be producing a DocumentInfo for a source generated document.");
            return DocumentInfo.Create(Id, Name, Folders, SourceCodeKind, Loader, FilePath, isGenerated: false)
                .WithDocumentServiceProvider(_documentServiceProvider);
        }
    }
}