Skip to content

Commit

Permalink
[WIP] nugraph
Browse files Browse the repository at this point in the history
  • Loading branch information
0xced committed May 7, 2024
1 parent c170202 commit 4788554
Show file tree
Hide file tree
Showing 9 changed files with 531 additions and 0 deletions.
7 changes: 7 additions & 0 deletions Chisel.sln
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SqlClientSample", "samples\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Identity.Client", "samples\Microsoft.Identity.Client\Microsoft.Identity.Client.csproj", "{F40FA01A-EB18-4785-9A3C-379F2E7A7A02}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "nugraph", "src\nugraph\nugraph.csproj", "{7E6E162B-49CA-4D32-8DD9-EAD5E6FE253C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -60,6 +62,10 @@ Global
{F40FA01A-EB18-4785-9A3C-379F2E7A7A02}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F40FA01A-EB18-4785-9A3C-379F2E7A7A02}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F40FA01A-EB18-4785-9A3C-379F2E7A7A02}.Release|Any CPU.Build.0 = Release|Any CPU
{7E6E162B-49CA-4D32-8DD9-EAD5E6FE253C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7E6E162B-49CA-4D32-8DD9-EAD5E6FE253C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7E6E162B-49CA-4D32-8DD9-EAD5E6FE253C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7E6E162B-49CA-4D32-8DD9-EAD5E6FE253C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{845EDA2A-5207-4C6D-ABE9-9635F4630D90} = {0CC84E67-19D2-480B-B36A-6BB15A9109E7}
Expand All @@ -68,5 +74,6 @@ Global
{8B1B3D6A-7100-4DFB-97C9-CF5ACF1A3B08} = {AC8C6685-EDF9-443A-BAF6-A5E7CF777B2A}
{611D4DE0-F729-48A6-A496-2EA3B5DF8EC6} = {0CC84E67-19D2-480B-B36A-6BB15A9109E7}
{F40FA01A-EB18-4785-9A3C-379F2E7A7A02} = {0CC84E67-19D2-480B-B36A-6BB15A9109E7}
{7E6E162B-49CA-4D32-8DD9-EAD5E6FE253C} = {89268D80-B21D-4C76-AF7F-796AAD1E00D9}
EndGlobalSection
EndGlobal
69 changes: 69 additions & 0 deletions src/nugraph/Dotnet.cs
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);
}
234 changes: 234 additions & 0 deletions src/nugraph/GraphCommand.cs
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();
}
}
42 changes: 42 additions & 0 deletions src/nugraph/JsonPipeTarget.cs
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;
}
}
}

0 comments on commit 4788554

Please sign in to comment.