Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions src/Fallout.Cli/BuildScaffolder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Xml.Linq;
using Fallout.Common;
using Fallout.Common.IO;
using Fallout.Common.Utilities;
using static Fallout.Common.Constants;
using static Fallout.Common.EnvironmentInfo;
using static Fallout.Common.Tooling.ProcessTasks;
using static Fallout.Common.Utilities.TemplateUtility;

namespace Fallout.Cli;

/// <summary>Writes the build scripts, configuration file and solution wiring when scaffolding a build.</summary>
internal interface IBuildScaffolder
{
void WriteBuildScripts(AbsolutePath scriptDirectory, AbsolutePath rootDirectory);
void WriteConfigurationFile(AbsolutePath rootDirectory, AbsolutePath solutionFile);
void UpdateSolutionFileContent(List<string> content, string buildProjectFileRelative, string buildProjectGuid, string buildProjectName);
void UpdateSolutionXmlFileContent(XDocument content, string buildProjectFileRelative);
string[] GetTemplate(string templateName);
}

/// <inheritdoc />
internal sealed class BuildScaffolder : IBuildScaffolder
{
private const string PROJECT_KIND = "9A19103F-16F7-4668-BE54-9A1E7A4F7556";

public string[] GetTemplate(string templateName)
{
// Anchored to the Fallout.Cli root namespace where the templates/* resources are embedded.
return ResourceUtility.GetResourceAllLines<BuildScaffolder>($"templates.{templateName}");
}

public void WriteBuildScripts(
AbsolutePath scriptDirectory,
AbsolutePath rootDirectory)
{
(scriptDirectory / "build.sh").WriteAllLines(
FillTemplate(
GetTemplate("build.sh"),
tokens: GetDictionary(
new
{
RootDirectory = scriptDirectory.GetUnixRelativePathTo(rootDirectory),
})),
platformFamily: PlatformFamily.Linux);

(scriptDirectory / "build.ps1").WriteAllLines(
FillTemplate(
GetTemplate("build.ps1"),
tokens: GetDictionary(
new
{
RootDirectory = scriptDirectory.GetWinRelativePathTo(rootDirectory),
})),
platformFamily: PlatformFamily.Windows);

// .config/dotnet-tools.json pins Fallout.GlobalTools as a local tool so the thin shims
// (build.sh / build.ps1) can `dotnet tool restore` and `dotnet fallout` deterministically.
// Skip if the consumer already has a manifest — they may have other tools pinned and we
// don't want to clobber. They can add the `fallout.globaltools` entry manually.
var toolManifest = rootDirectory / ".config" / "dotnet-tools.json";
if (!toolManifest.FileExists())
{
(rootDirectory / ".config").CreateDirectory();
toolManifest.WriteAllLines(
FillTemplate(
GetTemplate("dotnet-tools.json"),
tokens: GetDictionary(
new
{
FalloutCliVersion = typeof(BuildScaffolder).GetTypeInfo().Assembly.GetVersionText(),
})));
}

MakeExecutable(scriptDirectory / "build.sh");

void MakeExecutable(AbsolutePath scriptFile)
{
if (rootDirectory.ContainsDirectory(".git"))
StartProcess("git", $"update-index --add --chmod=+x {scriptFile}", logInvocation: false, logOutput: false);

if (rootDirectory.ContainsDirectory(".svn"))
StartProcess("svn", $"propset svn:executable on {scriptFile}", logInvocation: false, logOutput: false);

if (IsUnix)
StartProcess("chmod", $"+x {scriptFile}", logInvocation: false, logOutput: false);
}
}

public void WriteConfigurationFile(AbsolutePath rootDirectory, AbsolutePath solutionFile)
{
var parametersFile = GetDefaultParametersFile(rootDirectory);
var dictionary = new Dictionary<string, string> { ["$schema"] = BuildSchemaFileName };
if (solutionFile != null)
dictionary["Solution"] = rootDirectory.GetUnixRelativePathTo(solutionFile).ToString();
parametersFile.WriteJson(dictionary, JsonExtensions.DefaultSerializerOptions);
}

public void UpdateSolutionFileContent(
List<string> content,
string buildProjectFileRelative,
string buildProjectGuid,
string buildProjectName)
{
if (content.Any(x => x.Contains(buildProjectFileRelative)))
return;

var globalIndex = content.IndexOf("Global");
Assert.True(globalIndex != -1, "Could not find a 'Global' section in solution file");

var projectConfigurationIndex = content.FindIndex(x => x.Contains("GlobalSection(ProjectConfigurationPlatforms)"));
if (projectConfigurationIndex == -1)
{
var solutionConfigurationIndex = content.FindIndex(x => x.Contains("GlobalSection(SolutionConfigurationPlatforms)"));
if (solutionConfigurationIndex == -1)
{
content.Insert(globalIndex + 1, "\tGlobalSection(SolutionConfigurationPlatforms) = preSolution");
content.Insert(globalIndex + 2, "\t\tDebug|Any CPU = Debug|Any CPU");
content.Insert(globalIndex + 3, "\t\tRelease|Any CPU = Release|Any CPU");
content.Insert(globalIndex + 4, "\tEndGlobalSection");

solutionConfigurationIndex = globalIndex + 1;
}

var endGlobalSectionIndex = content.FindIndex(solutionConfigurationIndex, x => x.Contains("EndGlobalSection"));

content.Insert(endGlobalSectionIndex + 1, "\tGlobalSection(ProjectConfigurationPlatforms) = postSolution");
content.Insert(endGlobalSectionIndex + 2, "\tEndGlobalSection");

projectConfigurationIndex = endGlobalSectionIndex + 1;
}

content.Insert(projectConfigurationIndex + 1, $"\t\t{{{buildProjectGuid}}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU");
content.Insert(projectConfigurationIndex + 2, $"\t\t{{{buildProjectGuid}}}.Release|Any CPU.ActiveCfg = Release|Any CPU");

content.Insert(globalIndex,
$"Project(\"{{{PROJECT_KIND}}}\") = \"{buildProjectName}\", \"{buildProjectFileRelative}\", \"{{{buildProjectGuid}}}\"");
content.Insert(globalIndex + 1,
"EndProject");
}

public void UpdateSolutionXmlFileContent(XDocument content, string buildProjectFileRelative)
{
var solutionElement = content.Root;
Assert.True(solutionElement?.Name == "Solution", "Could not find a root 'Solution' element in solution file");

// file uses forward slashes for paths on every platform
var path = buildProjectFileRelative.Replace(oldChar: '\\', newChar: '/');

if (solutionElement.Elements("Project").Any(x => x.GetAttributeValue("Path").EqualsOrdinalIgnoreCase(path)))
{
return;
}

var projectElement = new XElement("Project", new XAttribute("Path", path));
projectElement.Add(new XElement("Build", new XAttribute("Project", value: false)));
solutionElement.Add(projectElement);
}
}
69 changes: 69 additions & 0 deletions src/Fallout.Cli/CakeConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Fallout.Common;
using Fallout.Common.IO;
using Fallout.Common.Tooling;
using Fallout.Common.Utilities;
using Fallout.Cli.Rewriting.Cake;
using static Fallout.Common.Constants;
using static Fallout.Common.EnvironmentInfo;

