From 357657fc4509470808367a9086fa2fedace97e1e Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Sat, 20 Oct 2018 21:18:38 -0700 Subject: [PATCH] Implements versions for generated code This change implements version tracking the inputs and outputs of generated code. Version tracking is still best-effort - meaning that in some cases a perfect system could avoid doing more work. However, since we base the versions off of all of the inputs, we now that the guarantee that code generation operations that happen 'out of order' will always result in the newer inputs generating the newer outputs. Fixes: https://github.com/aspnet/Razor/issues/2650 --- .../ProjectSystem/DefaultDocumentSnapshot.cs | 14 +- .../DefaultImportDocumentSnapshot.cs | 85 ++++++ .../ProjectSystem/DefaultProjectSnapshot.cs | 8 +- .../DocumentGeneratedOutputTracker.cs | 179 ----------- .../ProjectSystem/DocumentImportsTracker.cs | 167 ---------- .../ProjectSystem/DocumentSnapshot.cs | 2 + .../ProjectSystem/DocumentState.cs | 284 ++++++++++++++++-- .../ProjectSystem/GeneratedCodeContainer.cs | 43 ++- .../ProjectSystem/ProjectEngineTracker.cs | 88 ------ .../ProjectSystem/ProjectState.cs | 235 ++++++++++++--- .../ProjectSystem/ProjectTagHelperTracker.cs | 79 ----- .../DefaultProjectSnapshotTest.cs | 24 -- .../ProjectSystem/DocumentStateTest.cs | 37 +-- .../GeneratedCodeContainerTest.cs | 25 +- .../ProjectStateGeneratedOutputTest.cs | 264 ++++++++++++++++ .../ProjectSystem/ProjectStateTest.cs | 159 +++++++--- .../Shared/TestTagHelperResolver.cs | 2 +- .../DefaultVisualStudioDocumentTrackerTest.cs | 45 ++- 18 files changed, 1019 insertions(+), 721 deletions(-) create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultImportDocumentSnapshot.cs delete mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentGeneratedOutputTracker.cs delete mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentImportsTracker.cs delete mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectEngineTracker.cs delete mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectTagHelperTracker.cs create mode 100644 test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/ProjectStateGeneratedOutputTest.cs diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultDocumentSnapshot.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultDocumentSnapshot.cs index d553088e5c..fda5da61eb 100644 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultDocumentSnapshot.cs +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultDocumentSnapshot.cs @@ -37,9 +37,11 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public override ProjectSnapshot Project => ProjectInternal; + public override bool SupportsOutput => true; + public override IReadOnlyList GetImports() { - return State.Imports.GetImports(Project, this); + return State.GetImports(ProjectInternal); } public override Task GetTextAsync() @@ -52,10 +54,10 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem return State.GetTextVersionAsync(); } - public override Task GetGeneratedOutputAsync() + public override async Task GetGeneratedOutputAsync() { - // IMPORTANT: Don't put more code here. We want this to return a cached task. - return State.GeneratedOutput.GetGeneratedOutputInitializationTask(Project, this); + var (output, _, _) = await State.GetGeneratedOutputAndVersionAsync(ProjectInternal, this).ConfigureAwait(false); + return output; } public override bool TryGetText(out SourceText result) @@ -70,9 +72,9 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public override bool TryGetGeneratedOutput(out RazorCodeDocument result) { - if (State.GeneratedOutput.IsResultAvailable) + if (State.IsGeneratedOutputResultAvailable) { - result = State.GeneratedOutput.GetGeneratedOutputInitializationTask(Project, this).Result; + result = State.GetGeneratedOutputAndVersionAsync(ProjectInternal, this).Result.output; return true; } diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultImportDocumentSnapshot.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultImportDocumentSnapshot.cs new file mode 100644 index 0000000000..1edc432e25 --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultImportDocumentSnapshot.cs @@ -0,0 +1,85 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.CodeAnalysis.Razor.ProjectSystem +{ + internal class DefaultImportDocumentSnapshot : DocumentSnapshot + { + private ProjectSnapshot _project; + private RazorProjectItem _importItem; + private SourceText _sourceText; + private VersionStamp _version; + + public DefaultImportDocumentSnapshot(ProjectSnapshot project, RazorProjectItem item) + { + _project = project; + _importItem = item; + _version = VersionStamp.Default; + } + + public override string FilePath => null; + + public override string TargetPath => null; + + public override bool SupportsOutput => false; + + public override ProjectSnapshot Project => _project; + + public override Task GetGeneratedOutputAsync() + { + throw new NotSupportedException(); + } + + public override IReadOnlyList GetImports() + { + return Array.Empty(); + } + + public async override Task GetTextAsync() + { + using (var stream = _importItem.Read()) + using (var reader = new StreamReader(stream)) + { + var content = await reader.ReadToEndAsync(); + _sourceText = SourceText.From(content); + } + + return _sourceText; + } + + public override Task GetTextVersionAsync() + { + return Task.FromResult(_version); + } + + public override bool TryGetText(out SourceText result) + { + if (_sourceText != null) + { + result = _sourceText; + return true; + } + + result = null; + return false; + } + + public override bool TryGetTextVersion(out VersionStamp result) + { + result = _version; + return true; + } + + public override bool TryGetGeneratedOutput(out RazorCodeDocument result) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultProjectSnapshot.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultProjectSnapshot.cs index 3a85347d8d..dff471af3d 100644 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultProjectSnapshot.cs +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultProjectSnapshot.cs @@ -89,20 +89,20 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public override RazorProjectEngine GetProjectEngine() { - return State.ProjectEngine.GetProjectEngine(this.State); + return State.ProjectEngine; } public override Task> GetTagHelpersAsync() { // IMPORTANT: Don't put more code here. We want this to return a cached task. - return State.TagHelpers.GetTagHelperInitializationTask(this); + return State.GetTagHelpersAsync(this); } public override bool TryGetTagHelpers(out IReadOnlyList result) { - if (State.TagHelpers.IsResultAvailable) + if (State.IsTagHelperResultAvailable) { - result = State.TagHelpers.GetTagHelperInitializationTask(this).Result; + result = State.GetTagHelpersAsync(this).Result; return true; } diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentGeneratedOutputTracker.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentGeneratedOutputTracker.cs deleted file mode 100644 index e84cd5fd0b..0000000000 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentGeneratedOutputTracker.cs +++ /dev/null @@ -1,179 +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. - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.CodeAnalysis.Text; -using Microsoft.Extensions.Internal; - -namespace Microsoft.CodeAnalysis.Razor.ProjectSystem -{ - internal class DocumentGeneratedOutputTracker - { - private readonly object _lock; - - private DocumentGeneratedOutputTracker _older; - private Task _task; - - private IReadOnlyList _tagHelpers; - private IReadOnlyList _imports; - - public DocumentGeneratedOutputTracker(DocumentGeneratedOutputTracker older) - { - _older = older; - - _lock = new object(); - } - - public bool IsResultAvailable => _task?.IsCompleted == true; - - public DocumentGeneratedOutputTracker Older => _older; - - public Task GetGeneratedOutputInitializationTask(ProjectSnapshot project, DocumentSnapshot document) - { - if (project == null) - { - throw new ArgumentNullException(nameof(project)); - } - - if (document == null) - { - throw new ArgumentNullException(nameof(document)); - } - - if (_task == null) - { - lock (_lock) - { - if (_task == null) - { - _task = GetGeneratedOutputInitializationTaskCore(project, document); - } - } - } - - return _task; - } - - public DocumentGeneratedOutputTracker Fork() - { - return new DocumentGeneratedOutputTracker(this); - } - - private async Task GetGeneratedOutputInitializationTaskCore(ProjectSnapshot project, DocumentSnapshot document) - { - var tagHelpers = await project.GetTagHelpersAsync().ConfigureAwait(false); - var imports = await GetImportsAsync(project, document); - - if (_older != null && _older.IsResultAvailable) - { - var tagHelperDifference = new HashSet(TagHelperDescriptorComparer.Default); - tagHelperDifference.UnionWith(_older._tagHelpers); - tagHelperDifference.SymmetricExceptWith(tagHelpers); - - var importDifference = new HashSet(); - importDifference.UnionWith(_older._imports); - importDifference.SymmetricExceptWith(imports); - - if (tagHelperDifference.Count == 0 && importDifference.Count == 0) - { - // We can use the cached result. - var result = _older._task.Result; - - // Drop reference so it can be GC'ed - _older = null; - - // Cache the tag helpers and imports so the next version can use them - _tagHelpers = tagHelpers; - _imports = imports; - - return result; - } - } - - // Drop reference so it can be GC'ed - _older = null; - - // Cache the tag helpers and imports so the next version can use them - _tagHelpers = tagHelpers; - _imports = imports; - - var importSources = new List(); - foreach (var item in imports) - { - var sourceDocument = await GetRazorSourceDocumentAsync(item.Import); - importSources.Add(sourceDocument); - } - - var documentSource = await GetRazorSourceDocumentAsync(document); - - var projectEngine = project.GetProjectEngine(); - - var codeDocument = projectEngine.ProcessDesignTime(documentSource, importSources, tagHelpers); - var csharpDocument = codeDocument.GetCSharpDocument(); - if (document is DefaultDocumentSnapshot defaultDocument) - { - defaultDocument.State.HostDocument.GeneratedCodeContainer.SetOutput(csharpDocument, defaultDocument); - } - - return codeDocument; - } - - private async Task GetRazorSourceDocumentAsync(DocumentSnapshot document) - { - var sourceText = await document.GetTextAsync(); - - return sourceText.GetRazorSourceDocument(document.FilePath); - } - - private async Task> GetImportsAsync(ProjectSnapshot project, DocumentSnapshot document) - { - var imports = new List(); - foreach (var snapshot in document.GetImports()) - { - var versionStamp = await snapshot.GetTextVersionAsync(); - imports.Add(new ImportItem(snapshot.FilePath, versionStamp, snapshot)); - } - - return imports; - } - - private struct ImportItem : IEquatable - { - public ImportItem(string filePath, VersionStamp versionStamp, DocumentSnapshot import) - { - FilePath = filePath; - VersionStamp = versionStamp; - Import = import; - } - - public string FilePath { get; } - - public VersionStamp VersionStamp { get; } - - public DocumentSnapshot Import { get; } - - public bool Equals(ImportItem other) - { - return - FilePathComparer.Instance.Equals(FilePath, other.FilePath) && - VersionStamp == other.VersionStamp; - } - - public override bool Equals(object obj) - { - return obj is ImportItem item ? Equals(item) : false; - } - - public override int GetHashCode() - { - var hash = new HashCodeCombiner(); - hash.Add(FilePath, FilePathComparer.Instance); - hash.Add(VersionStamp); - return hash; - } - } - } -} diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentImportsTracker.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentImportsTracker.cs deleted file mode 100644 index 91224532fa..0000000000 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentImportsTracker.cs +++ /dev/null @@ -1,167 +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. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.CodeAnalysis.Text; - -namespace Microsoft.CodeAnalysis.Razor.ProjectSystem -{ - internal class DocumentImportsTracker - { - private readonly object _lock; - - private IReadOnlyList _imports; - - public DocumentImportsTracker() - { - _lock = new object(); - } - - public IReadOnlyList GetImports(ProjectSnapshot project, DocumentSnapshot document) - { - if (project == null) - { - throw new ArgumentNullException(nameof(project)); - } - - if (document == null) - { - throw new ArgumentNullException(nameof(document)); - } - - if (_imports == null) - { - lock (_lock) - { - if (_imports == null) - { - _imports = GetImportsCore(project, document); - } - } - } - - return _imports; - } - - private IReadOnlyList GetImportsCore(ProjectSnapshot project, DocumentSnapshot document) - { - var projectEngine = project.GetProjectEngine(); - var importFeature = projectEngine.ProjectFeatures.OfType().FirstOrDefault(); - var projectItem = projectEngine.FileSystem.GetItem(document.FilePath); - var importItems = importFeature?.GetImports(projectItem).Where(i => i.Exists); - if (importItems == null) - { - return Array.Empty(); - } - - var imports = new List(); - foreach (var item in importItems) - { - if (item.PhysicalPath == null) - { - // This is a default import. - var defaultImport = new DefaultImportDocumentSnapshot(project, item); - imports.Add(defaultImport); - } - else - { - var import = project.GetDocument(item.PhysicalPath); - if (import == null) - { - // We are not tracking this document in this project. So do nothing. - continue; - } - - imports.Add(import); - } - } - - return imports; - } - - private class DefaultImportDocumentSnapshot : DocumentSnapshot - { - private ProjectSnapshot _project; - private RazorProjectItem _importItem; - private SourceText _sourceText; - private VersionStamp _version; - private DocumentGeneratedOutputTracker _generatedOutput; - - public DefaultImportDocumentSnapshot(ProjectSnapshot project, RazorProjectItem item) - { - _project = project; - _importItem = item; - _version = VersionStamp.Default; - _generatedOutput = new DocumentGeneratedOutputTracker(null); - } - - public override string FilePath => null; - - public override string TargetPath => null; - - public override ProjectSnapshot Project => _project; - - public override Task GetGeneratedOutputAsync() - { - return _generatedOutput.GetGeneratedOutputInitializationTask(_project, this); - } - - public override IReadOnlyList GetImports() - { - return Array.Empty(); - } - - public async override Task GetTextAsync() - { - using (var stream = _importItem.Read()) - using (var reader = new StreamReader(stream)) - { - var content = await reader.ReadToEndAsync(); - _sourceText = SourceText.From(content); - } - - return _sourceText; - } - - public override Task GetTextVersionAsync() - { - return Task.FromResult(_version); - } - - public override bool TryGetText(out SourceText result) - { - if (_sourceText != null) - { - result = _sourceText; - return true; - } - - result = null; - return false; - } - - public override bool TryGetTextVersion(out VersionStamp result) - { - result = _version; - return true; - } - - public override bool TryGetGeneratedOutput(out RazorCodeDocument result) - { - if (_generatedOutput.IsResultAvailable) - { - result = GetGeneratedOutputAsync().Result; - return true; - } - - result = null; - return false; - } - } - } -} diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentSnapshot.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentSnapshot.cs index 755a15e579..4d606cc499 100644 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentSnapshot.cs +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentSnapshot.cs @@ -16,6 +16,8 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public abstract ProjectSnapshot Project { get; } + public abstract bool SupportsOutput { get; } + public abstract IReadOnlyList GetImports(); public abstract Task GetTextAsync(); diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentState.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentState.cs index b6ad36ddb0..31b9447126 100644 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentState.cs +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DocumentState.cs @@ -2,7 +2,10 @@ // 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.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; using Microsoft.CodeAnalysis.Host; using Microsoft.CodeAnalysis.Text; @@ -18,14 +21,13 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem private readonly object _lock; + private ComputedStateTracker _computedState; + private Func> _loader; private Task _loaderTask; private SourceText _sourceText; private VersionStamp? _version; - private DocumentGeneratedOutputTracker _generatedOutput; - private DocumentImportsTracker _imports; - public static DocumentState Create( HostWorkspaceServices services, HostDocument hostDocument, @@ -67,42 +69,35 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public GeneratedCodeContainer GeneratedCodeContainer => HostDocument.GeneratedCodeContainer; - public DocumentGeneratedOutputTracker GeneratedOutput + public bool IsGeneratedOutputResultAvailable => ComputedState.IsResultAvailable == true; + + private ComputedStateTracker ComputedState { get { - if (_generatedOutput == null) + if (_computedState == null) { lock (_lock) { - if (_generatedOutput == null) + if (_computedState == null) { - _generatedOutput = new DocumentGeneratedOutputTracker(null); + _computedState = new ComputedStateTracker(this); } } } - return _generatedOutput; + return _computedState; } } - public DocumentImportsTracker Imports + public Task<(RazorCodeDocument output, VersionStamp inputVersion, VersionStamp outputVersion)> GetGeneratedOutputAndVersionAsync(DefaultProjectSnapshot project, DefaultDocumentSnapshot document) { - get - { - if (_imports == null) - { - lock (_lock) - { - if (_imports == null) - { - _imports = new DocumentImportsTracker(); - } - } - } + return ComputedState.GetGeneratedOutputAndVersionAsync(project, document); + } - return _imports; - } + public IReadOnlyList GetImports(DefaultProjectSnapshot project) + { + return GetImportsCore(project); } public async Task GetTextAsync() @@ -180,6 +175,8 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem state._version = _version; state._loaderTask = _loaderTask; + // Do not cache computed state + return state; } @@ -192,6 +189,9 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem state._version = _version; state._loaderTask = _loaderTask; + // Optimisically cache the computed state + state._computedState = new ComputedStateTracker(state, _computedState); + return state; } @@ -204,8 +204,8 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem state._version = _version; state._loaderTask = _loaderTask; - // Opportunistically cache the generated code - state._generatedOutput = _generatedOutput?.Fork(); + // Optimisically cache the computed state + state._computedState = new ComputedStateTracker(state, _computedState); return state; } @@ -217,6 +217,8 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem throw new ArgumentNullException(nameof(sourceText)); } + // Do not cache the computed state + return new DocumentState(Services, HostDocument, sourceText, version, null); } @@ -227,7 +229,239 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem throw new ArgumentNullException(nameof(loader)); } + // Do not cache the computed state + return new DocumentState(Services, HostDocument, null, null, loader); } + + private IReadOnlyList GetImportsCore(DefaultProjectSnapshot project) + { + var projectEngine = project.GetProjectEngine(); + var importFeature = projectEngine.ProjectFeatures.OfType().FirstOrDefault(); + var projectItem = projectEngine.FileSystem.GetItem(HostDocument.FilePath); + var importItems = importFeature?.GetImports(projectItem); + if (importItems == null) + { + return Array.Empty(); + } + + var imports = new List(); + foreach (var item in importItems) + { + if (item.PhysicalPath == null) + { + // This is a default import. + var defaultImport = new DefaultImportDocumentSnapshot(project, item); + imports.Add(defaultImport); + } + else + { + var import = project.GetDocument(item.PhysicalPath); + if (import == null) + { + // We are not tracking this document in this project. So do nothing. + continue; + } + + imports.Add(import); + } + } + + return imports; + } + + // See design notes on ProjectState.ComputedStateTracker. + private class ComputedStateTracker + { + private readonly object _lock; + + private ComputedStateTracker _older; + public Task<(RazorCodeDocument, VersionStamp, VersionStamp)> TaskUnsafe; + + public ComputedStateTracker(DocumentState state, ComputedStateTracker older = null) + { + _lock = state._lock; + _older = older; + } + + public bool IsResultAvailable => TaskUnsafe?.IsCompleted == true; + + public Task<(RazorCodeDocument, VersionStamp, VersionStamp)> GetGeneratedOutputAndVersionAsync(DefaultProjectSnapshot project, DocumentSnapshot document) + { + if (project == null) + { + throw new ArgumentNullException(nameof(project)); + } + + if (document == null) + { + throw new ArgumentNullException(nameof(document)); + } + + if (TaskUnsafe == null) + { + lock (_lock) + { + if (TaskUnsafe == null) + { + TaskUnsafe = GetGeneratedOutputAndVersionCoreAsync(project, document); + } + } + } + + return TaskUnsafe; + } + + private async Task<(RazorCodeDocument, VersionStamp, VersionStamp)> GetGeneratedOutputAndVersionCoreAsync(DefaultProjectSnapshot project, DocumentSnapshot document) + { + // We only need to produce the generated code if any of our inputs is newer than the + // previously cached output. + // + // First find the versions that are the inputs: + // - The project + computed state + // - The imports + // - This document + // + // All of these things are cached, so no work is wasted if we do need to generate the code. + var computedStateVersion = await project.State.GetComputedStateVersionAsync(project).ConfigureAwait(false); + var documentCollectionVersion = project.State.DocumentCollectionVersion; + var imports = await GetImportsAsync(project, document).ConfigureAwait(false); + var documentVersion = await document.GetTextVersionAsync().ConfigureAwait(false); + + // OK now that have the previous output and all of the versions, we can see if anything + // has changed that would require regenerating the code. + var inputVersion = documentVersion; + if (inputVersion.GetNewerVersion(computedStateVersion) == computedStateVersion) + { + inputVersion = computedStateVersion; + } + + if (inputVersion.GetNewerVersion(documentCollectionVersion) == documentCollectionVersion) + { + inputVersion = documentCollectionVersion; + } + + for (var i = 0; i < imports.Count; i++) + { + var importVersion = imports[i].Version; + if (inputVersion.GetNewerVersion(importVersion) == importVersion) + { + inputVersion = importVersion; + } + } + + RazorCodeDocument olderOutput = null; + var olderInputVersion = default(VersionStamp); + var olderOutputVersion = default(VersionStamp); + if (_older?.TaskUnsafe != null) + { + (olderOutput, olderInputVersion, olderOutputVersion) = await _older.TaskUnsafe.ConfigureAwait(false); + if (inputVersion.GetNewerVersion(olderInputVersion) == olderInputVersion) + { + // Nothing has changed, we can use the cached result. + lock (_lock) + { + TaskUnsafe = _older.TaskUnsafe; + _older = null; + return (olderOutput, olderInputVersion, olderOutputVersion); + } + } + } + + // OK we have to generate the code. + var tagHelpers = await project.GetTagHelpersAsync().ConfigureAwait(false); + var importSources = new List(); + foreach (var item in imports) + { + var sourceDocument = await GetRazorSourceDocumentAsync(item.Document).ConfigureAwait(false); + importSources.Add(sourceDocument); + } + + var documentSource = await GetRazorSourceDocumentAsync(document).ConfigureAwait(false); + + var projectEngine = project.GetProjectEngine(); + + var codeDocument = projectEngine.ProcessDesignTime(documentSource, importSources, tagHelpers); + var csharpDocument = codeDocument.GetCSharpDocument(); + + // OK now we've generated the code. Let's check if the output is actually different. This is + // a valuable optimization for our use cases because lots of changes you could make require + // us to run code generation, but don't change the result. + // + // Note that we're talking about the effect on the generated C# code here (not the other artifacts). + // This is the reason why we have two versions associated with the output. + // + // The INPUT version is related the .cshtml files and tag helpers + // The OUTPUT version is related to the generated C#. + // + // Examples: + // + // A change to a tag helper not used by this document - updates the INPUT version, but not + // the OUTPUT version. + // + // A change in the HTML - updates the INPUT version, but not the OUTPUT version. + // + // + // Razor IDE features should always retrieve the output and party on it regardless. Depending + // on the use cases we may or may not need to synchronize the output. + + var outputVersion = inputVersion; + if (olderOutput != null) + { + if (string.Equals( + olderOutput.GetCSharpDocument().GeneratedCode, + csharpDocument.GeneratedCode, + StringComparison.Ordinal)) + { + outputVersion = olderOutputVersion; + } + } + + if (document is DefaultDocumentSnapshot defaultDocument) + { + defaultDocument.State.HostDocument.GeneratedCodeContainer.SetOutput( + defaultDocument, + csharpDocument, + inputVersion, + outputVersion); + } + + return (codeDocument, inputVersion, outputVersion); + } + + private async Task GetRazorSourceDocumentAsync(DocumentSnapshot document) + { + var sourceText = await document.GetTextAsync(); + return sourceText.GetRazorSourceDocument(document.FilePath); + } + + private async Task> GetImportsAsync(ProjectSnapshot project, DocumentSnapshot document) + { + var imports = new List(); + foreach (var snapshot in document.GetImports()) + { + var versionStamp = await snapshot.GetTextVersionAsync(); + imports.Add(new ImportItem(snapshot.FilePath, versionStamp, snapshot)); + } + + return imports; + } + + private readonly struct ImportItem + { + public ImportItem(string filePath, VersionStamp version, DocumentSnapshot document) + { + FilePath = filePath; + Version = version; + Document = document; + } + + public string FilePath { get; } + + public VersionStamp Version { get; } + + public DocumentSnapshot Document { get; } + } + } } } diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/GeneratedCodeContainer.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/GeneratedCodeContainer.cs index 0cc1de85b0..dec2a48d4a 100644 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/GeneratedCodeContainer.cs +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/GeneratedCodeContainer.cs @@ -13,7 +13,8 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public event EventHandler GeneratedCodeChanged; private SourceText _source; - private VersionStamp? _sourceVersion; + private VersionStamp? _inputVersion; + private VersionStamp? _outputVersion; private RazorCSharpDocument _output; private DocumentSnapshot _latestDocument; @@ -37,13 +38,24 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem } } - public VersionStamp SourceVersion + public VersionStamp InputVersion { get { lock (_setOutputLock) { - return _sourceVersion.Value; + return _inputVersion.Value; + } + } + } + + public VersionStamp OutputVersion + { + get + { + lock (_setOutputLock) + { + return _outputVersion.Value; } } } @@ -81,19 +93,17 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem } } - public void SetOutput(RazorCSharpDocument csharpDocument, DefaultDocumentSnapshot document) + public void SetOutput( + DefaultDocumentSnapshot document, + RazorCSharpDocument output, + VersionStamp inputVersion, + VersionStamp outputVersion) { lock (_setOutputLock) { - if (!document.TryGetTextVersion(out var version)) - { - Debug.Fail("The text version should have already been evaluated."); - return; - } - - if (_sourceVersion.HasValue && - _sourceVersion != version && - _sourceVersion == SourceVersion.GetNewerVersion(version)) + if (_inputVersion.HasValue && + _inputVersion != inputVersion && + _inputVersion == _inputVersion.Value.GetNewerVersion(inputVersion)) { // Latest document is newer than the provided document. return; @@ -106,10 +116,11 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem } _source = source; - _sourceVersion = version; - _output = csharpDocument; + _inputVersion = inputVersion; + _outputVersion = outputVersion; + _output = output; _latestDocument = document; - _textContainer.SetText(SourceText.From(Output.GeneratedCode)); + _textContainer.SetText(SourceText.From(_output.GeneratedCode)); } } diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectEngineTracker.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectEngineTracker.cs deleted file mode 100644 index 0be857f443..0000000000 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectEngineTracker.cs +++ /dev/null @@ -1,88 +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. - -using System; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.IO; -using System.Linq; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.CodeAnalysis.Host; - -namespace Microsoft.CodeAnalysis.Razor.ProjectSystem -{ - internal class ProjectEngineTracker - { - private const ProjectDifference Mask = ProjectDifference.ConfigurationChanged; - - private readonly object _lock = new object(); - - private readonly HostWorkspaceServices _services; - private RazorProjectEngine _projectEngine; - - public ProjectEngineTracker(ProjectState state) - { - if (state == null) - { - throw new ArgumentNullException(nameof(state)); - } - - _services = state.Services; - } - - public ProjectEngineTracker ForkFor(ProjectState state, ProjectDifference difference) - { - if (state == null) - { - throw new ArgumentNullException(nameof(state)); - } - - if ((difference & Mask) != 0) - { - return null; - } - - return this; - } - - public RazorProjectEngine GetProjectEngine(ProjectState state) - { - if (state == null) - { - throw new ArgumentNullException(nameof(state)); - } - - if (_projectEngine == null) - { - lock (_lock) - { - if (_projectEngine == null) - { - var factory = _services.GetRequiredService(); - _projectEngine = factory.Create(state.HostProject.Configuration, Path.GetDirectoryName(state.HostProject.FilePath), configure: null); - } - } - } - - return _projectEngine; - } - - public List GetImportDocumentTargetPaths(ProjectState state, string targetPath) - { - var projectEngine = GetProjectEngine(state); - var importFeature = projectEngine.ProjectFeatures.OfType().FirstOrDefault(); - var projectItem = projectEngine.FileSystem.GetItem(targetPath); - var importItems = importFeature?.GetImports(projectItem).Where(i => i.FilePath != null); - - // Target path looks like `Foo\\Bar.cshtml` - var targetPaths = new List(); - foreach (var importItem in importItems) - { - var itemTargetPath = importItem.FilePath.Replace('/', '\\').TrimStart('\\'); - targetPaths.Add(itemTargetPath); - } - - return targetPaths; - } - } -} diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectState.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectState.cs index 27e33e6f5c..7708b0c709 100644 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectState.cs +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectState.cs @@ -4,7 +4,10 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.IO; +using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; using Microsoft.CodeAnalysis.Host; using Microsoft.CodeAnalysis.Text; @@ -13,12 +16,24 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem // Internal tracker for DefaultProjectSnapshot internal class ProjectState { + private const ProjectDifference ClearComputedStateMask = ProjectDifference.ConfigurationChanged; + + private const ProjectDifference ClearCachedTagHelpersMask = + ProjectDifference.ConfigurationChanged | + ProjectDifference.WorkspaceProjectAdded | + ProjectDifference.WorkspaceProjectChanged | + ProjectDifference.WorkspaceProjectRemoved; + + private const ProjectDifference ClearDocumentCollectionVersionMask = + ProjectDifference.ConfigurationChanged | + ProjectDifference.DocumentAdded | + ProjectDifference.DocumentRemoved; + private static readonly ImmutableDictionary EmptyDocuments = ImmutableDictionary.Create(FilePathComparer.Instance); private static readonly ImmutableDictionary> EmptyImportsToRelatedDocuments = ImmutableDictionary.Create>(FilePathComparer.Instance); private readonly object _lock; - - private ProjectEngineTracker _projectEngine; - private ProjectTagHelperTracker _tagHelpers; + + private ComputedStateTracker _computedState; public static ProjectState Create(HostWorkspaceServices services, HostProject hostProject, Project workspaceProject = null) { @@ -34,7 +49,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem return new ProjectState(services, hostProject, workspaceProject); } - + private ProjectState( HostWorkspaceServices services, HostProject hostProject, @@ -46,6 +61,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem Documents = EmptyDocuments; ImportsToRelatedDocuments = EmptyImportsToRelatedDocuments; Version = VersionStamp.Create(); + DocumentCollectionVersion = Version; _lock = new object(); } @@ -88,8 +104,27 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem _lock = new object(); - _projectEngine = older._projectEngine?.ForkFor(this, difference); - _tagHelpers = older._tagHelpers?.ForkFor(this, difference); + if ((difference & ClearDocumentCollectionVersionMask) == 0) + { + // Document collection hasn't changed + DocumentCollectionVersion = older.DocumentCollectionVersion; + } + else + { + DocumentCollectionVersion = Version; + } + + if ((difference & ClearComputedStateMask) == 0 && older._computedState != null) + { + // Optimistically cache the RazorProjectEngine. + _computedState = new ComputedStateTracker(this, older._computedState); + } + + if ((difference & ClearCachedTagHelpersMask) == 0 && _computedState != null) + { + // It's OK to keep the computed Tag Helpers. + _computedState.TaskUnsafe = older._computedState?.TaskUnsafe; + } } // Internal set for testing. @@ -104,46 +139,68 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public Project WorkspaceProject { get; } + /// + /// Gets the version of this project, INCLUDING content changes. The is + /// incremented for each new instance created. + /// public VersionStamp Version { get; } - // Computed State - public ProjectEngineTracker ProjectEngine + /// + /// Gets the version of this project, NOT INCLUDING computed or content changes. The + /// is incremented each time the configuration changes or + /// a document is added or removed. + /// + public VersionStamp DocumentCollectionVersion { get; } + + public RazorProjectEngine ProjectEngine => ComputedState.ProjectEngine; + + public bool IsTagHelperResultAvailable => ComputedState.TaskUnsafe?.IsCompleted == true; + + private ComputedStateTracker ComputedState { get { - if (_projectEngine == null) + if (_computedState == null) { lock (_lock) { - if (_projectEngine == null) + if (_computedState == null) { - _projectEngine = new ProjectEngineTracker(this); + _computedState = new ComputedStateTracker(this); } } } - return _projectEngine; + return _computedState; } } - // Computed State - public ProjectTagHelperTracker TagHelpers + /// + /// Gets the version of this project based on the computed state, NOT INCLUDING content + /// changes. The computed state is guaranteed to change when the configuration or tag helpers + /// change. + /// + /// Asynchronously returns the computed version. + public async Task GetComputedStateVersionAsync(ProjectSnapshot snapshot) { - get + if (snapshot == null) { - if (_tagHelpers == null) - { - lock (_lock) - { - if (_tagHelpers == null) - { - _tagHelpers = new ProjectTagHelperTracker(this); - } - } - } - - return _tagHelpers; + throw new ArgumentNullException(nameof(snapshot)); } + + var (_, version) = await ComputedState.GetTagHelpersAndVersionAsync(snapshot).ConfigureAwait(false); + return version; + } + + public async Task> GetTagHelpersAsync(ProjectSnapshot snapshot) + { + if (snapshot == null) + { + throw new ArgumentNullException(nameof(snapshot)); + } + + var (tagHelpers, _) = await ComputedState.GetTagHelpersAndVersionAsync(snapshot).ConfigureAwait(false); + return tagHelpers; } public ProjectState WithAddedHostDocument(HostDocument hostDocument, Func> loader) @@ -164,13 +221,13 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem { return this; } - + var documents = Documents.Add(hostDocument.FilePath, DocumentState.Create(Services, hostDocument, loader)); // Compute the effect on the import map - var importTargetPaths = ProjectEngine.GetImportDocumentTargetPaths(this, hostDocument.TargetPath); + var importTargetPaths = GetImportDocumentTargetPaths(hostDocument.TargetPath); var importsToRelatedDocuments = AddToImportsToRelatedDocuments(ImportsToRelatedDocuments, hostDocument, importTargetPaths); - + // Now check if the updated document is an import - it's important this this happens after // updating the imports map. if (importsToRelatedDocuments.TryGetValue(hostDocument.TargetPath, out var relatedDocuments)) @@ -196,7 +253,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem { return this; } - + var documents = Documents.Remove(hostDocument.FilePath); // First check if the updated document is an import - it's important that this happens @@ -210,7 +267,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem } // Compute the effect on the import map - var importTargetPaths = ProjectEngine.GetImportDocumentTargetPaths(this, hostDocument.TargetPath); + var importTargetPaths = GetImportDocumentTargetPaths(hostDocument.TargetPath); var importsToRelatedDocuments = RemoveFromImportsToRelatedDocuments(ImportsToRelatedDocuments, hostDocument, importTargetPaths); var state = new ProjectState(this, ProjectDifference.DocumentRemoved, HostProject, WorkspaceProject, documents, importsToRelatedDocuments); @@ -280,7 +337,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem { return this; } - + var documents = Documents.ToImmutableDictionary(kvp => kvp.Key, kvp => kvp.Value.WithConfigurationChange(), FilePathComparer.Instance); // If the host project has changed then we need to recompute the imports map @@ -288,11 +345,10 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem foreach (var document in documents) { - var importTargetPaths = ProjectEngine.GetImportDocumentTargetPaths(this, document.Value.HostDocument.TargetPath); + var importTargetPaths = GetImportDocumentTargetPaths(document.Value.HostDocument.TargetPath); importsToRelatedDocuments = AddToImportsToRelatedDocuments(ImportsToRelatedDocuments, document.Value.HostDocument, importTargetPaths); } - var state = new ProjectState(this, ProjectDifference.ConfigurationChanged, hostProject, WorkspaceProject, documents, importsToRelatedDocuments); return state; } @@ -367,5 +423,114 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem return importsToRelatedDocuments; } + + private RazorProjectEngine CreateProjectEngine() + { + var factory = Services.GetRequiredService(); + return factory.Create(HostProject.Configuration, Path.GetDirectoryName(HostProject.FilePath), configure: null); + } + + public List GetImportDocumentTargetPaths(string targetPath) + { + var projectEngine = ComputedState.ProjectEngine; + var importFeature = projectEngine.ProjectFeatures.OfType().FirstOrDefault(); + var projectItem = projectEngine.FileSystem.GetItem(targetPath); + var importItems = importFeature?.GetImports(projectItem).Where(i => i.FilePath != null); + + // Target path looks like `Foo\\Bar.cshtml` + var targetPaths = new List(); + foreach (var importItem in importItems) + { + var itemTargetPath = importItem.FilePath.Replace('/', '\\').TrimStart('\\'); + targetPaths.Add(itemTargetPath); + } + + return targetPaths; + } + + // ComputedStateTracker is the 'holder' of all of the state that can be cached based on + // the data in a ProjectState. It should not hold onto a ProjectState directly + // as that could lead to things being in memory longer than we want them to. + // + // Rather, a ComputedStateTracker instance can hold on to a previous instance from an older + // version of the same project. + private class ComputedStateTracker + { + // ProjectState.Version + private readonly VersionStamp _projectStateVersion; + private readonly object _lock; + + private ComputedStateTracker _older; // We be set to null when state is computed + public Task<(IReadOnlyList, VersionStamp)> TaskUnsafe; + + public ComputedStateTracker(ProjectState state, ComputedStateTracker older = null) + { + _projectStateVersion = state.Version; + _lock = state._lock; + _older = older; + + ProjectEngine = _older?.ProjectEngine; + if (ProjectEngine == null) + { + ProjectEngine = state.CreateProjectEngine(); + } + } + + public RazorProjectEngine ProjectEngine { get; } + + public Task<(IReadOnlyList, VersionStamp)> GetTagHelpersAndVersionAsync(ProjectSnapshot snapshot) + { + if (TaskUnsafe == null) + { + lock (_lock) + { + if (TaskUnsafe == null) + { + TaskUnsafe = GetTagHelpersAndVersionCoreAsync(snapshot); + } + } + } + + return TaskUnsafe; + } + + private async Task<(IReadOnlyList, VersionStamp)> GetTagHelpersAndVersionCoreAsync(ProjectSnapshot snapshot) + { + // Don't allow synchronous execution - we expect this to always be called with the lock. + await Task.Yield(); + + var services = ((DefaultProjectSnapshot)snapshot).State.Services; + var resolver = services.GetLanguageServices(RazorLanguage.Name).GetRequiredService(); + + var tagHelpers = (await resolver.GetTagHelpersAsync(snapshot).ConfigureAwait(false)).Descriptors; + if (_older?.TaskUnsafe != null) + { + // We have something to diff against. + var (olderTagHelpers, olderVersion) = await _older.TaskUnsafe.ConfigureAwait(false); + + var difference = new HashSet(TagHelperDescriptorComparer.Default); + difference.UnionWith(olderTagHelpers); + difference.SymmetricExceptWith(tagHelpers); + + if (difference.Count == 0) + { + lock (_lock) + { + + // Everything is the same. Return the cached version. + TaskUnsafe = _older.TaskUnsafe; + _older = null; + return (olderTagHelpers, olderVersion); + } + } + } + + lock (_lock) + { + _older = null; + return (tagHelpers, _projectStateVersion); + } + } + } } } diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectTagHelperTracker.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectTagHelperTracker.cs deleted file mode 100644 index 0c3be4ddec..0000000000 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectTagHelperTracker.cs +++ /dev/null @@ -1,79 +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. - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.CodeAnalysis.Host; - -namespace Microsoft.CodeAnalysis.Razor.ProjectSystem -{ - internal class ProjectTagHelperTracker - { - private const ProjectDifference Mask = - ProjectDifference.ConfigurationChanged | - ProjectDifference.WorkspaceProjectAdded | - ProjectDifference.WorkspaceProjectChanged | - ProjectDifference.WorkspaceProjectRemoved; - - private readonly object _lock = new object(); - private readonly HostWorkspaceServices _services; - - private Task> _task; - - public ProjectTagHelperTracker(ProjectState state) - { - if (state == null) - { - throw new ArgumentNullException(nameof(state)); - } - - _services = state.Services; - } - - public bool IsResultAvailable => _task?.IsCompleted == true; - - public ProjectTagHelperTracker ForkFor(ProjectState state, ProjectDifference difference) - { - if (state == null) - { - throw new ArgumentNullException(nameof(state)); - } - - if ((difference & Mask) != 0) - { - return null; - } - - return this; - } - - public Task> GetTagHelperInitializationTask(ProjectSnapshot snapshot) - { - if (snapshot == null) - { - throw new ArgumentNullException(nameof(snapshot)); - } - - if (_task == null) - { - lock (_lock) - { - if (_task == null) - { - _task = GetTagHelperInitializationTaskCore(snapshot); - } - } - } - - return _task; - } - - private async Task> GetTagHelperInitializationTaskCore(ProjectSnapshot snapshot) - { - var resolver = _services.GetLanguageServices(RazorLanguage.Name).GetRequiredService(); - return (await resolver.GetTagHelpersAsync(snapshot)).Descriptors; - } - } -} diff --git a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/DefaultProjectSnapshotTest.cs b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/DefaultProjectSnapshotTest.cs index 03800d062a..3abc655cc8 100644 --- a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/DefaultProjectSnapshotTest.cs +++ b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/DefaultProjectSnapshotTest.cs @@ -94,30 +94,6 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem d => Assert.Same(d.Value, snapshot.GetDocument(d.Key))); } - [Fact] - public void ProjectSnapshot_CachesTagHelperTask() - { - // Arrange - TagHelperResolver.CompletionSource = new TaskCompletionSource(); - - try - { - var state = ProjectState.Create(Workspace.Services, HostProject, WorkspaceProject); - var snapshot = new DefaultProjectSnapshot(state); - - // Act - var task1 = snapshot.GetTagHelpersAsync(); - var task2 = snapshot.GetTagHelpersAsync(); - - // Assert - Assert.Same(task1, task2); - } - finally - { - TagHelperResolver.CompletionSource.SetCanceled(); - } - } - [Fact] public void IsImportDocument_NonImportDocument_ReturnsFalse() { diff --git a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/DocumentStateTest.cs b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/DocumentStateTest.cs index caa1927d8b..58e64d287c 100644 --- a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/DocumentStateTest.cs +++ b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/DocumentStateTest.cs @@ -33,13 +33,13 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem SomeTagHelpers = new List(); SomeTagHelpers.Add(TagHelperDescriptorBuilder.Create("Test1", "TestAssembly").Build()); - Document = TestProjectData.SomeProjectFile1; + HostDocument = TestProjectData.SomeProjectFile1; Text = SourceText.From("Hello, world!"); TextLoader = () => Task.FromResult(TextAndVersion.Create(Text, VersionStamp.Create())); } - private HostDocument Document { get; } + private HostDocument HostDocument { get; } private HostProject HostProject { get; } @@ -64,7 +64,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public async Task DocumentState_CreatedNew_HasEmptyText() { // Arrange & Act - var state = DocumentState.Create(Workspace.Services, Document, DocumentState.EmptyLoader); + var state = DocumentState.Create(Workspace.Services, HostDocument, DocumentState.EmptyLoader); // Assert var text = await state.GetTextAsync(); @@ -75,7 +75,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public async Task DocumentState_WithText_CreatesNewState() { // Arrange - var original = DocumentState.Create(Workspace.Services, Document, DocumentState.EmptyLoader); + var original = DocumentState.Create(Workspace.Services, HostDocument, DocumentState.EmptyLoader); // Act var state = original.WithText(Text, VersionStamp.Create()); @@ -89,7 +89,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public async Task DocumentState_WithTextLoader_CreatesNewState() { // Arrange - var original = DocumentState.Create(Workspace.Services, Document, DocumentState.EmptyLoader); + var original = DocumentState.Create(Workspace.Services, HostDocument, DocumentState.EmptyLoader); // Act var state = original.WithTextLoader(TextLoader); @@ -103,7 +103,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public void DocumentState_WithConfigurationChange_CachesSnapshotText() { // Arrange - var original = DocumentState.Create(Workspace.Services, Document, DocumentState.EmptyLoader) + var original = DocumentState.Create(Workspace.Services, HostDocument, DocumentState.EmptyLoader) .WithText(Text, VersionStamp.Create()); // Act @@ -118,7 +118,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public async Task DocumentState_WithConfigurationChange_CachesLoadedText() { // Arrange - var original = DocumentState.Create(Workspace.Services, Document, DocumentState.EmptyLoader) + var original = DocumentState.Create(Workspace.Services, HostDocument, DocumentState.EmptyLoader) .WithTextLoader(TextLoader); await original.GetTextAsync(); @@ -135,7 +135,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public void DocumentState_WithImportsChange_CachesSnapshotText() { // Arrange - var original = DocumentState.Create(Workspace.Services, Document, DocumentState.EmptyLoader) + var original = DocumentState.Create(Workspace.Services, HostDocument, DocumentState.EmptyLoader) .WithText(Text, VersionStamp.Create()); // Act @@ -150,7 +150,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public async Task DocumentState_WithImportsChange_CachesLoadedText() { // Arrange - var original = DocumentState.Create(Workspace.Services, Document, DocumentState.EmptyLoader) + var original = DocumentState.Create(Workspace.Services, HostDocument, DocumentState.EmptyLoader) .WithTextLoader(TextLoader); await original.GetTextAsync(); @@ -167,7 +167,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public void DocumentState_WithWorkspaceProjectChange_CachesSnapshotText() { // Arrange - var original = DocumentState.Create(Workspace.Services, Document, DocumentState.EmptyLoader) + var original = DocumentState.Create(Workspace.Services, HostDocument, DocumentState.EmptyLoader) .WithText(Text, VersionStamp.Create()); // Act @@ -182,7 +182,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public async Task DocumentState_WithWorkspaceProjectChange_CachesLoadedText() { // Arrange - var original = DocumentState.Create(Workspace.Services, Document, DocumentState.EmptyLoader) + var original = DocumentState.Create(Workspace.Services, HostDocument, DocumentState.EmptyLoader) .WithTextLoader(TextLoader); await original.GetTextAsync(); @@ -194,20 +194,5 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem Assert.True(state.TryGetText(out _)); Assert.True(state.TryGetTextVersion(out _)); } - - [Fact] - public void DocumentState_WithWorkspaceProjectChange_TriesToCacheGeneratedOutput() - { - // Arrange - var original = DocumentState.Create(Workspace.Services, Document, DocumentState.EmptyLoader); - - GC.KeepAlive(original.GeneratedOutput); - - // Act - var state = original.WithWorkspaceProjectChange(); - - // Assert - Assert.Same(state.GeneratedOutput.Older, original.GeneratedOutput); - } } } diff --git a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/GeneratedCodeContainerTest.cs b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/GeneratedCodeContainerTest.cs index b1942002ff..f3b04cc39f 100644 --- a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/GeneratedCodeContainerTest.cs +++ b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/GeneratedCodeContainerTest.cs @@ -15,22 +15,26 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public void SetOutput_AcceptsSameVersionedDocuments() { // Arrange - var csharpDocument = RazorCSharpDocument.Create("...", RazorCodeGenerationOptions.CreateDefault(), Enumerable.Empty()); - var hostProject = new HostProject("C:/project.csproj", RazorConfiguration.Default); var services = TestWorkspace.Create().Services; + var hostProject = new HostProject("C:/project.csproj", RazorConfiguration.Default); var projectState = ProjectState.Create(services, hostProject); var project = new DefaultProjectSnapshot(projectState); - var hostDocument = new HostDocument("C:/file.cshtml", "C:/file.cshtml"); + var text = SourceText.From("..."); var textAndVersion = TextAndVersion.Create(text, VersionStamp.Default); + var hostDocument = new HostDocument("C:/file.cshtml", "C:/file.cshtml"); var documentState = new DocumentState(services, hostDocument, text, VersionStamp.Default, () => Task.FromResult(textAndVersion)); var document = new DefaultDocumentSnapshot(project, documentState); var newDocument = new DefaultDocumentSnapshot(project, documentState); + + var csharpDocument = RazorCSharpDocument.Create("...", RazorCodeGenerationOptions.CreateDefault(), Enumerable.Empty()); + + var version = VersionStamp.Create(); var container = new GeneratedCodeContainer(); - container.SetOutput(csharpDocument, document); + container.SetOutput(document, csharpDocument, version, version); // Act - container.SetOutput(csharpDocument, newDocument); + container.SetOutput(newDocument, csharpDocument, version, version); // Assert Assert.Same(newDocument, container.LatestDocument); @@ -40,20 +44,23 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public void SetOutput_AcceptsInitialOutput() { // Arrange - var csharpDocument = RazorCSharpDocument.Create("...", RazorCodeGenerationOptions.CreateDefault(), Enumerable.Empty()); - var hostProject = new HostProject("C:/project.csproj", RazorConfiguration.Default); var services = TestWorkspace.Create().Services; + var hostProject = new HostProject("C:/project.csproj", RazorConfiguration.Default); var projectState = ProjectState.Create(services, hostProject); var project = new DefaultProjectSnapshot(projectState); - var hostDocument = new HostDocument("C:/file.cshtml", "C:/file.cshtml"); + var text = SourceText.From("..."); var textAndVersion = TextAndVersion.Create(text, VersionStamp.Default); + var hostDocument = new HostDocument("C:/file.cshtml", "C:/file.cshtml"); var documentState = new DocumentState(services, hostDocument, text, VersionStamp.Default, () => Task.FromResult(textAndVersion)); var document = new DefaultDocumentSnapshot(project, documentState); + var csharpDocument = RazorCSharpDocument.Create("...", RazorCodeGenerationOptions.CreateDefault(), Enumerable.Empty()); + + var version = VersionStamp.Create(); var container = new GeneratedCodeContainer(); // Act - container.SetOutput(csharpDocument, document); + container.SetOutput(document, csharpDocument, version, version); // Assert Assert.NotNull(container.LatestDocument); diff --git a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/ProjectStateGeneratedOutputTest.cs b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/ProjectStateGeneratedOutputTest.cs new file mode 100644 index 0000000000..db8390f554 --- /dev/null +++ b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/ProjectStateGeneratedOutputTest.cs @@ -0,0 +1,264 @@ +// 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.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.Text; +using Xunit; + +namespace Microsoft.CodeAnalysis.Razor.ProjectSystem +{ + public class ProjectStateGeneratedOutputTest : WorkspaceTestBase + { + public ProjectStateGeneratedOutputTest() + { + HostProject = new HostProject(TestProjectData.SomeProject.FilePath, FallbackRazorConfiguration.MVC_2_0); + HostProjectWithConfigurationChange = new HostProject(TestProjectData.SomeProject.FilePath, FallbackRazorConfiguration.MVC_1_0); + + var projectId = ProjectId.CreateNewId("Test"); + var solution = Workspace.CurrentSolution.AddProject(ProjectInfo.Create( + projectId, + VersionStamp.Default, + "Test", + "Test", + LanguageNames.CSharp, + TestProjectData.SomeProject.FilePath)); + WorkspaceProject = solution.GetProject(projectId); + + SomeTagHelpers = new List(); + SomeTagHelpers.Add(TagHelperDescriptorBuilder.Create("Test1", "TestAssembly").Build()); + + HostDocument = TestProjectData.SomeProjectFile1; + + Text = SourceText.From("Hello, world!"); + TextLoader = () => Task.FromResult(TextAndVersion.Create(Text, VersionStamp.Create())); + } + + private HostDocument HostDocument { get; } + + private HostProject HostProject { get; } + + private HostProject HostProjectWithConfigurationChange { get; } + + private Project WorkspaceProject { get; } + + private TestTagHelperResolver TagHelperResolver { get; } = new TestTagHelperResolver(); + + private List SomeTagHelpers { get; } + + private Func> TextLoader { get; } + + private SourceText Text { get; } + + protected override void ConfigureLanguageServices(List services) + { + services.Add(TagHelperResolver); + } + + protected override void ConfigureProjectEngine(RazorProjectEngineBuilder builder) + { + builder.Features.Remove(builder.Features.OfType().Single()); + builder.Features.Add(new TestImportProjectFeature()); + } + + [Fact] + public async Task HostDocumentAdded_CachesOutput() + { + // Arrange + var original = + ProjectState.Create(Workspace.Services, HostProject) + .WithAddedHostDocument(HostDocument, DocumentState.EmptyLoader); + + var (originalOutput, originalInputVersion, originalOutputVersion) = await GetOutputAsync(original, HostDocument); + + // Act + var state = original.WithAddedHostDocument(TestProjectData.AnotherProjectFile1, DocumentState.EmptyLoader); + + // Assert + var (actualOutput, actualInputVersion, actualOutputVersion) = await GetOutputAsync(state, HostDocument); + Assert.Same(originalOutput, actualOutput); + Assert.Equal(originalInputVersion, actualInputVersion); + Assert.Equal(originalOutputVersion, actualOutputVersion); + Assert.Equal(await state.GetComputedStateVersionAsync(new DefaultProjectSnapshot(state)), actualOutputVersion); + } + + [Fact] + public async Task HostDocumentAdded_Import_DoesNotCacheOutput() + { + // Arrange + var original = + ProjectState.Create(Workspace.Services, HostProject) + .WithAddedHostDocument(HostDocument, DocumentState.EmptyLoader); + + var (originalOutput, originalInputVersion, originalOutputVersion) = await GetOutputAsync(original, HostDocument); + + // Act + var state = original.WithAddedHostDocument(TestProjectData.SomeProjectImportFile, DocumentState.EmptyLoader); + + // Assert + var (actualOutput, actualInputVersion, actualOutputVersion) = await GetOutputAsync(state, HostDocument); + Assert.NotSame(originalOutput, actualOutput); + Assert.NotEqual(originalInputVersion, actualInputVersion); + Assert.Equal(originalOutputVersion, actualOutputVersion); + Assert.Equal(state.DocumentCollectionVersion, actualInputVersion); + } + + [Fact] + public async Task HostDocumentChanged_DoesNotCacheOutput() + { + // Arrange + var original = + ProjectState.Create(Workspace.Services, HostProject) + .WithAddedHostDocument(HostDocument, DocumentState.EmptyLoader) + .WithAddedHostDocument(TestProjectData.SomeProjectImportFile, DocumentState.EmptyLoader); + + var (originalOutput, originalInputVersion, originalOutputVersion) = await GetOutputAsync(original, HostDocument); + + // Act + var version = VersionStamp.Create(); + var state = original.WithChangedHostDocument(HostDocument, () => + { + return Task.FromResult(TextAndVersion.Create(SourceText.From("@using System"), version)); + }); + + // Assert + var (actualOutput, actualInputVersion, actualOutputVersion) = await GetOutputAsync(state, HostDocument); + Assert.NotSame(originalOutput, actualOutput); + Assert.NotEqual(originalInputVersion, actualInputVersion); + Assert.NotEqual(originalOutputVersion, actualOutputVersion); + Assert.Equal(version, actualInputVersion); + } + + [Fact] + public async Task HostDocumentChanged_Import_DoesNotCacheOutput() + { + // Arrange + var original = + ProjectState.Create(Workspace.Services, HostProject) + .WithAddedHostDocument(HostDocument, DocumentState.EmptyLoader) + .WithAddedHostDocument(TestProjectData.SomeProjectImportFile, DocumentState.EmptyLoader); + + var (originalOutput, originalInputVersion, originalOutputVersion) = await GetOutputAsync(original, HostDocument); + + // Act + var version = VersionStamp.Create(); + var state = original.WithChangedHostDocument(TestProjectData.SomeProjectImportFile, () => + { + return Task.FromResult(TextAndVersion.Create(SourceText.From("@using System"), version)); + }); + + // Assert + var (actualOutput, actualInputVersion, actualOutputVersion) = await GetOutputAsync(state, HostDocument); + Assert.NotSame(originalOutput, actualOutput); + Assert.NotEqual(originalInputVersion, actualInputVersion); + Assert.NotEqual(originalOutputVersion, actualOutputVersion); + Assert.Equal(version, actualInputVersion); + } + + [Fact] + public async Task HostDocumentRemoved_Import_DoesNotCacheOutput() + { + // Arrange + var original = + ProjectState.Create(Workspace.Services, HostProject) + .WithAddedHostDocument(HostDocument, DocumentState.EmptyLoader) + .WithAddedHostDocument(TestProjectData.SomeProjectImportFile, DocumentState.EmptyLoader); + + var (originalOutput, originalInputVersion, originalOutputVersion) = await GetOutputAsync(original, HostDocument); + + // Act + var state = original.WithRemovedHostDocument(TestProjectData.SomeProjectImportFile); + + // Assert + var (actualOutput, actualInputVersion, actualOutputVersion) = await GetOutputAsync(state, HostDocument); + Assert.NotSame(originalOutput, actualOutput); + Assert.NotEqual(originalInputVersion, actualInputVersion); + Assert.Equal(originalOutputVersion, actualOutputVersion); + Assert.Equal(state.DocumentCollectionVersion, actualInputVersion); + } + + [Fact] + public async Task WorkspaceProjectChange_CachesOutput() + { + // Arrange + var original = + ProjectState.Create(Workspace.Services, HostProject) + .WithAddedHostDocument(HostDocument, DocumentState.EmptyLoader); + + var (originalOutput, originalInputVersion, originalOutputVersion) = await GetOutputAsync(original, HostDocument); + + // Act + var state = original.WithWorkspaceProject(WorkspaceProject.WithAssemblyName("Test2")); + + // Assert + var (actualOutput, actualInputVersion, actualOutputVersion) = await GetOutputAsync(state, HostDocument); + Assert.Same(originalOutput, actualOutput); + Assert.Equal(originalInputVersion, actualInputVersion); + Assert.Equal(originalOutputVersion, actualOutputVersion); + Assert.Equal(await state.GetComputedStateVersionAsync(new DefaultProjectSnapshot(state)), actualInputVersion); + } + + // The generated code's text doesn't change as a result, so the output version does not change + [Fact] + public async Task WorkspaceProjectChange_WithTagHelperChange_DoesNotCacheOutput() + { + // Arrange + var original = + ProjectState.Create(Workspace.Services, HostProject) + .WithAddedHostDocument(HostDocument, DocumentState.EmptyLoader); + + var (originalOutput, originalInputVersion, originalOutputVersion) = await GetOutputAsync(original, HostDocument); + + TagHelperResolver.TagHelpers = SomeTagHelpers; + + // Act + var state = original.WithWorkspaceProject(WorkspaceProject.WithAssemblyName("Test2")); + + // Assert + var (actualOutput, actualInputVersion, actualOutputVersion) = await GetOutputAsync(state, HostDocument); + Assert.NotSame(originalOutput, actualOutput); + Assert.NotEqual(originalInputVersion, actualInputVersion); + Assert.Equal(originalOutputVersion, actualOutputVersion); + Assert.Equal(await state.GetComputedStateVersionAsync(new DefaultProjectSnapshot(state)), actualInputVersion); + } + + [Fact] + public async Task ConfigurationChange_DoesNotCacheOutput() + { + // Arrange + var original = + ProjectState.Create(Workspace.Services, HostProject) + .WithAddedHostDocument(HostDocument, DocumentState.EmptyLoader); + + var (originalOutput, originalInputVersion, originalOutputVersion) = await GetOutputAsync(original, HostDocument); + + // Act + var state = original.WithHostProject(HostProjectWithConfigurationChange); + + // Assert + var (actualOutput, actualInputVersion, actualOutputVersion) = await GetOutputAsync(state, HostDocument); + Assert.NotSame(originalOutput, actualOutput); + Assert.NotEqual(originalInputVersion, actualInputVersion); + Assert.NotEqual(originalOutputVersion, actualOutputVersion); + Assert.Equal(await state.GetComputedStateVersionAsync(new DefaultProjectSnapshot(state)), actualInputVersion); + } + + private static Task<(RazorCodeDocument, VersionStamp, VersionStamp)> GetOutputAsync(ProjectState project, HostDocument hostDocument) + { + var document = project.Documents[hostDocument.FilePath]; + return GetOutputAsync(project, document); + } + + private static Task<(RazorCodeDocument, VersionStamp, VersionStamp)> GetOutputAsync(ProjectState project, DocumentState document) + { + + var projectSnapshot = new DefaultProjectSnapshot(project); + var documentSnapshot = new DefaultDocumentSnapshot(projectSnapshot, document); + return document.GetGeneratedOutputAndVersionAsync(projectSnapshot, documentSnapshot); + } + } +} diff --git a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/ProjectStateTest.cs b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/ProjectStateTest.cs index 81f022e67f..9ee22b31a3 100644 --- a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/ProjectStateTest.cs +++ b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/ProjectStateTest.cs @@ -17,8 +17,6 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem { public ProjectStateTest() { - TagHelperResolver = new TestTagHelperResolver(); - HostProject = new HostProject(TestProjectData.SomeProject.FilePath, FallbackRazorConfiguration.MVC_2_0); HostProjectWithConfigurationChange = new HostProject(TestProjectData.SomeProject.FilePath, FallbackRazorConfiguration.MVC_1_0); @@ -56,7 +54,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem private Project WorkspaceProject { get; } - private TestTagHelperResolver TagHelperResolver { get; } + private TestTagHelperResolver TagHelperResolver { get; set; } private List SomeTagHelpers { get; } @@ -66,6 +64,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem protected override void ConfigureLanguageServices(List services) { + TagHelperResolver = new TestTagHelperResolver(); services.Add(TagHelperResolver); } @@ -103,6 +102,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem Assert.Collection( state.Documents.OrderBy(kvp => kvp.Key), d => Assert.Same(Documents[0], d.Value.HostDocument)); + Assert.NotEqual(original.DocumentCollectionVersion, state.DocumentCollectionVersion); } [Fact] // When we first add a document, we have no way to read the text, so it's empty. @@ -138,6 +138,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem d => Assert.Same(Documents[2], d.Value.HostDocument), d => Assert.Same(Documents[0], d.Value.HostDocument), d => Assert.Same(Documents[1], d.Value.HostDocument)); + Assert.NotEqual(original.DocumentCollectionVersion, state.DocumentCollectionVersion); } [Fact] @@ -225,7 +226,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem } [Fact] - public void ProjectState_AddHostDocument_RetainsComputedState() + public async Task ProjectState_AddHostDocument_RetainsComputedState() { // Arrange var original = ProjectState.Create(Workspace.Services, HostProject, WorkspaceProject) @@ -233,15 +234,19 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem .WithAddedHostDocument(Documents[1], DocumentState.EmptyLoader); // Force init - GC.KeepAlive(original.ProjectEngine); - GC.KeepAlive(original.TagHelpers); + var originalTagHelpers = await original.GetTagHelpersAsync(new DefaultProjectSnapshot(original)); + var originalComputedVersion = await original.GetComputedStateVersionAsync(new DefaultProjectSnapshot(original)); // Act var state = original.WithAddedHostDocument(Documents[0], DocumentState.EmptyLoader); // Assert + var actualTagHelpers = await state.GetTagHelpersAsync(new DefaultProjectSnapshot(state)); + var actualComputedVersion = await state.GetComputedStateVersionAsync(new DefaultProjectSnapshot(state)); + Assert.Same(original.ProjectEngine, state.ProjectEngine); - Assert.Same(original.TagHelpers, state.TagHelpers); + Assert.Same(originalTagHelpers, actualTagHelpers); + Assert.Equal(originalComputedVersion, actualComputedVersion); Assert.Same(original.Documents[Documents[1].FilePath], state.Documents[Documents[1].FilePath]); Assert.Same(original.Documents[Documents[2].FilePath], state.Documents[Documents[2].FilePath]); @@ -278,6 +283,8 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem var text = await state.Documents[Documents[1].FilePath].GetTextAsync(); Assert.Same(Text, text); + + Assert.Equal(original.DocumentCollectionVersion, state.DocumentCollectionVersion); } [Fact] @@ -296,10 +303,12 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem var text = await state.Documents[Documents[1].FilePath].GetTextAsync(); Assert.Same(Text, text); + + Assert.Equal(original.DocumentCollectionVersion, state.DocumentCollectionVersion); } [Fact] - public void ProjectState_WithChangedHostDocument_Loader_RetainsComputedState() + public async Task ProjectState_WithChangedHostDocument_Loader_RetainsComputedState() { // Arrange var original = ProjectState.Create(Workspace.Services, HostProject, WorkspaceProject) @@ -307,21 +316,25 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem .WithAddedHostDocument(Documents[1], DocumentState.EmptyLoader); // Force init - GC.KeepAlive(original.ProjectEngine); - GC.KeepAlive(original.TagHelpers); + var originalTagHelpers = await original.GetTagHelpersAsync(new DefaultProjectSnapshot(original)); + var originalComputedVersion = await original.GetComputedStateVersionAsync(new DefaultProjectSnapshot(original)); // Act var state = original.WithChangedHostDocument(Documents[1], TextLoader); // Assert + var actualTagHelpers = await state.GetTagHelpersAsync(new DefaultProjectSnapshot(state)); + var actualComputedVersion = await state.GetComputedStateVersionAsync(new DefaultProjectSnapshot(state)); + Assert.Same(original.ProjectEngine, state.ProjectEngine); - Assert.Same(original.TagHelpers, state.TagHelpers); + Assert.Same(originalTagHelpers, actualTagHelpers); + Assert.Equal(originalComputedVersion, actualComputedVersion); Assert.NotSame(original.Documents[Documents[1].FilePath], state.Documents[Documents[1].FilePath]); } [Fact] - public void ProjectState_WithChangedHostDocument_Snapshot_RetainsComputedState() + public async Task ProjectState_WithChangedHostDocument_Snapshot_RetainsComputedState() { // Arrange var original = ProjectState.Create(Workspace.Services, HostProject, WorkspaceProject) @@ -329,15 +342,19 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem .WithAddedHostDocument(Documents[1], DocumentState.EmptyLoader); // Force init - GC.KeepAlive(original.ProjectEngine); - GC.KeepAlive(original.TagHelpers); + var originalTagHelpers = await original.GetTagHelpersAsync(new DefaultProjectSnapshot(original)); + var originalComputedVersion = await original.GetComputedStateVersionAsync(new DefaultProjectSnapshot(original)); // Act var state = original.WithChangedHostDocument(Documents[1], Text, VersionStamp.Create()); // Assert + var actualTagHelpers = await state.GetTagHelpersAsync(new DefaultProjectSnapshot(state)); + var actualComputedVersion = await state.GetComputedStateVersionAsync(new DefaultProjectSnapshot(state)); + Assert.Same(original.ProjectEngine, state.ProjectEngine); - Assert.Same(original.TagHelpers, state.TagHelpers); + Assert.Same(originalTagHelpers, actualTagHelpers); + Assert.Equal(originalComputedVersion, actualComputedVersion); Assert.NotSame(original.Documents[Documents[1].FilePath], state.Documents[Documents[1].FilePath]); } @@ -389,6 +406,8 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem Assert.Collection( state.Documents.OrderBy(kvp => kvp.Key), d => Assert.Same(Documents[2], d.Value.HostDocument)); + + Assert.NotEqual(original.DocumentCollectionVersion, state.DocumentCollectionVersion); } [Fact] @@ -454,7 +473,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem } [Fact] - public void ProjectState_RemoveHostDocument_RetainsComputedState() + public async Task ProjectState_RemoveHostDocument_RetainsComputedState() { // Arrange var original = ProjectState.Create(Workspace.Services, HostProject, WorkspaceProject) @@ -462,15 +481,19 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem .WithAddedHostDocument(Documents[1], DocumentState.EmptyLoader); // Force init - GC.KeepAlive(original.ProjectEngine); - GC.KeepAlive(original.TagHelpers); + var originalTagHelpers = await original.GetTagHelpersAsync(new DefaultProjectSnapshot(original)); + var originalComputedVersion = await original.GetComputedStateVersionAsync(new DefaultProjectSnapshot(original)); // Act var state = original.WithRemovedHostDocument(Documents[2]); // Assert + var actualTagHelpers = await state.GetTagHelpersAsync(new DefaultProjectSnapshot(state)); + var actualComputedVersion = await state.GetComputedStateVersionAsync(new DefaultProjectSnapshot(state)); + Assert.Same(original.ProjectEngine, state.ProjectEngine); - Assert.Same(original.TagHelpers, state.TagHelpers); + Assert.Same(originalTagHelpers, actualTagHelpers); + Assert.Equal(originalComputedVersion, actualComputedVersion); Assert.Same(original.Documents[Documents[1].FilePath], state.Documents[Documents[1].FilePath]); } @@ -491,7 +514,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem } [Fact] - public void ProjectState_WithHostProject_ConfigurationChange_UpdatesComputedState() + public async Task ProjectState_WithHostProject_ConfigurationChange_UpdatesComputedState() { // Arrange var original = ProjectState.Create(Workspace.Services, HostProject, WorkspaceProject) @@ -499,8 +522,10 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem .WithAddedHostDocument(Documents[1], DocumentState.EmptyLoader); // Force init - GC.KeepAlive(original.ProjectEngine); - GC.KeepAlive(original.TagHelpers); + var originalTagHelpers = await original.GetTagHelpersAsync(new DefaultProjectSnapshot(original)); + var originalComputedVersion = await original.GetComputedStateVersionAsync(new DefaultProjectSnapshot(original)); + + TagHelperResolver.TagHelpers = SomeTagHelpers; // Act var state = original.WithHostProject(HostProjectWithConfigurationChange); @@ -509,15 +534,21 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem Assert.NotEqual(original.Version, state.Version); Assert.Same(HostProjectWithConfigurationChange, state.HostProject); + var actualTagHelpers = await state.GetTagHelpersAsync(new DefaultProjectSnapshot(state)); + var actualComputedVersion = await state.GetComputedStateVersionAsync(new DefaultProjectSnapshot(state)); + Assert.NotSame(original.ProjectEngine, state.ProjectEngine); - Assert.NotSame(original.TagHelpers, state.TagHelpers); + Assert.NotSame(originalTagHelpers, actualTagHelpers); + Assert.NotEqual(originalComputedVersion, actualComputedVersion); Assert.NotSame(original.Documents[Documents[1].FilePath], state.Documents[Documents[1].FilePath]); Assert.NotSame(original.Documents[Documents[2].FilePath], state.Documents[Documents[2].FilePath]); + + Assert.NotEqual(original.DocumentCollectionVersion, state.DocumentCollectionVersion); } [Fact] - public void ProjectState_WithHostProject_NoConfigurationChange_Noops() + public async Task ProjectState_WithHostProject_NoConfigurationChange_Noops() { // Arrange var original = ProjectState.Create(Workspace.Services, HostProject, WorkspaceProject) @@ -525,8 +556,8 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem .WithAddedHostDocument(Documents[1], DocumentState.EmptyLoader); // Force init - GC.KeepAlive(original.ProjectEngine); - GC.KeepAlive(original.TagHelpers); + var originalTagHelpers = await original.GetTagHelpersAsync(new DefaultProjectSnapshot(original)); + var originalComputedVersion = await original.GetComputedStateVersionAsync(new DefaultProjectSnapshot(original)); // Act var state = original.WithHostProject(HostProject); @@ -560,7 +591,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem } [Fact] - public void ProjectState_WithWorkspaceProject_Removed() + public async Task ProjectState_WithWorkspaceProject_Removed() { // Arrange var original = ProjectState.Create(Workspace.Services, HostProject, WorkspaceProject) @@ -568,8 +599,8 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem .WithAddedHostDocument(Documents[1], DocumentState.EmptyLoader); // Force init - GC.KeepAlive(original.ProjectEngine); - GC.KeepAlive(original.TagHelpers); + var originalTagHelpers = await original.GetTagHelpersAsync(new DefaultProjectSnapshot(original)); + var originalComputedVersion = await original.GetComputedStateVersionAsync(new DefaultProjectSnapshot(original)); // Act var state = original.WithWorkspaceProject(null); @@ -578,15 +609,20 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem Assert.NotEqual(original.Version, state.Version); Assert.Null(state.WorkspaceProject); + var actualTagHelpers = await state.GetTagHelpersAsync(new DefaultProjectSnapshot(state)); + var actualComputedVersion = await state.GetComputedStateVersionAsync(new DefaultProjectSnapshot(state)); + + // The configuration didn't change, and the tag helpers didn't actually change Assert.Same(original.ProjectEngine, state.ProjectEngine); - Assert.NotSame(original.TagHelpers, state.TagHelpers); + Assert.Same(originalTagHelpers, actualTagHelpers); + Assert.Equal(originalComputedVersion, actualComputedVersion); Assert.NotSame(original.Documents[Documents[1].FilePath], state.Documents[Documents[1].FilePath]); Assert.NotSame(original.Documents[Documents[2].FilePath], state.Documents[Documents[2].FilePath]); } [Fact] - public void ProjectState_WithWorkspaceProject_Added() + public async Task ProjectState_WithWorkspaceProject_Added() { // Arrange var original = ProjectState.Create(Workspace.Services, HostProject, null) @@ -594,8 +630,8 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem .WithAddedHostDocument(Documents[1], DocumentState.EmptyLoader); // Force init - GC.KeepAlive(original.ProjectEngine); - GC.KeepAlive(original.TagHelpers); + var originalTagHelpers = await original.GetTagHelpersAsync(new DefaultProjectSnapshot(original)); + var originalComputedVersion = await original.GetComputedStateVersionAsync(new DefaultProjectSnapshot(original)); // Act var state = original.WithWorkspaceProject(WorkspaceProject); @@ -604,15 +640,20 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem Assert.NotEqual(original.Version, state.Version); Assert.Same(WorkspaceProject, state.WorkspaceProject); + var actualTagHelpers = await state.GetTagHelpersAsync(new DefaultProjectSnapshot(state)); + var actualComputedVersion = await state.GetComputedStateVersionAsync(new DefaultProjectSnapshot(state)); + + // The configuration didn't change, and the tag helpers didn't actually change Assert.Same(original.ProjectEngine, state.ProjectEngine); - Assert.NotSame(original.TagHelpers, state.TagHelpers); + Assert.Same(originalTagHelpers, actualTagHelpers); + Assert.Equal(originalComputedVersion, actualComputedVersion); Assert.NotSame(original.Documents[Documents[1].FilePath], state.Documents[Documents[1].FilePath]); Assert.NotSame(original.Documents[Documents[1].FilePath], state.Documents[Documents[1].FilePath]); } [Fact] - public void ProjectState_WithWorkspaceProject_Changed() + public async Task ProjectState_WithWorkspaceProject_Changed() { // Arrange var original = ProjectState.Create(Workspace.Services, HostProject, WorkspaceProject) @@ -620,8 +661,8 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem .WithAddedHostDocument(Documents[1], DocumentState.EmptyLoader); // Force init - GC.KeepAlive(original.ProjectEngine); - GC.KeepAlive(original.TagHelpers); + var originalTagHelpers = await original.GetTagHelpersAsync(new DefaultProjectSnapshot(original)); + var originalComputedVersion = await original.GetComputedStateVersionAsync(new DefaultProjectSnapshot(original)); var changed = WorkspaceProject.WithAssemblyName("Test1"); @@ -632,8 +673,50 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem Assert.NotEqual(original.Version, state.Version); Assert.Same(changed, state.WorkspaceProject); + var actualTagHelpers = await state.GetTagHelpersAsync(new DefaultProjectSnapshot(state)); + var actualComputedVersion = await state.GetComputedStateVersionAsync(new DefaultProjectSnapshot(state)); + + // The configuration didn't change, and the tag helpers didn't actually change Assert.Same(original.ProjectEngine, state.ProjectEngine); - Assert.NotSame(original.TagHelpers, state.TagHelpers); + Assert.Same(originalTagHelpers, actualTagHelpers); + Assert.Equal(originalComputedVersion, actualComputedVersion); + + Assert.NotSame(original.Documents[Documents[1].FilePath], state.Documents[Documents[1].FilePath]); + Assert.NotSame(original.Documents[Documents[2].FilePath], state.Documents[Documents[2].FilePath]); + } + + [Fact] + public async Task ProjectState_WithWorkspaceProject_Changed_TagHelpersChanged() + { + // Arrange + var original = ProjectState.Create(Workspace.Services, HostProject, WorkspaceProject) + .WithAddedHostDocument(Documents[2], DocumentState.EmptyLoader) + .WithAddedHostDocument(Documents[1], DocumentState.EmptyLoader); + + // Force init + var originalTagHelpers = await original.GetTagHelpersAsync(new DefaultProjectSnapshot(original)); + var originalComputedVersion = await original.GetComputedStateVersionAsync(new DefaultProjectSnapshot(original)); + + var changed = WorkspaceProject.WithAssemblyName("Test1"); + + // Now create some tag helpers + TagHelperResolver.TagHelpers = SomeTagHelpers; + + // Act + var state = original.WithWorkspaceProject(changed); + + // Assert + Assert.NotEqual(original.Version, state.Version); + Assert.Same(changed, state.WorkspaceProject); + + var actualTagHelpers = await state.GetTagHelpersAsync(new DefaultProjectSnapshot(state)); + var actualComputedVersion = await state.GetComputedStateVersionAsync(new DefaultProjectSnapshot(state)); + + // The configuration didn't change, but the tag helpers did + Assert.Same(original.ProjectEngine, state.ProjectEngine); + Assert.NotEqual(originalTagHelpers, actualTagHelpers); + Assert.NotEqual(originalComputedVersion, actualComputedVersion); + Assert.Equal(state.Version, actualComputedVersion); Assert.NotSame(original.Documents[Documents[1].FilePath], state.Documents[Documents[1].FilePath]); Assert.NotSame(original.Documents[Documents[2].FilePath], state.Documents[Documents[2].FilePath]); diff --git a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/Shared/TestTagHelperResolver.cs b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/Shared/TestTagHelperResolver.cs index bc623a7163..82fccb653b 100644 --- a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/Shared/TestTagHelperResolver.cs +++ b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/Shared/TestTagHelperResolver.cs @@ -15,7 +15,7 @@ namespace Microsoft.CodeAnalysis.Razor { public TaskCompletionSource CompletionSource { get; set; } - public IList TagHelpers { get; } = new List(); + public IList TagHelpers { get; set; } = new List(); public override Task GetTagHelpersAsync(ProjectSnapshot project, CancellationToken cancellationToken = default) { diff --git a/test/Microsoft.VisualStudio.Editor.Razor.Test/DefaultVisualStudioDocumentTrackerTest.cs b/test/Microsoft.VisualStudio.Editor.Razor.Test/DefaultVisualStudioDocumentTrackerTest.cs index 0a1051d62d..b5d63b1b34 100644 --- a/test/Microsoft.VisualStudio.Editor.Razor.Test/DefaultVisualStudioDocumentTrackerTest.cs +++ b/test/Microsoft.VisualStudio.Editor.Razor.Test/DefaultVisualStudioDocumentTrackerTest.cs @@ -19,7 +19,7 @@ using Xunit; namespace Microsoft.VisualStudio.Editor.Razor { - public class DefaultVisualStudioDocumentTrackerTest : ForegroundDispatcherTestBase + public class DefaultVisualStudioDocumentTrackerTest : ForegroundDispatcherWorkspaceTestBase { public DefaultVisualStudioDocumentTrackerTest() { @@ -32,27 +32,11 @@ namespace Microsoft.VisualStudio.Editor.Razor ImportDocumentManager = Mock.Of(); WorkspaceEditorSettings = new DefaultWorkspaceEditorSettings(Mock.Of(), Mock.Of()); - TagHelperResolver = new TestTagHelperResolver(); SomeTagHelpers = new List() { TagHelperDescriptorBuilder.Create("test", "test").Build(), }; - - HostServices = TestServices.Create( - new IWorkspaceService[] { }, - new ILanguageService[] { TagHelperResolver, }); - - Workspace = TestWorkspace.Create(HostServices, w => - { - WorkspaceProject = w.AddProject(ProjectInfo.Create( - ProjectId.CreateNewId(), - new VersionStamp(), - "Test1", - "TestAssembly", - LanguageNames.CSharp, - filePath: ProjectPath)); - }); - + ProjectManager = new TestProjectSnapshotManager(Dispatcher, Workspace) { AllowNotifyListeners = true }; HostProject = new HostProject(ProjectPath, FallbackRazorConfiguration.MVC_2_1); @@ -92,16 +76,29 @@ namespace Microsoft.VisualStudio.Editor.Razor private List SomeTagHelpers { get; } - private TestTagHelperResolver TagHelperResolver { get; } + private TestTagHelperResolver TagHelperResolver { get; set; } private ProjectSnapshotManagerBase ProjectManager { get; } - private HostServices HostServices { get; } - - private Workspace Workspace { get; } - private DefaultVisualStudioDocumentTracker DocumentTracker { get; } + protected override void ConfigureLanguageServices(List services) + { + TagHelperResolver = new TestTagHelperResolver(); + services.Add(TagHelperResolver); + } + + protected override void ConfigureWorkspace(AdhocWorkspace workspace) + { + WorkspaceProject = workspace.AddProject(ProjectInfo.Create( + ProjectId.CreateNewId(), + new VersionStamp(), + "Test1", + "TestAssembly", + LanguageNames.CSharp, + filePath: TestProjectData.SomeProject.FilePath)); + } + [ForegroundFact] public void Subscribe_NoopsIfAlreadySubscribed() { @@ -561,7 +558,7 @@ namespace Microsoft.VisualStudio.Editor.Razor await DocumentTracker.PendingTagHelperTask; // Assert - Assert.Same(DocumentTracker.TagHelpers, SomeTagHelpers); + Assert.Same(SomeTagHelpers, DocumentTracker.TagHelpers); Assert.Collection( args,