Skip to content

Commit

Permalink
[WIP] Chisel MSBuild task
Browse files Browse the repository at this point in the history
  • Loading branch information
0xced committed Mar 4, 2024
1 parent bf8ef1f commit fbdb95e
Show file tree
Hide file tree
Showing 9 changed files with 731 additions and 13 deletions.
27 changes: 26 additions & 1 deletion src/Chisel.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
</PropertyGroup>

<PropertyGroup Label="Compiling">
Expand Down Expand Up @@ -55,7 +56,31 @@
</ItemGroup>

<ItemGroup>
<!-- <Content Include="Chisel.props;Chisel.targets" Pack="true" PackagePath="build/" />-->
<!-- <Content Include="$(OutputPath)/*.dll" Pack="true" PackagePath="tasks/" />-->
</ItemGroup>

<!-- https://learn.microsoft.com/en-us/visualstudio/msbuild/tutorial-custom-task-code-generation#bundle-dependencies-into-the-package -->
<PropertyGroup>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<TargetsForTfmSpecificBuildOutput>$(TargetsForTfmSpecificBuildOutput);CopyProjectReferencesToPackage</TargetsForTfmSpecificBuildOutput>
<BuildOutputTargetFolder>tasks</BuildOutputTargetFolder>
<NoWarn>NU5100</NoWarn>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
</PropertyGroup>
<Target Name="CopyProjectReferencesToPackage" DependsOnTargets="ResolveReferences">
<ItemGroup>
<!-- The TargetPath is the path inside the package that the source file will be placed. This is already precomputed in the ReferenceCopyLocalPaths items' DestinationSubPath, so reuse it here. -->
<BuildOutputInPackage Include="@(ReferenceCopyLocalPaths)" TargetPath="%(ReferenceCopyLocalPaths.DestinationSubPath)" />
</ItemGroup>
</Target>

<ItemGroup>
<PackageReference Include="CliWrap" Version="3.6.6" PrivateAssets="all" />
<PackageReference Include="GiGraph.Dot" Version="3.0.1" PrivateAssets="all" />
<PackageReference Include="Microsoft.Build.Utilities.Core" Version="17.9.5" PrivateAssets="all" ExcludeAssets="runtime" />
<PackageReference Include="MinVer" Version="5.0.0" PrivateAssets="all" />
<PackageReference Include="NuGet.ProjectModel" Version="6.9.1" PrivateAssets="all" />
</ItemGroup>

<Target Name="ValidateNuGetPackage" AfterTargets="Pack">
Expand Down
108 changes: 108 additions & 0 deletions src/ChiselTask.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using System;
using System.IO;
using System.Linq;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;

namespace Chisel;

