Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions eng/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@
<PackageVersion Include="Microsoft.TestPlatform.TranslationLayer" Version="$(MicrosoftNETTestSdkVersion)" />
<PackageVersion Include="Microsoft.TestPlatform.ObjectModel" Version="$(MicrosoftNETTestSdkVersion)" />

<!--
MSBuildWorkspace
-->
<PackageVersion Include="Microsoft.VisualStudio.SolutionPersistence" Version="1.0.52" />

<!--
Analyzers
-->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,19 +57,7 @@ public async Task OpenSolutionAsync(string solutionFilePath)
_logger.LogInformation(string.Format(LanguageServerResources.Loading_0, solutionFilePath));
ProjectFactory.SolutionPath = solutionFilePath;

// We'll load solutions out-of-proc, since it's possible we might be running on a runtime that doesn't have a matching SDK installed,
// and we don't want any MSBuild registration to set environment variables in our process that might impact child processes.
await using var buildHostProcessManager = new BuildHostProcessManager(globalMSBuildProperties: AdditionalProperties, loggerFactory: LoggerFactory);
var buildHost = await buildHostProcessManager.GetBuildHostAsync(BuildHostProcessKind.NetCore, CancellationToken.None);

// If we don't have a .NET Core SDK on this machine at all, try .NET Framework
if (!await buildHost.HasUsableMSBuildAsync(solutionFilePath, CancellationToken.None))
{
var kind = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? BuildHostProcessKind.NetFramework : BuildHostProcessKind.Mono;
buildHost = await buildHostProcessManager.GetBuildHostAsync(kind, CancellationToken.None);
}

var projects = await buildHost.GetProjectsInSolutionAsync(solutionFilePath, CancellationToken.None);
var (_, projects) = await SolutionFileReader.ReadSolutionFileAsync(solutionFilePath, CancellationToken.None);
Copy link
Member Author

Choose a reason for hiding this comment

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

In the case that solutionFilePath points to a .slnf ReadSolutionFileAsync returns the filtered solution's file path. Should we be assigning that path to the ProjectFactory.SolutionPath?

Copy link
Member

Choose a reason for hiding this comment

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

Are you basically asking whether to use the path to the slnf or the path to the associated sln? I think we would want to use the slnf, which this PR appears to be doing right now.

Copy link
Member Author

Choose a reason for hiding this comment

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

True, it is working today without this change and is currently using the .slnf path.

Copy link
Member

Choose a reason for hiding this comment

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

I'd say that's probably reasonable anyways; the only real thing the solution path is used for is caches, and I could imagine what we have to cache is different for different filters over the same solution.

Copy link
Member

Choose a reason for hiding this comment

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

