Skip to content
Open
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
29 changes: 29 additions & 0 deletions docs/docs/how-to/custom-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,35 @@ This is the equivalent to running:

`dotnet tool install --global dotnet-coverage`

By default, `Arguments` appears after generated non-terminal options and operands. It
appears before `RunSettings` (and its `--` marker) and before options in the `Terminal`
phase. When `ArgumentsContainToolOptions` is enabled, recognized tool options can be
hoisted ahead of a structured or declared end-of-options marker.

## Adding Unmodeled Options

Use `AdditionalArguments` when a strongly typed or generated options record does not yet
model a tool option. Each entry accepts a `CommandLinePhase`; entries with
`IsGlobalOption: true` appear before the command or subcommand parts.

```csharp
var options = new SomeGeneratedOptions
{
AdditionalArguments =
[
new("--global-flag", IsGlobalOption: true),
new("--new-option", CommandLinePhase.Normal),
new("value", CommandLinePhase.Normal),
],
};
```

Within each non-terminal phase, additional tokens retain their declared order and appear
before generated tokens. The supported phases render as `EarlyOperand`, `Normal`,
`Passthrough`, then `Terminal`. Use `RunSettings` or a declared marker in `Arguments` for
end-of-options pass-through values. Terminal tokens appear after `Arguments` and cannot
be combined with an end-of-options marker or `RunSettings`.

## Strongly Typed Options

Static command identities use one source for each part:
Expand Down
5 changes: 5 additions & 0 deletions src/ModularPipelines/Attributes/CommandLinePhase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,8 @@ public enum CommandLinePhase
/// </summary>
Passthrough = 3,
}

internal static class CommandLinePhaseCompatibility
{
internal const CommandLinePhase LegacyEndOfOptions = (CommandLinePhase) 2;
}
136 changes: 130 additions & 6 deletions src/ModularPipelines/Context/CommandLineBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace ModularPipelines.Context;
/// 1. Resolve tool name from [CliTool] attribute or constructor parameter
/// 2. Get subcommand parts from [CliSubCommand] or a preferred [CliCommandAlias]
/// 3. Build arguments from [CliOption], [CliFlag], and [CliArgument] attributes
/// 4. Combine global arguments, command parts, and command-specific arguments.
/// 4. Insert phase-aware AdditionalArguments and combine command parts.
/// 5. Add manual Arguments if present.
/// 6. Render RunSettings as option-terminated pass-through arguments.
/// 7. Validate option terminators against terminal options in one place.
Expand Down Expand Up @@ -57,6 +57,9 @@ public CommandLine Build(CommandLineToolOptions options)
// on a [CliGlobalOptions] base belong before the subcommand; command-specific
// properties retain their normal position after it.
var commandModel = _commandModelProvider.GetCommandModel(options.GetType());
var additionalArguments = options.AdditionalArguments?.ToList() ?? [];
ValidateAdditionalArguments(additionalArguments);

var terminalCommandModel = commandModel
.Where(part => part.Phase == CommandLinePhase.Terminal)
.ToList();
Expand All @@ -66,17 +69,21 @@ public CommandLine Build(CommandLineToolOptions options)
var globalCommandModel = nonTerminalCommandModel.Where(part => part.IsGlobalOption).ToList();
var commandSpecificModel = nonTerminalCommandModel.Where(part => !part.IsGlobalOption).ToList();
var emittedOptionTerminator = false;
var globalArgs = _commandArgumentBuilder.BuildArguments(
var globalArgs = BuildNonTerminalArguments(
globalCommandModel,
additionalArguments,
options,
isGlobalOption: true,
ref emittedOptionTerminator,
out var globalOptionTerminatorIndex).ToList();
out var globalOptionTerminatorIndex);
var terminatorEmittedBeforeProperties = emittedOptionTerminator;
var propertyArgs = _commandArgumentBuilder.BuildArguments(
var propertyArgs = BuildNonTerminalArguments(
commandSpecificModel,
additionalArguments,
options,
isGlobalOption: false,
ref emittedOptionTerminator,
out var commandOptionTerminatorIndex).ToList();
out var commandOptionTerminatorIndex);
var manualArgs = options.Arguments?.ToList() ?? [];
ValidateManualOptionsAfterGlobalTerminator(
options,
Expand All @@ -96,6 +103,10 @@ public CommandLine Build(CommandLineToolOptions options)
[.. terminalCommandModel.Where(static part => part is ArgumentPart)],
options,
ref pendingTerminatorState);
var terminalAdditionalArgs = GetAdditionalArguments(
additionalArguments,
CommandLinePhase.Terminal)
.ToList();
var hasOptionTerminator = pendingTerminatorState;
var extractedManualOptions = options.ArgumentsContainToolOptions
&& hasOptionTerminator
Expand Down Expand Up @@ -145,7 +156,8 @@ [.. terminalCommandModel.Where(static part => part is FlagPart or OptionPart)],
allArgs.AddRange(runSettingsArgs);

