From 81904f579ab196dcd16239d6bb13b2aeacaaffa2 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Sat, 20 Oct 2018 16:35:41 -0700 Subject: [PATCH] Add definitions for Document Services This change adds mock ups of the interfaces that we've been designing as part of Razor FAR as well as the implementations. This isn't wired up to anything yet in this PR, but the basic functionality here is stable enough for us to stabilize and review. For now we have the interface definitions in the Razor codebase until a build of Roslyn is available with these definitions + IVT for us to use them. --- .../DefaultDocumentServiceProviderFactory.cs | 29 ++ .../DocumentServiceProviderFactory.cs | 15 + .../Experiment/IDocumentServiceFactory.cs | 12 - .../Experiment/ISpanMapper.cs | 12 - .../Experiment/SpanMapResult.cs | 18 - .../Host/IDocumentExcerptService.cs | 79 ++++ .../Host/IDocumentOperationService.cs | 27 ++ .../Host/IDocumentService.cs | 16 + .../Host/IDocumentServiceProvider.cs | 18 + .../Host/ISpanMappingService.cs | 70 +++ .../ProjectSystem/DefaultDocumentSnapshot.cs | 6 +- .../ProjectSystem/DocumentImportsTracker.cs | 2 + .../ProjectSystem/DocumentSnapshot.cs | 2 + .../ProjectSystem/EmptyTextLoader.cs | 26 ++ .../ProjectSystem/GeneratedCodeContainer.cs | 84 +--- .../GeneratedOutputTextLoader.cs | 36 ++ .../RazorDocumentExcerptService.cs | 192 ++++++++ .../RazorDocumentServiceProvider.cs | 71 +++ .../RazorSpanMappingService.cs | 102 +++++ .../DefaultWorkspaceProjectContextFactory.cs | 13 +- .../ProjectSystem/IWorkspaceProjectContext.cs | 8 +- .../ProjectSystem/RazorProjectHostBase.cs | 4 +- .../DefaultDocumentSnapshotTest.cs | 26 +- .../GeneratedCodeContainerTest.cs | 109 ----- .../RazorExcerptServiceTest.cs | 429 ++++++++++++++++++ .../RazorSpanMappingServiceTest.cs | 164 +++++++ 26 files changed, 1317 insertions(+), 253 deletions(-) create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultDocumentServiceProviderFactory.cs create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/DocumentServiceProviderFactory.cs delete mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/Experiment/IDocumentServiceFactory.cs delete mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/Experiment/ISpanMapper.cs delete mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/Experiment/SpanMapResult.cs create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/Host/IDocumentExcerptService.cs create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/Host/IDocumentOperationService.cs create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/Host/IDocumentService.cs create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/Host/IDocumentServiceProvider.cs create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/Host/ISpanMappingService.cs create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/EmptyTextLoader.cs create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/GeneratedOutputTextLoader.cs create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/RazorDocumentExcerptService.cs create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/RazorDocumentServiceProvider.cs create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/RazorSpanMappingService.cs create mode 100644 test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/RazorExcerptServiceTest.cs create mode 100644 test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/RazorSpanMappingServiceTest.cs diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultDocumentServiceProviderFactory.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultDocumentServiceProviderFactory.cs new file mode 100644 index 0000000000..ceebd2813f --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultDocumentServiceProviderFactory.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Composition; +using Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; + +namespace Microsoft.CodeAnalysis.Razor +{ + [Export(typeof(DocumentServiceProviderFactory))] + internal class DefaultDocumentServiceProviderFactory : DocumentServiceProviderFactory + { + public override IDocumentServiceProvider Create(DocumentSnapshot document) + { + if (document == null) + { + throw new ArgumentNullException(nameof(document)); + } + + return new RazorDocumentServiceProvider(document); + } + + public override IDocumentServiceProvider CreateEmpty() + { + return new RazorDocumentServiceProvider(); + } + } +} diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/DocumentServiceProviderFactory.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/DocumentServiceProviderFactory.cs new file mode 100644 index 0000000000..2abe4c61de --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/DocumentServiceProviderFactory.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; + +namespace Microsoft.CodeAnalysis.Razor +{ + internal abstract class DocumentServiceProviderFactory + { + public abstract IDocumentServiceProvider CreateEmpty(); + + public abstract IDocumentServiceProvider Create(DocumentSnapshot document); + } +} diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/Experiment/IDocumentServiceFactory.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/Experiment/IDocumentServiceFactory.cs deleted file mode 100644 index fd8253234c..0000000000 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/Experiment/IDocumentServiceFactory.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -// Temporary code until this gets merged into Roslyn -#if DOCUMENT_SERVICE_FACTORY -namespace Microsoft.CodeAnalysis.Experiment -{ - public interface IDocumentServiceFactory - { - } -} -#endif diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/Experiment/ISpanMapper.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/Experiment/ISpanMapper.cs deleted file mode 100644 index 6d3c276116..0000000000 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/Experiment/ISpanMapper.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -// Temporary code until this gets merged into Roslyn -#if DOCUMENT_SERVICE_FACTORY -namespace Microsoft.CodeAnalysis.Experiment -{ - public interface ISpanMapper - { - } -} -#endif diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/Experiment/SpanMapResult.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/Experiment/SpanMapResult.cs deleted file mode 100644 index ea720c37ed..0000000000 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/Experiment/SpanMapResult.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -// Temporary code until this gets merged into Roslyn -#if DOCUMENT_SERVICE_FACTORY -using Microsoft.CodeAnalysis.Text; - -namespace Microsoft.CodeAnalysis.Experiment -{ - public class SpanMapResult - { - public SpanMapResult(Document document, LinePositionSpan linePositionSpan) - { - } - - } -} -#endif diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/Host/IDocumentExcerptService.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/Host/IDocumentExcerptService.cs new file mode 100644 index 0000000000..8729432a08 --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/Host/IDocumentExcerptService.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All Rights Reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +#if DOCUMENT_SERVICE_FACTORY + +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Classification; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.CodeAnalysis.Host +{ + /// + /// excerpt some part of + /// + internal interface IDocumentExcerptService : IDocumentService + { + /// + /// return of given and + /// + /// the result might not be an exact copy of the given source or contains more then given span + /// + Task TryExcerptAsync(Document document, TextSpan span, ExcerptMode mode, CancellationToken cancellationToken); + } + + /// + /// this mode shows intention not actual behavior. it is up to implementation how to interpret the intention. + /// + internal enum ExcerptMode + { + SingleLine, + Tooltip + } + + /// + /// Result of excerpt + /// + internal struct ExcerptResult + { + /// + /// excerpt content + /// + public readonly SourceText Content; + + /// + /// span on that given got mapped to + /// + public readonly TextSpan MappedSpan; + + /// + /// classification information on the + /// + public readonly ImmutableArray ClassifiedSpans; + + /// + /// this excerpt is from + /// + public readonly Document Document; + + /// + /// span on this excerpt is from + /// + public readonly TextSpan Span; + + public ExcerptResult(SourceText content, TextSpan mappedSpan, ImmutableArray classifiedSpans, Document document, TextSpan span) + { + Content = content; + MappedSpan = mappedSpan; + ClassifiedSpans = classifiedSpans; + + // these 2 might not actually needed + Document = document; + Span = span; + } + } +} + +#endif \ No newline at end of file diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/Host/IDocumentOperationService.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/Host/IDocumentOperationService.cs new file mode 100644 index 0000000000..ae3e5f2191 --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/Host/IDocumentOperationService.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All Rights Reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +#if DOCUMENT_SERVICE_FACTORY + +namespace Microsoft.CodeAnalysis.Host +{ + /// + /// provide various operations for this document + /// + /// I followed name from EditorOperation for now. + /// + internal interface IDocumentOperationService : IDocumentService + { + /// + /// document version of + /// + bool CanApplyChange { get; } + + /// + /// indicates whether this document supports diagnostics or not + /// + bool SupportDiagnostics { get; } + } +} + +#endif \ No newline at end of file diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/Host/IDocumentService.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/Host/IDocumentService.cs new file mode 100644 index 0000000000..fe6b075d0e --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/Host/IDocumentService.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All Rights Reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +#if DOCUMENT_SERVICE_FACTORY + +namespace Microsoft.CodeAnalysis.Host +{ + /// + /// Empty interface just to mark document services. + /// + internal interface IDocumentService + { + } +} + +#endif diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/Host/IDocumentServiceProvider.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/Host/IDocumentServiceProvider.cs new file mode 100644 index 0000000000..ba36d6fe04 --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/Host/IDocumentServiceProvider.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All Rights Reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +#if DOCUMENT_SERVICE_FACTORY + +namespace Microsoft.CodeAnalysis.Host +{ + internal interface IDocumentServiceProvider + { + /// + /// Gets a document specific service provided by the host identified by the service type. + /// If the host does not provide the service, this method returns null. + /// + TService GetService() where TService : class, IDocumentService; + } +} + +#endif \ No newline at end of file diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/Host/ISpanMappingService.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/Host/ISpanMappingService.cs new file mode 100644 index 0000000000..4c32f09180 --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/Host/ISpanMappingService.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All Rights Reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +#if DOCUMENT_SERVICE_FACTORY + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.CodeAnalysis.Host +{ + /// + /// Map spans in a document to other spans even in other document + /// + /// this will be used by various features if provided to convert span in one document to other spans. + /// + /// for example, it is used to show spans users expect in a razor file rather than spans in + /// auto generated file that is implementation detail or navigate to the right place rather + /// than the generated file and etc. + /// + internal interface ISpanMappingService : IDocumentService + { + /// + /// Map spans in the document to more appropriate locations + /// + /// in current design, this can NOT map a span to a span that is not backed by a file. + /// for example, roslyn supports someone to have a document that is not backed by a file. and current design doesn't allow + /// such document to be returned from this API + /// for example, span on razor secondary buffer document in roslyn solution mapped to a span on razor cshtml file is possible but + /// a span on razor cshtml file to a span on secondary buffer document is not possible since secondary buffer document is not backed by a file + /// + /// Document given spans belong to + /// Spans in the document + /// Cancellation token + /// Return mapped span. order of result should be same as the given span + Task> MapSpansAsync(Document document, IEnumerable spans, CancellationToken cancellationToken); + } + + /// + /// Result of span mapping + /// + internal struct MappedSpanResult + { + /// + /// Path to mapped file + /// + public readonly string FilePath; + + /// + /// LinePosition representation of the Span + /// + public readonly LinePositionSpan LinePositionSpan; + + /// + /// Mapped span + /// + public readonly TextSpan Span; + + public MappedSpanResult(string filePath, LinePositionSpan linePositionSpan, TextSpan span) + { + FilePath = filePath; + LinePositionSpan = linePositionSpan; + Span = span; + } + } +} + +#endif diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultDocumentSnapshot.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultDocumentSnapshot.cs index d25b38ddbe..d553088e5c 100644 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultDocumentSnapshot.cs +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultDocumentSnapshot.cs @@ -23,11 +23,11 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem throw new ArgumentNullException(nameof(state)); } - Project = project; + ProjectInternal = project; State = state; } - public DefaultProjectSnapshot Project { get; } + public DefaultProjectSnapshot ProjectInternal { get; } public DocumentState State { get; } @@ -35,6 +35,8 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public override string TargetPath => State.HostDocument.TargetPath; + public override ProjectSnapshot Project => ProjectInternal; + public override IReadOnlyList GetImports() { return State.Imports.GetImports(Project, this); diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentImportsTracker.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentImportsTracker.cs index 96b927e42f..91224532fa 100644 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentImportsTracker.cs +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentImportsTracker.cs @@ -104,6 +104,8 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public override string TargetPath => null; + public override ProjectSnapshot Project => _project; + public override Task GetGeneratedOutputAsync() { return _generatedOutput.GetGeneratedOutputInitializationTask(_project, this); diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentSnapshot.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentSnapshot.cs index 0cc9da8f04..755a15e579 100644 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentSnapshot.cs +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentSnapshot.cs @@ -14,6 +14,8 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public abstract string TargetPath { get; } + public abstract ProjectSnapshot Project { get; } + public abstract IReadOnlyList GetImports(); public abstract Task GetTextAsync(); diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/EmptyTextLoader.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/EmptyTextLoader.cs new file mode 100644 index 0000000000..765523fcf8 --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/EmptyTextLoader.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.CodeAnalysis.Razor.ProjectSystem +{ + internal class EmptyTextLoader : TextLoader + { + private readonly string _filePath; + private readonly VersionStamp _version; + + public EmptyTextLoader(string filePath) + { + _filePath = filePath; + _version = VersionStamp.Create(); // Version will never change so this can be reused. + } + + public override Task LoadTextAndVersionAsync(Workspace workspace, DocumentId documentId, CancellationToken cancellationToken) + { + return Task.FromResult(TextAndVersion.Create(SourceText.From(""), _version, _filePath)); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/GeneratedCodeContainer.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/GeneratedCodeContainer.cs index 5b9c0c2d01..0cc1de85b0 100644 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/GeneratedCodeContainer.cs +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/GeneratedCodeContainer.cs @@ -2,18 +2,13 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Generic; -using System.Collections.Immutable; using System.Diagnostics; -using System.Threading; -using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; -using Microsoft.CodeAnalysis.Experiment; using Microsoft.CodeAnalysis.Text; namespace Microsoft.CodeAnalysis.Razor.ProjectSystem { - internal class GeneratedCodeContainer : IDocumentServiceFactory, ISpanMapper + internal class GeneratedCodeContainer { public event EventHandler GeneratedCodeChanged; @@ -86,16 +81,6 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem } } - public TService GetService() - { - if (this is TService service) - { - return service; - } - - return default(TService); - } - public void SetOutput(RazorCSharpDocument csharpDocument, DefaultDocumentSnapshot document) { lock (_setOutputLock) @@ -128,73 +113,6 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem } } - public Task> MapSpansAsync( - Document document, - IEnumerable spans, - CancellationToken cancellationToken) - { - RazorCSharpDocument output; - SourceText source; - lock (_setOutputLock) - { - if (Output == null) - { - return Task.FromResult(ImmutableArray.Empty); - } - - output = Output; - source = Source; - } - - var results = ImmutableArray.CreateBuilder(); - foreach (var span in spans) - { - if (TryGetLinePositionSpan(span, source, output, out var linePositionSpan)) - { - results.Add(new SpanMapResult(document, linePositionSpan)); - } - else - { - results.Add(null); - } - } - - return Task.FromResult(results.ToImmutable()); - } - - // Internal for testing. - internal static bool TryGetLinePositionSpan(TextSpan span, SourceText source, RazorCSharpDocument output, out LinePositionSpan linePositionSpan) - { - var mappings = output.SourceMappings; - for (var i = 0; i < mappings.Count; i++) - { - var mapping = mappings[i]; - var original = mapping.OriginalSpan.AsTextSpan(); - var generated = mapping.GeneratedSpan.AsTextSpan(); - - if (!generated.Contains(span)) - { - // If the search span isn't contained within the generated span, it is not a match. - // A C# identifier won't cover multiple generated spans. - continue; - } - - var leftOffset = span.Start - generated.Start; - var rightOffset = span.End - generated.End; - Debug.Assert(leftOffset >= 0); - Debug.Assert(rightOffset <= 0); - - // Note: we don't handle imports here - the assumption is that for all of the scenarios we - // support, the span is in the original source document. - var adjusted = new TextSpan(original.Start + leftOffset, (original.End + rightOffset) - (original.Start + leftOffset)); - linePositionSpan = source.Lines.GetLinePositionSpan(adjusted); - return true; - } - - linePositionSpan = default; - return false; - } - private void TextContainer_TextChanged(object sender, TextChangeEventArgs args) { GeneratedCodeChanged?.Invoke(this, args); diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/GeneratedOutputTextLoader.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/GeneratedOutputTextLoader.cs new file mode 100644 index 0000000000..8ca7821b63 --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/GeneratedOutputTextLoader.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.CodeAnalysis.Razor.ProjectSystem +{ + internal class GeneratedOutputTextLoader : TextLoader + { + private readonly DocumentSnapshot _document; + private readonly string _filePath; + private readonly VersionStamp _version; + + public GeneratedOutputTextLoader(DocumentSnapshot document, string filePath) + { + if (document == null) + { + throw new ArgumentNullException(nameof(document)); + } + + _document = document; + _filePath = filePath; + _version = VersionStamp.Create(); // Version will never change so this can be reused. + } + + public override async Task LoadTextAndVersionAsync(Workspace workspace, DocumentId documentId, CancellationToken cancellationToken) + { + var output = await _document.GetGeneratedOutputAsync().ConfigureAwait(false); + return TextAndVersion.Create(SourceText.From(output.GetCSharpDocument().GeneratedCode), _version, _filePath); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/RazorDocumentExcerptService.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/RazorDocumentExcerptService.cs new file mode 100644 index 0000000000..6fe7d9b979 --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/RazorDocumentExcerptService.cs @@ -0,0 +1,192 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis.Classification; +using Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.CodeAnalysis.Razor +{ + internal class RazorDocumentExcerptService : IDocumentExcerptService + { + private readonly DocumentSnapshot _document; + private readonly ISpanMappingService _mapper; + + public RazorDocumentExcerptService(DocumentSnapshot document, ISpanMappingService mapper) + { + if (mapper == null) + { + throw new ArgumentNullException(nameof(mapper)); + } + + _document = document; + _mapper = mapper; + } + + public async Task TryExcerptAsync( + Document document, + TextSpan span, + ExcerptMode mode, + CancellationToken cancellationToken) + { + if (_document == null) + { + return null; + } + + var mapped = await _mapper.MapSpansAsync(document, new[] { span }, cancellationToken).ConfigureAwait(false); + if (mapped.Length == 0 || mapped[0].Equals(default(MappedSpanResult))) + { + return null; + } + + var project = _document.Project; + var primaryDocument = project.GetDocument(mapped[0].FilePath); + if (primaryDocument == null) + { + return null; + } + + var primaryText = await primaryDocument.GetTextAsync().ConfigureAwait(false); + var primarySpan = primaryText.Lines.GetTextSpan(mapped[0].LinePositionSpan); + + var secondaryDocument = document; + var secondarySpan = span; + + // First compute the range of text we want to we to display relative to the primary document. + var excerptSpan = ChooseExcerptSpan(primaryText, primarySpan, mode); + + // Then we'll classify the spans based on the primary document, since that's the coordinate + // space that our output mappings use. + var output = await _document.GetGeneratedOutputAsync().ConfigureAwait(false); + var mappings = output.GetCSharpDocument().SourceMappings; + var classifiedSpans = await ClassifyPreviewAsync( + primaryText, + excerptSpan, + secondaryDocument, + mappings, + cancellationToken).ConfigureAwait(false); + + // Now translate everything to be relative to the excerpt + var offset = 0 - excerptSpan.Start; + var excerptText = primaryText.GetSubText(excerptSpan); + excerptSpan = new TextSpan(excerptSpan.Start + offset, excerptSpan.Length); + + for (var i = 0; i < classifiedSpans.Count; i++) + { + var classifiedSpan = classifiedSpans[i]; + var updated = new TextSpan(classifiedSpan.TextSpan.Start + offset, classifiedSpan.TextSpan.Length); + Debug.Assert(excerptSpan.Contains(updated)); + + classifiedSpans[i] = new ClassifiedSpan(classifiedSpan.ClassificationType, updated); + } + + return new ExcerptResult(excerptText, excerptSpan, classifiedSpans.ToImmutable(), document, span); + } + + private TextSpan ChooseExcerptSpan(SourceText primaryText, TextSpan primarySpan, ExcerptMode mode) + { + var startLine = primaryText.Lines.GetLineFromPosition(primarySpan.Start); + var endLine = primaryText.Lines.GetLineFromPosition(primarySpan.End); + + // If we're showing a single line then this will do. Otherwise expand the range by 1 in + // each direction (if possible). + if (mode == ExcerptMode.Tooltip && startLine.LineNumber > 0) + { + startLine = primaryText.Lines[startLine.LineNumber - 1]; + } + + if (mode == ExcerptMode.Tooltip && endLine.LineNumber < primaryText.Lines.Count - 1) + { + endLine = primaryText.Lines[endLine.LineNumber + 1]; + } + + return new TextSpan(startLine.Start, endLine.End - startLine.Start); + } + + private async Task.Builder> ClassifyPreviewAsync( + SourceText primaryText, + TextSpan excerptSpan, + Document secondaryDocument, + IReadOnlyList mappings, + CancellationToken cancellationToken) + { + var builder = ImmutableArray.CreateBuilder(); + + var sorted = new List(mappings); + sorted.Sort((x, y) => x.OriginalSpan.AbsoluteIndex.CompareTo(y.OriginalSpan.AbsoluteIndex)); + + // The algorithm here is to iterate through the source mappings (sorted) and use the C# classifier + // on the spans that are known to the C#. For the spans that are not known to be C# then + // we just treat them as text since we'd don't currently have our own classifications. + + var remainingSpan = excerptSpan; + for (var i = 0; i < sorted.Count && excerptSpan.Length > 0; i++) + { + var primarySpan = sorted[i].OriginalSpan.AsTextSpan(); + var intersection = primarySpan.Intersection(remainingSpan); + if (intersection == null) + { + // This span is outside the area we're interested in. + continue; + } + + // OK this span intersects with the excerpt span, so we will process it. Let's compute + // the secondary span that matches the intersection. + var secondarySpan = sorted[i].GeneratedSpan.AsTextSpan(); + secondarySpan = new TextSpan(secondarySpan.Start + intersection.Value.Start - primarySpan.Start, intersection.Value.Length); + primarySpan = intersection.Value; + + if (remainingSpan.Start < primarySpan.Start) + { + // The position is before the next C# span. Classify everything up to the C# start + // as text. + builder.Add(new ClassifiedSpan(ClassificationTypeNames.Text, new TextSpan(remainingSpan.Start, primarySpan.Start - remainingSpan.Start))); + + // Advance to the start of the C# span. + remainingSpan = new TextSpan(primarySpan.Start, remainingSpan.Length - (primarySpan.Start - remainingSpan.Start)); + } + + // We should be able to process this whole span as C#, so classify it. + // + // However, we'll have to translate it to the the secondary document's coordinates to do that. + Debug.Assert(remainingSpan.Contains(primarySpan) && remainingSpan.Start == primarySpan.Start); + var classifiedSecondarySpans = await Classifier.GetClassifiedSpansAsync( + secondaryDocument, + secondarySpan, + cancellationToken); + + // Now we have to translate back to the primary document's coordinates. + var offset = primarySpan.Start - secondarySpan.Start; + foreach (var classifiedSecondarySpan in classifiedSecondarySpans) + { + Debug.Assert(secondarySpan.Contains(classifiedSecondarySpan.TextSpan)); + + var updated = new TextSpan(classifiedSecondarySpan.TextSpan.Start + offset, classifiedSecondarySpan.TextSpan.Length); + Debug.Assert(primarySpan.Contains(updated)); + + builder.Add(new ClassifiedSpan(classifiedSecondarySpan.ClassificationType, updated)); + } + + remainingSpan = new TextSpan(primarySpan.End, remainingSpan.Length - primarySpan.Length); + } + + // Deal with residue + if (remainingSpan.Length > 0) + { + // Trailing Razor/markup text. + builder.Add(new ClassifiedSpan(ClassificationTypeNames.Text, remainingSpan)); + } + + return builder; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/RazorDocumentServiceProvider.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/RazorDocumentServiceProvider.cs new file mode 100644 index 0000000000..68a38f1c08 --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/RazorDocumentServiceProvider.cs @@ -0,0 +1,71 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.CodeAnalysis.Razor; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; + +namespace Microsoft.CodeAnalysis.Host +{ + internal class RazorDocumentServiceProvider : IDocumentServiceProvider, IDocumentOperationService + { + private readonly DocumentSnapshot _document; + private readonly object _lock; + + private RazorSpanMappingService _spanMappingService; + private RazorDocumentExcerptService _excerptService; + + public RazorDocumentServiceProvider() + : this(null) + { + } + + public RazorDocumentServiceProvider(DocumentSnapshot document) + { + _document = document; + + _lock = new object(); + } + + public bool CanApplyChange => false; + + public bool SupportDiagnostics => false; + + public TService GetService() where TService : class, IDocumentService + { + if (typeof(TService) == typeof(ISpanMappingService)) + { + if (_spanMappingService == null) + { + lock (_lock) + { + if (_spanMappingService == null) + { + _spanMappingService = new RazorSpanMappingService(_document); + } + } + } + + return (TService)(object)_spanMappingService; + } + + if (typeof(TService) == typeof(IDocumentExcerptService)) + { + if (_excerptService == null) + { + lock (_lock) + { + if (_excerptService == null) + { + _excerptService = new RazorDocumentExcerptService(_document, GetService()); + } + } + } + + return (TService)(object)_excerptService; + } + + return this as TService; + } + } +} diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/RazorSpanMappingService.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/RazorSpanMappingService.cs new file mode 100644 index 0000000000..7303d8a69a --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/RazorSpanMappingService.cs @@ -0,0 +1,102 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.CodeAnalysis.Razor +{ + internal class RazorSpanMappingService: ISpanMappingService + { + private readonly DocumentSnapshot _document; + + public RazorSpanMappingService(DocumentSnapshot document) + { + if (document == null) + { + throw new ArgumentNullException(nameof(document)); + } + + _document = document; + } + + public async Task> MapSpansAsync( + Document document, + IEnumerable spans, + CancellationToken cancellationToken) + { + if (document == null) + { + throw new ArgumentNullException(nameof(document)); + } + + if (spans == null) + { + throw new ArgumentNullException(nameof(spans)); + } + + // Called on an uninitialized document. + if (_document == null) + { + return ImmutableArray.Create(); + } + + var source = await _document.GetTextAsync().ConfigureAwait(false); + var output = await _document.GetGeneratedOutputAsync().ConfigureAwait(false); + + var results = ImmutableArray.CreateBuilder(); + foreach (var span in spans) + { + if (TryGetLinePositionSpan(span, source, output.GetCSharpDocument(), out var linePositionSpan)) + { + results.Add(new MappedSpanResult(output.Source.FilePath, linePositionSpan, span)); + } + else + { + results.Add(default); + } + } + + return results.ToImmutable(); + } + + // Internal for testing. + internal static bool TryGetLinePositionSpan(TextSpan span, SourceText source, RazorCSharpDocument output, out LinePositionSpan linePositionSpan) + { + var mappings = output.SourceMappings; + for (var i = 0; i < mappings.Count; i++) + { + var mapping = mappings[i]; + var original = mapping.OriginalSpan.AsTextSpan(); + var generated = mapping.GeneratedSpan.AsTextSpan(); + + if (!generated.Contains(span)) + { + // If the search span isn't contained within the generated span, it is not a match. + // A C# identifier won't cover multiple generated spans. + continue; + } + + var leftOffset = span.Start - generated.Start; + var rightOffset = span.End - generated.End; + if (leftOffset >= 0 && rightOffset <= 0) + { + // This span mapping contains the span. + var adjusted = new TextSpan(original.Start + leftOffset, (original.End + rightOffset) - (original.Start + leftOffset)); + linePositionSpan = source.Lines.GetLinePositionSpan(adjusted); + return true; + } + } + + linePositionSpan = default; + return false; + } + } +} diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/DefaultWorkspaceProjectContextFactory.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/DefaultWorkspaceProjectContextFactory.cs index b562bd74c1..0842ecc8fc 100644 --- a/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/DefaultWorkspaceProjectContextFactory.cs +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/DefaultWorkspaceProjectContextFactory.cs @@ -8,8 +8,6 @@ using System; using System.Collections.Generic; using System.ComponentModel.Composition; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Experiment; -using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServices.Implementation.TaskList; using IWorkspaceProjectContextFactory = Microsoft.VisualStudio.LanguageServices.ProjectSystem.IWorkspaceProjectContextFactory2; @@ -56,11 +54,7 @@ namespace Microsoft.VisualStudio.LanguageServices.ProjectSystem { } - public void AddSourceFile(string filePath, bool isInCurrentContext = true, IEnumerable folderNames = null, SourceCodeKind sourceCodeKind = SourceCodeKind.Regular, IDocumentServiceFactory documentServiceFactory = null) - { - } - - public void AddSourceFile(string filePath, SourceTextContainer container, bool isInCurrentContext = true, IEnumerable folderNames = null, SourceCodeKind sourceCodeKind = SourceCodeKind.Regular, IDocumentServiceFactory documentServiceFactory = null) + public void AddDynamicSourceFile(string filePath, IEnumerable folderNames = null) { } @@ -88,6 +82,11 @@ namespace Microsoft.VisualStudio.LanguageServices.ProjectSystem { } + public void RemoveDynamicSourceFile(string filePath) + { + + } + public void SetOptions(string commandLineForOptions) { } diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/IWorkspaceProjectContext.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/IWorkspaceProjectContext.cs index 8b34558d85..91d7f2e90e 100644 --- a/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/IWorkspaceProjectContext.cs +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/IWorkspaceProjectContext.cs @@ -7,7 +7,6 @@ using System; using System.Collections.Generic; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Experiment; using Microsoft.CodeAnalysis.Text; namespace Microsoft.VisualStudio.LanguageServices.ProjectSystem @@ -33,11 +32,10 @@ namespace Microsoft.VisualStudio.LanguageServices.ProjectSystem void RemoveAnalyzerReference(string referencePath); // Files. - void AddSourceFile(string filePath, bool isInCurrentContext, IEnumerable folderNames, SourceCodeKind sourceCodeKind); // This overload just for binary compat with existing code - void AddSourceFile(string filePath, bool isInCurrentContext = true, IEnumerable folderNames = null, SourceCodeKind sourceCodeKind = SourceCodeKind.Regular, IDocumentServiceFactory documentServiceFactory = null); - void AddSourceFile(string filePath, SourceTextContainer container, bool isInCurrentContext = true, IEnumerable folderNames = null, SourceCodeKind sourceCodeKind = SourceCodeKind.Regular, IDocumentServiceFactory documentServiceFactory = null); - + void AddSourceFile(string filePath, bool isInCurrentContext, IEnumerable folderNames, SourceCodeKind sourceCodeKind); + void AddDynamicSourceFile(string filePath, IEnumerable folderNames = null); void RemoveSourceFile(string filePath); + void RemoveDynamicSourceFile(string filePath); void AddAdditionalFile(string filePath, bool isInCurrentContext = true); void RemoveAdditionalFile(string filePath); void SetRuleSetFile(string filePath); diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/RazorProjectHostBase.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/RazorProjectHostBase.cs index 930262b57a..6d5700a796 100644 --- a/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/RazorProjectHostBase.cs +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/RazorProjectHostBase.cs @@ -302,7 +302,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem } projectManager.DocumentAdded(_current, document, new FileTextLoader(document.FilePath, null)); - _projectContext?.AddSourceFile(document.FilePath, document.GeneratedCodeContainer.SourceTextContainer, true, GetFolders(document), SourceCodeKind.Regular, document.GeneratedCodeContainer); + _projectContext?.AddDynamicSourceFile(document.FilePath, GetFolders(document)); _currentDocuments.Add(document.FilePath, document); } @@ -310,7 +310,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem { var projectManager = GetProjectManager(); - _projectContext?.RemoveSourceFile(document.FilePath); + _projectContext?.RemoveDynamicSourceFile(document.FilePath); projectManager.DocumentRemoved(_current, document); _currentDocuments.Remove(document.FilePath); } diff --git a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/DefaultDocumentSnapshotTest.cs b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/DefaultDocumentSnapshotTest.cs index 1ca2f6e5d9..2d0eeab92c 100644 --- a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/DefaultDocumentSnapshotTest.cs +++ b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/DefaultDocumentSnapshotTest.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.CodeAnalysis.Host; @@ -15,9 +16,9 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem { var projectState = ProjectState.Create(Workspace.Services, TestProjectData.SomeProject); var project = new DefaultProjectSnapshot(projectState); - HostDocument = TestProjectData.SomeProjectFile1; + HostDocument = new HostDocument(TestProjectData.SomeProjectFile1.FilePath, TestProjectData.SomeProjectFile1.TargetPath); SourceText = SourceText.From("