(if that's bad, maybe the fix is we should be passing around the workspace path or something else for that...)

foreach (var (path, guid) in projects)
{
await BeginLoadingProjectAsync(path, guid);
Expand Down
1 change: 0 additions & 1 deletion src/LanguageServer/ProtocolUnitTests/Hover/HoverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Build.Evaluation;
using Microsoft.CodeAnalysis.PooledObjects;
using Roslyn.Test.Utilities;
using Roslyn.Text.Adornments;
Expand Down
26 changes: 0 additions & 26 deletions src/Workspaces/MSBuild/BuildHost/BuildHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Build.Construction;
using Microsoft.Build.Locator;
using Microsoft.Build.Logging;
using Roslyn.Utilities;
Expand Down Expand Up @@ -133,31 +132,6 @@ private void EnsureMSBuildLoaded(string projectFilePath)
Contract.ThrowIfFalse(TryEnsureMSBuildLoaded(projectFilePath), $"We don't have an MSBuild to use; {nameof(HasUsableMSBuild)} should have been called first to check.");
}

public ImmutableArray<(string ProjectPath, string ProjectGuid)> GetProjectsInSolution(string solutionFilePath)
{
EnsureMSBuildLoaded(solutionFilePath);
return GetProjectsInSolutionCore(solutionFilePath);
}

[MethodImpl(MethodImplOptions.NoInlining)] // Do not inline this, since this uses MSBuild types which are being loaded by the caller
private static ImmutableArray<(string ProjectPath, string ProjectGuid)> GetProjectsInSolutionCore(string solutionFilePath)
{
// WARNING: do not use a lambda in this function, as it internally will be put in a class that contains other lambdas used in
// TryEnsureMSBuildLoaded; on Mono this causes type load errors.

var builder = ImmutableArray.CreateBuilder<(string ProjectPath, string ProjectGuid)>();

foreach (var project in SolutionFile.Parse(solutionFilePath).ProjectsInOrder)
{
if (project.ProjectType != SolutionProjectType.SolutionFolder)
{
builder.Add((project.AbsolutePath, project.ProjectGuid));
}
}

return builder.ToImmutableAndClear();
}

/// <summary>
/// Returns the target ID of the <see cref="ProjectFile"/> object created for this.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;

Expand All @@ -14,7 +13,6 @@ namespace Microsoft.CodeAnalysis.MSBuild;
internal interface IBuildHost
{
bool HasUsableMSBuild(string projectOrSolutionFilePath);
ImmutableArray<(string ProjectPath, string ProjectGuid)> GetProjectsInSolution(string solutionFilePath);
Task<int> LoadProjectFileAsync(string projectFilePath, string languageName, CancellationToken cancellationToken);

/// <summary>Permits loading a project file which only exists in-memory, for example, for file-based program scenarios.</summary>
Expand Down
43 changes: 6 additions & 37 deletions src/Workspaces/MSBuild/Core/MSBuild/MSBuildProjectLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,51 +167,20 @@ public async Task<SolutionInfo> LoadSolutionInfoAsync(
throw new ArgumentNullException(nameof(solutionFilePath));
}

if (!_pathResolver.TryGetAbsoluteSolutionPath(solutionFilePath, baseDirectory: Directory.GetCurrentDirectory(), DiagnosticReportingMode.Throw, out var absoluteSolutionPath))
Copy link
Member Author

Choose a reason for hiding this comment

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

Moved this bit into a new SolutionFileReader class.

{
// TryGetAbsoluteSolutionPath should throw before we get here.
return null!;
}

var projectFilter = ImmutableHashSet<string>.Empty;
if (SolutionFilterReader.IsSolutionFilterFilename(absoluteSolutionPath) &&
!SolutionFilterReader.TryRead(absoluteSolutionPath, _pathResolver, out absoluteSolutionPath, out projectFilter))
{
throw new Exception(string.Format(WorkspaceMSBuildResources.Failed_to_load_solution_filter_0, solutionFilePath));
}
var (absoluteSolutionPath, projects) = await SolutionFileReader.ReadSolutionFileAsync(solutionFilePath, _pathResolver, cancellationToken).ConfigureAwait(false);
var projectPaths = projects.SelectAsArray(p => p.ProjectPath);

using (_dataGuard.DisposableWait(cancellationToken))
{
this.SetSolutionProperties(absoluteSolutionPath);
SetSolutionProperties(absoluteSolutionPath);
}

var solutionFile = MSB.Construction.SolutionFile.Parse(absoluteSolutionPath);
var reportingMode = GetReportingModeForUnrecognizedProjects();

var reportingOptions = new DiagnosticReportingOptions(
onPathFailure: reportingMode,
onLoaderFailure: reportingMode);

var projectPaths = ImmutableArray.CreateBuilder<string>();

// load all the projects
foreach (var project in solutionFile.ProjectsInOrder)
{
cancellationToken.ThrowIfCancellationRequested();

if (project.ProjectType == MSB.Construction.SolutionProjectType.SolutionFolder)
{
continue;
}

// Load project if we have an empty project filter and the project path is present.
if (projectFilter.IsEmpty ||
projectFilter.Contains(project.AbsolutePath))
{
projectPaths.Add(project.RelativePath);
}
}

var buildHostProcessManager = new BuildHostProcessManager(Properties, loggerFactory: _loggerFactory);
await using var _ = buildHostProcessManager.ConfigureAwait(false);

Expand All @@ -221,7 +190,7 @@ public async Task<SolutionInfo> LoadSolutionInfoAsync(
_pathResolver,
_projectFileExtensionRegistry,
buildHostProcessManager,
projectPaths.ToImmutable(),
projectPaths,
// TryGetAbsoluteSolutionPath should not return an invalid path
baseDirectory: Path.GetDirectoryName(absoluteSolutionPath)!,
Properties,
Expand All @@ -231,14 +200,14 @@ public async Task<SolutionInfo> LoadSolutionInfoAsync(
discoveredProjectOptions: reportingOptions,
preferMetadataForReferencesOfDiscoveredProjects: false);

var projects = await worker.LoadAsync(cancellationToken).ConfigureAwait(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,
projects);
projectInfos);
}

/// <summary>
Expand Down
12 changes: 6 additions & 6 deletions src/Workspaces/MSBuild/Core/MSBuild/PathResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ namespace Microsoft.CodeAnalysis.MSBuild;

internal sealed class PathResolver
{
private readonly DiagnosticReporter _diagnosticReporter;
private readonly DiagnosticReporter? _diagnosticReporter;

public PathResolver(DiagnosticReporter diagnosticReporter)
public PathResolver(DiagnosticReporter? diagnosticReporter)
Copy link
Member Author

@JoeRobich JoeRobich May 21, 2025

Choose a reason for hiding this comment

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

Made the DiagnosticReporter optional since this will also be indirectly used by the LanguageServerProjectSystem which does not use a reporter.

Copy link
Member

Choose a reason for hiding this comment

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

Should we be though?

{
_diagnosticReporter = diagnosticReporter;
}
Expand All @@ -26,14 +26,14 @@ public bool TryGetAbsoluteSolutionPath(string path, string baseDirectory, Diagno
}
catch (Exception)
{
_diagnosticReporter.Report(reportingMode, string.Format(WorkspacesResources.Invalid_solution_file_path_colon_0, path));
_diagnosticReporter?.Report(reportingMode, string.Format(WorkspacesResources.Invalid_solution_file_path_colon_0, path));
absolutePath = null;
return false;
}

if (!File.Exists(absolutePath))
{
_diagnosticReporter.Report(
_diagnosticReporter?.Report(
reportingMode,
string.Format(WorkspacesResources.Solution_file_not_found_colon_0, absolutePath),
msg => new FileNotFoundException(msg));
Expand All @@ -51,14 +51,14 @@ public bool TryGetAbsoluteProjectPath(string path, string baseDirectory, Diagnos
}
catch (Exception)
{
_diagnosticReporter.Report(reportingMode, string.Format(WorkspacesResources.Invalid_project_file_path_colon_0, path));
_diagnosticReporter?.Report(reportingMode, string.Format(WorkspacesResources.Invalid_project_file_path_colon_0, path));
absolutePath = null;
return false;
}

if (!File.Exists(absolutePath))
{
_diagnosticReporter.Report(
_diagnosticReporter?.Report(
reportingMode,
string.Format(WorkspacesResources.Project_file_not_found_colon_0, absolutePath),
msg => new FileNotFoundException(msg));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

namespace Microsoft.CodeAnalysis.MSBuild;

public partial class MSBuildProjectLoader
internal partial class SolutionFileReader
{
private static class SolutionFilterReader
{
Expand Down
79 changes: 79 additions & 0 deletions src/Workspaces/MSBuild/Core/MSBuild/SolutionFileReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// 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 System;
using System.Collections.Immutable;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.SolutionPersistence.Serializer;
using Roslyn.Utilities;

namespace Microsoft.CodeAnalysis.MSBuild;

internal partial class SolutionFileReader
{
public static Task<(string AbsoluteSolutionPath, ImmutableArray<(string ProjectPath, string ProjectGuid)> Projects)> ReadSolutionFileAsync(string solutionFilePath, CancellationToken cancellationToken)
{
return ReadSolutionFileAsync(solutionFilePath, new PathResolver(diagnosticReporter: null), cancellationToken);
}

public static async Task<(string AbsoluteSolutionPath, ImmutableArray<(string ProjectPath, string ProjectGuid)> Projects)> ReadSolutionFileAsync(string solutionFilePath, PathResolver pathResolver, CancellationToken cancellationToken)
{
if (!pathResolver.TryGetAbsoluteSolutionPath(solutionFilePath, baseDirectory: Directory.GetCurrentDirectory(), DiagnosticReportingMode.Throw, out var absoluteSolutionPath))
{
// TryGetAbsoluteSolutionPath should throw before we get here.
return (solutionFilePath, []);
}

// When passed a solution filter, we need to read the filter file to get the solution path and included project paths.
var projectFilter = ImmutableHashSet<string>.Empty;
if (SolutionFilterReader.IsSolutionFilterFilename(absoluteSolutionPath) &&
!SolutionFilterReader.TryRead(absoluteSolutionPath, pathResolver, out absoluteSolutionPath, out projectFilter))
{
throw new Exception(string.Format(WorkspaceMSBuildResources.Failed_to_load_solution_filter_0, solutionFilePath));
}

var projects = await TryReadSolutionFileAsync(absoluteSolutionPath, pathResolver, projectFilter, cancellationToken).ConfigureAwait(false);
if (!projects.HasValue)
{
throw new Exception(string.Format(WorkspaceMSBuildResources.Failed_to_load_solution_0, absoluteSolutionPath));
Copy link
Member

Choose a reason for hiding this comment

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

Is the only case this can happen is the format of the solution file isn't recognized? Should we give a better message in this case?

}

return (absoluteSolutionPath, projects.Value);
}

private static async Task<ImmutableArray<(string ProjectPath, string ProjectGuid)>?> TryReadSolutionFileAsync(string solutionFilePath, PathResolver pathResolver, ImmutableHashSet<string> projectFilter, CancellationToken cancellationToken)
{
var serializer = SolutionSerializers.GetSerializerByMoniker(solutionFilePath);
if (serializer == null)
{
return null;
Copy link
Member

Choose a reason for hiding this comment

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

what does the serializer being null here mean? Are there different serializers for sln vs slnx?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes! And I imagine when solution filter support is add it will be a separate serializer.

Copy link
Member

Choose a reason for hiding this comment

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

Should we have this just throw? Otherwise we're having to deal with the null return at the caller which isn't really helping much since it makes me think the rest of the method isn't supposed to throw (but absolutely would if something went wrong?)

}

// The solution folder is the base directory for project paths.
var baseDirectory = Path.GetDirectoryName(solutionFilePath);
RoslynDebug.AssertNotNull(baseDirectory);

var solutionModel = await serializer.OpenAsync(solutionFilePath, cancellationToken).ConfigureAwait(false);

var builder = ImmutableArray.CreateBuilder<(string ProjectPath, string ProjectGuid)>();
foreach (var projectModel in solutionModel.SolutionProjects)
{
// If we are filtering based on a solution filter, then we need to verify the project is included.
if (!projectFilter.IsEmpty)
Copy link
Member

Choose a reason for hiding this comment

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

If you have an empty filter, what happens? Should this use a nullable set to make it more clear?

{
if (!pathResolver.TryGetAbsoluteProjectPath(projectModel.FilePath, baseDirectory, DiagnosticReportingMode.Throw, out var absoluteProjectPath)
|| !projectFilter.Contains(absoluteProjectPath))
{
continue;
}
}

builder.Add((projectModel.FilePath, projectModel.Id.ToString()));
}

return builder.ToImmutable();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@
</PackageDescription>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Build" />
<PackageReference Include="Microsoft.Build.Tasks.Core" />
<PackageReference Include="Microsoft.Build.Framework" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.VisualStudio.SolutionPersistence" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="System.Text.Json" Condition="'$(TargetFramework)' == 'net472'" />
Copy link
Member Author

Choose a reason for hiding this comment

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

Originally brought in by Microsoft.Build and is already available as part of .NET.

Copy link
Member

Choose a reason for hiding this comment

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

why do we only need this for net472?

Copy link
Member Author

Choose a reason for hiding this comment

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

System.Text.Json is part of the .NET BCL.

Copy link
Member Author

Choose a reason for hiding this comment

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

Having it as a PackageReference for .NET TFM will pin the version to whatever we use in the build instead of allowing it to use the version included with newer runtimes.

Copy link
Member Author

Choose a reason for hiding this comment

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

I will add all that as a comment.

</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\Portable\Microsoft.CodeAnalysis.Workspaces.csproj" />
Expand Down
3 changes: 0 additions & 3 deletions src/Workspaces/MSBuild/Core/Rpc/RemoteBuildHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,6 @@ public RemoteBuildHost(RpcClient client)
public Task<bool> HasUsableMSBuildAsync(string projectOrSolutionFilePath, CancellationToken cancellationToken)
=> _client.InvokeAsync<bool>(BuildHostTargetObject, nameof(IBuildHost.HasUsableMSBuild), parameters: [projectOrSolutionFilePath], cancellationToken);

public Task<ImmutableArray<(string ProjectPath, string ProjectGuid)>> GetProjectsInSolutionAsync(string solutionFilePath, CancellationToken cancellationToken)
=> _client.InvokeAsync<ImmutableArray<(string ProjectPath, string ProjectGuid)>>(BuildHostTargetObject, nameof(IBuildHost.GetProjectsInSolution), parameters: [solutionFilePath], cancellationToken);

public async Task<RemoteProjectFile> LoadProjectFileAsync(string projectFilePath, string languageName, CancellationToken cancellationToken)
{
var remoteProjectFileTargetObject = await _client.InvokeAsync<int>(BuildHostTargetObject, nameof(IBuildHost.LoadProjectFileAsync), parameters: [projectFilePath, languageName], cancellationToken).ConfigureAwait(false);
Expand Down
3 changes: 3 additions & 0 deletions src/Workspaces/MSBuild/Core/WorkspaceMSBuildResources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@
<data name="Failed_to_load_solution_filter_0" xml:space="preserve">
<value>Failed to load solution filter: '{0}'</value>
</data>
<data name="Failed_to_load_solution_0" xml:space="preserve">
<value>Failed to load solution: '{0}'</value>
</data>
<data name="Found_project_reference_without_a_matching_metadata_reference_0" xml:space="preserve">
<value>Found project reference without a matching metadata reference: {0}</value>
</data>
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading