EnvDTE.Host/Callback/Util/ProjectExtensions.cs (130 lines of code) (raw):
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using JetBrains.Annotations;
using JetBrains.Application.Components;
using JetBrains.Lifetimes;
using JetBrains.Platform.MsBuildHost.Models;
using JetBrains.Platform.MsBuildHost.ProjectModel;
using JetBrains.Platform.MsBuildHost.Utils;
using JetBrains.ProjectModel;
using JetBrains.ProjectModel.ProjectsHost;
using JetBrains.ProjectModel.ProjectsHost.MsBuild;
using JetBrains.ProjectModel.ProjectsHost.SolutionHost;
using JetBrains.ProjectModel.Properties;
using JetBrains.RdBackend.Common.Features.ProjectModel.View;
using JetBrains.ProjectModel.Properties.Flavours;
using JetBrains.ReSharper.Resources.Shell;
using JetBrains.Threading;
using JetBrains.Util;
using JetBrains.Util.Logging;
namespace JetBrains.EnvDTE.Host.Callback.Util;
public static class ProjectExtensions
{
private const string FSharpProjectTypeGuid = "f2a71f9b-5d33-465a-a702-920d77279786";
private static readonly Key UniqueNamePropertyKey = new("EnvDTE.UniqueName");
private static readonly Key IsCPSPropertyKey = new("EnvDTE.IsCPS");
private static readonly ILogger Log = Logger.GetLogger<IProject>();
/// <summary>
/// Asynchronously retrieves the specified project property.
/// </summary>
/// <remarks>
/// First tries to retrieve the property value from the configuration's properties collection. If the property is not
/// found, it falls back to reading the property value from the underlying MSBuild project model.
/// </remarks>
[PublicAPI]
[ItemCanBeNull]
public static async Task<string> GetPropertyAsync(
[NotNull] this IProject project,
Lifetime lifetime,
[NotNull] string name)
{
var value = await lifetime.StartReadActionAsync(() =>
project.GetRequestedProjectProperty(project.GetCurrentTargetFrameworkId(), name));
if (value is not null) return value;
Log.Verbose($"Property '{name}' not found in configuration's properties collection. Falling back to MSBuild.");
var projectHostContainer = project.GetSolution().ProjectsHostContainer();
var solutionHost = projectHostContainer.GetComponent<ISolutionHost>();
var projectMark = project.GetProjectMark();
if (projectMark is null)
{
Log.Warn($"Project mark not found for project: {project.Name}.");
return null;
}
if (solutionHost.GetProjectHost(projectMark) is MsBuildProjectHost projectHost)
{
value = await lifetime.StartMainRead(() => projectHost.Session.GetProjectProperty(projectMark, name,
project.GetCurrentTargetFrameworkId(),
MsBuildEvaluationMode.Expand));
}
else
{
Log.Warn($"Project '{project.Name}' is not hosted on {nameof(MsBuildProjectHost)}.");
}
return value;
}
/// <summary>
/// Asynchronously sets the specified project property.
/// </summary>
/// <remarks>
/// Writes the property value directly to the underlying MSBuild project model and triggers the project reload so the
/// updated value becomes visible in the project as soon as possible.
/// </remarks>
[PublicAPI]
public static async Task SetPropertyAsync(
[NotNull] this IProject project,
Lifetime lifetime,
[NotNull] string name,
[CanBeNull] string value)
{
var projectMark = project.GetProjectMark();
if (projectMark is null)
{
Log.Warn($"Project mark not found for project: {project.Name}.");
return;
}
var projectHostContainer = project.GetSolution().ProjectsHostContainer();
var projectHost = projectHostContainer.GetComponent<MsBuildProjectHost>();
var solutionHost = projectHostContainer.GetComponent<ISolutionHost>();
var rdSaveProperties = new List<RdSaveProperty>
{
// Even though we use `null` for configuration, the property will be set at the right place
MsBuildModelHelper.CreateSimpleSaveProperty(name, value, null)
};
await lifetime.StartMainWrite(() => projectHost.SaveProperties(projectMark, rdSaveProperties));
project.GetSolution().GetSolutionLifetimes().UntilSolutionCloseLifetime.StartMainWrite(() =>
solutionHost.ReloadProjectAsync(projectMark)).NoAwait();
}
// TODO: Move the methpds that use property for caching into a separate component so that they can get their own lifetime, without asking the user to provide one
/// <summary>
/// Asynchronously retrieves the unique Visual Studio name for the specified project.
/// </summary>
[PublicAPI]
public static string GetVSUniqueName([NotNull] this IProject project, Lifetime propertyWriteLifetime)
{
if (project.IsSolutionProject())
{
Log.Warn("Visual Studio does not have the project for the solution, returning empty string.");
return string.Empty;
}
// Save the unique name to the project properties so we don't have to calculate it every time
return project.GetOrCreateProperty(propertyWriteLifetime, UniqueNamePropertyKey, CalculateProjectUniqueName);
}
/// <summary>
/// Determines whether the project is a CPS (Common Project System) project in Visual Studio.
/// </summary>
/// <remarks>
/// <para>
/// The official documentation for the Visual Studio’s CPS-based project system states that it is primarily used for
/// SDK-style .NET projects and .NET shared projects. In practice, there are additional project types that are implemented
/// on top of CPS and have the 'CPS' capability.
/// </para>
/// <para>
/// CPS is also used for FSharp, EcmaScript (JS, TS, ...), SDK-style SQL, Docker Compose, Service Fabric and
/// Windows Application Packaging projects.
/// CPS is not used for classic .NET, classic SQL, Python and C++ projects, as well as some niche project types like Hive,
/// Pig, Azure Stream Analytics, Azure Cloud Service and U-SQL projects.
/// </para>
/// <para>
/// Neither of the above lists is complete, but they cover the major, commonly encountered, project types.
/// Existing checks also have a high chance of covering other project types that are not explicitly listed here.
/// </para>
/// </remarks>
[PublicAPI]
public static bool IsCPSProject([NotNull] this IProject project, Lifetime propertyWriteLifetime) =>
project.GetOrCreateProperty(propertyWriteLifetime, IsCPSPropertyKey, p =>
p.IsProjectFromUserView() && !p.IsSolutionFolder() &&
(p.IsDotNetCoreProject() || (p.IsSharedProject() && !IsCppProject(p)) || p.IsDockerComposeProject()
|| p.IsEcmaScriptProject() || IsFSharpProject(p) || IsServiceFabricProject(p)));
[PublicAPI]
private static bool IsFSharpProject([NotNull] this IProject project) =>
project.ProjectProperties.ProjectTypeGuids.LastOrDefault().ToString().Equals(FSharpProjectTypeGuid);
[PublicAPI]
public static bool IsCppProject([NotNull] this IProject project) => project.HasFlavour<CppProjectFlavor>();
[PublicAPI]
public static bool IsServiceFabricProject([NotNull] this IProject project) =>
project.HasFlavour<ServiceFabricProjectFlavor>();
/// <summary>
/// Returns the hierarchical path of the project as a list of project model item IDs.
/// This path is meant to be used with <c>ProjectImplementation.GetFromPath</c> on the client side to retrieve the project.
/// </summary>
[PublicAPI]
public static List<int> GetProjectPath([NotNull] this IProject project, ProjectModelViewHost viewHost) =>
project.GetPathChain().Select(viewHost.GetIdByItem).Reverse().ToList();
private static string CalculateProjectUniqueName([NotNull] IProject project)
{
if (project.IsMiscFilesProject()) return "<MiscFiles>";
if (project.IsSolutionFolder()) return $"{project.Name}{project.Guid.ToString("B").ToUpperInvariant()}";
var solutionDirPath = project.GetSolution().SolutionDirectory;
var projectFilePath = project.ProjectFileLocation;
return projectFilePath.MakeRelativeTo(solutionDirPath).FullPath;
}
private static T GetOrCreateProperty<T>([NotNull] this IProject project, Lifetime lifetime, Key key, Func<IProject, T> calculateValue)
{
using (ReadLockCookie.Create())
{
var curr = project.GetProperty(key);
if (curr is not null) return curr.GetType() == typeof(T) ? (T)curr : default;
}
var newValue = calculateValue(project);
lifetime.StartMainWrite(() => project.SetProperty(key, newValue)).NoAwait();
return newValue;
}
}