Skip to content

Commit

Permalink
[WIP] New nugraph global tool to create dependency graphs from the co…
Browse files Browse the repository at this point in the history
…mmand line
  • Loading branch information
0xced committed Dec 23, 2024
1 parent 68332d6 commit dd7e516
Show file tree
Hide file tree
Showing 11 changed files with 724 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
80 changes: 80 additions & 0 deletions src/nugraph/Dotnet.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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
{
public static async Task<ProjectInfo> RestoreAsync(FileSystemInfo? source)
{
var stdout = new StringBuilder();
var stderr = new StringBuilder();
var jsonPipe = new JsonPipeTarget<Result>(SourceGenerationContext.Default.Result);
var dotnet = Cli.Wrap("dotnet")
.WithArguments(args =>
{
args.Add("restore");
if (source != null)
{
args.Add(source.FullName);
}

// !!! Requires a recent .NET SDK (see https://github.com/dotnet/msbuild/issues/3911)
// arguments.Add("--target:ResolvePackageAssets"); // may enable if the project is an exe in order to get RuntimeCopyLocalItems + NativeCopyLocalItems
args.Add($"--getProperty:{nameof(Property.ProjectAssetsFile)}");
args.Add($"--getProperty:{nameof(Property.TargetFramework)}");
args.Add($"--getProperty:{nameof(Property.TargetFrameworks)}");
args.Add($"--getItem:{nameof(Item.RuntimeCopyLocalItems)}");
args.Add($"--getItem:{nameof(Item.NativeCopyLocalItems)}");
})
.WithEnvironmentVariables(env => env
.Set("DOTNET_NOLOGO", "1")
.Set("DOTNET_CLI_UI_LANGUAGE", "en")
)
.WithValidation(CommandResultValidation.None)
.WithStandardOutputPipe(PipeTarget.Merge(jsonPipe, PipeTarget.ToStringBuilder(stdout)))
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stderr));

var commandResult = await dotnet.ExecuteAsync();

if (!commandResult.IsSuccess)
{
var message = stderr.Length > 0 && stdout.Length > 0 ? $"{stderr}{Environment.NewLine}{stdout}" : $"{stderr}{stdout}";
if (message.Contains("MSB1001"))
{
throw new Exception($"nugraph requires the .NET 8 SDK. Make sure that it's installed and that the global.json file (if any) is configured to use it.");
}
throw new Exception($"Running \"{dotnet}\" in \"{dotnet.WorkingDirPath}\" failed with exit code {commandResult.ExitCode}.{Environment.NewLine}{message}");
}

var (properties, items) = jsonPipe.Result ?? throw new Exception($"Running \"{dotnet}\" in \"{dotnet.WorkingDirPath}\" returned a literal 'null' JSON payload");
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);
}
182 changes: 182 additions & 0 deletions src/nugraph/GraphCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Chisel;
using NuGet.Commands;
using NuGet.Common;
using NuGet.Configuration;
using NuGet.Frameworks;
using NuGet.LibraryModel;
using NuGet.Packaging.Core;
using NuGet.ProjectModel;
using NuGet.Protocol.Core.Types;
using NuGet.Versioning;
using OneOf;
using Spectre.Console;
using Spectre.Console.Cli;

namespace nugraph;

[GenerateOneOf]
internal partial class FileOrPackage : OneOfBase<FileSystemInfo?, PackageIdentity>
{
public override string ToString() => Match(file => file?.FullName ?? Environment.CurrentDirectory, package => package.ToString());
}