namespace Fallout.Cli;

/// <summary>Best-effort syntax rewriting of Cake (<c>*.cake</c>) scripts into Fallout C#.</summary>
internal static class CakeConverter
{
public const string FilePattern = "*.cake";

public static IEnumerable<AbsolutePath> GetCakeFiles()
{
return (TryGetRootDirectoryFrom(WorkingDirectory) ?? WorkingDirectory).GlobFiles($"**/{FilePattern}");
}

public static string GetConvertedContent(string content)
{
var options = new CSharpParseOptions(LanguageVersion.Latest, DocumentationMode.None, SourceCodeKind.Script);
var syntaxTree = CSharpSyntaxTree.ParseText(content, options);
return new CSharpSyntaxRewriter[]
{
new RemoveUsingDirectivesRewriter(),
new RenameFieldIdentifierRewriter(),
new ParameterRewriter(),
new AbsolutePathRewriter(),
new RegularFieldRewriter(),
new TargetDefinitionRewriter(),
new InvocationRewriter(),
new MemberAccessRewriter(),
new IdentifierNameRewriter(),
new ToolInvocationRewriter(),
new ClassRewriter(),
new FormattingRewriter()
}.Aggregate(syntaxTree.GetRoot(), (root, rewriter) => rewriter.Visit(root.NormalizeWhitespace(elasticTrivia: true)))
.ToFullString();
}

public static IEnumerable<(string Type, string Id, string Version)> GetPackages(string content)
{
IEnumerable<(string Type, string Id, string Version)> GetPackages(
string packageType,
string regexPattern)
{
var regex = new Regex(regexPattern);
foreach (Match match in regex.Matches(content))
{
var packageId = match.Groups["packageId"].Value;
var packageVersion = match.Groups["version"].Value;
if (packageVersion.IsNullOrEmpty())
packageVersion = AsyncHelper.RunSync(() => NuGetVersionResolver.GetLatestVersion(packageId, includePrereleases: false));
yield return new(packageType, packageId, packageVersion);
}
}

return GetPackages(PackageManager.DownloadType, @"#tool ""nuget:\?package=(?'packageId'[\w\d\.]+)(&version=(?'version'[\w\d\.]+))?S*""")
.Concat(GetPackages(PackageManager.ReferenceType, @"#addin ""nuget:\?package=(?'packageId'[\w\d\.]+)(&version=(?'version'[\w\d\.]+))?S*"""))
.Where(x => !x.Id.ContainsOrdinalIgnoreCase("Cake"));
}
}
18 changes: 18 additions & 0 deletions src/Fallout.Cli/CliConventions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Fallout.Common;
using Fallout.Common.Utilities;