/// <summary>
/// Task that determines which package to remove from the build.
/// </summary>
public class ChiselTask : Task
{
/// <summary>
/// The project assets file path (project.assets.json).
/// </summary>
[Required]
public string ProjectAssetsFile { get; set; } = "";

/// <summary>
/// The target framework.
/// </summary>
[Required]
public string TargetFramework { get; set; } = "";

/// <summary>
/// The runtime identifier (rid).
/// </summary>
[Required]
public string RuntimeIdentifier { get; set; } = "";

/// <summary>
/// The package references to remove from the build.
/// </summary>
[Required]
public ITaskItem[] ChiselPackages { get; set; } = [];

/// <summary>
///
/// </summary>
[Required]
public ITaskItem[] RuntimeAssemblies { get; set; } = [];

/// <summary>
///
/// </summary>
[Required]
public ITaskItem[] NativeLibraries { get; set; } = [];

/// <summary>
/// The intermediate output path where the <see cref="Graph"/> is saved.
/// </summary>
public string IntermediateOutputPath { get; set; } = "";

/// <summary>
/// The optional dependency graph file name.
/// If the file name ends with <c>.svg</c> then Graphviz <c>dot</c> command line is used to produce a SVG file.
/// Otherwise, a Graphviz <a href="https://graphviz.org/doc/info/lang.html">dot file</a> is written.
/// Use <c>false</c> to disable writing the dependency graph.
/// </summary>
public string Graph { get; set; } = "";

/// <summary>
/// The <c>RuntimeCopyLocalItems</c> to remove from the build.
/// </summary>
[Output]
public ITaskItem[] RemoveRuntimeAssemblies { get; private set; } = [];

/// <summary>
/// The <c>NativeCopyLocalItems</c> to remove from the build.
/// </summary>
[Output]
public ITaskItem[] RemoveNativeLibraries { get; private set; } = [];

/// <inheritdoc />
public override bool Execute()
{
try
{
var graph = new DependencyGraph(ProjectAssetsFile, TargetFramework, RuntimeIdentifier);
var removed = graph.Remove(ChiselPackages.Select(e => e.ItemSpec));

RemoveRuntimeAssemblies = RuntimeAssemblies.Where(item => removed.Contains(item.GetMetadata("NuGetPackageId"))).ToArray();
RemoveNativeLibraries = NativeLibraries.Where(item => removed.Contains(item.GetMetadata("NuGetPackageId"))).ToArray();

if (!string.IsNullOrEmpty(Graph) && !Graph.Equals("false", StringComparison.OrdinalIgnoreCase) && Graph == Path.GetFileName(Graph))
{
try
{
using var output = new FileStream(Path.Combine(IntermediateOutputPath, Graph), FileMode.Create);
var format = Path.GetExtension(output.Name).Equals(".svg", StringComparison.OrdinalIgnoreCase) ? GraphFormat.Svg : GraphFormat.Dot;
graph.WriteAsync(output, format).GetAwaiter().GetResult();
}
catch (Exception exception)
{
Log.LogWarningFromException(exception, showStackTrace: true);
}
}

return true;
}
catch (Exception exception)
{
Log.LogErrorFromException(exception, showStackTrace: true, showDetail: true, null);
return false;
}
}
}
180 changes: 180 additions & 0 deletions src/DependencyGraph.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CliWrap;
using GiGraph.Dot.Entities.Edges;
using GiGraph.Dot.Entities.Graphs;
using GiGraph.Dot.Extensions;
using GiGraph.Dot.Types.Nodes;
using NuGet.ProjectModel;

namespace Chisel;

internal class DependencyGraph
{
private readonly HashSet<Package> _roots;
private readonly Dictionary<Package, HashSet<Package>> _graph = new();
private readonly Dictionary<Package, HashSet<Package>> _reverseGraph = new();

private static Package CreatePackage(LockFileTargetLibrary library)
{
var name = library.Name ?? throw new ArgumentException("The library must have a name", nameof(library));
var version = library.Version?.ToString() ?? throw new ArgumentException("The library must have a version", nameof(library));
var dependencies = library.Dependencies.Select(e => e.Id).ToList();
return new Package(name, version, dependencies);
}

public DependencyGraph(string projectAssetsFile, string targetFramework, string runtimeIdentifier)
{
var assetsLockFile = new LockFileFormat().Read(projectAssetsFile);
var targetFrameworks = assetsLockFile.PackageSpec.TargetFrameworks.Where(e => e.TargetAlias == targetFramework).ToList();
var targetFrameworkInformation = targetFrameworks.Count switch
{
0 => throw new ArgumentException($"Target framework \"{targetFramework}\" is not available in \"{projectAssetsFile}\"", targetFramework),
1 => targetFrameworks[0],
_ => throw new ArgumentException($"Multiple target frameworks match \"{targetFramework}\" in \"{projectAssetsFile}\"", targetFramework),
};
var target = assetsLockFile.Targets.Single(e => e.TargetFramework == targetFrameworkInformation.FrameworkName && e.RuntimeIdentifier == runtimeIdentifier);
var packages = target.Libraries.ToDictionary(e => e.Name ?? "", CreatePackage);

_roots = new HashSet<Package>(targetFrameworkInformation.Dependencies.Select(e => packages[e.Name]));

foreach (var package in packages.Values)
{
var dependencies = new HashSet<Package>(package.Dependencies.Select(e => packages[e]));

if (dependencies.Count > 0)
{
_graph.Add(package, dependencies);
}

foreach (var dependency in dependencies)
{
if (_reverseGraph.TryGetValue(dependency, out var reverseDependencies))
{
reverseDependencies.Add(package);
}
else
{
_reverseGraph[dependency] = [package];
}
}
}
}

internal HashSet<string> Remove(IEnumerable<string> packages)
{
var notFound = new List<string>();
var dependencies = new HashSet<Package>();
foreach (var packageName in packages)
{
var packageDependency = _reverseGraph.Keys.SingleOrDefault(e => e.Name == packageName);
if (packageDependency == null)
{
notFound.Add(packageName);
}
else
{
dependencies.Add(packageDependency);
}
}

_ = notFound.Count switch
{
0 => 0,
1 => throw new ArgumentException($"\"{notFound[0]}\" was not found in the dependency graph.", nameof(packages)),
2 => throw new ArgumentException($"\"{notFound[0]}\" and \"{notFound[1]}\" were not found in the dependency graph.", nameof(packages)),
_ => throw new ArgumentException($"{string.Join(", ", notFound.Take(notFound.Count - 1).Select(e => $"\"{e}\""))} and \"{notFound.Last()}\" were not found in the dependency graph.", nameof(packages)),
};

