journal/Journal.DevTool/Core/CommandResolver.cs

174 lines
6.2 KiB
C#

namespace Sdt.Core;
public enum CommandResolutionSource
{
Exact,
Path,
Shim,
NodeAdjacentShim,
ConfiguredOverride,
Fallback,
}
public sealed record CommandResolutionResult(
string Requested,
string Resolved,
CommandResolutionSource Source);
public static class CommandResolver
{
public static CommandResolutionResult ResolveWithTrace(string command, Config.DevToolConfig? config = null, string? tool = null)
{
if (string.IsNullOrWhiteSpace(command))
return new CommandResolutionResult(command, command, CommandResolutionSource.Exact);
if (!OperatingSystem.IsWindows())
return new CommandResolutionResult(command, command, CommandResolutionSource.Exact);
if (command.Contains(Path.DirectorySeparatorChar) || command.Contains(Path.AltDirectorySeparatorChar))
return new CommandResolutionResult(command, command, CommandResolutionSource.Exact);
var normalized = command.ToLowerInvariant();
if (Path.HasExtension(command))
{
var extensionResolved = ResolveFromPath(command);
return extensionResolved is null
? new CommandResolutionResult(command, command, CommandResolutionSource.Fallback)
: new CommandResolutionResult(command, extensionResolved, CommandResolutionSource.Path);
}
var overrideTool = string.IsNullOrWhiteSpace(tool) ? normalized : tool.ToLowerInvariant();
var configuredCandidates = config?.Tooling?.Tools
.FirstOrDefault(t => string.Equals(t.Tool, overrideTool, StringComparison.OrdinalIgnoreCase))
?.Executables
?.Where(x => !string.IsNullOrWhiteSpace(x))
.ToList();
if (configuredCandidates is not null)
{
foreach (var configured in configuredCandidates)
{
var resolvedConfigured = ResolveFromPath(configured!) ?? configured!;
if (IsUsableExecutable(resolvedConfigured))
return new CommandResolutionResult(command, resolvedConfigured, CommandResolutionSource.ConfiguredOverride);
}
}
foreach (var candidate in BuildWindowsCandidates(command, normalized))
{
var resolved = ResolveFromPath(candidate);
if (!string.IsNullOrWhiteSpace(resolved))
{
var source = candidate.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase) ||
candidate.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ||
candidate.EndsWith(".bat", StringComparison.OrdinalIgnoreCase)
? CommandResolutionSource.Shim
: CommandResolutionSource.Path;
return new CommandResolutionResult(command, resolved!, source);
}
}
if (normalized is "npm" or "npx" or "pnpm" or "yarn")
{
var nodePath = ResolveFromPath("node.exe") ?? ResolveFromPath("node");
if (!string.IsNullOrWhiteSpace(nodePath))
{
var nodeDir = Path.GetDirectoryName(nodePath);
if (!string.IsNullOrWhiteSpace(nodeDir))
{
var shim = Path.Combine(nodeDir, normalized + ".cmd");
if (File.Exists(shim))
return new CommandResolutionResult(command, shim, CommandResolutionSource.NodeAdjacentShim);
}
}
}
var fallback = BuildWindowsCandidates(command, normalized).LastOrDefault() ?? command;
return new CommandResolutionResult(command, fallback, CommandResolutionSource.Fallback);
}
public static string Resolve(string command)
{
return ResolveWithTrace(command).Resolved;
}
private static string? ResolveFromPath(string executable)
{
var pathValue = Environment.GetEnvironmentVariable("PATH");
if (string.IsNullOrWhiteSpace(pathValue))
return null;
foreach (var segment in pathValue.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
try
{
var expandedSegment = ExpandWindowsPathTokens(segment.Trim());
var candidate = Path.Combine(expandedSegment, executable);
if (File.Exists(candidate))
{
// If PATH lookup hit extensionless npm but npm.cmd exists beside it, prefer npm.cmd.
var fileName = Path.GetFileName(candidate).ToLowerInvariant();
if (fileName is "npm" or "npx" or "pnpm" or "yarn" or "tauri")
{
var shim = candidate + ".cmd";
if (File.Exists(shim))
return shim;
}
return candidate;
}
}
catch
{
// Ignore malformed PATH segments.
}
}
return null;
}
private static string ExpandWindowsPathTokens(string segment)
{
if (string.IsNullOrWhiteSpace(segment) || !OperatingSystem.IsWindows())
return segment;
var expanded = segment;
for (var i = 0; i < 4; i++)
{
var next = Environment.ExpandEnvironmentVariables(expanded);
if (string.Equals(next, expanded, StringComparison.Ordinal))
break;
expanded = next;
}
return expanded;
}
private static List<string> BuildWindowsCandidates(string command, string normalized)
{
var candidates = new List<string>();
if (normalized is "npm" or "npx" or "pnpm" or "yarn" or "tauri")
{
candidates.Add(command + ".cmd");
candidates.Add(command + ".exe");
candidates.Add(command + ".bat");
candidates.Add(command);
}
else
{
candidates.Add(command);
}
return candidates;
}
private static bool IsUsableExecutable(string resolved)
{
if (string.IsNullOrWhiteSpace(resolved))
return false;
if (Path.IsPathRooted(resolved))
return File.Exists(resolved);
return ResolveFromPath(resolved) is not null;
}
}