// 7. A terminal option must not follow any rendered or manually supplied option terminator.
if (terminalOptionArgs.Count > 0 && emittedOptionTerminator)
if ((terminalAdditionalArgs.Count > 0 || terminalOptionArgs.Count > 0)
&& emittedOptionTerminator)
{
throw new InvalidOperationException(
"Terminal options cannot be combined with arguments that emit or supply an "
Expand All @@ -154,11 +166,123 @@ [.. terminalCommandModel.Where(static part => part is FlagPart or OptionPart)],

// Terminal options must follow every positional argument source.
allArgs.AddRange(terminalArgumentArgs);
allArgs.AddRange(terminalAdditionalArgs);
allArgs.AddRange(terminalOptionArgs);

return new CommandLine(tool, allArgs);
}

private List<string> BuildNonTerminalArguments(
IReadOnlyList<PropertyCommandLinePart> commandModel,
IReadOnlyList<AdditionalCommandLineArgument> additionalArguments,
CommandLineToolOptions options,
bool isGlobalOption,
ref bool emittedOptionTerminator,
out int? emittedOptionTerminatorIndex)
{
var result = new List<string>();
emittedOptionTerminatorIndex = null;

foreach (var phase in Enum.GetValues<CommandLinePhase>()
.Where(static phase => phase != CommandLinePhase.Terminal))
{
var phaseAdditionalArguments = GetAdditionalArguments(
additionalArguments,
phase,
isGlobalOption)
.ToList();
if (phase == CommandLinePhaseCompatibility.LegacyEndOfOptions
&& phaseAdditionalArguments.Count > 0)
{
if (emittedOptionTerminator)
{
throw new InvalidOperationException(
"An additional end-of-options marker cannot follow one that was already emitted.");
}

emittedOptionTerminatorIndex = result.Count;
emittedOptionTerminator = true;
}

result.AddRange(phaseAdditionalArguments);

var phaseModel = commandModel.Where(part => part.Phase == phase).ToList();
var phaseArguments = _commandArgumentBuilder.BuildArguments(
phaseModel,
options,
ref emittedOptionTerminator,
out var phaseOptionTerminatorIndex);
if (emittedOptionTerminatorIndex is null
&& phaseOptionTerminatorIndex is { } phaseIndex)
{
emittedOptionTerminatorIndex = result.Count + phaseIndex;
}

result.AddRange(phaseArguments);
}

return result;
}

private static IEnumerable<string> GetAdditionalArguments(
IEnumerable<AdditionalCommandLineArgument> additionalArguments,
CommandLinePhase phase,
bool? isGlobalOption = null)
=> additionalArguments
.Where(argument => argument.Phase == phase
&& (isGlobalOption is null || argument.IsGlobalOption == isGlobalOption))
.Select(argument => argument.Value);

private static void ValidateAdditionalArguments(
IReadOnlyCollection<AdditionalCommandLineArgument> additionalArguments)
{
foreach (var argument in additionalArguments)
{
ArgumentNullException.ThrowIfNull(argument);

if (!Enum.IsDefined(argument.Phase))
{
throw new ArgumentOutOfRangeException(
nameof(CommandLineToolOptions.AdditionalArguments),
argument.Phase,
"The additional argument phase is not defined.");
}

ArgumentNullException.ThrowIfNull(argument.Value);

if (argument is { IsGlobalOption: true, Phase: CommandLinePhase.Terminal })
{
throw new ArgumentException(
"A terminal additional argument cannot be a global option.",
nameof(CommandLineToolOptions.AdditionalArguments));
}

if (argument.Phase == CommandLinePhaseCompatibility.LegacyEndOfOptions
&& argument.Value != "--")
{
throw new ArgumentException(
"The legacy end-of-options phase only accepts the '--' marker.",
nameof(CommandLineToolOptions.AdditionalArguments));
}

if (argument.Value == "--"
&& argument.Phase != CommandLinePhaseCompatibility.LegacyEndOfOptions)
{
throw new ArgumentException(
"The '--' marker must use the legacy end-of-options phase.",
nameof(CommandLineToolOptions.AdditionalArguments));
}
}

if (additionalArguments.Count(argument =>
argument.Phase == CommandLinePhaseCompatibility.LegacyEndOfOptions) > 1)
{
throw new ArgumentException(
"Additional arguments can contain at most one end-of-options marker.",
nameof(CommandLineToolOptions.AdditionalArguments));
}
}

private static void ValidateTerminatorState(
CommandLineToolOptions options,
IReadOnlyCollection<string> commandParts,
Expand Down
10 changes: 4 additions & 6 deletions src/ModularPipelines/Helpers/Internal/CommandArgumentBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ namespace ModularPipelines.Helpers.Internal;
/// <inheritdoc/>
internal sealed class CommandArgumentBuilder : ICommandArgumentBuilder
{
private const CommandLinePhase LegacyEndOfOptionsPhase = (CommandLinePhase) 2;

/// <inheritdoc/>
public IReadOnlyList<string> BuildArguments(
IReadOnlyList<PropertyCommandLinePart> commandModel,
Expand Down Expand Up @@ -87,7 +85,7 @@ public IReadOnlyList<string> BuildArguments(
{
CommandLinePhase.EarlyOperand => 0,
CommandLinePhase.Normal => 1,
LegacyEndOfOptionsPhase => 2,
CommandLinePhaseCompatibility.LegacyEndOfOptions => 2,
CommandLinePhase.Passthrough => 3,
CommandLinePhase.Terminal => 4,
_ => throw new ArgumentOutOfRangeException(nameof(phase), phase, null),
Expand Down Expand Up @@ -123,7 +121,7 @@ private static RenderedPhase RenderPhase(
else
{
AddFlagsAndOptions(rendered, phaseOptions, renderedOptionValues);
if (phase == LegacyEndOfOptionsPhase
if (phase == CommandLinePhaseCompatibility.LegacyEndOfOptions
&& rendered.IndexOf("--") is var terminatorIndex
&& terminatorIndex >= 0)
{
Expand Down Expand Up @@ -219,11 +217,11 @@ private static void ValidateOptionTerminatorOrdering(
}

var legacyOptionTerminatorRendered = renderedOptionValues.Any(static pair =>
pair.Key.Phase == LegacyEndOfOptionsPhase
pair.Key.Phase == CommandLinePhaseCompatibility.LegacyEndOfOptions
&& pair.Value.Contains("--", StringComparer.Ordinal));
if (legacyOptionTerminatorRendered
&& renderedOptions.Any(static option =>
GetRenderOrder(option.Phase) > GetRenderOrder(LegacyEndOfOptionsPhase)))
GetRenderOrder(option.Phase) > GetRenderOrder(CommandLinePhaseCompatibility.LegacyEndOfOptions)))
{
throw new InvalidOperationException(
"CLI flags or options cannot be rendered after a legacy end-of-options marker.");
Expand Down
16 changes: 16 additions & 0 deletions src/ModularPipelines/Options/AdditionalCommandLineArgument.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using ModularPipelines.Attributes;

namespace ModularPipelines.Options;

/// <summary>
/// A manually supplied command-line token with explicit placement metadata.
/// </summary>
/// <param name="Value">The token to add to the command line.</param>
/// <param name="Phase">The semantic rendering phase for the token.</param>
/// <param name="IsGlobalOption">
/// Whether the token belongs before the command or subcommand parts.
/// </param>
public sealed record AdditionalCommandLineArgument(
string Value,
CommandLinePhase Phase = CommandLinePhase.Normal,
bool IsGlobalOption = false);
10 changes: 9 additions & 1 deletion src/ModularPipelines/Options/CommandLineToolOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,18 @@ public abstract record CommandLineToolOptions
public IReadOnlyList<string>? CommandParts { get; init; }

/// <summary>
/// Gets used for providing switches and arguments to the tool.
/// Gets manual tokens appended after generated non-terminal options and operands,
/// unless recognized tool options are hoisted when <see cref="ArgumentsContainToolOptions"/>
/// is enabled. These tokens precede <see cref="RunSettings"/> and terminal options.
/// </summary>
public IEnumerable<string>? Arguments { get; init; }

/// <summary>
/// Gets manual tokens whose placement is controlled by their command-line phase.
/// Use this for unmodeled options on strongly typed or generated option records.
/// </summary>
public IEnumerable<AdditionalCommandLineArgument>? AdditionalArguments { get; init; }

/// <summary>
/// Gets whether option-shaped tokens in <see cref="Arguments"/> are options for this tool.
/// When enabled, recognized options can be moved before an end-of-options marker emitted by
Expand Down
Loading
Loading