-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
9 changed files
with
531 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
using System; | ||
using System.Collections.Generic; | ||
using System.IO; | ||
using System.Linq; | ||
using System.Text; | ||
using System.Text.Json.Serialization; | ||
using System.Threading.Tasks; | ||
using CliWrap; | ||
|
||
namespace nugraph; | ||
|
||
internal static partial class Dotnet | ||
{ | ||
private static readonly Dictionary<string, string?> EnvironmentVariables = new() { ["DOTNET_NOLOGO"] = "true" }; | ||
|
||
public static async Task<ProjectInfo> RestoreAsync(FileSystemInfo? source) | ||
{ | ||
var arguments = new List<string> { "restore" }; // may use "build" instead of "restore" if the project is an exe | ||
if (source != null) | ||
{ | ||
arguments.Add(source.FullName); | ||
} | ||
// arguments.Add("--target:ResolvePackageAssets"); // may enable if the project is an exe in order to get RuntimeCopyLocalItems + NativeCopyLocalItems | ||
arguments.Add($"--getProperty:{nameof(Property.ProjectAssetsFile)}"); | ||
arguments.Add($"--getProperty:{nameof(Property.TargetFramework)}"); | ||
arguments.Add($"--getProperty:{nameof(Property.TargetFrameworks)}"); | ||
arguments.Add($"--getItem:{nameof(Item.RuntimeCopyLocalItems)}"); | ||
arguments.Add($"--getItem:{nameof(Item.NativeCopyLocalItems)}"); | ||
|
||
var dotnet = Cli.Wrap("dotnet").WithArguments(arguments).WithEnvironmentVariables(EnvironmentVariables).WithValidation(CommandResultValidation.None); | ||
var jsonPipe = new JsonPipeTarget<Result>(SourceGenerationContext.Default.Result, dotnet, c => new Exception($"Running \"{c}\" in \"{c.WorkingDirPath}\" returned a literal 'null' JSON payload")); | ||
var stdout = new StringBuilder(); | ||
var stderr = new StringBuilder(); | ||
var commandResult = await dotnet | ||
.WithStandardOutputPipe(PipeTarget.Merge(jsonPipe, PipeTarget.ToStringBuilder(stdout))) | ||
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stderr)).ExecuteAsync(); | ||
|
||
if (!commandResult.IsSuccess) | ||
{ | ||
var message = stderr.Length > 0 ? stderr.ToString() : stdout.ToString(); | ||
throw new Exception($"Running \"{dotnet}\" in \"{dotnet.WorkingDirPath}\" failed with exit code {commandResult.ExitCode}.{Environment.NewLine}{message}"); | ||
} | ||
|
||
var properties = jsonPipe.Result.Properties; | ||
var items = jsonPipe.Result.Items; | ||
var copyLocalPackages = items.RuntimeCopyLocalItems.Concat(items.NativeCopyLocalItems).Select(e => e.NuGetPackageId).ToHashSet(); | ||
return new ProjectInfo(properties.ProjectAssetsFile, properties.GetTargetFrameworks(), copyLocalPackages); | ||
} | ||
|
||
public record ProjectInfo(string ProjectAssetsFile, IReadOnlyCollection<string> TargetFrameworks, IReadOnlyCollection<string> CopyLocalPackages); | ||
|
||
[JsonSerializable(typeof(Result))] | ||
private partial class SourceGenerationContext : JsonSerializerContext; | ||
|
||
private record Result(Property Properties, Item Items); | ||
|
||
private record Property(string ProjectAssetsFile, string TargetFramework, string TargetFrameworks) | ||
{ | ||
public IReadOnlyCollection<string> GetTargetFrameworks() | ||
{ | ||
var targetFrameworks = TargetFrameworks.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToHashSet(); | ||
return targetFrameworks.Count > 0 ? targetFrameworks : [TargetFramework]; | ||
} | ||
} | ||
|
||
private record Item(CopyLocalItem[] RuntimeCopyLocalItems, CopyLocalItem[] NativeCopyLocalItems); | ||
|
||
private record CopyLocalItem(string NuGetPackageId); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,234 @@ | ||
using System; | ||
using System.Collections.Generic; | ||
using System.ComponentModel; | ||
using System.Diagnostics; | ||
using System.IO; | ||
using System.Linq; | ||
using System.Reflection; | ||
using System.Threading.Tasks; | ||
using Chisel; | ||
using NuGet.Commands; | ||
using NuGet.Common; | ||
using NuGet.Configuration; | ||
using NuGet.Frameworks; | ||
using NuGet.LibraryModel; | ||
using NuGet.ProjectModel; | ||
using NuGet.Protocol.Core.Types; | ||
using NuGet.Versioning; | ||
using OneOf; | ||
using Spectre.Console; | ||
using Spectre.Console.Cli; | ||
|
||
namespace nugraph; | ||
|
||
[GenerateOneOf] | ||
public partial class FileOrPackages : OneOfBase<FileSystemInfo?, string[]> | ||
{ | ||
public override string ToString() => Match(file => file?.FullName ?? Environment.CurrentDirectory, ids => string.Join(", ", ids)); | ||
} | ||
|
||
internal class GraphCommand(IAnsiConsole console) : AsyncCommand<GraphCommand.GraphSettings> | ||
{ | ||
internal class GraphSettings : CommandSettings | ||
{ | ||
[CommandArgument(0, "[SOURCE]")] | ||
[Description("The source of the graph. Can be either an existing directory, an existing file or names of NuGet packages.")] | ||
public string[] Sources { get; init; } = []; | ||
|
||
internal FileOrPackages GetSource() | ||
{ | ||
if (Sources.Length == 0) | ||
return (FileSystemInfo?)null; | ||
|
||
if (Sources.Length == 1) | ||
{ | ||
var file = new FileInfo(Sources[0]); | ||
if (file.Exists) | ||
{ | ||
return file; | ||
} | ||
|
||
var directory = new DirectoryInfo(Sources[0]); | ||
if (directory.Exists) | ||
{ | ||
return directory; | ||
} | ||
} | ||
|
||
return Sources; | ||
} | ||
|
||
[CommandOption("-V|--version")] | ||
[Description("Prints version information")] | ||
public bool PrintVersion { get; init; } | ||
|
||
[CommandOption("-o|--output <OUTPUT>")] | ||
[Description("The path to the dependency graph output file. If not specified, the dependency graph is URL is written on the standard output and opened in the browser.")] | ||
public FileInfo? OutputFile { get; init; } | ||
|
||
[CommandOption("-f|--framework <FRAMEWORK>")] | ||
[Description("The target framework to consider when building the dependency graph.")] | ||
public string? Framework { get; init; } | ||
|
||
[CommandOption("-r|--runtime <RUNTIME_IDENTIFIER>")] | ||
[Description("The target runtime to consider when building the dependency graph.")] | ||
public string? RuntimeIdentifier { get; init; } | ||
|
||
[CommandOption("-m|--mode <MERMAID_MODE>")] | ||
[Description($"The mode to use for the Mermaid Live Editor (https://mermaid.live). Possible values are [b]{nameof(MermaidEditorMode.View)}[/] and [b]{nameof(MermaidEditorMode.Edit)}[/]. " + | ||
$"Used only when no output path is specified.")] | ||
[DefaultValue(MermaidEditorMode.View)] | ||
public MermaidEditorMode MermaidEditorMode { get; init; } | ||
|
||
[CommandOption("-d|--direction <GRAPH_DIRECTION>")] | ||
[Description($"The direction of the dependency graph. Possible values are [b]{nameof(GraphDirection.LeftToRight)}[/] and [b]{nameof(GraphDirection.TopToBottom)}[/]")] | ||
[DefaultValue(GraphDirection.LeftToRight)] | ||
public GraphDirection GraphDirection { get; init; } | ||
|
||
[CommandOption("-v|--package-version")] | ||
[Description("Include package versions in the dependency graph. E.g. [b]Serilog/3.1.1[/] instead of [b]Serilog[/]")] | ||
[DefaultValue(false)] | ||
public bool GraphIncludeVersions { get; init; } | ||
|
||
[CommandOption("-i|--ignore")] | ||
[Description("Ignored packages in the dependency graph.")] | ||
public string[] GraphIgnore { get; init; } = []; | ||
|
||
[CommandOption("--include-ignored-packages", IsHidden = true)] | ||
[Description("Include ignored packages in the dependency graph. Used for debugging.")] | ||
[DefaultValue(false)] | ||
public bool GraphWriteIgnoredPackages { get; init; } | ||
} | ||
|
||
public override async Task<int> ExecuteAsync(CommandContext commandContext, GraphSettings settings) | ||
{ | ||
if (settings.PrintVersion) | ||
{ | ||
console.WriteLine($"nugraph {GetVersion()}"); | ||
return 0; | ||
} | ||
|
||
var source = settings.GetSource(); | ||
var url = await console.Status().StartAsync($"Generating dependency graph for {source}", async _ => | ||
{ | ||
var graph = await source.Match( | ||
f => ComputeDependencyGraphAsync(f, settings), | ||
f => ComputeDependencyGraphAsync(f, settings, new SpectreLogger(console, LogLevel.Debug)) | ||
); | ||
var mermaidData = await WriteGraphAsync(graph, settings); | ||
return mermaidData != null ? Mermaid.GetLiveEditorUri(mermaidData, settings.MermaidEditorMode) : null; | ||
}); | ||
|
||
if (url != null) | ||
{ | ||
var fileName = url.ToString(); | ||
console.WriteLine(fileName); | ||
Process.Start(new ProcessStartInfo(fileName) { UseShellExecute = true }); | ||
} | ||
else | ||
{ | ||
var outputFile = settings.OutputFile?.FullName; | ||
if (outputFile != null) | ||
console.MarkupLineInterpolated($"The {source} dependency graph has been written to [lime]{new Uri(outputFile)}[/]"); | ||
} | ||
|
||
return 0; | ||
} | ||
|
||
private static string GetVersion() | ||
{ | ||
var assembly = typeof(GraphCommand).Assembly; | ||
var version = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? assembly.GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version; | ||
if (version == null) | ||
return "?"; | ||
|
||
return SemanticVersion.TryParse(version, out var semanticVersion) ? semanticVersion.ToNormalizedString() : version; | ||
} | ||
|
||
private static async Task<DependencyGraph> ComputeDependencyGraphAsync(FileSystemInfo? source, GraphSettings settings) | ||
{ | ||
var projectInfo = await Dotnet.RestoreAsync(source); | ||
var targetFramework = settings.Framework ?? projectInfo.TargetFrameworks.First(); | ||
var lockFile = new LockFileFormat().Read(projectInfo.ProjectAssetsFile); | ||
Predicate<Package> filter = projectInfo.CopyLocalPackages.Count > 0 ? package => projectInfo.CopyLocalPackages.Contains(package.Name) : _ => true; | ||
var (packages, roots) = lockFile.ReadPackages(targetFramework, settings.RuntimeIdentifier, filter); | ||
return new DependencyGraph(packages, roots, ignores: settings.GraphIgnore); | ||
} | ||
|
||
private static async Task<DependencyGraph> ComputeDependencyGraphAsync(string[] packageIds, GraphSettings settings, ILogger logger) | ||
{ | ||
using var cacheContext = new SourceCacheContext(); | ||
var dependencyGraphSpec = new DependencyGraphSpec(isReadOnly: true); | ||
var projectName = $"Dependency graph of {string.Join(", ", packageIds)}"; | ||
var path = $"{projectName}.csproj"; | ||
var framework = settings.Framework == null ? FrameworkConstants.CommonFrameworks.NetStandard20 : NuGetFramework.Parse(settings.Framework); | ||
IList<TargetFrameworkInformation> targetFrameworks = [ new TargetFrameworkInformation { FrameworkName = framework } ]; | ||
// TODO: loop over packageIds and get the default version my querying the package sources (see dotnet-validate => PackageDownloader) | ||
var version = new NuGetVersion(8, 0, 2); | ||
var projectSpec = new PackageSpec(targetFrameworks) | ||
{ | ||
FilePath = path, | ||
Name = projectName, | ||
Version = version, | ||
RestoreMetadata = new ProjectRestoreMetadata | ||
{ | ||
ProjectName = projectName, | ||
ProjectPath = path, | ||
ProjectUniqueName = path, | ||
ProjectStyle = ProjectStyle.PackageReference, | ||
OutputPath = OperatingSystem.IsWindows() ? "NUL" : "/dev/null", // Required, else we get NuGet.Commands.RestoreSpecException: Invalid restore input. Missing required property 'OutputPath' for project type 'PackageReference'. | ||
OriginalTargetFrameworks = targetFrameworks.Select(e => e.ToString()).ToList(), | ||
}, | ||
Dependencies = { new LibraryDependency(new LibraryRange(packageIds.First(), new VersionRange(version), LibraryDependencyTarget.Package)) }, | ||
}; | ||
dependencyGraphSpec.AddProject(projectSpec); | ||
dependencyGraphSpec.AddRestore(projectSpec.RestoreMetadata.ProjectUniqueName); | ||
|
||
var nugetSettings = Settings.LoadDefaultSettings(null); | ||
var restoreCommandProvidersCache = new RestoreCommandProvidersCache(); | ||
var dependencyGraphSpecRequestProvider = new DependencyGraphSpecRequestProvider(restoreCommandProvidersCache, dependencyGraphSpec, nugetSettings); | ||
var restoreContext = new RestoreArgs | ||
{ | ||
CacheContext = cacheContext, | ||
Log = logger, | ||
GlobalPackagesFolder = SettingsUtility.GetGlobalPackagesFolder(nugetSettings), | ||
PreLoadedRequestProviders = [ dependencyGraphSpecRequestProvider ], | ||
}; | ||
|
||
#if true | ||
var requests = await RestoreRunner.GetRequests(restoreContext); | ||
var result = await RestoreRunner.RunWithoutCommit(requests, restoreContext); | ||
var lockFile = result.Single().Result.LockFile; | ||
#else | ||
var result = await RestoreRunner.RunAsync(restoreContext); | ||
var errors = result.SelectMany(e => e.Errors).ToList(); | ||
if (errors.Count > 0) | ||
throw new Exception(string.Join(Environment.NewLine, errors.Select(e => $"[{e.Code}] {e.Message}"))); | ||
var lockFile = new LockFileFormat().Read(Path.Combine(projectSpec.RestoreMetadata.OutputPath, LockFileFormat.AssetsFileName)); | ||
#endif | ||
|
||
var (packages, roots) = lockFile.ReadPackages(targetFrameworks.First().TargetAlias, settings.RuntimeIdentifier); | ||
return new DependencyGraph(packages, roots, ignores: []); | ||
} | ||
|
||
private static async Task<byte[]?> WriteGraphAsync(DependencyGraph graph, GraphSettings settings) | ||
{ | ||
var fileStream = settings.OutputFile?.OpenWrite(); | ||
var memoryStream = fileStream == null ? new MemoryStream() : null; | ||
var stream = (fileStream ?? memoryStream as Stream)!; | ||
await using (var streamWriter = new StreamWriter(stream)) | ||
{ | ||
var isMermaid = fileStream == null || Path.GetExtension(fileStream.Name) is ".mmd" or ".mermaid"; | ||
var graphWriter = isMermaid ? GraphWriter.Mermaid(streamWriter) : GraphWriter.Graphviz(streamWriter); | ||
var graphOptions = new GraphOptions | ||
{ | ||
Direction = settings.GraphDirection, | ||
IncludeVersions = settings.GraphIncludeVersions, | ||
WriteIgnoredPackages = settings.GraphWriteIgnoredPackages, | ||
}; | ||
graphWriter.Write(graph, graphOptions); | ||
} | ||
|
||
return memoryStream?.ToArray(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
using System; | ||
using System.IO; | ||
using System.Text.Json; | ||
using System.Text.Json.Serialization.Metadata; | ||
using System.Threading; | ||
using System.Threading.Tasks; | ||
using CliWrap; | ||
|
||
namespace nugraph; | ||
|
||
internal class JsonPipeTarget<T>(JsonTypeInfo<T> jsonTypeInfo, Command command, Func<Command, Exception> exception) : PipeTarget | ||
{ | ||
private T? _result; | ||
private JsonException? _exception; | ||
|
||
public override async Task CopyFromAsync(Stream stream, CancellationToken cancellationToken = default) | ||
{ | ||
try | ||
{ | ||
_result = await JsonSerializer.DeserializeAsync(stream, jsonTypeInfo, cancellationToken) ?? throw exception(command); | ||
} | ||
catch (JsonException jsonException) | ||
{ | ||
_exception = jsonException; | ||
} | ||
} | ||
|
||
public T Result | ||
{ | ||
get | ||
{ | ||
if (_result == null) | ||
{ | ||
if (_exception != null) | ||
throw _exception; | ||
|
||
throw new InvalidOperationException($"Result is only available after {nameof(CopyFromAsync)} has executed."); | ||
} | ||
return _result; | ||
} | ||
} | ||
} |
Oops, something went wrong.