namespace Fallout.Cli;

/// <summary>Small CLI-wide conventions shared by the entry point and commands.</summary>
internal static class CliConventions
{
/// <summary>The build-script file name for the current platform.</summary>
public static string CurrentBuildScriptName => EnvironmentInfo.IsWin ? "build.ps1" : "build.sh";
}

/// <summary>Prints the global-tool banner.</summary>
internal static class ToolBanner
{
public static void Print()
=> Host.Information($"Fallout Global Tool 🌐 {typeof(ToolBanner).Assembly.GetInformationalText()}");
}
61 changes: 61 additions & 0 deletions src/Fallout.Cli/Commands/AddPackageCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System.Linq;
using System.Threading.Tasks;
using Fallout.Common;
using Fallout.Common.Execution;
using Fallout.Common.IO;
using Fallout.Solutions;
using Fallout.Common.Tooling;
using Fallout.Common.Tools.DotNet;

namespace Fallout.Cli.Commands;

/// <summary>
/// <c>fallout :add-package</c>: adds (or upgrades) a NuGet package reference in the build project.
/// </summary>
internal sealed class AddPackageCommand : IFalloutCommand
{
private readonly IConfigurationReader _configuration;
private readonly IPackageManager _packages;

public AddPackageCommand(IConfigurationReader configuration, IPackageManager packages)
{
_configuration = configuration;
_packages = packages;
}

public string Name => "add-package";

public Task<int> ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript)
=> Task.FromResult(Execute(args, rootDirectory, buildScript));

private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript)
{
ToolBanner.Print();
Logging.Configure();
Telemetry.AddPackage();
ProjectModelTasks.Initialize();

var packageId = args.ElementAt(0);
var packageVersion =
(EnvironmentInfo.GetNamedArgument<string>("version") ??
args.ElementAtOrDefault(1) ??
NuGetVersionResolver.GetLatestVersion(packageId, includePrereleases: false).GetAwaiter().GetResult() ??
NuGetPackageResolver.GetGlobalInstalledPackage(packageId, version: null, packagesConfigFile: null)?.Version.ToString())
.NotNull("packageVersion != null");

var configuration = _configuration.Read(buildScript, evaluate: true);
var buildProjectFile = configuration[ConfigurationReader.BuildProjectFileKey];
Host.Information($"Installing {packageId}/{packageVersion} to {buildProjectFile} ...");
_packages.AddOrReplacePackage(packageId, packageVersion, PackageManager.DownloadType, buildProjectFile);
DotNetTasks.DotNet($"restore {buildProjectFile}");

var installedPackage = NuGetPackageResolver.GetGlobalInstalledPackage(packageId, packageVersion, packagesConfigFile: null)
.NotNull("installedPackage != null");
var hasToolsDirectory = installedPackage.Directory.GlobDirectories("tools").Any();
if (!hasToolsDirectory)
_packages.AddOrReplacePackage(packageId, packageVersion, PackageManager.ReferenceType, buildProjectFile);

Host.Information($"Done installing {packageId}/{packageVersion} to {buildProjectFile}");
return 0;
}
}
34 changes: 34 additions & 0 deletions src/Fallout.Cli/Commands/CakeCleanCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using System.Linq;
using System.Threading.Tasks;
using Fallout.Cli.Prompts;
using Fallout.Common;
using Fallout.Common.IO;

namespace Fallout.Cli.Commands;

/// <summary>
/// <c>fallout :cake-clean</c>: lists and optionally deletes the <c>*.cake</c> scripts.
/// </summary>
internal sealed class CakeCleanCommand : IFalloutCommand
{
private readonly IConsolePrompts _prompts;

public CakeCleanCommand(IConsolePrompts prompts) => _prompts = prompts;

public string Name => "cake-clean";

public Task<int> ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript)
=> Task.FromResult(Execute(args, rootDirectory, buildScript));

private int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript)
{
var cakeFiles = CakeConverter.GetCakeFiles().ToList();
Host.Information("Found .cake files:");
cakeFiles.ForEach(x => Host.Debug($" - {x}"));

if (_prompts.PromptForConfirmation("Delete?"))
cakeFiles.ForEach(x => x.DeleteFile());

return 0;
}
}
Loading
Loading