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,