foreach (var dependency in dependencies)
{
Remove(dependency);
Restore(dependency, dependencies);
}

return [.._reverseGraph.Keys.Where(e => !e.Keep).Select(e => e.Name)];
}

private void Remove(Package package)
{
package.Keep = false;
if (_graph.TryGetValue(package, out var dependencies))
{
foreach (var dependency in dependencies)
{
Remove(dependency);
}
}
}

private void Restore(Package package, ICollection<Package> removedPackages)
{
if ((_reverseGraph[package].Any(e => e.Keep) && !removedPackages.Contains(package)) || _roots.Contains(package))
{
package.Keep = true;
}

if (_graph.TryGetValue(package, out var dependencies))
{
foreach (var dependency in dependencies)
{
Restore(dependency, removedPackages);
}
}
}

public async Task WriteAsync(Stream output, GraphFormat format)
{
var dotGraph = new DotGraph
{
Nodes =
{
Shape = DotNodeShape.Box,
Style = { FillStyle = DotNodeFillStyle.Normal },
Font = { Name = "Segoe UI, sans-serif" },
},
};

foreach (var package in _reverseGraph.Keys.Union(_roots).OrderBy(e => e.Name))
{
dotGraph.Nodes.Add(package.Id, node =>
{
node.Color = package.Keep ? Color.Aquamarine : Color.LightCoral;
node.Attributes.Collection.Set("URL", $"https://www.nuget.org/packages/{package.Name}/{package.Version}");
node.Attributes.Collection.Set("target", "_blank");
});
}

foreach (var (package, dependencies) in _graph.Select(e => (e.Key, e.Value)))
{
foreach (var dependency in dependencies)
{
dotGraph.Edges.Add(new DotEdge(package.Id, dependency.Id));
}
}

void WriteGraph(Stream stream)
{
using var writer = new StreamWriter(stream);
dotGraph.Build(writer);
}

if (format == GraphFormat.Dot)
{
WriteGraph(output);
return;
}

var input = PipeSource.Create(WriteGraph);

var dot = Cli.Wrap("dot")
.WithArguments([ $"-T{format.ToString().ToLowerInvariant()}" ])
.WithStandardErrorPipe(PipeTarget.ToDelegate(Console.Error.WriteLine));

await (input | dot | output).ExecuteAsync();
}
}
7 changes: 7 additions & 0 deletions src/GraphFormat.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Chisel;

internal enum GraphFormat
{
Dot,
Svg,
}
42 changes: 42 additions & 0 deletions src/Package.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;

namespace Chisel;

[DebuggerDisplay("{Name}/{Version}")]
internal class Package : IEquatable<Package>
{
public Package(string name, string version, IReadOnlyCollection<string> dependencies)
{
Name = name;
Version = version;
Dependencies = dependencies;
}

public string Name { get; }
public string Version { get; }
public IReadOnlyCollection<string> Dependencies { get; }

public string Id => $"{Name}/{Version}";

public bool Keep { get; set; } = true;

public override string ToString() => Name;

public bool Equals(Package? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return Name == other.Name;
}

public override bool Equals(object? obj)
{
if (ReferenceEquals(null, obj)) return false;
if (ReferenceEquals(this, obj)) return true;
return obj.GetType() == GetType() && Equals((Package)obj);
}

public override int GetHashCode() => Name.GetHashCode();
}
7 changes: 7 additions & 0 deletions src/build/Chisel.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Project>

<PropertyGroup>
<ChiselEnabled Condition="$(ChiselEnabled) == ''">true</ChiselEnabled>
</PropertyGroup>

</Project>
Loading

0 comments on commit fbdb95e

Please sign in to comment.