Skip to content
Merged
1 change: 1 addition & 0 deletions eng/config/PublishData.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"Microsoft.CodeAnalysis.Scripting.Common": "arcade",
"Microsoft.CodeAnalysis.Workspaces.Common": "vssdk",
"Microsoft.CodeAnalysis.Workspaces.MSBuild": "arcade",
"Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost": "arcade",
"Microsoft.CodeAnalysis.Workspaces.Desktop": "arcade",
"Microsoft.CodeAnalysis.Workspaces.Test.Utilities": "vs-impl",
"Microsoft.CodeAnalysis.Compiler.Test.Resources": "vs-impl",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

namespace Microsoft.CodeAnalysis.ExternalAccess.HotReload.Api;

internal enum HotReloadFileChangeKind
{
Update,
Add,
Delete
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

extern alias BuildHost;

using System;
using System.Collections.Immutable;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.ExternalAccess.HotReload.Internal;
using Microsoft.CodeAnalysis.MSBuild;

using MSB = Microsoft.Build;
using MSBuildHost = BuildHost::Microsoft.CodeAnalysis.MSBuild;

namespace Microsoft.CodeAnalysis.ExternalAccess.HotReload.Api;

internal sealed partial class HotReloadMSBuildWorkspace
{
private sealed class ProjectFileInfoProvider(
Func<string, (ImmutableArray<MSB.Execution.ProjectInstance> instances, MSB.Evaluation.Project? project)> getBuildProjects,
ProjectFileExtensionRegistry projectFileExtensionRegistry)
: IProjectFileInfoProvider
{
public Task<ImmutableArray<ProjectFileInfo>> LoadProjectFileInfosAsync(string projectPath, DiagnosticReportingOptions reportingOptions, CancellationToken cancellationToken)
{
var (instances, project) = getBuildProjects(projectPath);

if (instances.IsEmpty ||
!projectFileExtensionRegistry.TryGetLanguageNameFromProjectPath(projectPath, DiagnosticReportingMode.Ignore, out var languageName))
{
return Task.FromResult(ImmutableArray<ProjectFileInfo>.Empty);
}

return Task.FromResult(instances.SelectAsArray(instance =>
{
var reader = new MSBuildHost.ProjectInstanceReader(
MSBuildHost.ProjectCommandLineProvider.Create(languageName),
instance,
project);

return reader.CreateProjectFileInfo().Convert();
}));
}

public Task<ImmutableArray<string>> GetProjectOutputPathsAsync(string projectPath, CancellationToken cancellationToken)
=> Task.FromResult(
getBuildProjects(projectPath).instances.SelectAsArray(static instance => instance.GetPropertyValue(MSBuildHost.PropertyNames.TargetPath)));
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

extern alias BuildHost;

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.MSBuild;
using Microsoft.CodeAnalysis.Text;
using Microsoft.Extensions.Logging;

using MSB = Microsoft.Build;

namespace Microsoft.CodeAnalysis.ExternalAccess.HotReload.Api;

internal sealed partial class HotReloadMSBuildWorkspace : Workspace
{
private readonly ILogger _logger;
private readonly MSBuildProjectLoader _loader;
private readonly ProjectFileInfoProvider _projectGraphFileInfoProvider;

public HotReloadMSBuildWorkspace(ILogger logger, Func<string, (ImmutableArray<MSB.Execution.ProjectInstance> instances, MSB.Evaluation.Project? project)> getBuildProjects)
: base(MSBuildMefHostServices.DefaultServices, WorkspaceKind.MSBuild)
{
RegisterWorkspaceFailedHandler(args =>
{
// Report both Warning and Failure as warnings.
// MSBuildProjectLoader reports Failures for cases where we can safely continue loading projects
// (e.g. non-C#/VB project is ignored).
// https://github.com/dotnet/roslyn/issues/75170
logger.LogWarning($"msbuild: {args.Diagnostic}");
});

_logger = logger;
_loader = new MSBuildProjectLoader(this);
_projectGraphFileInfoProvider = new ProjectFileInfoProvider(getBuildProjects, _loader.ProjectFileExtensionRegistry);
}

public async ValueTask<Solution> UpdateProjectConeAsync(string projectPath, CancellationToken cancellationToken)
{
Contract.ThrowIfFalse(Path.IsPathFullyQualified(projectPath));

var oldSolution = CurrentSolution;

var projectMap = ProjectMap.Create();

var projectInfos = await _loader.LoadInfosAsync(
[projectPath],
_projectGraphFileInfoProvider,
projectMap,
progress: null,
cancellationToken).ConfigureAwait(false);

var oldProjectIdsByPath = oldSolution.Projects.ToDictionary(keySelector: static p => (p.FilePath!, p.Name), elementSelector: static p => p.Id);

// Map new project id to the corresponding old one based on file path and project name (includes TFM), if it exists, and null for added projects.
// Deleted projects won't be included in this map.
var projectIdMap = projectInfos.ToDictionary(
keySelector: static info => info.Id,
elementSelector: info => oldProjectIdsByPath.TryGetValue((info.FilePath!, info.Name), out var oldProjectId) ? oldProjectId : null);

var newSolution = oldSolution;

foreach (var newProjectInfo in projectInfos)
{
Contract.ThrowIfNull(newProjectInfo.FilePath);

var oldProjectId = projectIdMap[newProjectInfo.Id];
if (oldProjectId == null)
{
newSolution = newSolution.AddProject(newProjectInfo);
continue;
}

newSolution = newSolution.WithProjectInfo(ProjectInfo.Create(
oldProjectId,
newProjectInfo.Version,
newProjectInfo.Name,
newProjectInfo.AssemblyName,
newProjectInfo.Language,
newProjectInfo.FilePath,
newProjectInfo.OutputFilePath,
newProjectInfo.CompilationOptions,
newProjectInfo.ParseOptions,
MapDocuments(oldProjectId, newProjectInfo.Documents),
newProjectInfo.ProjectReferences.Select(MapProjectReference),
newProjectInfo.MetadataReferences,
newProjectInfo.AnalyzerReferences,
MapDocuments(oldProjectId, newProjectInfo.AdditionalDocuments),
isSubmission: false,
hostObjectType: null,
outputRefFilePath: newProjectInfo.OutputRefFilePath)
.WithAnalyzerConfigDocuments(MapDocuments(oldProjectId, newProjectInfo.AnalyzerConfigDocuments))
.WithCompilationOutputInfo(newProjectInfo.CompilationOutputInfo));
}

var result = SetCurrentSolution(newSolution);
UpdateReferencesAfterAdd();

return result;

ProjectReference MapProjectReference(ProjectReference pr)
{
// Only C# and VB projects are loaded by the MSBuildProjectLoader, so some references might be missing.
// When a new project is added along with a new project reference the old project id is also null.
return new(
projectId: projectIdMap.TryGetValue(pr.ProjectId, out var oldProjectId) && oldProjectId != null ? oldProjectId : pr.ProjectId,
aliases: pr.Aliases,
embedInteropTypes: pr.EmbedInteropTypes);
}

ImmutableArray<DocumentInfo> MapDocuments(ProjectId mappedProjectId, IReadOnlyList<DocumentInfo> documents)
=> documents.Select(docInfo =>
{
// TODO: can there be multiple documents of the same path in the project?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to document it somewhere, yes that can happen if a file is added twice. Or is added as both a source file and an additional file. None of these are "real" scenarios, but can happen in broken project scenarios. The only expectation is we don't crash. There might be a bug here since maybe if a source file is being added as an additional file we might try to reuse the same ID which would be bad in this case. Maybe file a bug for tracking, if we refactor this later this code might go away.


// Map to a document of the same path. If there isn't one (a new document is added to the project),
// create a new document id with the mapped project id.
var mappedDocumentId = oldSolution.GetDocumentIdsWithFilePath(docInfo.FilePath).FirstOrDefault(id => id.ProjectId == mappedProjectId)
?? DocumentId.CreateNewId(mappedProjectId);

return docInfo.WithId(mappedDocumentId);
}).ToImmutableArray();
}

public async ValueTask<Solution> UpdateFileContentAsync(IEnumerable<(string path, HotReloadFileChangeKind change)> changedFiles, CancellationToken cancellationToken)
{
var updatedSolution = CurrentSolution;

var documentsToRemove = new List<DocumentId>();

foreach (var (path, change) in changedFiles)
{
// when a file is added we reevaluate the project:
Contract.ThrowIfTrue(change == HotReloadFileChangeKind.Add);

var documentIds = updatedSolution.GetDocumentIdsWithFilePath(path);
if (change == HotReloadFileChangeKind.Delete)
{
documentsToRemove.AddRange(documentIds);
continue;
}

foreach (var documentId in documentIds)
{
var textDocument = updatedSolution.GetDocument(documentId)
?? updatedSolution.GetAdditionalDocument(documentId)
?? updatedSolution.GetAnalyzerConfigDocument(documentId);

if (textDocument == null)
{
_logger.LogDebug("Could not find document with path '{FilePath}' in the workspace.", path);
continue;
}

var project = updatedSolution.GetProject(documentId.ProjectId);
Debug.Assert(project?.FilePath != null);

var oldText = await textDocument.GetTextAsync(cancellationToken).ConfigureAwait(false);
Debug.Assert(oldText.Encoding != null);

var newText = await GetSourceTextAsync(path, oldText.Encoding, oldText.ChecksumAlgorithm, cancellationToken).ConfigureAwait(false);

_logger.LogDebug("Updating document text of '{FilePath}'.", path);

updatedSolution = textDocument switch
{
Document document => document.WithText(newText).Project.Solution,
AdditionalDocument ad => updatedSolution.WithAdditionalDocumentText(textDocument.Id, newText, PreservationMode.PreserveValue),
AnalyzerConfigDocument acd => updatedSolution.WithAnalyzerConfigDocumentText(textDocument.Id, newText, PreservationMode.PreserveValue),
_ => throw ExceptionUtilities.UnexpectedValue(textDocument),
};
}
}

updatedSolution = RemoveDocuments(updatedSolution, documentsToRemove);

return SetCurrentSolution(updatedSolution);
}

private static Solution RemoveDocuments(Solution solution, IEnumerable<DocumentId> ids)
=> solution
.RemoveDocuments([.. ids.Where(id => solution.GetDocument(id) != null)])
.RemoveAdditionalDocuments([.. ids.Where(id => solution.GetAdditionalDocument(id) != null)])
.RemoveAnalyzerConfigDocuments([.. ids.Where(id => solution.GetAnalyzerConfigDocument(id) != null)]);

private static async ValueTask<SourceText> GetSourceTextAsync(string filePath, Encoding encoding, SourceHashAlgorithm checksumAlgorithm, CancellationToken cancellationToken)
{
var zeroLengthRetryPerformed = false;
for (var attemptIndex = 0; attemptIndex < 6; attemptIndex++)
{
try
{
// File.OpenRead opens the file with FileShare.Read. This may prevent IDEs from saving file
// contents to disk
SourceText sourceText;
using (var stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
{
sourceText = SourceText.From(stream, encoding, checksumAlgorithm);
}

if (!zeroLengthRetryPerformed && sourceText.Length == 0)
{
zeroLengthRetryPerformed = true;

// VSCode (on Windows) will sometimes perform two separate writes when updating a file on disk.
// In the first update, it clears the file contents, and in the second, it writes the intended
// content.
// It's atypical that a file being watched for hot reload would be empty. We'll use this as a
// hueristic to identify this case and perform an additional retry reading the file after a delay.
await Task.Delay(20, cancellationToken).ConfigureAwait(false);

using var stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
sourceText = SourceText.From(stream, encoding, checksumAlgorithm);
}

return sourceText;
}
catch (IOException) when (attemptIndex < 5)
{
await Task.Delay(20 * (attemptIndex + 1), cancellationToken).ConfigureAwait(false);
}
}

throw ExceptionUtilities.Unreachable();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

extern alias BuildHost;
using Microsoft.CodeAnalysis.MSBuild;

namespace Microsoft.CodeAnalysis.ExternalAccess.HotReload.Internal;

internal static class ContractConversions
{
public static ProjectFileInfo Convert(this BuildHost::Microsoft.CodeAnalysis.MSBuild.ProjectFileInfo info)
=> new()
{
IsEmpty = info.IsEmpty,
Language = info.Language,
FilePath = info.FilePath,
OutputFilePath = info.OutputFilePath,
OutputRefFilePath = info.OutputRefFilePath,
IntermediateOutputFilePath = info.IntermediateOutputFilePath,
GeneratedFilesOutputDirectory = info.GeneratedFilesOutputDirectory,
DefaultNamespace = info.DefaultNamespace,
TargetFramework = info.TargetFramework,
TargetFrameworkIdentifier = info.TargetFrameworkIdentifier,
CommandLineArgs = info.CommandLineArgs,
Documents = info.Documents.SelectAsArray(Convert),
AdditionalDocuments = info.AdditionalDocuments.SelectAsArray(Convert),
AnalyzerConfigDocuments = info.AnalyzerConfigDocuments.SelectAsArray(Convert),
ProjectReferences = info.ProjectReferences.SelectAsArray(Convert),
ProjectCapabilities = info.ProjectCapabilities,
ContentFilePaths = info.ContentFilePaths,
ProjectAssetsFilePath = info.ProjectAssetsFilePath,
PackageReferences = info.PackageReferences.SelectAsArray(Convert),
TargetFrameworkVersion = info.TargetFrameworkVersion,
FileGlobs = info.FileGlobs.SelectAsArray(Convert),
};

public static DocumentFileInfo Convert(this BuildHost::Microsoft.CodeAnalysis.MSBuild.DocumentFileInfo info)
=> new(info.FilePath, info.LogicalPath, info.IsLinked, info.IsGenerated, info.Folders);

public static ProjectFileReference Convert(this BuildHost::Microsoft.CodeAnalysis.MSBuild.ProjectFileReference reference)
=> new(reference.Path, reference.Aliases, reference.ReferenceOutputAssembly);

public static PackageReference Convert(this BuildHost::Microsoft.CodeAnalysis.MSBuild.PackageReference reference)
=> new(reference.Name, reference.VersionRange);

public static FileGlobs Convert(this BuildHost::Microsoft.CodeAnalysis.MSBuild.FileGlobs globs)
=> new(globs.Includes, globs.Excludes, globs.Removes);
}
Loading