Files
2026-07-13 12:38:36 +08:00

300 lines
12 KiB
C#

using MsbuildAnalyzer.Models;
namespace MsbuildAnalyzer.Utilities;
/// <summary>
/// Path helper methods for TargetBuilder.
///
/// Path helpers return fully-qualified output paths prefixed with a location
/// token Nx understands: <c>{projectRoot}/…</c> for paths inside the project
/// directory (the MSBuild default) and <c>{workspaceRoot}/…</c> for paths that
/// escape the project directory but remain inside the workspace (for example
/// a centralized <c>dist/</c> folder configured via <c>Directory.Build.props</c>).
/// Paths that escape the workspace cannot be expressed as Nx outputs, so the
/// helpers return <c>null</c> for that case and callers filter them out.
/// </summary>
public static partial class TargetBuilder
{
/// <summary>
/// Checks if the project uses the new artifacts output layout.
/// </summary>
private static bool UsesArtifactsOutput(Dictionary<string, string> properties)
{
return properties.GetValueOrDefault("UseArtifactsOutput")
?.Equals("true", StringComparison.OrdinalIgnoreCase) == true;
}
/// <summary>
/// Resolves a raw MSBuild path string to an Nx-prefixed output path.
/// - Relative paths are treated as project-relative (MSBuild convention)
/// and returned with a <c>{projectRoot}/</c> prefix.
/// - Absolute paths under the project directory are returned relative to
/// the project directory with a <c>{projectRoot}/</c> prefix.
/// - Absolute paths elsewhere in the workspace are returned relative to
/// the workspace root with a <c>{workspaceRoot}/</c> prefix.
/// - Absolute paths outside the workspace cannot be expressed as Nx
/// outputs and return <c>null</c> so callers can drop them.
/// </summary>
private static string? ResolvePath(string path, string projectDirectory, string workspaceRoot)
{
if (string.IsNullOrEmpty(path))
{
return null;
}
if (!Path.IsPathRooted(path))
{
var normalized = path.Replace('\\', '/').TrimEnd('/');
return string.IsNullOrEmpty(normalized) ? "{projectRoot}" : $"{{projectRoot}}/{normalized}";
}
var normalizedPath = Path.GetFullPath(path);
var normalizedProject = Path.GetFullPath(projectDirectory);
var normalizedWorkspace = Path.GetFullPath(workspaceRoot);
if (IsUnder(normalizedPath, normalizedProject))
{
var relative = Path.GetRelativePath(normalizedProject, normalizedPath)
.Replace('\\', '/').TrimEnd('/');
return relative == "." || string.IsNullOrEmpty(relative)
? "{projectRoot}"
: $"{{projectRoot}}/{relative}";
}
if (IsUnder(normalizedPath, normalizedWorkspace))
{
var relative = Path.GetRelativePath(normalizedWorkspace, normalizedPath)
.Replace('\\', '/').TrimEnd('/');
return relative == "." || string.IsNullOrEmpty(relative)
? "{workspaceRoot}"
: $"{{workspaceRoot}}/{relative}";
}
// Absolute path outside the workspace cannot be tokenized as an Nx
// output. Drop it rather than emit something Nx can't honour.
return null;
}
/// <summary>
/// Returns true when <paramref name="candidate"/> is the same as, or a
/// descendant of, <paramref name="parent"/>. Uses path-aware comparison so
/// a prefix like <c>/foo</c> does not match <c>/foobar</c>.
/// </summary>
private static bool IsUnder(string candidate, string parent)
{
if (candidate.Equals(parent, StringComparison.OrdinalIgnoreCase))
{
return true;
}
var parentWithSep = parent.EndsWith(Path.DirectorySeparatorChar) || parent.EndsWith(Path.AltDirectorySeparatorChar)
? parent
: parent + Path.DirectorySeparatorChar;
return candidate.StartsWith(parentWithSep, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Gets the artifacts root path relative to the workspace root (defaults to "artifacts").
/// Used when constructing paths under the artifacts output layout. Returns
/// <c>null</c> when the configured path lives outside the workspace, since
/// Nx outputs must be expressible relative to <c>{workspaceRoot}</c>.
/// </summary>
private static string? GetArtifactsRelativePath(Dictionary<string, string> properties, string workspaceRoot)
{
var artifactsPath = properties.GetValueOrDefault("ArtifactsPath") ?? "artifacts";
if (!Path.IsPathRooted(artifactsPath))
{
return artifactsPath.Replace('\\', '/').TrimEnd('/');
}
var normalizedPath = Path.GetFullPath(artifactsPath);
var normalizedWorkspaceRoot = Path.GetFullPath(workspaceRoot);
if (IsUnder(normalizedPath, normalizedWorkspaceRoot))
{
return Path.GetRelativePath(normalizedWorkspaceRoot, normalizedPath)
.Replace('\\', '/').TrimEnd('/');
}
return null;
}
/// <summary>
/// Strips common configuration names from the end of a path segment.
/// Handles Debug/Release as magic strings for configuration-agnostic caching.
/// </summary>
private static string StripConfiguration(string path)
{
if (string.IsNullOrEmpty(path))
{
return path;
}
// Normalize path separators
var normalizedPath = path.Replace('\\', '/').TrimEnd('/');
var segments = normalizedPath.Split('/');
// Check if the last segment is a configuration name
if (segments.Length > 0)
{
var lastSegment = segments[^1];
if (lastSegment.Equals("Debug", StringComparison.OrdinalIgnoreCase) ||
lastSegment.Equals("Release", StringComparison.OrdinalIgnoreCase))
{
// Remove the configuration segment
return string.Join("/", segments[..^1]);
}
}
return normalizedPath;
}
/// <summary>
/// Gets the output directory path for build outputs, as a fully-qualified
/// Nx-prefixed string. Handles both traditional and artifacts layouts.
/// Returns <c>null</c> when the path lives outside the workspace.
/// </summary>
private static string? GetOutputPath(Dictionary<string, string> properties, string projectName, string projectDirectory, string workspaceRoot)
{
if (UsesArtifactsOutput(properties))
{
var artifactsPath = GetArtifactsRelativePath(properties, workspaceRoot);
return artifactsPath is null ? null : $"{{workspaceRoot}}/{artifactsPath}/bin/{projectName}";
}
var baseOutputPath = properties.GetValueOrDefault("BaseOutputPath");
if (!string.IsNullOrEmpty(baseOutputPath))
{
return ResolvePath(baseOutputPath, projectDirectory, workspaceRoot);
}
var outputPath = properties.GetValueOrDefault("OutputPath")
?? properties.GetValueOrDefault("OutDir")
?? "bin";
var resolved = ResolvePath(outputPath, projectDirectory, workspaceRoot);
return resolved is null ? null : StripConfiguration(resolved);
}
/// <summary>
/// Gets the intermediate output directory path (obj), as a fully-qualified
/// Nx-prefixed string. Returns <c>null</c> when the path lives outside the
/// workspace.
/// </summary>
private static string? GetIntermediateOutputPath(Dictionary<string, string> properties, string projectName, string projectDirectory, string workspaceRoot)
{
if (UsesArtifactsOutput(properties))
{
var artifactsPath = GetArtifactsRelativePath(properties, workspaceRoot);
return artifactsPath is null ? null : $"{{workspaceRoot}}/{artifactsPath}/obj/{projectName}";
}
var baseIntermediatePath = properties.GetValueOrDefault("BaseIntermediateOutputPath");
if (!string.IsNullOrEmpty(baseIntermediatePath))
{
return ResolvePath(baseIntermediatePath, projectDirectory, workspaceRoot);
}
var intermediatePath = properties.GetValueOrDefault("IntermediateOutputPath") ?? "obj";
var resolved = ResolvePath(intermediatePath, projectDirectory, workspaceRoot);
return resolved is null ? null : StripConfiguration(resolved);
}
/// <summary>
/// Gets the publish output directory path, as a fully-qualified Nx-prefixed
/// string. Returns <c>null</c> when the path lives outside the workspace.
/// </summary>
private static string? GetPublishDir(Dictionary<string, string> properties, string projectName, string projectDirectory, string workspaceRoot)
{
if (UsesArtifactsOutput(properties))
{
var artifactsPath = GetArtifactsRelativePath(properties, workspaceRoot);
return artifactsPath is null ? null : $"{{workspaceRoot}}/{artifactsPath}/publish/{projectName}";
}
// PublishDir (e.g. "bin/Debug/publish") is evaluated by MSBuild at the
// project's default Configuration, but the publish target runs with the
// Configuration in `properties` (Release). Rewrite the configuration
// segment so the declared output matches where the publish actually lands.
var publishDir = properties.GetValueOrDefault("PublishDir");
if (!string.IsNullOrEmpty(publishDir))
{
var resolved = ResolvePath(publishDir, projectDirectory, workspaceRoot);
return ApplyConfiguration(resolved, properties.GetValueOrDefault("Configuration"));
}
var outputPath = GetOutputPath(properties, projectName, projectDirectory, workspaceRoot);
return outputPath is null ? null : $"{outputPath.TrimEnd('/')}/publish";
}
/// <summary>
/// Rewrites any <c>Debug</c>/<c>Release</c> path segment to the given
/// configuration. Used for paths (like PublishDir) that MSBuild evaluates at
/// the default configuration but a target consumes at another. A no-op when
/// the configuration is empty or the path contains no configuration segment.
/// </summary>
private static string? ApplyConfiguration(string? path, string? configuration)
{
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(configuration))
{
return path;
}
var segments = path.Split('/');
for (var i = 0; i < segments.Length; i++)
{
if (segments[i].Equals("Debug", StringComparison.OrdinalIgnoreCase) ||
segments[i].Equals("Release", StringComparison.OrdinalIgnoreCase))
{
segments[i] = configuration;
}
}
return string.Join('/', segments);
}
/// <summary>
/// Gets the package output directory path, as a fully-qualified Nx-prefixed
/// string. Returns <c>null</c> when the path lives outside the workspace.
/// </summary>
private static string? GetPackageOutputPath(Dictionary<string, string> properties, string projectName, string projectDirectory, string workspaceRoot)
{
if (UsesArtifactsOutput(properties))
{
var artifactsPath = GetArtifactsRelativePath(properties, workspaceRoot);
return artifactsPath is null ? null : $"{{workspaceRoot}}/{artifactsPath}/package";
}
var packageOutputPath = properties.GetValueOrDefault("PackageOutputPath");
if (!string.IsNullOrEmpty(packageOutputPath))
{
return ResolvePath(packageOutputPath, projectDirectory, workspaceRoot);
}
return GetOutputPath(properties, projectName, projectDirectory, workspaceRoot);
}
/// <summary>
/// Gets the test results directory path, as a fully-qualified Nx-prefixed
/// string. Returns <c>null</c> when the path lives outside the workspace.
/// </summary>
private static string? GetTestResultsDirectory(Dictionary<string, string> properties, string projectName, string projectDirectory, string workspaceRoot)
{
if (UsesArtifactsOutput(properties))
{
var artifactsPath = GetArtifactsRelativePath(properties, workspaceRoot);
return artifactsPath is null ? null : $"{{workspaceRoot}}/{artifactsPath}/TestResults/{projectName}";
}
var testResultsDir = properties.GetValueOrDefault("TestResultsDirectory");
if (!string.IsNullOrEmpty(testResultsDir))
{
return ResolvePath(testResultsDir, projectDirectory, workspaceRoot);
}
return "{projectRoot}/TestResults";
}
}