[Description("Generates dependency graphs for .NET projects and NuGet packages.")]
internal class GraphCommand(IAnsiConsole console) : AsyncCommand<GraphCommandSettings>
{
public override async Task<int> ExecuteAsync(CommandContext commandContext, GraphCommandSettings settings)
{
if (settings.PrintVersion)
{
console.WriteLine($"nugraph {GetVersion()}");
return 0;
}

var source = settings.Source;
var graphUrl = await console.Status().StartAsync($"Generating dependency graph for {source}", async _ =>
{
var graph = await source.Match(
file => ComputeDependencyGraphAsync(file, settings),
package => ComputeDependencyGraphAsync(package, settings, new SpectreLogger(console, settings.LogLevel), CancellationToken.None)
);
return await WriteGraphAsync(graph, settings);
});

if (graphUrl != null)
{
var url = graphUrl.ToString();
console.WriteLine(url);
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
else if (settings.OutputFile != null)
{
console.MarkupLineInterpolated($"The {source} dependency graph has been written to [lime]{new Uri(settings.OutputFile.FullName)}[/]");
}

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 "0.0.0";

return SemanticVersion.TryParse(version, out var semanticVersion) ? semanticVersion.ToNormalizedString() : version;
}

private static async Task<DependencyGraph> ComputeDependencyGraphAsync(FileSystemInfo? source, GraphCommandSettings 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(PackageIdentity package, GraphCommandSettings settings, ILogger logger, CancellationToken cancellationToken)
{
var nugetSettings = Settings.LoadDefaultSettings(null);
using var sourceCacheContext = new SourceCacheContext();
var packageSources = GetPackageSources(nugetSettings, logger);
var packageIdentityResolver = new NuGetPackageResolver(nugetSettings, logger, packageSources, sourceCacheContext);

var packageInfo = await packageIdentityResolver.ResolvePackageInfoAsync(package, cancellationToken);

var dependencyGraphSpec = new DependencyGraphSpec(isReadOnly: true);
var projectName = $"dependency graph of {packageInfo.PackageIdentity}";
// TODO: Figure out how to best guess which framework to use if none is specified.
var targetFramework = packageInfo.DependencyGroups.Select(e => e.TargetFramework).OrderBy(e => e, NuGetFrameworkSorter.Instance).FirstOrDefault();
var framework = settings.Framework == null ? targetFramework : NuGetFramework.Parse(settings.Framework);
IList<TargetFrameworkInformation> targetFrameworks = [ new TargetFrameworkInformation { FrameworkName = framework } ];
var projectSpec = new PackageSpec(targetFrameworks)
{
FilePath = projectName,
Name = projectName,
RestoreMetadata = new ProjectRestoreMetadata
{
ProjectName = projectName,
ProjectPath = projectName,
ProjectUniqueName = Guid.NewGuid().ToString(),
ProjectStyle = ProjectStyle.PackageReference,
// The output path is required, else we get NuGet.Commands.RestoreSpecException: Invalid restore input. Missing required property 'OutputPath' for project type 'PackageReference'.
// But it won't be used anyway since restore is performed with RestoreRunner.RunWithoutCommit instead of RestoreRunner.RunAsync
OutputPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.InternetCache), "nugraph"),
OriginalTargetFrameworks = targetFrameworks.Select(e => e.ToString()).ToList(),
Sources = packageSources,
},
Dependencies = [new LibraryDependency(new LibraryRange(packageInfo.PackageIdentity.Id, new VersionRange(packageInfo.PackageIdentity.Version), LibraryDependencyTarget.Package))],
};
dependencyGraphSpec.AddProject(projectSpec);
dependencyGraphSpec.AddRestore(projectSpec.RestoreMetadata.ProjectUniqueName);

var restoreCommandProvidersCache = new RestoreCommandProvidersCache();
var dependencyGraphSpecRequestProvider = new DependencyGraphSpecRequestProvider(restoreCommandProvidersCache, dependencyGraphSpec, nugetSettings);
var restoreContext = new RestoreArgs
{
CacheContext = sourceCacheContext,
Log = logger,
GlobalPackagesFolder = SettingsUtility.GetGlobalPackagesFolder(nugetSettings),
PreLoadedRequestProviders = [ dependencyGraphSpecRequestProvider ],
};

var requests = await RestoreRunner.GetRequests(restoreContext);
// TODO: Single() => how can I be sure? If only one request? And how can I be sure that there's only one request created out of the restore context?
var restoreResultPair = (await RestoreRunner.RunWithoutCommit(requests, restoreContext)).Single();
// TODO: filter log messages, only those with LogLevel == Error ?
if (!restoreResultPair.Result.Success)
throw new Exception(string.Join(Environment.NewLine, restoreResultPair.Result.LogMessages.Select(e => $"[{e.Code}] {e.Message}")));

var lockFile = restoreResultPair.Result.LockFile;
// TODO: build the package and roots out of restoreResultPair.Result.RestoreGraphs instead of the lock file?
var (packages, roots) = lockFile.ReadPackages(targetFrameworks.First().TargetAlias, settings.RuntimeIdentifier);
return new DependencyGraph(packages, roots, settings.GraphIgnore);
}

private static List<PackageSource> GetPackageSources(ISettings settings, ILogger logger)
{
var packageSourceProvider = new PackageSourceProvider(settings);
var packageSources = packageSourceProvider.LoadPackageSources().Where(e => e.IsEnabled).Distinct().ToList();

if (packageSources.Count == 0)
{
var officialPackageSource = new PackageSource(NuGetConstants.V3FeedUrl, NuGetConstants.NuGetHostName);
packageSources.Add(officialPackageSource);
var configFilePaths = settings.GetConfigFilePaths().Distinct();
logger.LogWarning($"No NuGet sources could be found in {string.Join(", ", configFilePaths)}. Using {officialPackageSource}");
}

return packageSources;
}

private static async Task<Uri?> WriteGraphAsync(DependencyGraph graph, GraphCommandSettings settings)
{
await using var fileStream = settings.OutputFile?.OpenWrite();
await using var memoryStream = fileStream == null ? new MemoryStream(capacity: 2048) : null;
var stream = (fileStream ?? memoryStream as Stream)!;
await using (var streamWriter = new StreamWriter(stream, leaveOpen: true))
{
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 == null ? null : Mermaid.GetLiveEditorUri(memoryStream.GetBuffer().AsSpan(0, Convert.ToInt32(memoryStream.Position)), settings.MermaidEditorMode);
}
}
Loading

0 comments on commit dd7e516

Please sign in to comment.