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