Hello World

"); - Version = VersionStamp.Default.GetNewerVersion(); + Version = VersionStamp.Create(); var textAndVersion = TextAndVersion.Create(SourceText, Version); var documentState = DocumentState.Create(Workspace.Services, HostDocument, () => Task.FromResult(textAndVersion)); Document = new DefaultDocumentSnapshot(project, documentState); @@ -47,13 +48,32 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem Assert.Same(SourceText, HostDocument.GeneratedCodeContainer.Source); } + [Fact] + public async Task GetGeneratedOutputAsync_SetsOutputWhenDocumentIsNewer() + { + // Arrange + var newSourceText = SourceText.From("NEW!"); + var newDocumentState = Document.State.WithText(newSourceText, Version.GetNewerVersion()); + var newDocument = new DefaultDocumentSnapshot(Document.ProjectInternal, newDocumentState); + + // Force the output to be the new output + await Document.GetGeneratedOutputAsync(); + + // Act + await newDocument.GetGeneratedOutputAsync(); + + // Assert + Assert.NotNull(HostDocument.GeneratedCodeContainer.Output); + Assert.Same(newSourceText, HostDocument.GeneratedCodeContainer.Source); + } + [Fact] public async Task GetGeneratedOutputAsync_OnlySetsOutputIfDocumentNewer() { // Arrange var newSourceText = SourceText.From("NEW!"); var newDocumentState = Document.State.WithText(newSourceText, Version.GetNewerVersion()); - var newDocument = new DefaultDocumentSnapshot(Document.Project, newDocumentState); + var newDocument = new DefaultDocumentSnapshot(Document.ProjectInternal, newDocumentState); // Force the output to be the new output await newDocument.GetGeneratedOutputAsync(); diff --git a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/GeneratedCodeContainerTest.cs b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/GeneratedCodeContainerTest.cs index e394596fc7..b1942002ff 100644 --- a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/GeneratedCodeContainerTest.cs +++ b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/GeneratedCodeContainerTest.cs @@ -59,115 +59,6 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem Assert.NotNull(container.LatestDocument); } - [Fact] - public void TryGetLinePositionSpan_SpanMatchesSourceMapping_ReturnsTrue() - { - // Arrange - var content = @" -@SomeProperty -"; - var sourceText = SourceText.From(content); - var codeDocument = GetCodeDocument(content); - var csharpDocument = codeDocument.GetCSharpDocument(); - var generatedCode = csharpDocument.GeneratedCode; - - var symbol = "SomeProperty"; - var span = new TextSpan(generatedCode.IndexOf(symbol), symbol.Length); - - // Position of `SomeProperty` in the source code. - var expectedLineSpan = new LinePositionSpan(new LinePosition(1, 1), new LinePosition(1, 13)); - - // Act - var result = GeneratedCodeContainer.TryGetLinePositionSpan(span, sourceText, csharpDocument, out var lineSpan); - - // Assert - Assert.True(result); - Assert.Equal(expectedLineSpan, lineSpan); - } - - [Fact] - public void TryGetLinePositionSpan_SpanMatchesSourceMapping_MatchingOnPosition_ReturnsTrue() - { - // Arrange - var content = @" -@SomeProperty -@SomeProperty -@SomeProperty -"; - var sourceText = SourceText.From(content); - var codeDocument = GetCodeDocument(content); - var csharpDocument = codeDocument.GetCSharpDocument(); - var generatedCode = csharpDocument.GeneratedCode; - - var symbol = "SomeProperty"; - - // Second occurrence - var span = new TextSpan(generatedCode.IndexOf(symbol, generatedCode.IndexOf(symbol) + symbol.Length), symbol.Length); - - // Position of `SomeProperty` in the source code. - var expectedLineSpan = new LinePositionSpan(new LinePosition(2, 1), new LinePosition(2, 13)); - - // Act - var result = GeneratedCodeContainer.TryGetLinePositionSpan(span, sourceText, csharpDocument, out var lineSpan); - - // Assert - Assert.True(result); - Assert.Equal(expectedLineSpan, lineSpan); - } - - [Fact] - public void TryGetLinePositionSpan_SpanWithinSourceMapping_ReturnsTrue() - { - // Arrange - var content = @" -@{ - var x = SomeClass.SomeProperty; -} -"; - var sourceText = SourceText.From(content); - var codeDocument = GetCodeDocument(content); - var csharpDocument = codeDocument.GetCSharpDocument(); - var generatedCode = csharpDocument.GeneratedCode; - - var symbol = "SomeProperty"; - var span = new TextSpan(generatedCode.IndexOf(symbol), symbol.Length); - - // Position of `SomeProperty` in the source code. - var expectedLineSpan = new LinePositionSpan(new LinePosition(2, 22), new LinePosition(2, 34)); - - // Act - var result = GeneratedCodeContainer.TryGetLinePositionSpan(span, sourceText, csharpDocument, out var lineSpan); - - // Assert - Assert.True(result); - Assert.Equal(expectedLineSpan, lineSpan); - } - - [Fact] - public void TryGetLinePositionSpan_SpanOutsideSourceMapping_ReturnsFalse() - { - // Arrange - var content = @" -@{ - var x = SomeClass.SomeProperty; -} -"; - var sourceText = SourceText.From(content); - var codeDocument = GetCodeDocument(content); - var csharpDocument = codeDocument.GetCSharpDocument(); - var generatedCode = csharpDocument.GeneratedCode; - - // Position of `ExecuteAsync` in the generated code. - var symbol = "ExecuteAsync"; - var span = new TextSpan(generatedCode.IndexOf(symbol), symbol.Length); - - // Act - var result = GeneratedCodeContainer.TryGetLinePositionSpan(span, sourceText, csharpDocument, out var lineSpan); - - // Assert - Assert.False(result); - } - private static RazorCodeDocument GetCodeDocument(string content) { var sourceProjectItem = new TestRazorProjectItem("test.cshtml") diff --git a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/RazorExcerptServiceTest.cs b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/RazorExcerptServiceTest.cs new file mode 100644 index 0000000000..29f7191bb2 --- /dev/null +++ b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/RazorExcerptServiceTest.cs @@ -0,0 +1,429 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis.Classification; +using Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Microsoft.CodeAnalysis.Text; +using Xunit; + +namespace Microsoft.CodeAnalysis.Razor +{ + public class RazorExcerptServiceTest : WorkspaceTestBase + { + public RazorExcerptServiceTest() + { + HostProject = TestProjectData.SomeProject; + HostDocument = TestProjectData.SomeProjectFile1; + } + + private HostProject HostProject { get; } + private HostDocument HostDocument { get; } + + protected override void ConfigureLanguageServices(List services) + { + services.Add(new TestTagHelperResolver()); + } + + [Fact] + public async Task TryExcerptAsync_SingleLine_CanClassifyCSharp() + { + // Arrange + var (sourceText, primarySpan) = CreateText( +@" + +@{ + var |foo| = ""Hello, World!""; +} + @foo +
@(3 + 4)
@(foo + foo)
+ +"); + + var (primary, secondary) = Initialize(sourceText); + var service = CreateExcerptService(primary); + + var secondarySpan = await GetSecondarySpanAsync(primary, primarySpan, secondary); + + // Act + var result = await service.TryExcerptAsync(secondary, secondarySpan, ExcerptMode.SingleLine, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(secondarySpan, result.Value.Span); + Assert.Same(secondary, result.Value.Document); + + Assert.Equal(@" var foo = ""Hello, World!"";", result.Value.Content.ToString(), ignoreLineEndingDifferences: true); + Assert.Collection( + result.Value.ClassifiedSpans, + c => + { + Assert.Equal(ClassificationTypeNames.Keyword, c.ClassificationType); + Assert.Equal("var", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.LocalName, c.ClassificationType); + Assert.Equal("foo", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.Operator, c.ClassificationType); + Assert.Equal("=", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.StringLiteral, c.ClassificationType); + Assert.Equal("\"Hello, World!\"", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.Punctuation, c.ClassificationType); + Assert.Equal(";", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }); + } + + [Fact] + public async Task TryExcerptAsync_SingleLine_CanClassifyCSharp_ImplicitExpression() + { + // Arrange + var (sourceText, primarySpan) = CreateText( +@" + +@{ + var foo = ""Hello, World!""; +} + @|foo| +
@(3 + 4)
@(foo + foo)
+ +"); + + var (primary, secondary) = Initialize(sourceText); + var service = CreateExcerptService(primary); + + var secondarySpan = await GetSecondarySpanAsync(primary, primarySpan, secondary); + + // Act + var result = await service.TryExcerptAsync(secondary, secondarySpan, ExcerptMode.SingleLine, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(secondarySpan, result.Value.Span); + Assert.Same(secondary, result.Value.Document); + + Assert.Equal(@" @foo", result.Value.Content.ToString(), ignoreLineEndingDifferences: true); + Assert.Collection( + result.Value.ClassifiedSpans, + c => + { + Assert.Equal(ClassificationTypeNames.Text, c.ClassificationType); + Assert.Equal(" @", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.LocalName, c.ClassificationType); + Assert.Equal("foo", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.Text, c.ClassificationType); + Assert.Equal("", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }); + } + + [Fact] + public async Task TryExcerptAsync_SingleLine_CanClassifyCSharp_ComplexLine() + { + // Arrange + var (sourceText, primarySpan) = CreateText( +@" + +@{ + var foo = ""Hello, World!""; +} + @foo +
@(3 + 4)
@(foo + |foo|)
+ +"); + + var (primary, secondary) = Initialize(sourceText); + var service = CreateExcerptService(primary); + + var secondarySpan = await GetSecondarySpanAsync(primary, primarySpan, secondary); + + // Act + var result = await service.TryExcerptAsync(secondary, secondarySpan, ExcerptMode.SingleLine, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(secondarySpan, result.Value.Span); + Assert.Same(secondary, result.Value.Document); + + Assert.Equal(@"
@(3 + 4)
@(foo + foo)
", result.Value.Content.ToString(), ignoreLineEndingDifferences: true); + Assert.Collection( + result.Value.ClassifiedSpans, + c => + { + Assert.Equal(ClassificationTypeNames.Text, c.ClassificationType); + Assert.Equal("
@(", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.NumericLiteral, c.ClassificationType); + Assert.Equal("3", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.Operator, c.ClassificationType); + Assert.Equal("+", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.NumericLiteral, c.ClassificationType); + Assert.Equal("4", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.Text, c.ClassificationType); + Assert.Equal(")
@(", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.LocalName, c.ClassificationType); + Assert.Equal("foo", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.Operator, c.ClassificationType); + Assert.Equal("+", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.LocalName, c.ClassificationType); + Assert.Equal("foo", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.Text, c.ClassificationType); + Assert.Equal(")
", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }); + } + + [Fact] + public async Task TryExcerptAsync_MultiLine_CanClassifyCSharp() + { + // Arrange + var (sourceText, primarySpan) = CreateText( +@" + +@{ + var |foo| = ""Hello, World!""; +} + @foo +
@(3 + 4)
@(foo + foo)
+ +"); + + var (primary, secondary) = Initialize(sourceText); + var service = CreateExcerptService(primary); + + var secondarySpan = await GetSecondarySpanAsync(primary, primarySpan, secondary); + + // Act + var result = await service.TryExcerptAsync(secondary, secondarySpan, ExcerptMode.Tooltip, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(secondarySpan, result.Value.Span); + Assert.Same(secondary, result.Value.Document); + + Assert.Equal( +@"@{ + var foo = ""Hello, World!""; +}", + result.Value.Content.ToString(), ignoreLineEndingDifferences: true); + + Assert.Collection( + result.Value.ClassifiedSpans, + c => + { + Assert.Equal(ClassificationTypeNames.Text, c.ClassificationType); + Assert.Equal("@{", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.Keyword, c.ClassificationType); + Assert.Equal("var", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.LocalName, c.ClassificationType); + Assert.Equal("foo", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.Operator, c.ClassificationType); + Assert.Equal("=", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.StringLiteral, c.ClassificationType); + Assert.Equal("\"Hello, World!\"", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.Punctuation, c.ClassificationType); + Assert.Equal(";", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.Text, c.ClassificationType); + Assert.Equal("}", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }); + } + + [Fact] + public async Task TryExcerptAsync_MultiLine_Boundaries_CanClassifyCSharp() + { + // Arrange + var (sourceText, primarySpan) = CreateText(@"@{ var |foo| = ""Hello, World!""; }"); + + var (primary, secondary) = Initialize(sourceText); + var service = CreateExcerptService(primary); + + var secondarySpan = await GetSecondarySpanAsync(primary, primarySpan, secondary); + + // Act + var result = await service.TryExcerptAsync(secondary, secondarySpan, ExcerptMode.Tooltip, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(secondarySpan, result.Value.Span); + Assert.Same(secondary, result.Value.Document); + + Assert.Equal( +@"@{ var foo = ""Hello, World!""; }", + result.Value.Content.ToString(), ignoreLineEndingDifferences: true); + + Assert.Collection( + result.Value.ClassifiedSpans, + c => + { + Assert.Equal(ClassificationTypeNames.Text, c.ClassificationType); + Assert.Equal("@{", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.Keyword, c.ClassificationType); + Assert.Equal("var", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.LocalName, c.ClassificationType); + Assert.Equal("foo", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.Operator, c.ClassificationType); + Assert.Equal("=", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.StringLiteral, c.ClassificationType); + Assert.Equal("\"Hello, World!\"", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.Punctuation, c.ClassificationType); + Assert.Equal(";", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }, + c => + { + Assert.Equal(ClassificationTypeNames.Text, c.ClassificationType); + Assert.Equal("}", result.Value.Content.GetSubText(c.TextSpan).ToString()); + }); + } + + public (SourceText sourceText, TextSpan span) CreateText(string text) + { + // Since we're using positions, normalize to Windows style + text = text.Replace("\r", "").Replace("\n", "\r\n"); + + var start = text.IndexOf('|'); + var length = text.IndexOf('|', start + 1) - start - 1; + text = text.Replace("|", ""); + + if (start < 0 || length < 0) + { + throw new InvalidOperationException("Could not find delimited text."); + } + + return (SourceText.From(text), new TextSpan(start, length)); + } + + // Adds the text to a ProjectSnapshot, generates code, and updates the workspace. + private (DocumentSnapshot primary, Document secondary) Initialize(SourceText sourceText) + { + var project = new DefaultProjectSnapshot( + ProjectState.Create(Workspace.Services, HostProject) + .WithAddedHostDocument(HostDocument, () => + { + return Task.FromResult(TextAndVersion.Create(sourceText, VersionStamp.Create())); + })); + + var primary = project.GetDocument(HostDocument.FilePath); + + var solution = Workspace.CurrentSolution.AddProject(ProjectInfo.Create( + ProjectId.CreateNewId(Path.GetFileNameWithoutExtension(HostDocument.FilePath)), + VersionStamp.Create(), + Path.GetFileNameWithoutExtension(HostDocument.FilePath), + Path.GetFileNameWithoutExtension(HostDocument.FilePath), + LanguageNames.CSharp, + HostDocument.FilePath)); + + solution = solution.AddDocument( + DocumentId.CreateNewId(solution.ProjectIds.Single(), HostDocument.FilePath), + HostDocument.FilePath, + new GeneratedOutputTextLoader(primary, HostDocument.FilePath)); + + var secondary = solution.Projects.Single().Documents.Single(); + return (primary, secondary); + } + + // Maps a span in the primary buffer to the secondary buffer. This is only valid for C# code + // that appears in the primary buffer. + private async Task GetSecondarySpanAsync(DocumentSnapshot primary, TextSpan primarySpan, Document secondary) + { + var output = await primary.GetGeneratedOutputAsync(); + + var mappings = output.GetCSharpDocument().SourceMappings; + for (var i = 0; i < mappings.Count; i++) + { + var mapping = mappings[i]; + if (mapping.OriginalSpan.AsTextSpan().Contains(primarySpan)) + { + var offset = mapping.GeneratedSpan.AbsoluteIndex - mapping.OriginalSpan.AbsoluteIndex; + var secondarySpan = new TextSpan(primarySpan.Start + offset, primarySpan.Length); + Assert.Equal( + (await primary.GetTextAsync()).GetSubText(primarySpan).ToString(), + (await secondary.GetTextAsync()).GetSubText(secondarySpan).ToString()); + return secondarySpan; + } + } + + throw new InvalidOperationException("Could not map the primary span to the generated code."); + } + + private RazorDocumentExcerptService CreateExcerptService(DocumentSnapshot document) + { + return new RazorDocumentExcerptService(document, new RazorSpanMappingService(document)); + } + } +} diff --git a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/RazorSpanMappingServiceTest.cs b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/RazorSpanMappingServiceTest.cs new file mode 100644 index 0000000000..f2153325f4 --- /dev/null +++ b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/RazorSpanMappingServiceTest.cs @@ -0,0 +1,164 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Microsoft.CodeAnalysis.Text; +using Xunit; + +namespace Microsoft.CodeAnalysis.Razor +{ + public class RazorSpanMappingServiceTest : WorkspaceTestBase + { + public RazorSpanMappingServiceTest() + { + HostProject = TestProjectData.SomeProject; + HostDocument = TestProjectData.SomeProjectFile1; + } + + private HostProject HostProject { get; } + private HostDocument HostDocument { get; } + + protected override void ConfigureLanguageServices(List services) + { + services.Add(new TestTagHelperResolver()); + } + + [Fact] + public async Task TryGetLinePositionSpan_SpanMatchesSourceMapping_ReturnsTrue() + { + // Arrange + var sourceText = SourceText.From(@" +@SomeProperty +"); + + var project = new DefaultProjectSnapshot( + ProjectState.Create(Workspace.Services, HostProject) + .WithAddedHostDocument(HostDocument, () => + { + return Task.FromResult(TextAndVersion.Create(sourceText, VersionStamp.Create())); + })); + + var document = project.GetDocument(HostDocument.FilePath); + var service = new RazorSpanMappingService(document); + + var output = await document.GetGeneratedOutputAsync(); + var generated = output.GetCSharpDocument(); + + var symbol = "SomeProperty"; + var span = new TextSpan(generated.GeneratedCode.IndexOf(symbol), symbol.Length); + + // Act + var result = RazorSpanMappingService.TryGetLinePositionSpan(span, await document.GetTextAsync(), generated, out var mapped); + + // Assert + Assert.True(result); + Assert.Equal(new LinePositionSpan(new LinePosition(1, 1), new LinePosition(1, 13)), mapped); + } + + [Fact] + public async Task TryGetLinePositionSpan_SpanMatchesSourceMappingAndPosition_ReturnsTrue() + { + // Arrange + var sourceText = SourceText.From(@" +@SomeProperty +@SomeProperty +@SomeProperty +"); + + var project = new DefaultProjectSnapshot( + ProjectState.Create(Workspace.Services, HostProject) + .WithAddedHostDocument(HostDocument, () => + { + return Task.FromResult(TextAndVersion.Create(sourceText, VersionStamp.Create())); + })); + + var document = project.GetDocument(HostDocument.FilePath); + var service = new RazorSpanMappingService(document); + + var output = await document.GetGeneratedOutputAsync(); + var generated = output.GetCSharpDocument(); + + var symbol = "SomeProperty"; + // Second occurrence + var span = new TextSpan(generated.GeneratedCode.IndexOf(symbol, generated.GeneratedCode.IndexOf(symbol) + symbol.Length), symbol.Length); + + // Act + var result = RazorSpanMappingService.TryGetLinePositionSpan(span, await document.GetTextAsync(), generated, out var mapped); + + // Assert + Assert.True(result); + Assert.Equal(new LinePositionSpan(new LinePosition(2, 1), new LinePosition(2, 13)), mapped); + } + + [Fact] + public async Task TryGetLinePositionSpan_SpanWithinSourceMapping_ReturnsTrue() + { + // Arrange + var sourceText = SourceText.From(@" +@{ + var x = SomeClass.SomeProperty; +} +"); + + var project = new DefaultProjectSnapshot( + ProjectState.Create(Workspace.Services, HostProject) + .WithAddedHostDocument(HostDocument, () => + { + return Task.FromResult(TextAndVersion.Create(sourceText, VersionStamp.Create())); + })); + + var document = project.GetDocument(HostDocument.FilePath); + var service = new RazorSpanMappingService(document); + + var output = await document.GetGeneratedOutputAsync(); + var generated = output.GetCSharpDocument(); + + var symbol = "SomeProperty"; + var span = new TextSpan(generated.GeneratedCode.IndexOf(symbol), symbol.Length); + + // Act + var result = RazorSpanMappingService.TryGetLinePositionSpan(span, await document.GetTextAsync(), generated, out var mapped); + + // Assert + Assert.True(result); + Assert.Equal(new LinePositionSpan(new LinePosition(2, 22), new LinePosition(2, 34)), mapped); + } + + [Fact] + public async Task TryGetLinePositionSpan_SpanOutsideSourceMapping_ReturnsFalse() + { + // Arrange + var sourceText = SourceText.From(@" +@{ + var x = SomeClass.SomeProperty; +} +"); + + var project = new DefaultProjectSnapshot( + ProjectState.Create(Workspace.Services, HostProject) + .WithAddedHostDocument(HostDocument, () => + { + return Task.FromResult(TextAndVersion.Create(sourceText, VersionStamp.Create())); + })); + + var document = project.GetDocument(HostDocument.FilePath); + var service = new RazorSpanMappingService(document); + + var output = await document.GetGeneratedOutputAsync(); + var generated = output.GetCSharpDocument(); + + var symbol = "ExecuteAsync"; + var span = new TextSpan(generated.GeneratedCode.IndexOf(symbol), symbol.Length); + + // Act + var result = RazorSpanMappingService.TryGetLinePositionSpan(span, await document.GetTextAsync(), generated, out var mapped); + + // Assert + Assert.False(result); + } + } +} \ No newline at end of file