diff --git a/src/SharpIDE.Application/Features/Analysis/CustomMsBuildProjectLoader.cs b/src/SharpIDE.Application/Features/Analysis/CustomMsBuildProjectLoader.cs deleted file mode 100644 index 731d6bb..0000000 --- a/src/SharpIDE.Application/Features/Analysis/CustomMsBuildProjectLoader.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Collections.Immutable; -using Microsoft.Build.Framework; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.MSBuild; - -namespace SharpIDE.Application.Features.Analysis; - -public class CustomMsBuildProjectLoader(Workspace workspace, ImmutableDictionary? properties = null) : MSBuildProjectLoader(workspace, properties) -{ - public async Task> LoadProjectInfosAsync( - List projectFilePaths, - ProjectMap? projectMap = null, - IProgress? progress = null, -#pragma warning disable IDE0060 // TODO: decide what to do with this unusued ILogger, since we can't reliabily use it if we're sending builds out of proc - ILogger? msbuildLogger = null, -#pragma warning restore IDE0060 - CancellationToken cancellationToken = default) - { - if (projectFilePaths.Count is 0) - { - throw new ArgumentException("At least one project file path must be specified.", nameof(projectFilePaths)); - } - - var requestedProjectOptions = DiagnosticReportingOptions.ThrowForAll; - - var reportingMode = GetReportingModeForUnrecognizedProjects(); - - var discoveredProjectOptions = new DiagnosticReportingOptions( - onPathFailure: reportingMode, - onLoaderFailure: reportingMode); - - var buildHostProcessManager = new BuildHostProcessManager(Properties, loggerFactory: _loggerFactory); - await using var _ = buildHostProcessManager.ConfigureAwait(false); - - var worker = new Worker( - _solutionServices, - _diagnosticReporter, - _pathResolver, - _projectFileExtensionRegistry, - buildHostProcessManager, - requestedProjectPaths: projectFilePaths.ToImmutableArray(), - baseDirectory: Directory.GetCurrentDirectory(), - projectMap, - progress, - requestedProjectOptions, - discoveredProjectOptions, - this.LoadMetadataForReferencedProjects); - - return await worker.LoadAsync(cancellationToken).ConfigureAwait(false); - } -} diff --git a/src/SharpIDE.Application/Features/Analysis/ProjectLoader/CustomMsBuildProjectLoader.Worker.AnalyzerReferencePathComparer.cs b/src/SharpIDE.Application/Features/Analysis/ProjectLoader/CustomMsBuildProjectLoader.Worker.AnalyzerReferencePathComparer.cs new file mode 100644 index 0000000..ea05b57 --- /dev/null +++ b/src/SharpIDE.Application/Features/Analysis/ProjectLoader/CustomMsBuildProjectLoader.Worker.AnalyzerReferencePathComparer.cs @@ -0,0 +1,26 @@ +// 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. + +using Microsoft.CodeAnalysis.Diagnostics; + +namespace SharpIDE.Application.Features.Analysis.ProjectLoader; + +public partial class CustomMsBuildProjectLoader +{ + private sealed partial class CustomWorker + { + private sealed class AnalyzerReferencePathComparer : IEqualityComparer + { + public static AnalyzerReferencePathComparer Instance = new(); + + private AnalyzerReferencePathComparer() { } + + public bool Equals(AnalyzerReference? x, AnalyzerReference? y) + => string.Equals(x?.FullPath, y?.FullPath, StringComparison.OrdinalIgnoreCase); + + public int GetHashCode(AnalyzerReference? obj) + => obj?.FullPath?.GetHashCode() ?? 0; + } + } +} diff --git a/src/SharpIDE.Application/Features/Analysis/ProjectLoader/CustomMsBuildProjectLoader.Worker.cs b/src/SharpIDE.Application/Features/Analysis/ProjectLoader/CustomMsBuildProjectLoader.Worker.cs new file mode 100644 index 0000000..11a01e2 --- /dev/null +++ b/src/SharpIDE.Application/Features/Analysis/ProjectLoader/CustomMsBuildProjectLoader.Worker.cs @@ -0,0 +1,496 @@ +using System.Collections.Immutable; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.MSBuild; +using Microsoft.CodeAnalysis.Text; +using Roslyn.Utilities; + +namespace SharpIDE.Application.Features.Analysis.ProjectLoader; + +public partial class CustomMsBuildProjectLoader +{ + private sealed partial class CustomWorker + { + private readonly SolutionServices _solutionServices; + private readonly DiagnosticReporter _diagnosticReporter; + private readonly PathResolver _pathResolver; + private readonly ProjectFileExtensionRegistry _projectFileExtensionRegistry; + private readonly BuildHostProcessManager _buildHostProcessManager; + private readonly string _baseDirectory; + + /// + /// An ordered list of paths to project files that should be loaded. In the case of a solution, + /// this is the list of project file paths in the solution. + /// + private readonly ImmutableArray _requestedProjectPaths; + + /// + /// Map of s, project paths, and output file paths. + /// + private readonly ProjectMap _projectMap; + + /// + /// Progress reporter. + /// + private readonly IProgress? _progress; + + /// + /// Provides options for how failures should be reported when loading requested project files. + /// + private readonly DiagnosticReportingOptions _requestedProjectOptions; + + /// + /// Provides options for how failures should be reported when loading any discovered project files. + /// + private readonly DiagnosticReportingOptions _discoveredProjectOptions; + + /// + /// When true, metadata is preferred for any project reference unless the referenced project is already loaded + /// because it was requested. + /// + private readonly bool _preferMetadataForReferencesOfDiscoveredProjects; + private readonly Dictionary _projectIdToFileInfoMap; + private readonly Dictionary> _projectIdToProjectReferencesMap; + private readonly Dictionary> _pathToDiscoveredProjectInfosMap; + + public CustomWorker( + SolutionServices services, + DiagnosticReporter diagnosticReporter, + PathResolver pathResolver, + ProjectFileExtensionRegistry projectFileExtensionRegistry, + BuildHostProcessManager buildHostProcessManager, + ImmutableArray requestedProjectPaths, + string baseDirectory, + ProjectMap? projectMap, + IProgress? progress, + DiagnosticReportingOptions requestedProjectOptions, + DiagnosticReportingOptions discoveredProjectOptions, + bool preferMetadataForReferencesOfDiscoveredProjects) + { + _solutionServices = services; + _diagnosticReporter = diagnosticReporter; + _pathResolver = pathResolver; + _projectFileExtensionRegistry = projectFileExtensionRegistry; + _buildHostProcessManager = buildHostProcessManager; + _baseDirectory = baseDirectory; + _requestedProjectPaths = requestedProjectPaths; + _projectMap = projectMap ?? ProjectMap.Create(); + _progress = progress; + _requestedProjectOptions = requestedProjectOptions; + _discoveredProjectOptions = discoveredProjectOptions; + _preferMetadataForReferencesOfDiscoveredProjects = preferMetadataForReferencesOfDiscoveredProjects; + _projectIdToFileInfoMap = []; + _pathToDiscoveredProjectInfosMap = new Dictionary>(PathUtilities.Comparer); + _projectIdToProjectReferencesMap = []; + } + + private async Task DoOperationAndReportProgressAsync(ProjectLoadOperation operation, string? projectPath, string? targetFramework, Func> doFunc) + { + var watch = _progress != null + ? Stopwatch.StartNew() + : null; + + TResult result; + try + { + result = await doFunc().ConfigureAwait(false); + } + finally + { + if (_progress != null && watch != null) + { + watch.Stop(); + _progress.Report(new ProjectLoadProgress(projectPath ?? string.Empty, operation, targetFramework, watch.Elapsed)); + } + } + + return result; + } + + public async Task> LoadAsync(CancellationToken cancellationToken) + { + var results = ImmutableArray.CreateBuilder(); + var processedPaths = new HashSet(PathUtilities.Comparer); + + foreach (var projectPath in _requestedProjectPaths) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!_pathResolver.TryGetAbsoluteProjectPath(projectPath, _baseDirectory, _requestedProjectOptions.OnPathFailure, out var absoluteProjectPath)) + { + continue; // Failure should already be reported. + } + + if (!processedPaths.Add(absoluteProjectPath)) + { + _diagnosticReporter.Report( + new WorkspaceDiagnostic( + WorkspaceDiagnosticKind.Warning, + string.Format(WorkspaceMSBuildResources.Duplicate_project_discovered_and_skipped_0, absoluteProjectPath))); + + continue; + } + + var projectFileInfos = await LoadProjectInfosFromPathAsync(absoluteProjectPath, _requestedProjectOptions, cancellationToken).ConfigureAwait(false); + + results.AddRange(projectFileInfos); + } + + foreach (var (projectPath, projectInfos) in _pathToDiscoveredProjectInfosMap) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!processedPaths.Contains(projectPath)) + { + results.AddRange(projectInfos); + } + } + + return results.ToImmutableAndClear(); + } + + private async Task> LoadProjectFileInfosAsync(string projectPath, DiagnosticReportingOptions reportingOptions, CancellationToken cancellationToken) + { + if (!_projectFileExtensionRegistry.TryGetLanguageNameFromProjectPath(projectPath, reportingOptions.OnLoaderFailure, out var languageName)) + { + return []; // Failure should already be reported. + } + + var preferredBuildHostKind = BuildHostProcessManager.GetKindForProject(projectPath); + var (buildHost, actualBuildHostKind) = await _buildHostProcessManager.GetBuildHostWithFallbackAsync(preferredBuildHostKind, projectPath, cancellationToken).ConfigureAwait(false); + var projectFile = await DoOperationAndReportProgressAsync( + ProjectLoadOperation.Evaluate, + projectPath, + targetFramework: null, + () => buildHost.LoadProjectFileAsync(projectPath, languageName, cancellationToken) + ).ConfigureAwait(false); + + // If there were any failures during load, we won't be able to build the project. So, bail early with an empty project. + var diagnosticItems = await projectFile.GetDiagnosticLogItemsAsync(cancellationToken).ConfigureAwait(false); + if (diagnosticItems.Any(d => d.Kind == DiagnosticLogItemKind.Error)) + { + _diagnosticReporter.Report(diagnosticItems); + + return [ProjectFileInfo.CreateEmpty(languageName, projectPath)]; + } + + var projectFileInfos = await DoOperationAndReportProgressAsync( + ProjectLoadOperation.Build, + projectPath, + targetFramework: null, + () => projectFile.GetProjectFileInfosAsync(cancellationToken) + ).ConfigureAwait(false); + + var results = ImmutableArray.CreateBuilder(projectFileInfos.Length); + + foreach (var projectFileInfo in projectFileInfos) + { + // Note: any diagnostics would have been logged to the original project file's log. + + results.Add(projectFileInfo); + } + + // We'll go check for any further diagnostics and report them + diagnosticItems = await projectFile.GetDiagnosticLogItemsAsync(cancellationToken).ConfigureAwait(false); + _diagnosticReporter.Report(diagnosticItems); + + return results.MoveToImmutable(); + } + + private async Task> LoadProjectInfosFromPathAsync( + string projectPath, DiagnosticReportingOptions reportingOptions, CancellationToken cancellationToken) + { + if (_projectMap.TryGetProjectInfosByProjectPath(projectPath, out var results) || + _pathToDiscoveredProjectInfosMap.TryGetValue(projectPath, out results)) + { + return results; + } + + var builder = ImmutableArray.CreateBuilder(); + + var projectFileInfos = await LoadProjectFileInfosAsync(projectPath, reportingOptions, cancellationToken).ConfigureAwait(false); + + var idsAndFileInfos = new List<(ProjectId id, ProjectFileInfo fileInfo)>(); + + foreach (var projectFileInfo in projectFileInfos) + { + var projectId = _projectMap.GetOrCreateProjectId(projectFileInfo); + + if (_projectIdToFileInfoMap.ContainsKey(projectId)) + { + // There are multiple projects with the same project path and output path. This can happen + // if a multi-TFM project does not have unique output file paths for each TFM. In that case, + // we'll create a new ProjectId to ensure that the project is added to the workspace. + + _diagnosticReporter.Report( + DiagnosticReportingMode.Log, + string.Format(WorkspaceMSBuildResources.Found_project_with_the_same_file_path_and_output_path_as_another_project_0, projectFileInfo.FilePath)); + + projectId = ProjectId.CreateNewId(debugName: projectFileInfo.FilePath); + } + + idsAndFileInfos.Add((projectId, projectFileInfo)); + _projectIdToFileInfoMap.Add(projectId, projectFileInfo); + } + + // If this project resulted in more than a single project, a discriminator (e.g. TFM) should be + // added to the project name. + var addDiscriminator = idsAndFileInfos.Count > 1; + + foreach (var (id, fileInfo) in idsAndFileInfos) + { + var projectInfo = await CreateProjectInfoAsync(fileInfo, id, addDiscriminator, cancellationToken).ConfigureAwait(false); + + builder.Add(projectInfo); + _projectMap.AddProjectInfo(projectInfo); + } + + results = builder.ToImmutable(); + + _pathToDiscoveredProjectInfosMap.Add(projectPath, results); + + return results; + } + + private Task CreateProjectInfoAsync(ProjectFileInfo projectFileInfo, ProjectId projectId, bool addDiscriminator, CancellationToken cancellationToken) + { + var language = projectFileInfo.Language; + var projectPath = projectFileInfo.FilePath; + var projectName = Path.GetFileNameWithoutExtension(projectPath) ?? string.Empty; + if (addDiscriminator && !RoslynString.IsNullOrWhiteSpace(projectFileInfo.TargetFramework)) + { + projectName += "(" + projectFileInfo.TargetFramework + ")"; + } + + var version = projectPath is null + ? VersionStamp.Default + : VersionStamp.Create(FileUtilities.GetFileTimeStamp(projectPath)); + + if (projectFileInfo.IsEmpty) + { + var assemblyName = GetAssemblyNameFromProjectPath(projectPath); + + var parseOptions = GetLanguageService(language) + ?.GetDefaultParseOptions(); + var compilationOptions = GetLanguageService(language) + ?.GetDefaultCompilationOptions(); + + return Task.FromResult( + ProjectInfo.Create( + new ProjectInfo.ProjectAttributes( + projectId, + version, + name: projectName, + assemblyName: assemblyName, + language: language, + compilationOutputInfo: new CompilationOutputInfo(projectFileInfo.IntermediateOutputFilePath, projectFileInfo.GeneratedFilesOutputDirectory), + checksumAlgorithm: SourceHashAlgorithms.Default, + outputFilePath: projectFileInfo.OutputFilePath, + outputRefFilePath: projectFileInfo.OutputRefFilePath, + filePath: projectPath), + compilationOptions: compilationOptions, + parseOptions: parseOptions)); + } + + return DoOperationAndReportProgressAsync(ProjectLoadOperation.Resolve, projectPath, projectFileInfo.TargetFramework, async () => + { + var projectDirectory = Path.GetDirectoryName(projectPath); + + // parse command line arguments + var commandLineParser = GetLanguageService(projectFileInfo.Language); + + if (commandLineParser is null) + { + var message = string.Format(WorkspaceMSBuildResources.Unable_to_find_a_0_for_1, nameof(ICommandLineParserService), projectFileInfo.Language); + throw new Exception(message); + } + + var commandLineArgs = commandLineParser.Parse( + arguments: projectFileInfo.CommandLineArgs, + baseDirectory: projectDirectory, + isInteractive: false, + sdkDirectory: RuntimeEnvironment.GetRuntimeDirectory()); + + var assemblyName = commandLineArgs.CompilationName; + if (RoslynString.IsNullOrWhiteSpace(assemblyName)) + { + // if there isn't an assembly name, make one from the file path. + // Note: This may not be necessary any longer if the command line args + // always produce a valid compilation name. + assemblyName = GetAssemblyNameFromProjectPath(projectPath); + } + + // Ensure sure that doc-comments are parsed + var parseOptions = commandLineArgs.ParseOptions; + if (parseOptions.DocumentationMode == DocumentationMode.None) + { + parseOptions = parseOptions.WithDocumentationMode(DocumentationMode.Parse); + } + + // add all the extra options that are really behavior overrides + var metadataService = GetWorkspaceService(); + var compilationOptions = commandLineArgs.CompilationOptions + .WithXmlReferenceResolver(new XmlFileResolver(projectDirectory)) + .WithSourceReferenceResolver(new SourceFileResolver([], projectDirectory)) + // TODO: https://github.com/dotnet/roslyn/issues/4967 + .WithMetadataReferenceResolver(new WorkspaceMetadataFileReferenceResolver(metadataService, new RelativePathResolver([], projectDirectory))) + .WithStrongNameProvider(new DesktopStrongNameProvider(commandLineArgs.KeyFileSearchPaths, Path.GetTempPath())) + .WithAssemblyIdentityComparer(DesktopAssemblyIdentityComparer.Default); + + var documents = CreateDocumentInfos(projectFileInfo.Documents, projectId, commandLineArgs.Encoding); + var additionalDocuments = CreateDocumentInfos(projectFileInfo.AdditionalDocuments, projectId, commandLineArgs.Encoding); + var analyzerConfigDocuments = CreateDocumentInfos(projectFileInfo.AnalyzerConfigDocuments, projectId, commandLineArgs.Encoding); + CheckForDuplicateDocuments(documents.Concat(additionalDocuments).Concat(analyzerConfigDocuments), projectPath, projectId); + + var analyzerReferences = ResolveAnalyzerReferences(commandLineArgs); + + var resolvedReferences = await ResolveReferencesAsync(projectId, projectFileInfo, commandLineArgs, cancellationToken).ConfigureAwait(false); + + return ProjectInfo.Create( + new ProjectInfo.ProjectAttributes( + projectId, + version, + projectName, + assemblyName, + language, + compilationOutputInfo: new CompilationOutputInfo(projectFileInfo.IntermediateOutputFilePath, projectFileInfo.GeneratedFilesOutputDirectory), + checksumAlgorithm: commandLineArgs.ChecksumAlgorithm, + filePath: projectPath, + outputFilePath: projectFileInfo.OutputFilePath, + outputRefFilePath: projectFileInfo.OutputRefFilePath, + isSubmission: false), + compilationOptions: compilationOptions, + parseOptions: parseOptions, + documents: documents, + projectReferences: resolvedReferences.ProjectReferences, + metadataReferences: resolvedReferences.MetadataReferences, + analyzerReferences: analyzerReferences, + additionalDocuments: additionalDocuments, + hostObjectType: null) + .WithDefaultNamespace(projectFileInfo.DefaultNamespace) + .WithAnalyzerConfigDocuments(analyzerConfigDocuments); + }); + } + + private static string GetAssemblyNameFromProjectPath(string? projectFilePath) + { + var assemblyName = Path.GetFileNameWithoutExtension(projectFilePath); + + // if this is still unreasonable, use a fixed name. + if (RoslynString.IsNullOrWhiteSpace(assemblyName)) + { + assemblyName = "assembly"; + } + + return assemblyName; + } + + private IEnumerable ResolveAnalyzerReferences(CommandLineArguments commandLineArgs) + { + // The one line that is changed from the original Roslyn code: + var analyzerAssemblyLoaderProvider = GetWorkspaceService(); + if (analyzerAssemblyLoaderProvider is null) + { + var message = string.Format(WorkspaceMSBuildResources.Unable_to_find_0, nameof(IAnalyzerService)); + throw new Exception(message); + } + + var analyzerLoader = analyzerAssemblyLoaderProvider.SharedShadowCopyLoader; + + foreach (var path in commandLineArgs.AnalyzerReferences.Select(r => r.FilePath)) + { + string? fullPath; + + if (PathUtilities.IsAbsolute(path)) + { + fullPath = FileUtilities.TryNormalizeAbsolutePath(path); + + if (fullPath != null && File.Exists(fullPath)) + { + analyzerLoader.AddDependencyLocation(fullPath); + } + } + } + + return commandLineArgs.ResolveAnalyzerReferences(analyzerLoader).Distinct(Microsoft.CodeAnalysis.MSBuild.MSBuildProjectLoader.Worker.AnalyzerReferencePathComparer.Instance); + } + + private ImmutableArray CreateDocumentInfos(IReadOnlyList documentFileInfos, ProjectId projectId, Encoding? encoding) + { + var results = new FixedSizeArrayBuilder(documentFileInfos.Count); + + foreach (var info in documentFileInfos) + { + GetDocumentNameAndFolders(info.LogicalPath, out var name, out var folders); + + var documentInfo = DocumentInfo.Create( + DocumentId.CreateNewId(projectId, debugName: info.FilePath), + name, + folders, + SourceCodeKind.Regular, + new WorkspaceFileTextLoader(_solutionServices, info.FilePath, encoding), + info.FilePath, + info.IsGenerated); + + results.Add(documentInfo); + } + + return results.MoveToImmutable(); + } + + private static readonly char[] s_directorySplitChars = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]; + + private static void GetDocumentNameAndFolders(string logicalPath, out string name, out ImmutableArray folders) + { + var pathNames = logicalPath.Split(s_directorySplitChars, StringSplitOptions.RemoveEmptyEntries); + if (pathNames.Length > 0) + { + folders = pathNames.Length > 1 + ? [.. pathNames.Take(pathNames.Length - 1)] + : []; + + name = pathNames[^1]; + } + else + { + name = logicalPath; + folders = []; + } + } + + private void CheckForDuplicateDocuments(ImmutableArray documents, string? projectFilePath, ProjectId projectId) + { + var paths = new HashSet(PathUtilities.Comparer); + foreach (var doc in documents) + { + if (doc.FilePath is null) + continue; + + if (paths.Contains(doc.FilePath)) + { + var message = string.Format(WorkspacesResources.Duplicate_source_file_0_in_project_1, doc.FilePath, projectFilePath); + var diagnostic = new ProjectDiagnostic(WorkspaceDiagnosticKind.Warning, message, projectId); + + _diagnosticReporter.Report(diagnostic); + } + + paths.Add(doc.FilePath); + } + } + + private TLanguageService? GetLanguageService(string languageName) + where TLanguageService : ILanguageService + => _solutionServices + .GetLanguageServices(languageName) + .GetService(); + + private TWorkspaceService? GetWorkspaceService() + where TWorkspaceService : IWorkspaceService + => _solutionServices + .GetService(); + } +} diff --git a/src/SharpIDE.Application/Features/Analysis/ProjectLoader/CustomMsBuildProjectLoader.Worker_ResolveReferences.cs b/src/SharpIDE.Application/Features/Analysis/ProjectLoader/CustomMsBuildProjectLoader.Worker_ResolveReferences.cs new file mode 100644 index 0000000..84e8f05 --- /dev/null +++ b/src/SharpIDE.Application/Features/Analysis/ProjectLoader/CustomMsBuildProjectLoader.Worker_ResolveReferences.cs @@ -0,0 +1,408 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.MSBuild; +using Microsoft.CodeAnalysis.PooledObjects; +using Roslyn.Utilities; + +namespace SharpIDE.Application.Features.Analysis.ProjectLoader; + +public partial class CustomMsBuildProjectLoader +{ + private sealed partial class CustomWorker + { + private readonly struct ResolvedReferences + { + public ImmutableHashSet ProjectReferences { get; } + public ImmutableArray MetadataReferences { get; } + + public ResolvedReferences(ImmutableHashSet projectReferences, ImmutableArray metadataReferences) + { + ProjectReferences = projectReferences; + MetadataReferences = metadataReferences; + } + } + + /// + /// This type helps produces lists of metadata and project references. Initially, it contains a list of metadata references. + /// As project references are added, the metadata references that match those project references are removed. + /// + private sealed class ResolvedReferencesBuilder + { + /// + /// The full list of s. + /// + private readonly ImmutableArray _metadataReferences; + + /// + /// A map of every metadata reference file paths to a set of indices whether than file path + /// exists in the list. It is expected that there may be multiple metadata references for the + /// same file path in the case where multiple extern aliases are provided. + /// + private readonly ImmutableDictionary> _pathToIndicesMap; + + /// + /// A set of indices into that are to be removed. + /// + private readonly HashSet _indicesToRemove; + + private readonly ImmutableHashSet.Builder _projectReferences; + + public ResolvedReferencesBuilder(IEnumerable metadataReferences) + { + _metadataReferences = [.. metadataReferences]; + _pathToIndicesMap = CreatePathToIndexMap(_metadataReferences); + _indicesToRemove = []; + _projectReferences = ImmutableHashSet.CreateBuilder(); + } + + private static ImmutableDictionary> CreatePathToIndexMap(ImmutableArray metadataReferences) + { + var builder = ImmutableDictionary.CreateBuilder>(PathUtilities.Comparer); + + for (var index = 0; index < metadataReferences.Length; index++) + { + var filePath = GetFilePath(metadataReferences[index]); + if (filePath != null) + { + builder.MultiAdd(filePath, index); + } + } + + return builder.ToImmutable(); + } + + private static string? GetFilePath(MetadataReference metadataReference) + { + return metadataReference switch + { + PortableExecutableReference portableExecutableReference => portableExecutableReference.FilePath, + UnresolvedMetadataReference unresolvedMetadataReference => unresolvedMetadataReference.Reference, + _ => null, + }; + } + + public void AddProjectReference(ProjectReference projectReference) + { + _projectReferences.Add(projectReference); + } + + public void SwapMetadataReferenceForProjectReference(ProjectReference projectReference, params string?[] possibleMetadataReferencePaths) + { + foreach (var path in possibleMetadataReferencePaths) + { + if (path != null) + { + Remove(path); + } + } + + AddProjectReference(projectReference); + } + + /// + /// Returns true if a metadata reference with the given file path is contained within this list. + /// + public bool Contains(string? filePath) + => filePath != null + && _pathToIndicesMap.ContainsKey(filePath); + + /// + /// Removes the metadata reference with the given file path from this list. + /// + public void Remove(string filePath) + { + if (filePath != null && _pathToIndicesMap.TryGetValue(filePath, out var indices)) + { + _indicesToRemove.AddRange(indices); + } + } + + public ProjectInfo? SelectProjectInfoByOutput(IEnumerable projectInfos) + { + foreach (var projectInfo in projectInfos) + { + var outputFilePath = projectInfo.OutputFilePath; + var outputRefFilePath = projectInfo.OutputRefFilePath; + if (outputFilePath != null && + outputRefFilePath != null && + (Contains(outputFilePath) || Contains(outputRefFilePath))) + { + return projectInfo; + } + } + + return null; + } + + public ImmutableArray GetUnresolvedMetadataReferences() + { + var builder = ImmutableArray.CreateBuilder(); + + foreach (var metadataReference in GetMetadataReferences()) + { + if (metadataReference is UnresolvedMetadataReference unresolvedMetadataReference) + { + builder.Add(unresolvedMetadataReference); + } + } + + return builder.ToImmutableAndClear(); + } + + private ImmutableArray GetMetadataReferences() + { + var builder = ImmutableArray.CreateBuilder(); + + // used to eliminate duplicates + var _ = PooledHashSet.GetInstance(out var set); + + for (var index = 0; index < _metadataReferences.Length; index++) + { + var reference = _metadataReferences[index]; + if (!_indicesToRemove.Contains(index) && set.Add(reference)) + { + builder.Add(reference); + } + } + + return builder.ToImmutableAndClear(); + } + + private ImmutableHashSet GetProjectReferences() + => _projectReferences.ToImmutable(); + + public ResolvedReferences ToResolvedReferences() + => new(GetProjectReferences(), GetMetadataReferences()); + } + + private async Task ResolveReferencesAsync(ProjectId id, ProjectFileInfo projectFileInfo, CommandLineArguments commandLineArgs, CancellationToken cancellationToken) + { + // First, gather all of the metadata references from the command-line arguments. + var resolvedMetadataReferences = commandLineArgs.ResolveMetadataReferences( + new WorkspaceMetadataFileReferenceResolver( + metadataService: _solutionServices.GetRequiredService(), + pathResolver: new RelativePathResolver(commandLineArgs.ReferencePaths, commandLineArgs.BaseDirectory))); + + var builder = new ResolvedReferencesBuilder(resolvedMetadataReferences); + + var projectDirectory = Path.GetDirectoryName(projectFileInfo.FilePath); + RoslynDebug.AssertNotNull(projectDirectory); + + // Next, iterate through all project references in the file and create project references. + foreach (var projectFileReference in projectFileInfo.ProjectReferences) + { + var aliases = projectFileReference.Aliases; + + if (_pathResolver.TryGetAbsoluteProjectPath(projectFileReference.Path, baseDirectory: projectDirectory, _discoveredProjectOptions.OnPathFailure, out var projectReferencePath)) + { + // The easiest case is to add a reference to a project we already know about. + if (TryAddReferenceToKnownProject(id, projectReferencePath, aliases, builder)) + { + continue; + } + + if (projectFileReference.ReferenceOutputAssembly) + { + // If we don't know how to load a project (that is, it's not a language we support), we can still + // attempt to verify that its output exists on disk and is included in our set of metadata references. + // If it is, we'll just leave it in place. + if (!IsProjectLoadable(projectReferencePath) && + await VerifyUnloadableProjectOutputExistsAsync(projectReferencePath, builder, cancellationToken).ConfigureAwait(false)) + { + continue; + } + + // If metadata is preferred, see if the project reference's output exists on disk and is included + // in our metadata references. If it is, don't create a project reference; we'll just use the metadata. + if (_preferMetadataForReferencesOfDiscoveredProjects && + await VerifyProjectOutputExistsAsync(projectReferencePath, builder, cancellationToken).ConfigureAwait(false)) + { + continue; + } + + // Finally, we'll try to load and reference the project. + if (await TryLoadAndAddReferenceAsync(id, projectReferencePath, aliases, builder, cancellationToken).ConfigureAwait(false)) + { + continue; + } + } + else + { + // Load the project but do not add a reference: + _ = await LoadProjectInfosFromPathAsync(projectReferencePath, _discoveredProjectOptions, cancellationToken).ConfigureAwait(false); + continue; + } + } + + // We weren't able to handle this project reference, so add it without further processing. + var unknownProjectId = _projectMap.GetOrCreateProjectId(projectFileReference.Path); + var newProjectReference = CreateProjectReference(from: id, to: unknownProjectId, aliases); + builder.AddProjectReference(newProjectReference); + } + + // Are there still any unresolved metadata references? If so, remove them and report diagnostics. + foreach (var unresolvedMetadataReference in builder.GetUnresolvedMetadataReferences()) + { + var filePath = unresolvedMetadataReference.Reference; + + builder.Remove(filePath); + + _diagnosticReporter.Report(new ProjectDiagnostic( + WorkspaceDiagnosticKind.Warning, + string.Format(WorkspaceMSBuildResources.Unresolved_metadata_reference_removed_from_project_0, filePath), + id)); + } + + return builder.ToResolvedReferences(); + } + + private async Task TryLoadAndAddReferenceAsync(ProjectId id, string projectReferencePath, ImmutableArray aliases, ResolvedReferencesBuilder builder, CancellationToken cancellationToken) + { + var projectReferenceInfos = await LoadProjectInfosFromPathAsync(projectReferencePath, _discoveredProjectOptions, cancellationToken).ConfigureAwait(false); + + if (projectReferenceInfos.IsEmpty) + { + return false; + } + + // Find the project reference info whose output we have a metadata reference for. + ProjectInfo? projectReferenceInfo = null; + foreach (var info in projectReferenceInfos) + { + var outputFilePath = info.OutputFilePath; + var outputRefFilePath = info.OutputRefFilePath; + if (outputFilePath != null && + outputRefFilePath != null && + (builder.Contains(outputFilePath) || builder.Contains(outputRefFilePath))) + { + projectReferenceInfo = info; + break; + } + } + + if (projectReferenceInfo is null) + { + // We didn't find the project reference info that matches any of our metadata references. + // In this case, we'll go ahead and use the first project reference info that was found, + // but report a warning because this likely means that either a metadata reference path + // or a project output path is incorrect. + + projectReferenceInfo = projectReferenceInfos[0]; + + _diagnosticReporter.Report(new ProjectDiagnostic( + WorkspaceDiagnosticKind.Warning, + string.Format(WorkspaceMSBuildResources.Found_project_reference_without_a_matching_metadata_reference_0, projectReferencePath), + id)); + } + + if (!ProjectReferenceExists(to: id, from: projectReferenceInfo)) + { + var newProjectReference = CreateProjectReference(from: id, to: projectReferenceInfo.Id, aliases); + builder.SwapMetadataReferenceForProjectReference(newProjectReference, projectReferenceInfo.OutputRefFilePath, projectReferenceInfo.OutputFilePath); + } + else + { + // This project already has a reference on us. Don't introduce a circularity by referencing it. + // However, if the project's output doesn't exist on disk, we need to remove from our list of + // metadata references to avoid failures later. Essentially, the concern here is that the metadata + // reference is an UnresolvedMetadataReference, which will throw when we try to create a + // Compilation with it. + + var outputRefFilePath = projectReferenceInfo.OutputRefFilePath; + if (outputRefFilePath != null && !File.Exists(outputRefFilePath)) + { + builder.Remove(outputRefFilePath); + } + + var outputFilePath = projectReferenceInfo.OutputFilePath; + if (outputFilePath != null && !File.Exists(outputFilePath)) + { + builder.Remove(outputFilePath); + } + } + + // Note that we return true even if we don't actually add a reference due to a circularity because, + // in that case, we've still handled everything. + return true; + } + + private bool IsProjectLoadable(string projectPath) + => _projectFileExtensionRegistry.TryGetLanguageNameFromProjectPath(projectPath, DiagnosticReportingMode.Ignore, out _); + + private async Task VerifyUnloadableProjectOutputExistsAsync(string projectPath, ResolvedReferencesBuilder builder, CancellationToken cancellationToken) + { + var buildHost = await _buildHostProcessManager.GetBuildHostWithFallbackAsync(projectPath, cancellationToken).ConfigureAwait(false); + var outputFilePath = await buildHost.TryGetProjectOutputPathAsync(projectPath, cancellationToken).ConfigureAwait(false); + return outputFilePath != null + && builder.Contains(outputFilePath) + && File.Exists(outputFilePath); + } + + private async Task VerifyProjectOutputExistsAsync(string projectPath, ResolvedReferencesBuilder builder, CancellationToken cancellationToken) + { + // Note: Load the project, but don't report failures. + var projectFileInfos = await LoadProjectFileInfosAsync(projectPath, DiagnosticReportingOptions.IgnoreAll, cancellationToken).ConfigureAwait(false); + + foreach (var projectFileInfo in projectFileInfos) + { + var outputFilePath = projectFileInfo.OutputFilePath; + var outputRefFilePath = projectFileInfo.OutputRefFilePath; + + if ((builder.Contains(outputFilePath) && File.Exists(outputFilePath)) || + (builder.Contains(outputRefFilePath) && File.Exists(outputRefFilePath))) + { + return true; + } + } + + return false; + } + + private ProjectReference CreateProjectReference(ProjectId from, ProjectId to, ImmutableArray aliases) + { + var newReference = new ProjectReference(to, aliases); + _projectIdToProjectReferencesMap.MultiAdd(from, newReference); + return newReference; + } + + private bool ProjectReferenceExists(ProjectId to, ProjectId from) + => _projectIdToProjectReferencesMap.TryGetValue(from, out var references) + && references.Any(pr => pr.ProjectId == to); + + private static bool ProjectReferenceExists(ProjectId to, ProjectInfo from) + => from.ProjectReferences.Any(pr => pr.ProjectId == to); + + private bool TryAddReferenceToKnownProject( + ProjectId id, + string projectReferencePath, + ImmutableArray aliases, + ResolvedReferencesBuilder builder) + { + if (_projectMap.TryGetIdsByProjectPath(projectReferencePath, out var projectReferenceIds)) + { + foreach (var projectReferenceId in projectReferenceIds) + { + // Don't add a reference if the project already has a reference on us. Otherwise, it will cause a circularity. + if (ProjectReferenceExists(to: id, from: projectReferenceId)) + { + return false; + } + + var outputRefFilePath = _projectMap.GetOutputRefFilePathById(projectReferenceId); + var outputFilePath = _projectMap.GetOutputFilePathById(projectReferenceId); + + if (builder.Contains(outputRefFilePath) || + builder.Contains(outputFilePath)) + { + var newProjectReference = CreateProjectReference(from: id, to: projectReferenceId, aliases); + builder.SwapMetadataReferenceForProjectReference(newProjectReference, outputRefFilePath, outputFilePath); + return true; + } + } + } + + return false; + } + } +} diff --git a/src/SharpIDE.Application/Features/Analysis/ProjectLoader/CustomMsBuildProjectLoader.cs b/src/SharpIDE.Application/Features/Analysis/ProjectLoader/CustomMsBuildProjectLoader.cs new file mode 100644 index 0000000..7c2f25e --- /dev/null +++ b/src/SharpIDE.Application/Features/Analysis/ProjectLoader/CustomMsBuildProjectLoader.cs @@ -0,0 +1,121 @@ +using System.Collections.Immutable; +using Microsoft.Build.Framework; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.MSBuild; + +namespace SharpIDE.Application.Features.Analysis.ProjectLoader; +// I really don't like having to duplicate this, but we need to use IAnalyzerAssemblyLoaderProvider rather than IAnalyzerService, +// so that analyzers are shadow copied to prevent locking. +// My attempts to provide a custom IAnalyzerService to the MEF composition were in vain. +// I think this will only be temporary, as I think a more sophisticated ProjectLoader mechanism is going to be necessary. +// see roslyn LanguageServerProjectLoader, LanguageServerProjectSystem, ProjectSystemProjectFactory +// https://github.com/dotnet/roslyn/blob/main/src/Workspaces/MSBuild/Core/MSBuild/MSBuildProjectLoader.cs +public partial class CustomMsBuildProjectLoader(Workspace workspace, ImmutableDictionary? properties = null) : MSBuildProjectLoader(workspace, properties) +{ + public async Task> LoadProjectInfosAsync( + List projectFilePaths, + ProjectMap? projectMap = null, + IProgress? progress = null, +#pragma warning disable IDE0060 // TODO: decide what to do with this unusued ILogger, since we can't reliabily use it if we're sending builds out of proc + ILogger? msbuildLogger = null, +#pragma warning restore IDE0060 + CancellationToken cancellationToken = default) + { + if (projectFilePaths.Count is 0) + { + throw new ArgumentException("At least one project file path must be specified.", nameof(projectFilePaths)); + } + + var requestedProjectOptions = DiagnosticReportingOptions.ThrowForAll; + + var reportingMode = GetReportingModeForUnrecognizedProjects(); + + var discoveredProjectOptions = new DiagnosticReportingOptions( + onPathFailure: reportingMode, + onLoaderFailure: reportingMode); + + var buildHostProcessManager = new BuildHostProcessManager(Properties, loggerFactory: _loggerFactory); + await using var _ = buildHostProcessManager.ConfigureAwait(false); + + var worker = new CustomWorker( + _solutionServices, + _diagnosticReporter, + _pathResolver, + _projectFileExtensionRegistry, + buildHostProcessManager, + requestedProjectPaths: projectFilePaths.ToImmutableArray(), + baseDirectory: Directory.GetCurrentDirectory(), + projectMap, + progress, + requestedProjectOptions, + discoveredProjectOptions, + this.LoadMetadataForReferencedProjects); + + return await worker.LoadAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Loads the for the specified solution file, including all projects referenced by the solution file and + /// all the projects referenced by the project files. + /// + /// The path to the solution file to be loaded. This may be an absolute path or a path relative to the + /// current working directory. + /// An optional that will receive updates as the solution is loaded. + /// An optional that will log MSBuild results. + /// An optional to allow cancellation of this operation. + public new async Task LoadSolutionInfoAsync( + string solutionFilePath, + IProgress? progress = null, + ILogger? msbuildLogger = null, + CancellationToken cancellationToken = default) + { + if (solutionFilePath == null) + { + throw new ArgumentNullException(nameof(solutionFilePath)); + } + + var reportingMode = GetReportingModeForUnrecognizedProjects(); + + var reportingOptions = new DiagnosticReportingOptions( + onPathFailure: reportingMode, + onLoaderFailure: reportingMode); + + var (absoluteSolutionPath, projects) = await SolutionFileReader.ReadSolutionFileAsync(solutionFilePath, _pathResolver, reportingMode, cancellationToken).ConfigureAwait(false); + var projectPaths = projects.SelectAsArray(p => p.ProjectPath); + + using (_dataGuard.DisposableWait(cancellationToken)) + { + SetSolutionProperties(absoluteSolutionPath); + } + + IBinLogPathProvider binLogPathProvider = null!; // TODO: Fix + + var buildHostProcessManager = new BuildHostProcessManager(Properties, binLogPathProvider, _loggerFactory); + await using var _ = buildHostProcessManager.ConfigureAwait(false); + + var worker = new CustomWorker( + _solutionServices, + _diagnosticReporter, + _pathResolver, + _projectFileExtensionRegistry, + buildHostProcessManager, + projectPaths, + // TryGetAbsoluteSolutionPath should not return an invalid path + baseDirectory: Path.GetDirectoryName(absoluteSolutionPath)!, + projectMap: null, + progress, + requestedProjectOptions: reportingOptions, + discoveredProjectOptions: reportingOptions, + preferMetadataForReferencesOfDiscoveredProjects: false); + + var projectInfos = await worker.LoadAsync(cancellationToken).ConfigureAwait(false); + + // construct workspace from loaded project infos + return SolutionInfo.Create( + SolutionId.CreateNewId(debugName: absoluteSolutionPath), + version: default, + absoluteSolutionPath, + projectInfos); + } +} diff --git a/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs b/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs index ec19150..1d02789 100644 --- a/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs +++ b/src/SharpIDE.Application/Features/Analysis/RoslynAnalysis.cs @@ -26,6 +26,7 @@ using Microsoft.Extensions.Logging; using NuGet.Frameworks; using Roslyn.LanguageServer.Protocol; using SharpIDE.Application.Features.Analysis.FixLoaders; +using SharpIDE.Application.Features.Analysis.ProjectLoader; using SharpIDE.Application.Features.Analysis.Razor; using SharpIDE.Application.Features.Build; using SharpIDE.Application.Features.SolutionDiscovery;