// Copyright (c) .NET Foundation and contributors. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Reflection.Metadata;
using System.Reflection.PortableExecutable;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
namespace ILLink.Tasks
{
public class ILLink : ToolTask
{
/// <summary>
/// Paths to the assembly files that should be considered as
/// input to the ILLink.
/// Optional metadata:
/// TrimMode ("copy", "link", etc...): sets the illink action to take for this assembly.
/// There is an optional metadata for each optimization that can be set to "True" or "False" to
/// enable or disable it per-assembly:
/// BeforeFieldInit
/// OverrideRemoval
/// UnreachableBodies
/// UnusedInterfaces
/// IPConstProp
/// Sealer
/// Optional metadata "TrimmerSingleWarn" may also be set to "True"/"False" to control
/// whether ILLink produces granular warnings for this assembly.
/// Maps to '-reference', and possibly '--action', '--enable-opt', '--disable-opt', '--verbose'
/// </summary>
[Required]
public ITaskItem[] AssemblyPaths { get; set; }
/// <summary>
/// Paths to assembly files that are reference assemblies,
/// representing the surface area for compilation.
/// Maps to '-reference', with action set to 'skip' via '--action'.
/// </summary>
public ITaskItem[] ReferenceAssemblyPaths { get; set; }
/// <summary>
/// The names of the assemblies to root. This should contain
/// assembly names without an extension, not file names or
/// paths. The default is to root everything in these assemblies.
// For more fine-grained control, set RootMode metadata.
/// </summary>
[Required]
public ITaskItem[] RootAssemblyNames { get; set; }
/// <summary>
/// The directory in which to place linked assemblies.
/// The task deletes and recreates this directory before running ILLink.
/// It must not contain inputs or files that need to be preserved.
/// Maps to '-out'.
/// </summary>
[Required]
public ITaskItem OutputDirectory { get; set; }
/// <summary>
/// The subset of warnings that have to be turned off.
/// Maps to '--nowarn'.
/// </summary>
public string NoWarn { get; set; }
/// <summary>
/// The warning version to use.
/// Maps to '--warn'.
/// </summary>
public string Warn { get; set; }
/// <summary>
/// Treat all warnings as errors.
/// Maps to '--warnaserror' if true, '--warnaserror-' if false.
/// </summary>
public bool TreatWarningsAsErrors { set => _treatWarningsAsErrors = value; }
bool? _treatWarningsAsErrors;
/// <summary>
/// Produce at most one trim analysis warning per assembly.
/// Maps to '--singlewarn' if true, '--singlewarn-' if false.
/// </summary>
public bool SingleWarn { set => _singleWarn = value; }
bool? _singleWarn;
/// <summary>
/// The list of warnings to report as errors.
/// Maps to '--warnaserror LIST-OF-WARNINGS'.
/// </summary>
public string WarningsAsErrors { get; set; }
/// <summary>
/// The list of warnings to report as usual.
/// Maps to '--warnaserror- LIST-OF-WARNINGS'.
/// </summary>
public string WarningsNotAsErrors { get; set; }
/// <summary>
/// A list of XML root descriptor files specifying ILLink
/// roots at a granular level. See the dotnet/linker
/// documentation for details about the format.
/// Maps to '-x'.
/// </summary>
public ITaskItem[] RootDescriptorFiles { get; set; }
/// <summary>
/// Boolean specifying whether to enable beforefieldinit optimization globally.
/// Maps to '--enable-opt beforefieldinit' or '--disable-opt beforefieldinit'.
/// </summary>
public bool BeforeFieldInit { set => _beforeFieldInit = value; }
bool? _beforeFieldInit;
/// <summary>
/// Boolean specifying whether to enable overrideremoval optimization globally.
/// Maps to '--enable-opt overrideremoval' or '--disable-opt overrideremoval'.
/// </summary>
public bool OverrideRemoval { set => _overrideRemoval = value; }
bool? _overrideRemoval;
/// <summary>
/// Boolean specifying whether to enable unreachablebodies optimization globally.
/// Maps to '--enable-opt unreachablebodies' or '--disable-opt unreachablebodies'.
/// </summary>
public bool UnreachableBodies { set => _unreachableBodies = value; }
bool? _unreachableBodies;
/// <summary>
/// Boolean specifying whether to enable unusedinterfaces optimization globally.
/// Maps to '--enable-opt unusedinterfaces' or '--disable-opt unusedinterfaces'.
/// </summary>
public bool UnusedInterfaces { set => _unusedInterfaces = value; }
bool? _unusedInterfaces;
/// <summary>
/// Boolean specifying whether to enable ipconstprop optimization globally.
/// Maps to '--enable-opt ipconstprop' or '--disable-opt ipconstprop'.
/// </summary>
public bool IPConstProp { set => _iPConstProp = value; }
bool? _iPConstProp;
/// <summary>
/// A list of feature names used by the body substitution logic.
/// Each Item requires "Value" boolean metadata with the value of
/// the feature setting.
/// Maps to '--feature'.
/// </summary>
public ITaskItem[] FeatureSettings { get; set; }
/// <summary>
/// Boolean specifying whether to enable sealer optimization globally.
/// Maps to '--enable-opt sealer' or '--disable-opt sealer'.
/// </summary>
public bool Sealer { set => _sealer = value; }
bool? _sealer;
static readonly string[] _optimizationNames = new string[] {
"BeforeFieldInit",
"OverrideRemoval",
"UnreachableBodies",
"UnusedInterfaces",
"IPConstProp",
"Sealer"
};
/// <summary>
/// Custom data key-value pairs to pass to ILLink.
/// The name of the item is the key, and the required "Value"
/// metadata is the value. Maps to '--custom-data key=value'.
/// </summary>
public ITaskItem[] CustomData { get; set; }
/// <summary>
/// Extra arguments to pass to illink, delimited by spaces.
/// </summary>
public string ExtraArgs { get; set; }
/// <summary>
/// Make illink dump dependencies file for ILLink analyzer tool.
/// Maps to '--dump-dependencies'.
/// </summary>
public bool DumpDependencies { get; set; }
/// <summary>
/// Make illink dump dependencies to the specified file type.
/// Maps to '--dependencies-file-format'.
/// </summary>
public string DependenciesFileFormat { get; set; }
/// <summary>
/// Remove debug symbols from linked assemblies.
/// Maps to '-b' if false.
/// Default if not specified is to remove symbols, like
/// the command-line. (Target files will likely set their own defaults to keep symbols.)
/// </summary>
public bool RemoveSymbols { set => _removeSymbols = value; }
bool? _removeSymbols;
/// <summary>
/// Preserve original path to debug symbols from each assembly's debug header.
/// Maps to '--preserve-symbol-paths' if true.
/// Default if not specified is to write out the full path to the pdb in the debug header.
/// </summary>
public bool PreserveSymbolPaths { get; set; }
/// <summary>
/// Sets the default action for trimmable assemblies.
/// Maps to '--trim-mode'
/// </summary>
public string TrimMode { get; set; }
/// <summary>
/// Sets the default action for assemblies which have not opted into trimming.
/// Maps to '--action'
/// </summary>
public string DefaultAction { get; set; }
/// <summary>
/// A list of custom steps to insert into the ILLink pipeline.
/// Each ItemSpec should be the path to the assembly containing the custom step.
/// Each Item requires "Type" metadata with the name of the custom step type.
/// Optional metadata:
/// BeforeStep: The name of a ILLink step. The custom step will be inserted before it.
/// AfterStep: The name of a ILLink step. The custom step will be inserted after it.
/// The default (if neither BeforeStep or AfterStep is specified) is to insert the
/// custom step at the end of the pipeline.
/// It is an error to specify both BeforeStep and AfterStep.
/// Maps to '--custom-step'.
/// </summary>
public ITaskItem[] CustomSteps { get; set; }
/// <summary>
/// A list selected metadata which should not be trimmed. It maps to 'keep-metadata' option
/// </summary>
public ITaskItem[] KeepMetadata { get; set; }
private const string DotNetHostPathEnvironmentName = "DOTNET_HOST_PATH";
private string _dotnetPath;
private string DotNetPath
{
get
{
if (!string.IsNullOrEmpty(_dotnetPath))
return _dotnetPath;
_dotnetPath = Environment.GetEnvironmentVariable(DotNetHostPathEnvironmentName);
if (string.IsNullOrEmpty(_dotnetPath))
throw new InvalidOperationException($"{DotNetHostPathEnvironmentName} is not set");
return _dotnetPath;
}
}
/// ToolTask implementation
protected override MessageImportance StandardErrorLoggingImportance => MessageImportance.High;
protected override string ToolName => Path.GetFileName(DotNetPath);
protected override string GenerateFullPathToTool() => DotNetPath;
protected override int ExecuteTool(string pathToTool, string responseFileCommands, string commandLineCommands)
{
DateTime outputTimestampUtc = DateTime.UtcNow;
ILLinkCache cache = ILLinkCache.TryCreateFromEnvironment(Log);
string inputHash = null;
if (cache is not null && !TryComputeCacheKey(pathToTool, commandLineCommands, responseFileCommands, out inputHash))
cache = null;
if (cache is not null && cache.TryRestore(inputHash, OutputDirectory.ItemSpec, outputTimestampUtc))
return 0;
try
{
string outputDirectory = Path.GetFullPath(OutputDirectory.ItemSpec);
if (Directory.Exists(outputDirectory))
Directory.Delete(outputDirectory, recursive: true);
Directory.CreateDirectory(outputDirectory);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.LogError($"Could not prepare ILLink output directory '{OutputDirectory.ItemSpec}': {ex.Message}");
return -1;
}
int exitCode = base.ExecuteTool(pathToTool, responseFileCommands, commandLineCommands);
if (exitCode == 0 && !Log.HasLoggedErrors && cache is not null)
cache.Store(inputHash, OutputDirectory.ItemSpec);
return exitCode;
}
private bool TryComputeCacheKey(string pathToTool, string commandLineCommands, string responseFileCommands, out string inputHash)
{
inputHash = string.Empty;
if (CustomSteps?.Length > 0 || CustomData?.Length > 0 || DumpDependencies ||
!string.IsNullOrEmpty(DependenciesFileFormat) || EnvironmentVariables?.Length > 0)
{
Log.LogMessage(MessageImportance.Low, "ILLink cache bypassed: custom steps/data, dependency dumps, and task environment overrides are not supported.");
return false;
}
try
{
var files = new SortedSet<string>(StringComparer.Ordinal);
var directories = new SortedSet<string>(StringComparer.Ordinal);
if (!TryAddCacheableExtraArgsInputs(files, directories))
{
Log.LogMessage(MessageImportance.Low, "ILLink cache bypassed: extra arguments are not supported.");
return false;
}
var assemblies = new HashSet<string>(StringComparer.Ordinal);
foreach (ITaskItem assembly in AssemblyPaths)
AddAssembly(assembly.ItemSpec);
foreach (ITaskItem assembly in ReferenceAssemblyPaths ?? Array.Empty<ITaskItem>())
AddAssembly(assembly.ItemSpec);
foreach (ITaskItem descriptor in RootDescriptorFiles ?? Array.Empty<ITaskItem>())
AddFile(descriptor.ItemSpec);
// Preserve AssemblyResolver's search behavior instead of replacing -d with
// explicit references. Include every candidate so additions and shadowing invalidate.
foreach (string directory in directories)
{
foreach (string file in Directory.EnumerateFiles(directory))
{
string extension = Path.GetExtension(file);
if (extension.Equals(".dll", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".exe", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".winmd", StringComparison.OrdinalIgnoreCase))
{
AddAssembly(file);
}
}
}
#pragma warning disable IL3000 // MSBuild tasks are loaded from assemblies on disk.
AddFile(typeof(ILLink).Assembly.Location);
#pragma warning restore IL3000
AddFile(pathToTool);
AddFile(ILLinkPath);
using var sha256 = SHA256.Create();
using var description = new MemoryStream();
using var writer = new BinaryWriter(description, Encoding.UTF8, leaveOpen: true);
writer.Write("ILLink task cache key v1");
writer.Write(commandLineCommands);
writer.Write(responseFileCommands);
writer.Write(Environment.CurrentDirectory);
writer.Write(Path.GetFullPath(OutputDirectory.ItemSpec));
writer.Write(RuntimeInformation.OSDescription);
writer.Write(RuntimeInformation.ProcessArchitecture.ToString());
writer.Write(CultureInfo.CurrentCulture.Name);
writer.Write(CultureInfo.CurrentUICulture.Name);
writer.Write(files.Count);
foreach (string file in files)
{
writer.Write(file);
using var input = File.OpenRead(file);
writer.Write(sha256.ComputeHash(input));
}
writer.Flush();
description.Position = 0;
#if NET
inputHash = Convert.ToHexStringLower(sha256.ComputeHash(description));
#else
inputHash = BitConverter.ToString(sha256.ComputeHash(description)).Replace("-", "").ToLowerInvariant();
#endif
return true;
void AddFile(string path) => files.Add(Path.GetFullPath(path));
void AddOptionalFile(string path)
{
if (File.Exists(path))
AddFile(path);
}
void AddAssembly(string path)
{
path = Path.GetFullPath(path);
if (!assemblies.Add(path))
return;
AddFile(path);
AddOptionalFile(path + ".config");
AddOptionalFile(path + ".mdb");
AddOptionalFile(Path.ChangeExtension(path, ".pdb"));
string directory = Path.GetDirectoryName(path);
string satelliteName = Path.GetFileNameWithoutExtension(path) + ".resources.dll";
foreach (string satelliteDirectory in Directory.EnumerateDirectories(directory))
AddOptionalFile(Path.Combine(satelliteDirectory, satelliteName));
using var input = File.OpenRead(path);
using var pe = new PEReader(input);
if (!pe.HasMetadata)
throw new BadImageFormatException("Input PE image has no managed metadata.", path);
MetadataReader metadata = pe.GetMetadataReader();
foreach (AssemblyFileHandle handle in metadata.AssemblyFiles)
{
AssemblyFile file = metadata.GetAssemblyFile(handle);
string filePath = Path.Combine(directory, metadata.GetString(file.Name));
if (file.ContainsMetadata)
AddAssembly(filePath);
else
AddFile(filePath);
}
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or BadImageFormatException or InvalidDataException)
{
Log.LogMessage(MessageImportance.Low, $"ILLink cache bypassed: input identity could not be computed: {ex.Message}");
return false;
}
}
private bool TryAddCacheableExtraArgsInputs(SortedSet<string> files, SortedSet<string> directories)
{
ReadOnlySpan<char> arguments = ExtraArgs.AsSpan().Trim();
if (arguments.IsEmpty)
return true;
// The runtime library builds pass this policy through ExtraArgs. Keep the
// supported shape narrow so arbitrary linker arguments continue to bypass caching.
if (!TryReadArgument(ref arguments, out string option) ||
option != "--ignore-link-attributes" ||
!TryReadArgument(ref arguments, out string value) ||
value != "true")
{
return false;
}
while (!arguments.TrimStart().IsEmpty)
{
if (!TryReadArgument(ref arguments, out option) ||
option is not ("--link-attributes" or "--substitutions" or "-d") ||
!TryReadArgument(ref arguments, out string path) ||
string.IsNullOrEmpty(path))
{
return false;
}
if (option == "-d")
directories.Add(Path.GetFullPath(path));
else
files.Add(Path.GetFullPath(path));
}
return true;
static bool TryReadArgument(ref ReadOnlySpan<char> arguments, out string argument)
{
arguments = arguments.TrimStart();
if (arguments.IsEmpty)
{
argument = string.Empty;
return false;
}
if (arguments[0] == '"')
{
int closingQuote = arguments.Slice(1).IndexOf('"');
if (closingQuote < 0)
{
argument = string.Empty;
return false;
}
argument = arguments.Slice(1, closingQuote).ToString();
arguments = arguments.Slice(closingQuote + 2);
return arguments.IsEmpty || char.IsWhiteSpace(arguments[0]);
}
int end = 0;
while (end < arguments.Length && !char.IsWhiteSpace(arguments[end]))
end++;
ReadOnlySpan<char> token = arguments.Slice(0, end);
if (token.IndexOf('"') >= 0)
{
argument = string.Empty;
return false;
}
argument = token.ToString();
arguments = arguments.Slice(end);
return true;
}
}
private string _illinkPath = "";
public string ILLinkPath
{
get
{
if (!string.IsNullOrEmpty(_illinkPath))
return _illinkPath;
#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file
var taskDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
#pragma warning restore IL3000 // Avoid accessing Assembly file path when publishing as a single file
// IL Linker always runs on .NET Core, even when using desktop MSBuild to host ILLink.Tasks.
_illinkPath = Path.Combine(Path.GetDirectoryName(taskDirectory), "net", "illink.dll");
return _illinkPath;
}
set => _illinkPath = value;
}
private static string Quote(string path)
{
return $"\"{path.TrimEnd('\\')}\"";
}
protected override string GenerateCommandLineCommands()
{
var args = new StringBuilder();
var path = ILLinkPath;
args.Append(Quote(path));
Log.LogMessage(MessageImportance.Normal, $"ILLink.Tasks path: {path}");
return args.ToString();
}
private static void SetOpt(StringBuilder args, string opt, bool enabled)
{
args.Append(enabled ? "--enable-opt " : "--disable-opt ").AppendLine(opt);
}
private static void SetOpt(StringBuilder args, string opt, string assembly, bool enabled)
{
args.Append(enabled ? "--enable-opt " : "--disable-opt ").Append(opt).Append(' ').AppendLine(assembly);
}
protected override string GenerateResponseFileCommands()
{
var args = new StringBuilder();
if (RootDescriptorFiles != null)
{
foreach (var rootFile in RootDescriptorFiles)
args.Append("-x ").AppendLine(Quote(rootFile.ItemSpec));
}
foreach (var assemblyItem in RootAssemblyNames)
{
args.Append("-a ").Append(Quote(assemblyItem.ItemSpec));
string rootMode = assemblyItem.GetMetadata("RootMode");
if (!string.IsNullOrEmpty(rootMode))
{
args.Append(' ');
args.Append(rootMode);
}
args.AppendLine();
}
if (_singleWarn is bool generalSingleWarn)
{
if (generalSingleWarn)
args.AppendLine("--singlewarn");
else
args.AppendLine("--singlewarn-");
}
string trimMode = TrimMode switch
{
"full" => "link",
"partial" => "link",
var x => x
};
if (trimMode != null)
args.Append("--trim-mode ").AppendLine(trimMode);
string defaultAction = TrimMode switch
{
"full" => "link",
"partial" => "copy",
_ => DefaultAction
};
if (defaultAction != null)
args.Append("--action ").AppendLine(defaultAction);
HashSet<string> assemblyNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var assembly in AssemblyPaths)
{
var assemblyPath = assembly.ItemSpec;
var assemblyName = Path.GetFileNameWithoutExtension(assemblyPath);
// If there are multiple paths with the same assembly name, only use the first one.
if (!assemblyNames.Add(assemblyName))
continue;
args.Append("-reference ").AppendLine(Quote(assemblyPath));
string assemblyTrimMode = assembly.GetMetadata("TrimMode");
string isTrimmable = assembly.GetMetadata("IsTrimmable");
if (string.IsNullOrEmpty(assemblyTrimMode))
{
if (isTrimmable.Equals("true", StringComparison.OrdinalIgnoreCase))
{
// isTrimmable ~= true
assemblyTrimMode = trimMode;
}
else if (isTrimmable.Equals("false", StringComparison.OrdinalIgnoreCase))
{
// isTrimmable ~= false
assemblyTrimMode = "copy";
}
}
if (!string.IsNullOrEmpty(assemblyTrimMode))
{
args.Append("--action ");
args.Append(assemblyTrimMode);
args.Append(' ').AppendLine(Quote(assemblyName));
}
// Add per-assembly optimization arguments
foreach (var optimization in _optimizationNames)
{
string optimizationValue = assembly.GetMetadata(optimization);
if (string.IsNullOrEmpty(optimizationValue))
continue;
if (!bool.TryParse(optimizationValue, out bool enabled))
throw new ArgumentException($"optimization metadata {optimization} must be True or False");
SetOpt(args, optimization, assemblyName, enabled);
}
// Add per-assembly verbosity arguments
string singleWarn = assembly.GetMetadata("TrimmerSingleWarn");
if (!string.IsNullOrEmpty(singleWarn))
{
if (!bool.TryParse(singleWarn, out bool value))
throw new ArgumentException($"TrimmerSingleWarn metadata must be True or False");
if (value)
args.Append("--singlewarn ").AppendLine(Quote(assemblyName));
else
args.Append("--singlewarn- ").AppendLine(Quote(assemblyName));
}
}
if (ReferenceAssemblyPaths != null)
{
foreach (var assembly in ReferenceAssemblyPaths)
{
var assemblyPath = assembly.ItemSpec;
var assemblyName = Path.GetFileNameWithoutExtension(assemblyPath);
// Don't process references for which we already have
// implementation assemblies.
if (assemblyNames.Contains(assemblyName))
continue;
args.Append("-reference ").AppendLine(Quote(assemblyPath));
// Treat reference assemblies as "skip". Ideally we
// would not even look at the IL, but only use them to
// resolve surface area.
args.Append("--action skip ").AppendLine(Quote(assemblyName));
}
}
if (NoWarn != null)
args.Append("--nowarn ").AppendLine(Quote(NoWarn));
if (Warn != null)
args.Append("--warn ").AppendLine(Quote(Warn));
if (_treatWarningsAsErrors is bool treatWarningsAsErrors && treatWarningsAsErrors)
args.Append("--warnaserror ");
else
args.Append("--warnaserror- ");
if (WarningsAsErrors != null)
args.Append("--warnaserror ").AppendLine(Quote(WarningsAsErrors));
if (WarningsNotAsErrors != null)
args.Append("--warnaserror- ").AppendLine(Quote(WarningsNotAsErrors));
// Add global optimization arguments
if (_beforeFieldInit is bool beforeFieldInit)
SetOpt(args, "beforefieldinit", beforeFieldInit);
if (_overrideRemoval is bool overrideRemoval)
SetOpt(args, "overrideremoval", overrideRemoval);
if (_unreachableBodies is bool unreachableBodies)
SetOpt(args, "unreachablebodies", unreachableBodies);
if (_unusedInterfaces is bool unusedInterfaces)
SetOpt(args, "unusedinterfaces", unusedInterfaces);
if (_iPConstProp is bool iPConstProp)
SetOpt(args, "ipconstprop", iPConstProp);
if (_sealer is bool sealer)
SetOpt(args, "sealer", sealer);
if (CustomData != null)
{
foreach (var customData in CustomData)
{
var key = customData.ItemSpec;
var value = customData.GetMetadata("Value");
if (string.IsNullOrEmpty(value))
throw new ArgumentException("custom data requires \"Value\" metadata");
args.Append("--custom-data ").Append(' ').Append(key).Append('=').AppendLine(Quote(value));
}
}
if (FeatureSettings != null)
{
foreach (var featureSetting in FeatureSettings)
{
var feature = featureSetting.ItemSpec;
var featureValue = featureSetting.GetMetadata("Value");
if (string.IsNullOrEmpty(featureValue))
throw new ArgumentException("feature settings require \"Value\" metadata");
args.Append("--feature ").Append(feature).Append(' ').AppendLine(featureValue);
}
}
if (KeepMetadata != null)
{
foreach (var metadata in KeepMetadata)
args.Append("--keep-metadata ").AppendLine(Quote(metadata.ItemSpec));
}
if (_removeSymbols == false)
args.AppendLine("-b");
if (PreserveSymbolPaths)
args.AppendLine("--preserve-symbol-paths");
if (CustomSteps != null)
{
foreach (var customStep in CustomSteps)
{
args.Append("--custom-step ");
var stepPath = customStep.ItemSpec;
var stepType = customStep.GetMetadata("Type");
if (stepType == null)
throw new ArgumentException("custom step requires \"Type\" metadata");
var customStepString = $"{stepType},{stepPath}";
// handle optional before/aftersteps
var beforeStep = customStep.GetMetadata("BeforeStep");
var afterStep = customStep.GetMetadata("AfterStep");
if (!string.IsNullOrEmpty(beforeStep) && !string.IsNullOrEmpty(afterStep))
throw new ArgumentException("custom step may not have both \"BeforeStep\" and \"AfterStep\" metadata");
if (!string.IsNullOrEmpty(beforeStep))
customStepString = $"-{beforeStep}:{customStepString}";
if (!string.IsNullOrEmpty(afterStep))
customStepString = $"+{afterStep}:{customStepString}";
args.AppendLine(Quote(customStepString));
}
}
if (ExtraArgs != null)
args.AppendLine(ExtraArgs);
if (DumpDependencies)
args.AppendLine("--dump-dependencies");
if (DependenciesFileFormat != null)
{
args.Append("--dependencies-file-format ").AppendLine(DependenciesFileFormat);
}
// Keep the tool's output directory consistent with the directory owned by the task.
if (OutputDirectory is not null)
args.Append("-out ").AppendLine(Quote(OutputDirectory.ItemSpec));
return args.ToString();
}
}
}