merge branch master into feature/custom-validators

This commit is contained in:
Oleksandr Shustov
2020-11-07 21:18:34 +02:00
44 changed files with 1136 additions and 614 deletions

View File

@@ -11,10 +11,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2.3.3
- name: Install .NET Core - name: Install .NET Core
uses: actions/setup-dotnet@v1.4.0 uses: actions/setup-dotnet@v1.7.2
with: with:
dotnet-version: 3.1.100 dotnet-version: 3.1.100

View File

@@ -12,10 +12,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2.3.3
- name: Install .NET Core - name: Install .NET Core
uses: actions/setup-dotnet@v1.4.0 uses: actions/setup-dotnet@v1.7.2
with: with:
dotnet-version: 3.1.100 dotnet-version: 3.1.100

View File

@@ -1,3 +1,9 @@
### v1.5 (23-Oct-2020)
- Added pretty-printing for unhandled exceptions thrown from within the application. This makes the errors easier to parse visually and should help in troubleshooting. This change does not affect `CommandException`, as it already has special treatment. (Thanks [@Mårten Åsberg](https://github.com/89netraM))
- Added support for custom value converters. You can now create a type that implements `CliFx.IArgumentValueConverter` and specify it as a converter for your parameters or options via the `Converter` named property. This should enable conversion between raw argument values and custom types which are not string-initializable. (Thanks [@Oleksandr Shustov](https://github.com/AlexandrShustov))
- Improved help text so that it also shows minimal usage examples for child and descendant commands, besides the actual command it was requested on. This should improve user experience for applications with many nested commands. (Thanks [@Nikiforov Alexey](https://github.com/NikiforovAll))
### v1.4 (20-Aug-2020) ### v1.4 (20-Aug-2020)
- Added `VirtualConsole.CreateBuffered()` method to simplify test setup when using in-memory backing stores for output and error streams. Please refer to the readme for updated recommendations on how to test applications built with CliFx. - Added `VirtualConsole.CreateBuffered()` method to simplify test setup when using in-memory backing stores for output and error streams. Please refer to the readme for updated recommendations on how to test applications built with CliFx.

View File

@@ -12,7 +12,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Gu.Roslyn.Asserts" Version="3.3.1" /> <PackageReference Include="Gu.Roslyn.Asserts" Version="3.3.1" />
<PackageReference Include="GitHubActionsTestLogger" Version="1.1.1" /> <PackageReference Include="GitHubActionsTestLogger" Version="1.1.2" />
<PackageReference Include="FluentAssertions" Version="5.10.3" /> <PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.4.0" /> <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.4.0" />

View File

@@ -140,6 +140,30 @@ public class MyCommand : ICommand
[CommandParameter(2)] [CommandParameter(2)]
public IReadOnlyList<string> ParamB { get; set; } public IReadOnlyList<string> ParamB { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}"
)
};
yield return new object[]
{
new AnalyzerTestCase(
"Parameter with valid converter",
Analyzer.SupportedDiagnostics,
// language=cs
@"
public class MyConverter : IArgumentValueConverter
{
public object ConvertFrom(string value) => value;
}
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0, Converter = typeof(MyConverter))]
public string Param { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default; public ValueTask ExecuteAsync(IConsole console) => default;
}" }"
) )
@@ -157,7 +181,7 @@ public class MyCommand : ICommand
public class MyCommand : ICommand public class MyCommand : ICommand
{ {
[CommandOption(""foo"")] [CommandOption(""foo"")]
public string Param { get; set; } public string Option { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default; public ValueTask ExecuteAsync(IConsole console) => default;
}" }"
@@ -176,7 +200,7 @@ public class MyCommand : ICommand
public class MyCommand : ICommand public class MyCommand : ICommand
{ {
[CommandOption(""foo"", 'f')] [CommandOption(""foo"", 'f')]
public string Param { get; set; } public string Option { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default; public ValueTask ExecuteAsync(IConsole console) => default;
}" }"
@@ -195,10 +219,10 @@ public class MyCommand : ICommand
public class MyCommand : ICommand public class MyCommand : ICommand
{ {
[CommandOption(""foo"")] [CommandOption(""foo"")]
public string ParamA { get; set; } public string OptionA { get; set; }
[CommandOption(""bar"")] [CommandOption(""bar"")]
public string ParamB { get; set; } public string OptionB { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default; public ValueTask ExecuteAsync(IConsole console) => default;
}" }"
@@ -217,10 +241,10 @@ public class MyCommand : ICommand
public class MyCommand : ICommand public class MyCommand : ICommand
{ {
[CommandOption('f')] [CommandOption('f')]
public string ParamA { get; set; } public string OptionA { get; set; }
[CommandOption('x')] [CommandOption('x')]
public string ParamB { get; set; } public string OptionB { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default; public ValueTask ExecuteAsync(IConsole console) => default;
}" }"
@@ -239,10 +263,34 @@ public class MyCommand : ICommand
public class MyCommand : ICommand public class MyCommand : ICommand
{ {
[CommandOption('a', EnvironmentVariableName = ""env_var_a"")] [CommandOption('a', EnvironmentVariableName = ""env_var_a"")]
public string ParamA { get; set; } public string OptionA { get; set; }
[CommandOption('b', EnvironmentVariableName = ""env_var_b"")] [CommandOption('b', EnvironmentVariableName = ""env_var_b"")]
public string ParamB { get; set; } public string OptionB { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}"
)
};
yield return new object[]
{
new AnalyzerTestCase(
"Option with valid converter",
Analyzer.SupportedDiagnostics,
// language=cs
@"
public class MyConverter : IArgumentValueConverter
{
public object ConvertFrom(string value) => value;
}
[Command]
public class MyCommand : ICommand
{
[CommandOption('o', Converter = typeof(MyConverter))]
public string Option { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default; public ValueTask ExecuteAsync(IConsole console) => default;
}" }"
@@ -366,6 +414,30 @@ public class MyCommand : ICommand
[CommandParameter(2)] [CommandParameter(2)]
public string ParamB { get; set; } public string ParamB { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}"
)
};
yield return new object[]
{
new AnalyzerTestCase(
"Parameter with invalid converter",
DiagnosticDescriptors.CliFx0025,
// language=cs
@"
public class MyConverter
{
public object ConvertFrom(string value) => value;
}
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0, Converter = typeof(MyConverter))]
public string Param { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default; public ValueTask ExecuteAsync(IConsole console) => default;
}" }"
) )
@@ -383,7 +455,7 @@ public class MyCommand : ICommand
public class MyCommand : ICommand public class MyCommand : ICommand
{ {
[CommandOption("""")] [CommandOption("""")]
public string Param { get; set; } public string Option { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default; public ValueTask ExecuteAsync(IConsole console) => default;
}" }"
@@ -402,7 +474,7 @@ public class MyCommand : ICommand
public class MyCommand : ICommand public class MyCommand : ICommand
{ {
[CommandOption(""a"")] [CommandOption(""a"")]
public string Param { get; set; } public string Option { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default; public ValueTask ExecuteAsync(IConsole console) => default;
}" }"
@@ -421,10 +493,10 @@ public class MyCommand : ICommand
public class MyCommand : ICommand public class MyCommand : ICommand
{ {
[CommandOption(""foo"")] [CommandOption(""foo"")]
public string ParamA { get; set; } public string OptionA { get; set; }
[CommandOption(""foo"")] [CommandOption(""foo"")]
public string ParamB { get; set; } public string OptionB { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default; public ValueTask ExecuteAsync(IConsole console) => default;
}" }"
@@ -443,10 +515,10 @@ public class MyCommand : ICommand
public class MyCommand : ICommand public class MyCommand : ICommand
{ {
[CommandOption('f')] [CommandOption('f')]
public string ParamA { get; set; } public string OptionA { get; set; }
[CommandOption('f')] [CommandOption('f')]
public string ParamB { get; set; } public string OptionB { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default; public ValueTask ExecuteAsync(IConsole console) => default;
}" }"
@@ -465,10 +537,34 @@ public class MyCommand : ICommand
public class MyCommand : ICommand public class MyCommand : ICommand
{ {
[CommandOption('a', EnvironmentVariableName = ""env_var"")] [CommandOption('a', EnvironmentVariableName = ""env_var"")]
public string ParamA { get; set; } public string OptionA { get; set; }
[CommandOption('b', EnvironmentVariableName = ""env_var"")] [CommandOption('b', EnvironmentVariableName = ""env_var"")]
public string ParamB { get; set; } public string OptionB { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}"
)
};
yield return new object[]
{
new AnalyzerTestCase(
"Option with invalid converter",
DiagnosticDescriptors.CliFx0046,
// language=cs
@"
public class MyConverter
{
public object ConvertFrom(string value) => value;
}
[Command]
public class MyCommand : ICommand
{
[CommandOption('o', Converter = typeof(MyConverter))]
public string Option { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default; public ValueTask ExecuteAsync(IConsole console) => default;
}" }"

View File

@@ -17,16 +17,19 @@ namespace CliFx.Analyzers
DiagnosticDescriptors.CliFx0022, DiagnosticDescriptors.CliFx0022,
DiagnosticDescriptors.CliFx0023, DiagnosticDescriptors.CliFx0023,
DiagnosticDescriptors.CliFx0024, DiagnosticDescriptors.CliFx0024,
DiagnosticDescriptors.CliFx0025,
DiagnosticDescriptors.CliFx0041, DiagnosticDescriptors.CliFx0041,
DiagnosticDescriptors.CliFx0042, DiagnosticDescriptors.CliFx0042,
DiagnosticDescriptors.CliFx0043, DiagnosticDescriptors.CliFx0043,
DiagnosticDescriptors.CliFx0044, DiagnosticDescriptors.CliFx0044,
DiagnosticDescriptors.CliFx0045 DiagnosticDescriptors.CliFx0045,
DiagnosticDescriptors.CliFx0046
); );
private static bool IsScalarType(ITypeSymbol typeSymbol) => private static bool IsScalarType(ITypeSymbol typeSymbol) =>
KnownSymbols.IsSystemString(typeSymbol) || KnownSymbols.IsSystemString(typeSymbol) ||
!typeSymbol.AllInterfaces.Select(i => i.ConstructedFrom).Any(KnownSymbols.IsSystemCollectionsGenericIEnumerable); !typeSymbol.AllInterfaces.Select(i => i.ConstructedFrom)
.Any(KnownSymbols.IsSystemCollectionsGenericIEnumerable);
private static void CheckCommandParameterProperties( private static void CheckCommandParameterProperties(
SymbolAnalysisContext context, SymbolAnalysisContext context,
@@ -50,11 +53,18 @@ namespace CliFx.Analyzers
.Select(a => a.Value.Value) .Select(a => a.Value.Value)
.FirstOrDefault() as string; .FirstOrDefault() as string;
var converter = attribute
.NamedArguments
.Where(a => a.Key == "Converter")
.Select(a => a.Value.Value)
.FirstOrDefault() as ITypeSymbol;
return new return new
{ {
Property = p, Property = p,
Order = order, Order = order,
Name = name Name = name,
Converter = converter
}; };
}) })
.ToArray(); .ToArray();
@@ -69,8 +79,9 @@ namespace CliFx.Analyzers
foreach (var parameter in duplicateOrderParameters) foreach (var parameter in duplicateOrderParameters)
{ {
context.ReportDiagnostic( context.ReportDiagnostic(Diagnostic.Create(
Diagnostic.Create(DiagnosticDescriptors.CliFx0021, parameter.Property.Locations.First())); DiagnosticDescriptors.CliFx0021, parameter.Property.Locations.First()
));
} }
// Duplicate name // Duplicate name
@@ -83,8 +94,9 @@ namespace CliFx.Analyzers
foreach (var parameter in duplicateNameParameters) foreach (var parameter in duplicateNameParameters)
{ {
context.ReportDiagnostic( context.ReportDiagnostic(Diagnostic.Create(
Diagnostic.Create(DiagnosticDescriptors.CliFx0022, parameter.Property.Locations.First())); DiagnosticDescriptors.CliFx0022, parameter.Property.Locations.First()
));
} }
// Multiple non-scalar // Multiple non-scalar
@@ -96,8 +108,9 @@ namespace CliFx.Analyzers
{ {
foreach (var parameter in nonScalarParameters) foreach (var parameter in nonScalarParameters)
{ {
context.ReportDiagnostic( context.ReportDiagnostic(Diagnostic.Create(
Diagnostic.Create(DiagnosticDescriptors.CliFx0023, parameter.Property.Locations.First())); DiagnosticDescriptors.CliFx0023, parameter.Property.Locations.First()
));
} }
} }
@@ -109,8 +122,23 @@ namespace CliFx.Analyzers
if (nonLastNonScalarParameter != null) if (nonLastNonScalarParameter != null)
{ {
context.ReportDiagnostic( context.ReportDiagnostic(Diagnostic.Create(
Diagnostic.Create(DiagnosticDescriptors.CliFx0024, nonLastNonScalarParameter.Property.Locations.First())); DiagnosticDescriptors.CliFx0024, nonLastNonScalarParameter.Property.Locations.First()
));
}
// Invalid converter
var invalidConverterParameters = parameters
.Where(p =>
p.Converter != null &&
!p.Converter.AllInterfaces.Any(KnownSymbols.IsArgumentValueConverterInterface))
.ToArray();
foreach (var parameter in invalidConverterParameters)
{
context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.CliFx0025, parameter.Property.Locations.First()
));
} }
} }
@@ -143,12 +171,19 @@ namespace CliFx.Analyzers
.Select(a => a.Value.Value) .Select(a => a.Value.Value)
.FirstOrDefault() as string; .FirstOrDefault() as string;
var converter = attribute
.NamedArguments
.Where(a => a.Key == "Converter")
.Select(a => a.Value.Value)
.FirstOrDefault() as ITypeSymbol;
return new return new
{ {
Property = p, Property = p,
Name = name, Name = name,
ShortName = shortName, ShortName = shortName,
EnvironmentVariableName = envVarName EnvironmentVariableName = envVarName,
Converter = converter
}; };
}) })
.ToArray(); .ToArray();
@@ -160,8 +195,9 @@ namespace CliFx.Analyzers
foreach (var option in noNameOptions) foreach (var option in noNameOptions)
{ {
context.ReportDiagnostic( context.ReportDiagnostic(Diagnostic.Create(
Diagnostic.Create(DiagnosticDescriptors.CliFx0041, option.Property.Locations.First())); DiagnosticDescriptors.CliFx0041, option.Property.Locations.First()
));
} }
// Too short name // Too short name
@@ -171,8 +207,9 @@ namespace CliFx.Analyzers
foreach (var option in invalidNameLengthOptions) foreach (var option in invalidNameLengthOptions)
{ {
context.ReportDiagnostic( context.ReportDiagnostic(Diagnostic.Create(
Diagnostic.Create(DiagnosticDescriptors.CliFx0042, option.Property.Locations.First())); DiagnosticDescriptors.CliFx0042, option.Property.Locations.First()
));
} }
// Duplicate name // Duplicate name
@@ -185,8 +222,9 @@ namespace CliFx.Analyzers
foreach (var option in duplicateNameOptions) foreach (var option in duplicateNameOptions)
{ {
context.ReportDiagnostic( context.ReportDiagnostic(Diagnostic.Create(
Diagnostic.Create(DiagnosticDescriptors.CliFx0043, option.Property.Locations.First())); DiagnosticDescriptors.CliFx0043, option.Property.Locations.First()
));
} }
// Duplicate name // Duplicate name
@@ -199,8 +237,9 @@ namespace CliFx.Analyzers
foreach (var option in duplicateShortNameOptions) foreach (var option in duplicateShortNameOptions)
{ {
context.ReportDiagnostic( context.ReportDiagnostic(Diagnostic.Create(
Diagnostic.Create(DiagnosticDescriptors.CliFx0044, option.Property.Locations.First())); DiagnosticDescriptors.CliFx0044, option.Property.Locations.First()
));
} }
// Duplicate environment variable name // Duplicate environment variable name
@@ -213,19 +252,31 @@ namespace CliFx.Analyzers
foreach (var option in duplicateEnvironmentVariableNameOptions) foreach (var option in duplicateEnvironmentVariableNameOptions)
{ {
context.ReportDiagnostic( context.ReportDiagnostic(Diagnostic.Create(
Diagnostic.Create(DiagnosticDescriptors.CliFx0045, option.Property.Locations.First())); DiagnosticDescriptors.CliFx0045, option.Property.Locations.First()
));
}
// Invalid converter
var invalidConverterOptions = options
.Where(o =>
o.Converter != null &&
!o.Converter.AllInterfaces.Any(KnownSymbols.IsArgumentValueConverterInterface))
.ToArray();
foreach (var option in invalidConverterOptions)
{
context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.CliFx0046, option.Property.Locations.First()
));
} }
} }
private static void CheckCommandType(SymbolAnalysisContext context) private static void CheckCommandType(SymbolAnalysisContext context)
{ {
// Named type: MyCommand // Named type: MyCommand
if (!(context.Symbol is INamedTypeSymbol namedTypeSymbol)) if (!(context.Symbol is INamedTypeSymbol namedTypeSymbol) ||
return; namedTypeSymbol.TypeKind != TypeKind.Class)
// Only classes
if (namedTypeSymbol.TypeKind != TypeKind.Class)
return; return;
// Implements ICommand? // Implements ICommand?
@@ -252,10 +303,12 @@ namespace CliFx.Analyzers
var isAlmostValidCommandType = implementsCommandInterface ^ hasCommandAttribute; var isAlmostValidCommandType = implementsCommandInterface ^ hasCommandAttribute;
if (isAlmostValidCommandType && !implementsCommandInterface) if (isAlmostValidCommandType && !implementsCommandInterface)
context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.CliFx0001, namedTypeSymbol.Locations.First())); context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.CliFx0001,
namedTypeSymbol.Locations.First()));
if (isAlmostValidCommandType && !hasCommandAttribute) if (isAlmostValidCommandType && !hasCommandAttribute)
context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.CliFx0002, namedTypeSymbol.Locations.First())); context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.CliFx0002,
namedTypeSymbol.Locations.First()));
return; return;
} }

View File

@@ -18,55 +18,49 @@ namespace CliFx.Analyzers
SyntaxNodeAnalysisContext context, SyntaxNodeAnalysisContext context,
InvocationExpressionSyntax invocationSyntax) InvocationExpressionSyntax invocationSyntax)
{ {
// Get the method member access (Console.WriteLine or Console.Error.WriteLine) if (invocationSyntax.Expression is MemberAccessExpressionSyntax memberAccessSyntax &&
if (!(invocationSyntax.Expression is MemberAccessExpressionSyntax memberAccessSyntax)) context.SemanticModel.GetSymbolInfo(memberAccessSyntax).Symbol is IMethodSymbol methodSymbol)
return false; {
// Direct call to System.Console (e.g. System.Console.WriteLine())
if (KnownSymbols.IsSystemConsole(methodSymbol.ContainingType))
{
return true;
}
// Get the semantic model for the invoked method // Indirect call to System.Console (e.g. System.Console.Error.WriteLine())
if (!(context.SemanticModel.GetSymbolInfo(memberAccessSyntax).Symbol is IMethodSymbol methodSymbol)) if (memberAccessSyntax.Expression is MemberAccessExpressionSyntax parentMemberAccessSyntax &&
return false; context.SemanticModel.GetSymbolInfo(parentMemberAccessSyntax).Symbol is IPropertySymbol propertySymbol)
{
// Check if contained within System.Console return KnownSymbols.IsSystemConsole(propertySymbol.ContainingType);
if (KnownSymbols.IsSystemConsole(methodSymbol.ContainingType)) }
return true; }
// In case with Console.Error.WriteLine that wouldn't work, we need to check parent member access too
if (!(memberAccessSyntax.Expression is MemberAccessExpressionSyntax parentMemberAccessSyntax))
return false;
// Get the semantic model for the parent member
if (!(context.SemanticModel.GetSymbolInfo(parentMemberAccessSyntax).Symbol is IPropertySymbol propertySymbol))
return false;
// Check if contained within System.Console
if (KnownSymbols.IsSystemConsole(propertySymbol.ContainingType))
return true;
return false; return false;
} }
private static void CheckSystemConsoleUsage(SyntaxNodeAnalysisContext context) private static void CheckSystemConsoleUsage(SyntaxNodeAnalysisContext context)
{ {
if (!(context.Node is InvocationExpressionSyntax invocationSyntax)) if (context.Node is InvocationExpressionSyntax invocationSyntax &&
return; IsSystemConsoleInvocation(context, invocationSyntax))
{
// Check if IConsole is available in scope as alternative to System.Console
var isConsoleInterfaceAvailable = invocationSyntax
.Ancestors()
.OfType<MethodDeclarationSyntax>()
.SelectMany(m => m.ParameterList.Parameters)
.Select(p => p.Type)
.Select(t => context.SemanticModel.GetSymbolInfo(t).Symbol)
.Where(s => s != null)
.Any(KnownSymbols.IsConsoleInterface!);
if (!IsSystemConsoleInvocation(context, invocationSyntax)) if (isConsoleInterfaceAvailable)
return; {
context.ReportDiagnostic(Diagnostic.Create(
// Check if IConsole is available in the scope as a viable alternative DiagnosticDescriptors.CliFx0100,
var isConsoleInterfaceAvailable = invocationSyntax invocationSyntax.GetLocation()
.Ancestors() ));
.OfType<MethodDeclarationSyntax>() }
.SelectMany(m => m.ParameterList.Parameters) }
.Select(p => p.Type)
.Select(t => context.SemanticModel.GetSymbolInfo(t).Symbol)
.Where(s => s != null)
.Any(KnownSymbols.IsConsoleInterface!);
if (!isConsoleInterfaceAvailable)
return;
context.ReportDiagnostic(Diagnostic.Create(DiagnosticDescriptors.CliFx0100, invocationSyntax.GetLocation()));
} }
public override void Initialize(AnalysisContext context) public override void Initialize(AnalysisContext context)

View File

@@ -8,72 +8,98 @@ namespace CliFx.Analyzers
new DiagnosticDescriptor(nameof(CliFx0001), new DiagnosticDescriptor(nameof(CliFx0001),
"Type must implement the 'CliFx.ICommand' interface in order to be a valid command", "Type must implement the 'CliFx.ICommand' interface in order to be a valid command",
"Type must implement the 'CliFx.ICommand' interface in order to be a valid command", "Type must implement the 'CliFx.ICommand' interface in order to be a valid command",
"Usage", DiagnosticSeverity.Error, true); "Usage", DiagnosticSeverity.Error, true
);
public static readonly DiagnosticDescriptor CliFx0002 = public static readonly DiagnosticDescriptor CliFx0002 =
new DiagnosticDescriptor(nameof(CliFx0002), new DiagnosticDescriptor(nameof(CliFx0002),
"Type must be annotated with the 'CliFx.Attributes.CommandAttribute' in order to be a valid command", "Type must be annotated with the 'CliFx.Attributes.CommandAttribute' in order to be a valid command",
"Type must be annotated with the 'CliFx.Attributes.CommandAttribute' in order to be a valid command", "Type must be annotated with the 'CliFx.Attributes.CommandAttribute' in order to be a valid command",
"Usage", DiagnosticSeverity.Error, true); "Usage", DiagnosticSeverity.Error, true
);
public static readonly DiagnosticDescriptor CliFx0021 = public static readonly DiagnosticDescriptor CliFx0021 =
new DiagnosticDescriptor(nameof(CliFx0021), new DiagnosticDescriptor(nameof(CliFx0021),
"Parameter order must be unique within its command", "Parameter order must be unique within its command",
"Parameter order must be unique within its command", "Parameter order must be unique within its command",
"Usage", DiagnosticSeverity.Error, true); "Usage", DiagnosticSeverity.Error, true
);
public static readonly DiagnosticDescriptor CliFx0022 = public static readonly DiagnosticDescriptor CliFx0022 =
new DiagnosticDescriptor(nameof(CliFx0022), new DiagnosticDescriptor(nameof(CliFx0022),
"Parameter order must have unique name within its command", "Parameter order must have unique name within its command",
"Parameter order must have unique name within its command", "Parameter order must have unique name within its command",
"Usage", DiagnosticSeverity.Error, true); "Usage", DiagnosticSeverity.Error, true
);
public static readonly DiagnosticDescriptor CliFx0023 = public static readonly DiagnosticDescriptor CliFx0023 =
new DiagnosticDescriptor(nameof(CliFx0023), new DiagnosticDescriptor(nameof(CliFx0023),
"Only one non-scalar parameter per command is allowed", "Only one non-scalar parameter per command is allowed",
"Only one non-scalar parameter per command is allowed", "Only one non-scalar parameter per command is allowed",
"Usage", DiagnosticSeverity.Error, true); "Usage", DiagnosticSeverity.Error, true
);
public static readonly DiagnosticDescriptor CliFx0024 = public static readonly DiagnosticDescriptor CliFx0024 =
new DiagnosticDescriptor(nameof(CliFx0024), new DiagnosticDescriptor(nameof(CliFx0024),
"Non-scalar parameter must be last in order", "Non-scalar parameter must be last in order",
"Non-scalar parameter must be last in order", "Non-scalar parameter must be last in order",
"Usage", DiagnosticSeverity.Error, true); "Usage", DiagnosticSeverity.Error, true
);
public static readonly DiagnosticDescriptor CliFx0025 =
new DiagnosticDescriptor(nameof(CliFx0025),
"Parameter converter must implement 'CliFx.IArgumentValueConverter'",
"Parameter converter must implement 'CliFx.IArgumentValueConverter'",
"Usage", DiagnosticSeverity.Error, true
);
public static readonly DiagnosticDescriptor CliFx0041 = public static readonly DiagnosticDescriptor CliFx0041 =
new DiagnosticDescriptor(nameof(CliFx0041), new DiagnosticDescriptor(nameof(CliFx0041),
"Option must have a name or short name specified", "Option must have a name or short name specified",
"Option must have a name or short name specified", "Option must have a name or short name specified",
"Usage", DiagnosticSeverity.Error, true); "Usage", DiagnosticSeverity.Error, true
);
public static readonly DiagnosticDescriptor CliFx0042 = public static readonly DiagnosticDescriptor CliFx0042 =
new DiagnosticDescriptor(nameof(CliFx0042), new DiagnosticDescriptor(nameof(CliFx0042),
"Option name must be at least 2 characters long", "Option name must be at least 2 characters long",
"Option name must be at least 2 characters long", "Option name must be at least 2 characters long",
"Usage", DiagnosticSeverity.Error, true); "Usage", DiagnosticSeverity.Error, true
);
public static readonly DiagnosticDescriptor CliFx0043 = public static readonly DiagnosticDescriptor CliFx0043 =
new DiagnosticDescriptor(nameof(CliFx0043), new DiagnosticDescriptor(nameof(CliFx0043),
"Option name must be unique within its command", "Option name must be unique within its command",
"Option name must be unique within its command", "Option name must be unique within its command",
"Usage", DiagnosticSeverity.Error, true); "Usage", DiagnosticSeverity.Error, true
);
public static readonly DiagnosticDescriptor CliFx0044 = public static readonly DiagnosticDescriptor CliFx0044 =
new DiagnosticDescriptor(nameof(CliFx0044), new DiagnosticDescriptor(nameof(CliFx0044),
"Option short name must be unique within its command", "Option short name must be unique within its command",
"Option short name must be unique within its command", "Option short name must be unique within its command",
"Usage", DiagnosticSeverity.Error, true); "Usage", DiagnosticSeverity.Error, true
);
public static readonly DiagnosticDescriptor CliFx0045 = public static readonly DiagnosticDescriptor CliFx0045 =
new DiagnosticDescriptor(nameof(CliFx0045), new DiagnosticDescriptor(nameof(CliFx0045),
"Option environment variable name must be unique within its command", "Option environment variable name must be unique within its command",
"Option environment variable name must be unique within its command", "Option environment variable name must be unique within its command",
"Usage", DiagnosticSeverity.Error, true); "Usage", DiagnosticSeverity.Error, true
);
public static readonly DiagnosticDescriptor CliFx0046 =
new DiagnosticDescriptor(nameof(CliFx0046),
"Option converter must implement 'CliFx.IArgumentValueConverter'",
"Option converter must implement 'CliFx.IArgumentValueConverter'",
"Usage", DiagnosticSeverity.Error, true
);
public static readonly DiagnosticDescriptor CliFx0100 = public static readonly DiagnosticDescriptor CliFx0100 =
new DiagnosticDescriptor(nameof(CliFx0100), new DiagnosticDescriptor(nameof(CliFx0100),
"Use the provided IConsole abstraction instead of System.Console to ensure that the command can be tested in isolation", "Use the provided IConsole abstraction instead of System.Console to ensure that the command can be tested in isolation",
"Use the provided IConsole abstraction instead of System.Console to ensure that the command can be tested in isolation", "Use the provided IConsole abstraction instead of System.Console to ensure that the command can be tested in isolation",
"Usage", DiagnosticSeverity.Warning, true); "Usage", DiagnosticSeverity.Warning, true
);
} }
} }

View File

@@ -3,7 +3,7 @@ using Microsoft.CodeAnalysis;
namespace CliFx.Analyzers namespace CliFx.Analyzers
{ {
public static class KnownSymbols internal static class KnownSymbols
{ {
public static bool IsSystemString(ISymbol symbol) => public static bool IsSystemString(ISymbol symbol) =>
symbol.DisplayNameMatches("string") || symbol.DisplayNameMatches("string") ||
@@ -25,6 +25,9 @@ namespace CliFx.Analyzers
public static bool IsCommandInterface(ISymbol symbol) => public static bool IsCommandInterface(ISymbol symbol) =>
symbol.DisplayNameMatches("CliFx.ICommand"); symbol.DisplayNameMatches("CliFx.ICommand");
public static bool IsArgumentValueConverterInterface(ISymbol symbol) =>
symbol.DisplayNameMatches("CliFx.IArgumentValueConverter");
public static bool IsCommandAttribute(ISymbol symbol) => public static bool IsCommandAttribute(ISymbol symbol) =>
symbol.DisplayNameMatches("CliFx.Attributes.CommandAttribute"); symbol.DisplayNameMatches("CliFx.Attributes.CommandAttribute");

View File

@@ -7,7 +7,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="3.1.8" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="3.1.9" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
</ItemGroup> </ItemGroup>

View File

@@ -232,6 +232,26 @@ namespace CliFx.Tests
_output.WriteLine(stdErr.GetString()); _output.WriteLine(stdErr.GetString());
} }
[Fact]
public async Task Command_parameter_custom_converter_must_implement_the_corresponding_interface()
{
var (console, _, stdErr) = VirtualConsole.CreateBuffered();
var application = new CliApplicationBuilder()
.AddCommand<InvalidCustomConverterParameterCommand>()
.UseConsole(console)
.Build();
// Act
var exitCode = await application.RunAsync(Array.Empty<string>());
// Assert
exitCode.Should().NotBe(0);
stdErr.GetString().Should().NotBeNullOrWhiteSpace();
_output.WriteLine(stdErr.GetString());
}
[Fact] [Fact]
public async Task Command_options_must_have_names_that_are_not_empty() public async Task Command_options_must_have_names_that_are_not_empty()
{ {
@@ -371,5 +391,25 @@ namespace CliFx.Tests
_output.WriteLine(stdErr.GetString()); _output.WriteLine(stdErr.GetString());
} }
[Fact]
public async Task Command_option_custom_converter_must_implement_the_corresponding_interface()
{
var (console, _, stdErr) = VirtualConsole.CreateBuffered();
var application = new CliApplicationBuilder()
.AddCommand<InvalidCustomConverterOptionCommand>()
.UseConsole(console)
.Build();
// Act
var exitCode = await application.RunAsync(Array.Empty<string>());
// Assert
exitCode.Should().NotBe(0);
stdErr.GetString().Should().NotBeNullOrWhiteSpace();
_output.WriteLine(stdErr.GetString());
}
} }
} }

View File

@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Tests.Commands; using CliFx.Tests.Commands;
using CliFx.Tests.Commands.Converters;
using CliFx.Tests.Internal; using CliFx.Tests.Internal;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;
@@ -18,7 +17,7 @@ namespace CliFx.Tests
public ArgumentConversionSpecs(ITestOutputHelper output) => _output = output; public ArgumentConversionSpecs(ITestOutputHelper output) => _output = output;
[Fact] [Fact]
public async Task Property_of_type_object_is_bound_directly_from_the_argument_value() public async Task Argument_value_can_be_bound_to_object()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -46,7 +45,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_object_array_is_bound_directly_from_the_argument_values() public async Task Argument_values_can_be_bound_to_array_of_object()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -74,7 +73,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_string_is_bound_directly_from_the_argument_value() public async Task Argument_value_can_be_bound_to_string()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -102,7 +101,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_string_array_is_bound_directly_from_the_argument_values() public async Task Argument_values_can_be_bound_to_array_of_string()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -130,7 +129,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_string_IEnumerable_is_bound_directly_from_the_argument_values() public async Task Argument_values_can_be_bound_to_IEnumerable_of_string()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -158,7 +157,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_string_IReadOnlyList_is_bound_directly_from_the_argument_values() public async Task Argument_values_can_be_bound_to_IReadOnlyList_of_string()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -186,7 +185,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_string_List_is_bound_directly_from_the_argument_values() public async Task Argument_values_can_be_bound_to_List_of_string()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -214,7 +213,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_string_HashSet_is_bound_directly_from_the_argument_values() public async Task Argument_values_can_be_bound_to_HashSet_of_string()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -242,7 +241,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_bool_is_bound_as_true_if_the_argument_value_is_true() public async Task Argument_value_can_be_bound_to_boolean_as_true_if_the_value_is_true()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -270,7 +269,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_bool_is_bound_as_false_if_the_argument_value_is_false() public async Task Argument_value_can_be_bound_to_boolean_as_false_if_the_value_is_false()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -298,7 +297,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_bool_is_bound_as_true_if_the_argument_value_is_not_set() public async Task Argument_value_can_be_bound_to_boolean_as_true_if_the_value_is_not_set()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -326,7 +325,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_char_is_bound_directly_from_the_argument_value_if_it_contains_only_one_character() public async Task Argument_value_can_be_bound_to_char_if_the_value_contains_a_single_character()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -354,7 +353,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_sbyte_is_bound_by_parsing_the_argument_value() public async Task Argument_value_can_be_bound_to_sbyte()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -382,7 +381,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_byte_is_bound_by_parsing_the_argument_value() public async Task Argument_value_can_be_bound_to_byte()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -410,7 +409,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_short_is_bound_by_parsing_the_argument_value() public async Task Argument_value_can_be_bound_to_short()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -438,7 +437,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_ushort_is_bound_by_parsing_the_argument_value() public async Task Argument_value_can_be_bound_to_ushort()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -466,7 +465,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_int_is_bound_by_parsing_the_argument_value() public async Task Argument_value_can_be_bound_to_int()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -494,7 +493,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_nullable_int_is_bound_by_parsing_the_argument_value_if_it_is_set() public async Task Argument_value_can_be_bound_to_nullable_of_int_as_actual_value_if_it_is_set()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -522,7 +521,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_nullable_int_is_bound_as_null_if_the_argument_value_is_not_set() public async Task Argument_value_can_be_bound_to_nullable_of_int_as_null_if_it_is_not_set()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -550,7 +549,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_int_array_is_bound_by_parsing_the_argument_values() public async Task Argument_values_can_be_bound_to_array_of_int()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -578,7 +577,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_nullable_int_array_is_bound_by_parsing_the_argument_values() public async Task Argument_values_can_be_bound_to_array_of_nullable_of_int()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -606,7 +605,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_uint_is_bound_by_parsing_the_argument_value() public async Task Argument_value_can_be_bound_to_uint()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -634,7 +633,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_long_is_bound_by_parsing_the_argument_value() public async Task Argument_value_can_be_bound_to_long()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -662,7 +661,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_ulong_is_bound_by_parsing_the_argument_value() public async Task Argument_value_can_be_bound_to_ulong()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -690,7 +689,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_float_is_bound_by_parsing_the_argument_value() public async Task Argument_value_can_be_bound_to_float()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -718,7 +717,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_double_is_bound_by_parsing_the_argument_value() public async Task Argument_value_can_be_bound_to_double()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -746,7 +745,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_decimal_is_bound_by_parsing_the_argument_value() public async Task Argument_value_can_be_bound_to_decimal()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -774,7 +773,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_DateTime_is_bound_by_parsing_the_argument_value() public async Task Argument_value_can_be_bound_to_DateTime()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -802,7 +801,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_DateTimeOffset_is_bound_by_parsing_the_argument_value() public async Task Argument_value_can_be_bound_to_DateTimeOffset()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -830,7 +829,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_TimeSpan_is_bound_by_parsing_the_argument_value() public async Task Argument_value_can_be_bound_to_TimeSpan()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -858,7 +857,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_nullable_TimeSpan_is_bound_by_parsing_the_argument_value_if_it_is_set() public async Task Argument_value_can_be_bound_to_nullable_of_TimeSpan_as_actual_value_if_it_is_set()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -886,7 +885,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_type_nullable_TimeSpan_is_bound_as_null_if_the_argument_value_is_not_set() public async Task Argument_value_can_be_bound_to_nullable_of_TimeSpan_as_null_if_it_is_not_set()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -914,7 +913,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_an_enum_type_is_bound_by_parsing_the_argument_value_as_name() public async Task Argument_value_can_be_bound_to_enum_type_by_name()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -942,7 +941,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_an_enum_type_is_bound_by_parsing_the_argument_value_as_id() public async Task Argument_value_can_be_bound_to_enum_type_by_id()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -970,7 +969,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_a_nullable_enum_type_is_bound_by_parsing_the_argument_value_as_name_if_it_is_set() public async Task Argument_value_can_be_bound_to_nullable_of_enum_type_by_name_if_it_is_set()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -998,7 +997,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_a_nullable_enum_type_is_bound_by_parsing_the_argument_value_as_id_if_it_is_set() public async Task Argument_value_can_be_bound_to_nullable_of_enum_type_by_id_if_it_is_set()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -1026,7 +1025,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_a_nullable_enum_type_is_bound_as_null_if_the_argument_value_is_not_set() public async Task Argument_value_can_be_bound_to_nullable_of_enum_type_as_null_if_it_is_not_set()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -1054,7 +1053,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_an_enum_array_type_is_bound_by_parsing_the_argument_values_as_names() public async Task Argument_values_can_be_bound_to_array_of_enum_type_by_names()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -1082,7 +1081,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_an_enum_array_type_is_bound_by_parsing_the_argument_values_as_ids() public async Task Argument_values_can_be_bound_to_array_of_enum_type_by_ids()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -1110,7 +1109,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_an_enum_array_type_is_bound_by_parsing_the_argument_values_as_either_names_or_ids() public async Task Argument_values_can_be_bound_to_array_of_enum_type_by_either_names_or_ids()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -1138,7 +1137,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_a_type_that_has_a_constructor_accepting_a_string_is_bound_by_invoking_the_constructor_with_the_argument_value() public async Task Argument_value_can_be_bound_to_a_custom_type_if_it_has_a_constructor_accepting_a_string()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -1166,7 +1165,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_an_array_of_type_that_has_a_constructor_accepting_a_string_is_bound_by_invoking_the_constructor_with_the_argument_values() public async Task Argument_values_can_be_bound_to_array_of_custom_type_if_it_has_a_constructor_accepting_a_string()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -1198,7 +1197,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_a_type_that_has_a_static_Parse_method_accepting_a_string_is_bound_by_invoking_the_method() public async Task Argument_value_can_be_bound_to_a_custom_type_if_it_has_a_static_Parse_method()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -1226,7 +1225,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_a_type_that_has_a_static_Parse_method_accepting_a_string_and_format_provider_is_bound_by_invoking_the_method() public async Task Argument_value_can_be_bound_to_a_custom_type_if_it_has_a_static_Parse_method_with_format_provider()
{ {
// Arrange // Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered(); var (console, stdOut, _) = VirtualConsole.CreateBuffered();
@@ -1255,7 +1254,72 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_custom_type_must_be_string_initializable_in_order_to_be_bound() public async Task Argument_value_can_be_bound_to_a_custom_type_if_a_converter_has_been_specified()
{
// Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered();
var application = new CliApplicationBuilder()
.AddCommand<SupportedArgumentTypesCommand>()
.UseConsole(console)
.Build();
// Act
var exitCode = await application.RunAsync(new[]
{
"cmd", "--convertible", "13"
});
var commandInstance = stdOut.GetString().DeserializeJson<SupportedArgumentTypesCommand>();
// Assert
exitCode.Should().Be(0);
commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand
{
Convertible =
(SupportedArgumentTypesCommand.CustomConvertible)
new SupportedArgumentTypesCommand.CustomConvertibleConverter().ConvertFrom("13")
});
}
[Fact]
public async Task Argument_values_can_be_bound_to_array_of_custom_type_if_a_converter_has_been_specified()
{
// Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered();
var application = new CliApplicationBuilder()
.AddCommand<SupportedArgumentTypesCommand>()
.UseConsole(console)
.Build();
// Act
var exitCode = await application.RunAsync(new[]
{
"cmd", "--convertible-array", "13", "42"
});
var commandInstance = stdOut.GetString().DeserializeJson<SupportedArgumentTypesCommand>();
// Assert
exitCode.Should().Be(0);
commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand
{
ConvertibleArray = new[]
{
(SupportedArgumentTypesCommand.CustomConvertible)
new SupportedArgumentTypesCommand.CustomConvertibleConverter().ConvertFrom("13"),
(SupportedArgumentTypesCommand.CustomConvertible)
new SupportedArgumentTypesCommand.CustomConvertibleConverter().ConvertFrom("42")
}
});
}
[Fact]
public async Task Argument_value_can_only_be_bound_if_the_target_type_is_supported()
{ {
// Arrange // Arrange
var (console, _, stdErr) = VirtualConsole.CreateBuffered(); var (console, _, stdErr) = VirtualConsole.CreateBuffered();
@@ -1268,7 +1332,7 @@ namespace CliFx.Tests
// Act // Act
var exitCode = await application.RunAsync(new[] var exitCode = await application.RunAsync(new[]
{ {
"cmd", "--str-non-initializable", "foobar" "cmd", "--custom"
}); });
// Assert // Assert
@@ -1279,20 +1343,20 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_custom_type_that_implements_IEnumerable_can_only_be_bound_if_that_type_has_a_constructor_accepting_an_array() public async Task Argument_value_can_only_be_bound_if_the_provided_value_can_be_converted_to_the_target_type()
{ {
// Arrange // Arrange
var (console, _, stdErr) = VirtualConsole.CreateBuffered(); var (console, _, stdErr) = VirtualConsole.CreateBuffered();
var application = new CliApplicationBuilder() var application = new CliApplicationBuilder()
.AddCommand<UnsupportedArgumentTypesCommand>() .AddCommand<SupportedArgumentTypesCommand>()
.UseConsole(console) .UseConsole(console)
.Build(); .Build();
// Act // Act
var exitCode = await application.RunAsync(new[] var exitCode = await application.RunAsync(new[]
{ {
"cmd", "--str-enumerable-non-initializable", "foobar" "cmd", "--int", "foo"
}); });
// Assert // Assert
@@ -1303,7 +1367,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_non_nullable_type_can_only_be_bound_if_the_argument_value_is_set() public async Task Argument_value_can_only_be_bound_to_non_nullable_type_if_it_is_set()
{ {
// Arrange // Arrange
var (console, _, stdErr) = VirtualConsole.CreateBuffered(); var (console, _, stdErr) = VirtualConsole.CreateBuffered();
@@ -1327,7 +1391,7 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_must_have_a_type_that_implements_IEnumerable_in_order_to_be_bound_from_multiple_argument_values() public async Task Argument_values_can_only_be_bound_to_a_type_that_implements_IEnumerable()
{ {
// Arrange // Arrange
var (console, _, stdErr) = VirtualConsole.CreateBuffered(); var (console, _, stdErr) = VirtualConsole.CreateBuffered();
@@ -1351,68 +1415,27 @@ namespace CliFx.Tests
} }
[Fact] [Fact]
public async Task Property_of_custom_type_is_bound_when_the_valid_converter_type_is_specified() public async Task Argument_values_can_only_be_bound_to_a_type_that_implements_IEnumerable_and_can_be_converted_from_an_array()
{ {
// Arrange // Arrange
const string foo = "foo"; var (console, _, stdErr) = VirtualConsole.CreateBuffered();
var (console, stdOut, _) = VirtualConsole.CreateBuffered();
var application = new CliApplicationBuilder() var application = new CliApplicationBuilder()
.AddCommand<CommandWithParameterOfCustomType>() .AddCommand<UnsupportedArgumentTypesCommand>()
.UseConsole(console) .UseConsole(console)
.Build(); .Build();
// Act // Act
var exitCode = await application.RunAsync(new[] var exitCode = await application.RunAsync(new[]
{ {
"cmd", "--prop", foo "cmd", "--custom-enumerable"
}); });
// Assert // Assert
exitCode.Should().Be(0); exitCode.Should().NotBe(0);
stdErr.GetString().Should().NotBeNullOrWhiteSpace();
var commandInstance = stdOut.GetString().DeserializeJson<CommandWithParameterOfCustomType>(); _output.WriteLine(stdErr.GetString());
commandInstance.Should().BeEquivalentTo(new CommandWithParameterOfCustomType()
{
MyProperty = (CustomType) new CustomTypeConverter().ConvertFrom(foo)
});
}
[Fact]
public async Task Enumerable_of_the_custom_type_is_bound_when_the_valid_converter_type_is_specified()
{
// Arrange
string foo = "foo";
string bar = "bar";
var (console, stdOut, _) = VirtualConsole.CreateBuffered();
var application = new CliApplicationBuilder()
.AddCommand<CommandWithEnumerableOfParametersOfCustomType>()
.UseConsole(console)
.Build();
// Act
var exitCode = await application.RunAsync(new[]
{
"cmd", "--prop", foo, bar
});
// Assert
exitCode.Should().Be(0);
var commandInstance = stdOut.GetString().DeserializeJson<CommandWithEnumerableOfParametersOfCustomType>();
commandInstance.Should().BeEquivalentTo(new CommandWithEnumerableOfParametersOfCustomType()
{
MyProperties = new List<CustomType>
{
(CustomType) new CustomTypeConverter().ConvertFrom(foo),
(CustomType) new CustomTypeConverter().ConvertFrom(bar)
}
});
} }
} }
} }

View File

@@ -15,9 +15,9 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CliWrap" Version="3.2.0" /> <PackageReference Include="CliWrap" Version="3.2.2" />
<PackageReference Include="FluentAssertions" Version="5.10.3" /> <PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="GitHubActionsTestLogger" Version="1.1.1" /> <PackageReference Include="GitHubActionsTestLogger" Version="1.1.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit" Version="2.4.1" />

View File

@@ -1,27 +0,0 @@
using CliFx.Attributes;
using System;
using System.Threading.Tasks;
using CliFx.Tests.Commands.Converters;
using System.Collections.Generic;
namespace CliFx.Tests.Commands
{
public class CustomType
{
public int SomeValue { get; set; }
}
[Command("cmd")]
public class CommandWithParameterOfCustomType : SelfSerializeCommandBase
{
[CommandOption("prop", Converter = typeof(CustomTypeConverter))]
public CustomType? MyProperty { get; set; }
}
[Command("cmd")]
public class CommandWithEnumerableOfParametersOfCustomType : SelfSerializeCommandBase
{
[CommandOption("prop", Converter = typeof(CustomTypeConverter))]
public List<CustomType>? MyProperties { get; set; }
}
}

View File

@@ -1,8 +0,0 @@
namespace CliFx.Tests.Commands.Converters
{
public class CustomTypeConverter : IArgumentValueConverter
{
public object ConvertFrom(string value) =>
new CustomType { SomeValue = value.Length };
}
}

View File

@@ -0,0 +1,19 @@
using System;
using System.Threading.Tasks;
using CliFx.Attributes;
namespace CliFx.Tests.Commands
{
[Command("cmd")]
public class GenericInnerExceptionCommand : ICommand
{
[CommandOption("msg", 'm')]
public string? Message { get; set; }
[CommandOption("inner-msg", 'i')]
public string? InnerMessage { get; set; }
public ValueTask ExecuteAsync(IConsole console) =>
throw new Exception(Message, new Exception(InnerMessage));
}
}

View File

@@ -0,0 +1,16 @@
using CliFx.Attributes;
namespace CliFx.Tests.Commands.Invalid
{
[Command]
public class InvalidCustomConverterOptionCommand : SelfSerializeCommandBase
{
[CommandOption('f', Converter = typeof(Converter))]
public string? Option { get; set; }
public class Converter
{
public object ConvertFrom(string value) => value;
}
}
}

View File

@@ -0,0 +1,16 @@
using CliFx.Attributes;
namespace CliFx.Tests.Commands.Invalid
{
[Command]
public class InvalidCustomConverterParameterCommand : SelfSerializeCommandBase
{
[CommandParameter(0, Converter = typeof(Converter))]
public string? Param { get; set; }
public class Converter
{
public object ConvertFrom(string value) => value;
}
}
}

View File

@@ -1,6 +1,6 @@
using System; using System;
using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using CliFx.Attributes; using CliFx.Attributes;
using Newtonsoft.Json; using Newtonsoft.Json;
@@ -84,6 +84,9 @@ namespace CliFx.Tests.Commands
[CommandOption("str-parseable-format")] [CommandOption("str-parseable-format")]
public CustomStringParseableWithFormatProvider? StringParseableWithFormatProvider { get; set; } public CustomStringParseableWithFormatProvider? StringParseableWithFormatProvider { get; set; }
[CommandOption("convertible", Converter = typeof(CustomConvertibleConverter))]
public CustomConvertible? Convertible { get; set; }
[CommandOption("obj-array")] [CommandOption("obj-array")]
public object[]? ObjectArray { get; set; } public object[]? ObjectArray { get; set; }
@@ -102,6 +105,9 @@ namespace CliFx.Tests.Commands
[CommandOption("str-constructible-array")] [CommandOption("str-constructible-array")]
public CustomStringConstructible[]? StringConstructibleArray { get; set; } public CustomStringConstructible[]? StringConstructibleArray { get; set; }
[CommandOption("convertible-array", Converter = typeof(CustomConvertibleConverter))]
public CustomConvertible[]? ConvertibleArray { get; set; }
[CommandOption("str-enumerable")] [CommandOption("str-enumerable")]
public IEnumerable<string>? StringEnumerable { get; set; } public IEnumerable<string>? StringEnumerable { get; set; }
@@ -151,5 +157,18 @@ namespace CliFx.Tests.Commands
public static CustomStringParseableWithFormatProvider Parse(string value, IFormatProvider formatProvider) => public static CustomStringParseableWithFormatProvider Parse(string value, IFormatProvider formatProvider) =>
new CustomStringParseableWithFormatProvider(value + " " + formatProvider); new CustomStringParseableWithFormatProvider(value + " " + formatProvider);
} }
public class CustomConvertible
{
public int Value { get; }
public CustomConvertible(int value) => Value = value;
}
public class CustomConvertibleConverter : IArgumentValueConverter
{
public object ConvertFrom(string value) =>
new CustomConvertible(int.Parse(value, CultureInfo.InvariantCulture));
}
} }
} }

View File

@@ -8,11 +8,11 @@ namespace CliFx.Tests.Commands
[Command("cmd")] [Command("cmd")]
public partial class UnsupportedArgumentTypesCommand : SelfSerializeCommandBase public partial class UnsupportedArgumentTypesCommand : SelfSerializeCommandBase
{ {
[CommandOption("str-non-initializable")] [CommandOption("custom")]
public CustomType? StringNonInitializable { get; set; } public CustomType? CustomNonConvertible { get; set; }
[CommandOption("str-enumerable-non-initializable")] [CommandOption("custom-enumerable")]
public CustomEnumerable<string>? StringEnumerableNonInitializable { get; set; } public CustomEnumerable<string>? CustomEnumerableNonConvertible { get; set; }
} }
public partial class UnsupportedArgumentTypesCommand public partial class UnsupportedArgumentTypesCommand

View File

@@ -29,7 +29,8 @@ namespace CliFx.Tests
// Act // Act
var exitCode = await application.RunAsync( var exitCode = await application.RunAsync(
new[] {"[preview]", "named", "param", "-abc", "--option", "foo"}, new[] {"[preview]", "named", "param", "-abc", "--option", "foo"},
new Dictionary<string, string>()); new Dictionary<string, string>()
);
// Assert // Assert
exitCode.Should().Be(0); exitCode.Should().Be(0);

View File

@@ -39,6 +39,34 @@ namespace CliFx.Tests
_output.WriteLine(stdErr.GetString()); _output.WriteLine(stdErr.GetString());
} }
[Fact]
public async Task Command_may_throw_a_generic_exception_with_inner_exception_which_exits_and_prints_error_message_and_stack_trace()
{
// Arrange
var (console, stdOut, stdErr) = VirtualConsole.CreateBuffered();
var application = new CliApplicationBuilder()
.AddCommand<GenericInnerExceptionCommand>()
.UseConsole(console)
.Build();
// Act
var exitCode = await application.RunAsync(new[] {"cmd", "-m", "Kaput", "-i", "FooBar"});
// Assert
exitCode.Should().NotBe(0);
stdOut.GetString().Should().BeEmpty();
stdErr.GetString().Should().ContainAll(
"System.Exception:",
"FooBar",
"Kaput", "at",
"CliFx.Tests"
);
_output.WriteLine(stdOut.GetString());
_output.WriteLine(stdErr.GetString());
}
[Fact] [Fact]
public async Task Command_may_throw_a_specialized_exception_which_exits_with_custom_code_and_prints_minimal_error_details() public async Task Command_may_throw_a_specialized_exception_which_exits_with_custom_code_and_prints_minimal_error_details()
{ {

View File

@@ -64,6 +64,35 @@ namespace CliFx.Tests
_output.WriteLine(stdOut.GetString()); _output.WriteLine(stdOut.GetString());
} }
[Fact]
public async Task Help_text_shows_commands_list_with_description_and_usage_info()
{
// Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered();
var application = new CliApplicationBuilder()
.AddCommand<DefaultCommand>()
.AddCommand<NamedCommand>()
.AddCommand<NamedSubCommand>()
.UseConsole(console)
.Build();
// Act
var exitCode = await application.RunAsync(new[] { "--help" });
// Assert
exitCode.Should().Be(0);
stdOut.GetString().Should().ContainAll(
"Commands",
"Named command description",
"named [options]",
"Named sub command description",
"named sub [options]"
);
_output.WriteLine(stdOut.GetString());
}
[Fact] [Fact]
public async Task Help_text_shows_all_valid_values_for_enum_arguments() public async Task Help_text_shows_all_valid_values_for_enum_arguments()
{ {

View File

@@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>1.4</Version> <Version>1.5</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>
<Copyright>Copyright (C) Alexey Golub</Copyright> <Copyright>Copyright (C) Alexey Golub</Copyright>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>

View File

@@ -28,7 +28,11 @@
/// <summary> /// <summary>
/// Initializes an instance of <see cref="ApplicationMetadata"/>. /// Initializes an instance of <see cref="ApplicationMetadata"/>.
/// </summary> /// </summary>
public ApplicationMetadata(string title, string executableName, string versionText, string? description) public ApplicationMetadata(
string title,
string executableName,
string versionText,
string? description)
{ {
Title = title; Title = title;
ExecutableName = executableName; ExecutableName = executableName;

View File

@@ -38,7 +38,8 @@ namespace CliFx.Attributes
public string? EnvironmentVariableName { get; set; } public string? EnvironmentVariableName { get; set; }
/// <summary> /// <summary>
/// Type of a converter to use for the option value evaluating. /// Type of converter to use when mapping the argument value.
/// Converter must implement <see cref="IArgumentValueConverter"/>.
/// </summary> /// </summary>
public Type? Converter { get; set; } public Type? Converter { get; set; }

View File

@@ -27,7 +27,8 @@ namespace CliFx.Attributes
public string? Description { get; set; } public string? Description { get; set; }
/// <summary> /// <summary>
/// Type of a converter to use for the parameter value evaluating. /// Type of converter to use when mapping the argument value.
/// Converter must implement <see cref="IArgumentValueConverter"/>.
/// </summary> /// </summary>
public Type? Converter { get; set; } public Type? Converter { get; set; }

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
@@ -22,7 +23,6 @@ namespace CliFx
private readonly ITypeActivator _typeActivator; private readonly ITypeActivator _typeActivator;
private readonly HelpTextWriter _helpTextWriter; private readonly HelpTextWriter _helpTextWriter;
private readonly ErrorTextWriter _errorTextWriter;
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CliApplication"/>. /// Initializes an instance of <see cref="CliApplication"/>.
@@ -37,12 +37,8 @@ namespace CliFx
_typeActivator = typeActivator; _typeActivator = typeActivator;
_helpTextWriter = new HelpTextWriter(metadata, console); _helpTextWriter = new HelpTextWriter(metadata, console);
_errorTextWriter = new ErrorTextWriter(console);
} }
private void WriteError(string message) => _console.WithForegroundColor(ConsoleColor.Red, () =>
_console.Error.WriteLine(message));
private async ValueTask LaunchAndWaitForDebuggerAsync() private async ValueTask LaunchAndWaitForDebuggerAsync()
{ {
var processId = ProcessEx.GetCurrentProcessId(); var processId = ProcessEx.GetCurrentProcessId();
@@ -53,7 +49,9 @@ namespace CliFx
Debugger.Launch(); Debugger.Launch();
while (!Debugger.IsAttached) while (!Debugger.IsAttached)
{
await Task.Delay(100); await Task.Delay(100);
}
} }
private void WriteCommandLineInput(CommandInput input) private void WriteCommandLineInput(CommandInput input)
@@ -105,9 +103,9 @@ namespace CliFx
} }
private ICommand GetCommandInstance(CommandSchema command) => private ICommand GetCommandInstance(CommandSchema command) =>
command != StubDefaultCommand.Schema command != FallbackDefaultCommand.Schema
? (ICommand) _typeActivator.CreateInstance(command.Type) ? (ICommand) _typeActivator.CreateInstance(command.Type)
: new StubDefaultCommand(); : new FallbackDefaultCommand();
/// <summary> /// <summary>
/// Runs the application with specified command line arguments and environment variables, and returns the exit code. /// Runs the application with specified command line arguments and environment variables, and returns the exit code.
@@ -143,7 +141,7 @@ namespace CliFx
var command = var command =
root.TryFindCommand(input.CommandName) ?? root.TryFindCommand(input.CommandName) ??
root.TryFindDefaultCommand() ?? root.TryFindDefaultCommand() ??
StubDefaultCommand.Schema; FallbackDefaultCommand.Schema;
// Version option // Version option
if (command.IsVersionOptionAvailable && input.IsVersionOptionSpecified) if (command.IsVersionOptionAvailable && input.IsVersionOptionSpecified)
@@ -161,7 +159,7 @@ namespace CliFx
// Help option // Help option
if (command.IsHelpOptionAvailable && input.IsHelpOptionSpecified || if (command.IsHelpOptionAvailable && input.IsHelpOptionSpecified ||
command == StubDefaultCommand.Schema && !input.Parameters.Any() && !input.Options.Any()) command == FallbackDefaultCommand.Schema && !input.Parameters.Any() && !input.Options.Any())
{ {
_helpTextWriter.Write(root, command, defaultValues); _helpTextWriter.Write(root, command, defaultValues);
return ExitCode.Success; return ExitCode.Success;
@@ -175,7 +173,10 @@ namespace CliFx
// This may throw exceptions which are useful only to the end-user // This may throw exceptions which are useful only to the end-user
catch (CliFxException ex) catch (CliFxException ex)
{ {
WriteError(ex.ToString()); _console.WithForegroundColor(ConsoleColor.Red, () =>
_console.Error.WriteLine(ex.ToString())
);
_helpTextWriter.Write(root, command, defaultValues); _helpTextWriter.Write(root, command, defaultValues);
return ExitCode.FromException(ex); return ExitCode.FromException(ex);
@@ -190,10 +191,14 @@ namespace CliFx
// Swallow command exceptions and route them to the console // Swallow command exceptions and route them to the console
catch (CommandException ex) catch (CommandException ex)
{ {
WriteError(ex.ToString()); _console.WithForegroundColor(ConsoleColor.Red, () =>
_console.Error.WriteLine(ex.ToString())
);
if (ex.ShowHelp) if (ex.ShowHelp)
{
_helpTextWriter.Write(root, command, defaultValues); _helpTextWriter.Write(root, command, defaultValues);
}
return ex.ExitCode; return ex.ExitCode;
} }
@@ -204,7 +209,13 @@ namespace CliFx
// because we still want the IDE to show them to the developer. // because we still want the IDE to show them to the developer.
catch (Exception ex) when (!Debugger.IsAttached) catch (Exception ex) when (!Debugger.IsAttached)
{ {
_errorTextWriter.WriteError(ex); _console.WithColors(ConsoleColor.White, ConsoleColor.DarkRed, () =>
_console.Error.Write("ERROR:")
);
_console.Error.Write(" ");
_console.WriteException(ex);
return ExitCode.FromException(ex); return ExitCode.FromException(ex);
} }
} }
@@ -259,11 +270,14 @@ namespace CliFx
: 1; : 1;
} }
// Fallback default command used when none is defined in the application
[Command] [Command]
private class StubDefaultCommand : ICommand private class FallbackDefaultCommand : ICommand
{ {
public static CommandSchema Schema { get; } = CommandSchema.TryResolve(typeof(StubDefaultCommand))!; public static CommandSchema Schema { get; } = CommandSchema.TryResolve(typeof(FallbackDefaultCommand))!;
// Never actually executed
[ExcludeFromCodeCoverage]
public ValueTask ExecuteAsync(IConsole console) => default; public ValueTask ExecuteAsync(IConsole console) => default;
} }
} }

View File

@@ -165,9 +165,9 @@ namespace CliFx
/// </summary> /// </summary>
public CliApplication Build() public CliApplication Build()
{ {
_title ??= TryGetDefaultTitle() ?? "App"; _title ??= GetDefaultTitle();
_executableName ??= TryGetDefaultExecutableName() ?? "app"; _executableName ??= GetDefaultExecutableName();
_versionText ??= TryGetDefaultVersionText() ?? "v1.0"; _versionText ??= GetDefaultVersionText();
_console ??= new SystemConsole(); _console ??= new SystemConsole();
_typeActivator ??= new DefaultTypeActivator(); _typeActivator ??= new DefaultTypeActivator();
@@ -185,23 +185,29 @@ namespace CliFx
// Entry assembly is null in tests // Entry assembly is null in tests
private static Assembly? EntryAssembly => LazyEntryAssembly.Value; private static Assembly? EntryAssembly => LazyEntryAssembly.Value;
private static string? TryGetDefaultTitle() => EntryAssembly?.GetName().Name; private static string GetDefaultTitle() => EntryAssembly?.GetName().Name?? "App";
private static string? TryGetDefaultExecutableName() private static string GetDefaultExecutableName()
{ {
var entryAssemblyLocation = EntryAssembly?.Location; var entryAssemblyLocation = EntryAssembly?.Location;
// The assembly can be an executable or a dll, depending on how it was packaged // The assembly can be an executable or a dll, depending on how it was packaged
var isDll = string.Equals(Path.GetExtension(entryAssemblyLocation), ".dll", StringComparison.OrdinalIgnoreCase); var isDll = string.Equals(
Path.GetExtension(entryAssemblyLocation),
".dll",
StringComparison.OrdinalIgnoreCase
);
return isDll var name = isDll
? "dotnet " + Path.GetFileName(entryAssemblyLocation) ? "dotnet " + Path.GetFileName(entryAssemblyLocation)
: Path.GetFileNameWithoutExtension(entryAssemblyLocation); : Path.GetFileNameWithoutExtension(entryAssemblyLocation);
return name ?? "app";
} }
private static string? TryGetDefaultVersionText() => private static string GetDefaultVersionText() =>
EntryAssembly != null EntryAssembly != null
? $"v{EntryAssembly.GetName().Version.ToSemanticString()}" ? $"v{EntryAssembly.GetName().Version.ToSemanticString()}"
: null; : "v1.0";
} }
} }

View File

@@ -1,5 +1,6 @@
using System; using System;
using CliFx.Exceptions; using CliFx.Exceptions;
using CliFx.Internal.Extensions;
namespace CliFx namespace CliFx
{ {
@@ -13,7 +14,7 @@ namespace CliFx
{ {
try try
{ {
return Activator.CreateInstance(type); return type.CreateInstance();
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -17,61 +17,70 @@ namespace CliFx.Domain
public bool IsScalar => TryGetEnumerableArgumentUnderlyingType() == null; public bool IsScalar => TryGetEnumerableArgumentUnderlyingType() == null;
protected Type? Converter { get; set; } public Type? ConverterType { get; }
private readonly Type[]? _validators; private readonly Type[]? _validators;
protected CommandArgumentSchema(PropertyInfo? property, string? description, Type? converter = null, Type[]? validators = null) protected CommandArgumentSchema(PropertyInfo? property, string? description, Type? converterType = null, Type[]? validators = null)
{ {
Property = property; Property = property;
Description = description; Description = description;
Converter = converter; ConverterType = converterType;
_validators = validators; _validators = validators;
} }
private Type? TryGetEnumerableArgumentUnderlyingType() => private Type? TryGetEnumerableArgumentUnderlyingType() =>
Property != null && Property.PropertyType != typeof(string) Property != null && Property.PropertyType != typeof(string)
? Property.PropertyType.GetEnumerableUnderlyingType() ? Property.PropertyType.TryGetEnumerableUnderlyingType()
: null; : null;
private object? ConvertScalar(string? value, Type targetType) private object? ConvertScalar(string? value, Type targetType)
{ {
try try
{ {
// Primitive // Custom conversion (always takes highest priority)
if (ConverterType != null)
return ConverterType.CreateInstance<IArgumentValueConverter>().ConvertFrom(value!);
// No conversion necessary
if (targetType == typeof(object) || targetType == typeof(string))
return value;
// Bool conversion (special case)
if (targetType == typeof(bool))
return string.IsNullOrWhiteSpace(value) || bool.Parse(value);
// Primitive conversion
var primitiveConverter = PrimitiveConverters.GetValueOrDefault(targetType); var primitiveConverter = PrimitiveConverters.GetValueOrDefault(targetType);
if (primitiveConverter != null) if (primitiveConverter != null && !string.IsNullOrWhiteSpace(value))
return primitiveConverter(value); return primitiveConverter(value);
// Enum // Enum conversion
if (targetType.IsEnum) if (targetType.IsEnum && !string.IsNullOrWhiteSpace(value))
return Enum.Parse(targetType, value, true); return Enum.Parse(targetType, value, true);
// Nullable // Nullable<T> conversion
var nullableUnderlyingType = targetType.GetNullableUnderlyingType(); var nullableUnderlyingType = targetType.TryGetNullableUnderlyingType();
if (nullableUnderlyingType != null) if (nullableUnderlyingType != null)
return !string.IsNullOrWhiteSpace(value) return !string.IsNullOrWhiteSpace(value)
? ConvertScalar(value, nullableUnderlyingType) ? ConvertScalar(value, nullableUnderlyingType)
: null; : null;
// String-constructible // String-constructible conversion
var stringConstructor = targetType.GetConstructor(new[] {typeof(string)}); var stringConstructor = targetType.GetConstructor(new[] {typeof(string)});
if (stringConstructor != null) if (stringConstructor != null)
return stringConstructor.Invoke(new object[] {value!}); return stringConstructor.Invoke(new object[] {value!});
// String-parseable (with format provider) // String-parseable conversion (with format provider)
var parseMethodWithFormatProvider = targetType.GetStaticParseMethod(true); var parseMethodWithFormatProvider = targetType.TryGetStaticParseMethod(true);
if (parseMethodWithFormatProvider != null) if (parseMethodWithFormatProvider != null)
return parseMethodWithFormatProvider.Invoke(null, new object[] {value!, FormatProvider}); return parseMethodWithFormatProvider.Invoke(null, new object[] {value!, FormatProvider});
// String-parseable (without format provider) // String-parseable conversion (without format provider)
var parseMethod = targetType.GetStaticParseMethod(); var parseMethod = targetType.TryGetStaticParseMethod();
if (parseMethod != null) if (parseMethod != null)
return parseMethod.Invoke(null, new object[] {value!}); return parseMethod.Invoke(null, new object[] {value!});
if (Converter != null)
return Converter.InstanceOf<IArgumentValueConverter>().ConvertFrom(value!);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -81,7 +90,10 @@ namespace CliFx.Domain
throw CliFxException.CannotConvertToType(this, value, targetType); throw CliFxException.CannotConvertToType(this, value, targetType);
} }
private object ConvertNonScalar(IReadOnlyList<string> values, Type targetEnumerableType, Type targetElementType) private object ConvertNonScalar(
IReadOnlyList<string> values,
Type targetEnumerableType,
Type targetElementType)
{ {
var array = values var array = values
.Select(v => ConvertScalar(v, targetElementType)) .Select(v => ConvertScalar(v, targetElementType))
@@ -143,7 +155,7 @@ namespace CliFx.Domain
return Array.Empty<string>(); return Array.Empty<string>();
var underlyingType = var underlyingType =
Property.PropertyType.GetNullableUnderlyingType() ?? Property.PropertyType.TryGetNullableUnderlyingType() ??
Property.PropertyType; Property.PropertyType;
// Enum // Enum
@@ -177,12 +189,9 @@ namespace CliFx.Domain
{ {
private static readonly IFormatProvider FormatProvider = CultureInfo.InvariantCulture; private static readonly IFormatProvider FormatProvider = CultureInfo.InvariantCulture;
private static readonly IReadOnlyDictionary<Type, Func<string?, object?>> PrimitiveConverters = private static readonly IReadOnlyDictionary<Type, Func<string, object?>> PrimitiveConverters =
new Dictionary<Type, Func<string?, object?>> new Dictionary<Type, Func<string, object?>>
{ {
[typeof(object)] = v => v,
[typeof(string)] = v => v,
[typeof(bool)] = v => string.IsNullOrWhiteSpace(v) || bool.Parse(v),
[typeof(char)] = v => v.Single(), [typeof(char)] = v => v.Single(),
[typeof(sbyte)] = v => sbyte.Parse(v, FormatProvider), [typeof(sbyte)] = v => sbyte.Parse(v, FormatProvider),
[typeof(byte)] = v => byte.Parse(v, FormatProvider), [typeof(byte)] = v => byte.Parse(v, FormatProvider),

View File

@@ -25,9 +25,9 @@ namespace CliFx.Domain
string? environmentVariableName, string? environmentVariableName,
bool isRequired, bool isRequired,
string? description, string? description,
Type? converter = null, Type? converterType = null,
Type[]? validators = null) Type[]? validators = null)
: base(property, description, converter, validators) : base(property, description, converterType, validators)
{ {
Name = name; Name = name;
ShortName = shortName; ShortName = shortName;
@@ -110,9 +110,9 @@ namespace CliFx.Domain
internal partial class CommandOptionSchema internal partial class CommandOptionSchema
{ {
public static CommandOptionSchema HelpOption { get; } = public static CommandOptionSchema HelpOption { get; } =
new CommandOptionSchema(null, "help", 'h', null, false, "Shows help text."); new CommandOptionSchema(null, "help", 'h', null, false, "Shows help text.", null);
public static CommandOptionSchema VersionOption { get; } = public static CommandOptionSchema VersionOption { get; } =
new CommandOptionSchema(null, "version", null, null, false, "Shows version information."); new CommandOptionSchema(null, "version", null, null, false, "Shows version information.", null);
} }
} }

View File

@@ -18,9 +18,9 @@ namespace CliFx.Domain
int order, int order,
string name, string name,
string? description, string? description,
Type? converter = null, Type? converterType = null,
Type[]? validators = null) Type[]? validators = null)
: base(property, description, converter, validators) : base(property, description, converterType, validators)
{ {
Order = order; Order = order;
Name = name; Name = name;

View File

@@ -1,226 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace CliFx.Domain
{
internal class ErrorTextWriter
{
private const int indent = 4;
private static readonly ConsoleColor NameColor = ConsoleColor.DarkGray;
private static readonly ConsoleColor SpecificNameColor = ConsoleColor.White;
private static readonly ConsoleColor MessageColor = ConsoleColor.Red;
private static readonly ConsoleColor MethodColor = ConsoleColor.Yellow;
private static readonly ConsoleColor ParameterTypeColor = ConsoleColor.Blue;
private static readonly ConsoleColor FileColor = ConsoleColor.Yellow;
private static readonly ConsoleColor LineColor = ConsoleColor.Blue;
private static readonly Regex MethodMatcher = new Regex(@"(?<prefix>\S+) (?<name>.*?)(?<methodName>[^\.]+)\(");
private static readonly Regex ParameterMatcher = new Regex(@"(?<type>.+? )(?<name>.+?)(?:(?<separator>, )|\))");
private static readonly Regex FileMatcher = new Regex(@"(?<prefix>\S+?) (?<path>.*?)(?<file>[^\\/]+?(?:\.\w*)?):[^:]+? (?<line>\d+).*");
private readonly IConsole _console;
public ErrorTextWriter(IConsole console)
{
_console = console;
}
public void WriteError(Exception ex) => WriteError(ex, 0);
private void WriteError(Exception ex, int indentLevel)
{
var indentation = new string(' ', indent * indentLevel);
var extraIndentation = new string(' ', indent / 2);
var exType = ex.GetType();
// (Fully qualified) type of the exception
Write(NameColor, indentation + exType.Namespace + ".");
Write(SpecificNameColor, exType.Name);
_console.Error.Write(": ");
// Message
Write(MessageColor, ex.Message);
_console.Error.WriteLine();
// Prints the inner exception
// with one higher indentation level
if (ex.InnerException is Exception innerException)
{
WriteError(innerException, indentLevel + 1);
}
// Print with formatting when successfully parsing all entries in the stack trace
if (ParseStackTrace(ex.StackTrace) is IEnumerable<StackTraceEntry> parsedStackTrace)
{
// Each step in the stack trace is formated and printed
foreach (var entry in parsedStackTrace)
{
_console.Error.Write(indentation + extraIndentation);
WriteMethodDescriptor(entry.MethodPrefix, entry.MethodName, entry.MethodSpecificName);
WriteParameters(entry.Parameters);
_console.Error.Write(entry.FilePrefix);
_console.Error.Write("\n" + indentation + extraIndentation + extraIndentation);
WriteFileDescriptor(entry.FilePath, entry.FileName, entry.FileLine);
_console.Error.WriteLine();
}
}
else
{
// Parsing failed. Print without formatting.
foreach (var trace in ex.StackTrace.Split('\n'))
{
_console.Error.WriteLine(indentation + trace);
}
}
}
private void WriteMethodDescriptor(string prefix, string name, string methodName)
{
_console.Error.Write(prefix + " ");
Write(NameColor, name);
Write(MethodColor, methodName);
}
private void WriteParameters(IEnumerable<ParameterEntry> parameters)
{
_console.Error.Write("(");
foreach (var parameter in parameters)
{
Write(ParameterTypeColor, parameter.Type);
Write(SpecificNameColor, parameter.Name);
if (parameter.Separator is string separator)
{
_console.Error.Write(separator);
}
}
_console.Error.Write(") ");
}
private void WriteFileDescriptor(string path, string fileName, string lineNumber)
{
Write(NameColor, path);
Write(FileColor, fileName);
_console.Error.Write(":");
Write(LineColor, lineNumber);
}
private void Write(ConsoleColor color, string value)
{
_console.WithForegroundColor(color, () => _console.Error.Write(value));
}
private IEnumerable<StackTraceEntry>? ParseStackTrace(string stackTrace)
{
IList<StackTraceEntry> stackTraceEntries = new List<StackTraceEntry>();
foreach (var trace in stackTrace.Split('\n'))
{
var methodMatch = MethodMatcher.Match(trace);
var parameterMatches = ParameterMatcher.Matches(trace, methodMatch.Index + methodMatch.Length);
var fileMatch = FileMatcher.Match(
trace,
parameterMatches.Count switch
{
0 => methodMatch.Index + methodMatch.Length + 1,
int c => parameterMatches[c - 1].Index + parameterMatches[c - 1].Length
}
);
if (fileMatch.Index + fileMatch.Length != trace.Length)
{
// Didnt match the whole trace
return null;
}
try
{
IList<ParameterEntry> parameters = new List<ParameterEntry>();
foreach (Match match in parameterMatches)
{
parameters.Add(new ParameterEntry(
match.Groups["type"].Success ? match.Groups["type"].Value : throw new Exception("type"),
match.Groups["name"].Success ? match.Groups["name"].Value : throw new Exception("name"),
match.Groups["separator"]?.Value // If this is null, it's just the last parameter
));
}
stackTraceEntries.Add(new StackTraceEntry(
methodMatch.Groups["prefix"].Success ? methodMatch.Groups["prefix"].Value : throw new Exception("prefix"),
methodMatch.Groups["name"].Success ? methodMatch.Groups["name"].Value : throw new Exception("name"),
methodMatch.Groups["methodName"].Success ? methodMatch.Groups["methodName"].Value : throw new Exception("methodName"),
parameters,
fileMatch.Groups["prefix"].Success ? fileMatch.Groups["prefix"].Value : throw new Exception("prefix"),
fileMatch.Groups["path"].Success ? fileMatch.Groups["path"].Value : throw new Exception("path"),
fileMatch.Groups["file"].Success ? fileMatch.Groups["file"].Value : throw new Exception("file"),
fileMatch.Groups["line"].Success ? fileMatch.Groups["line"].Value : throw new Exception("line")
));
}
catch
{
// One of the required groups failed to match
return null;
}
}
return stackTraceEntries;
}
private readonly struct StackTraceEntry
{
public string MethodPrefix { get; }
public string MethodName { get; }
public string MethodSpecificName { get; }
public IEnumerable<ParameterEntry> Parameters { get; }
public string FilePrefix { get; }
public string FilePath { get; }
public string FileName { get; }
public string FileLine { get; }
public StackTraceEntry(
string methodPrefix,
string methodName,
string methodSpecificName,
IEnumerable<ParameterEntry> parameters,
string filePrefix,
string filePath,
string fileName,
string fileLine)
{
MethodPrefix = methodPrefix;
MethodName = methodName;
MethodSpecificName = methodSpecificName;
Parameters = parameters;
FilePrefix = filePrefix;
FilePath = filePath;
FileName = fileName;
FileLine = fileLine;
}
}
private readonly struct ParameterEntry
{
public string Type { get; }
public string Name { get; }
public string? Separator { get; }
public ParameterEntry(
string type,
string name,
string? separator)
{
Type = type;
Name = name;
Separator = separator;
}
}
}
}

View File

@@ -105,7 +105,57 @@ namespace CliFx.Domain
WriteLine(); WriteLine();
} }
private void WriteCommandUsage(CommandSchema command, IReadOnlyList<CommandSchema> childCommands) private void WriteCommandUsageLineItem(CommandSchema command, bool showChildCommandsPlaceholder)
{
// Command name
if (!string.IsNullOrWhiteSpace(command.Name))
{
Write(ConsoleColor.Cyan, command.Name);
Write(' ');
}
// Child command placeholder
if (showChildCommandsPlaceholder)
{
Write(ConsoleColor.Cyan, "[command]");
Write(' ');
}
// Parameters
foreach (var parameter in command.Parameters)
{
Write(parameter.IsScalar
? $"<{parameter.Name}>"
: $"<{parameter.Name}...>"
);
Write(' ');
}
// Required options
foreach (var option in command.Options.Where(o => o.IsRequired))
{
Write(ConsoleColor.White, !string.IsNullOrWhiteSpace(option.Name)
? $"--{option.Name}"
: $"-{option.ShortName}"
);
Write(' ');
Write(option.IsScalar
? "<value>"
: "<values...>"
);
Write(' ');
}
// Options placeholder
Write(ConsoleColor.White, "[options]");
WriteLine();
}
private void WriteCommandUsage(
CommandSchema command,
IReadOnlyList<CommandSchema> childCommands)
{ {
if (!IsEmpty) if (!IsEmpty)
WriteVerticalMargin(); WriteVerticalMargin();
@@ -115,52 +165,10 @@ namespace CliFx.Domain
// Exe name // Exe name
WriteHorizontalMargin(); WriteHorizontalMargin();
Write(_metadata.ExecutableName); Write(_metadata.ExecutableName);
// Command name
if (!string.IsNullOrWhiteSpace(command.Name))
{
Write(' ');
Write(ConsoleColor.Cyan, command.Name);
}
// Child command placeholder
if (childCommands.Any())
{
Write(' ');
Write(ConsoleColor.Cyan, "[command]");
}
// Parameters
foreach (var parameter in command.Parameters)
{
Write(' ');
Write(parameter.IsScalar
? $"<{parameter.Name}>"
: $"<{parameter.Name}...>"
);
}
// Required options
foreach (var option in command.Options.Where(o => o.IsRequired))
{
Write(' ');
Write(ConsoleColor.White, !string.IsNullOrWhiteSpace(option.Name)
? $"--{option.Name}"
: $"-{option.ShortName}"
);
Write(' ');
Write(option.IsScalar
? "<value>"
: "<values...>"
);
}
// Options placeholder
Write(' '); Write(' ');
Write(ConsoleColor.White, "[options]");
WriteLine(); // Current command usage
WriteCommandUsageLineItem(command, childCommands.Any());
} }
private void WriteCommandParameters(CommandSchema command) private void WriteCommandParameters(CommandSchema command)
@@ -264,7 +272,7 @@ namespace CliFx.Domain
if (!option.IsRequired) if (!option.IsRequired)
{ {
var defaultValue = argumentDefaultValues.GetValueOrDefault(option); var defaultValue = argumentDefaultValues.GetValueOrDefault(option);
var defaultValueFormatted = FormatDefaultValue(defaultValue); var defaultValueFormatted = TryFormatDefaultValue(defaultValue);
if (defaultValueFormatted != null) if (defaultValueFormatted != null)
{ {
Write($"Default: {defaultValueFormatted}."); Write($"Default: {defaultValueFormatted}.");
@@ -277,9 +285,9 @@ namespace CliFx.Domain
private void WriteCommandChildren( private void WriteCommandChildren(
CommandSchema command, CommandSchema command,
IReadOnlyList<CommandSchema> childCommands) IReadOnlyList<CommandSchema> descendantCommands)
{ {
if (!childCommands.Any()) if (!descendantCommands.Any())
return; return;
if (!IsEmpty) if (!IsEmpty)
@@ -287,28 +295,29 @@ namespace CliFx.Domain
WriteHeader("Commands"); WriteHeader("Commands");
foreach (var childCommand in childCommands) foreach (var descendantCommand in descendantCommands)
{ {
var relativeCommandName = !string.IsNullOrWhiteSpace(command.Name) var relativeCommandName = !string.IsNullOrWhiteSpace(command.Name)
? childCommand.Name!.Substring(command.Name.Length).Trim() ? descendantCommand.Name!.Substring(command.Name.Length).Trim()
: childCommand.Name!; : descendantCommand.Name!;
// Name
WriteHorizontalMargin();
Write(ConsoleColor.Cyan, relativeCommandName);
// Description // Description
if (!string.IsNullOrWhiteSpace(childCommand.Description))
if (!string.IsNullOrWhiteSpace(descendantCommand.Description))
{ {
WriteColumnMargin(); WriteHorizontalMargin();
Write(childCommand.Description); Write(descendantCommand.Description);
WriteVerticalMargin();
} }
// Name
WriteHorizontalMargin(4);
WriteCommandUsageLineItem(descendantCommand, false);
WriteLine(); WriteLine();
} }
// Child command help tip // Child command help tip
WriteVerticalMargin();
Write("You can run `"); Write("You can run `");
Write(_metadata.ExecutableName); Write(_metadata.ExecutableName);
@@ -334,7 +343,8 @@ namespace CliFx.Domain
CommandSchema command, CommandSchema command,
IReadOnlyDictionary<CommandArgumentSchema, object?> defaultValues) IReadOnlyDictionary<CommandArgumentSchema, object?> defaultValues)
{ {
var childCommands = root.GetChildCommands(command.Name); var commandName = command.Name;
var descendantCommands = root.GetDescendantCommands(commandName);
_console.ResetColor(); _console.ResetColor();
@@ -342,10 +352,10 @@ namespace CliFx.Domain
WriteApplicationInfo(); WriteApplicationInfo();
WriteCommandDescription(command); WriteCommandDescription(command);
WriteCommandUsage(command, childCommands); WriteCommandUsage(command, descendantCommands);
WriteCommandParameters(command); WriteCommandParameters(command);
WriteCommandOptions(command, defaultValues); WriteCommandOptions(command, defaultValues);
WriteCommandChildren(command, childCommands); WriteCommandChildren(command, descendantCommands);
} }
} }
@@ -354,7 +364,7 @@ namespace CliFx.Domain
private static string FormatValidValues(IReadOnlyList<string> values) => private static string FormatValidValues(IReadOnlyList<string> values) =>
values.Select(v => v.Quote()).JoinToString(", "); values.Select(v => v.Quote()).JoinToString(", ");
private static string? FormatDefaultValue(object? defaultValue) private static string? TryFormatDefaultValue(object? defaultValue)
{ {
if (defaultValue == null) if (defaultValue == null)
return null; return null;
@@ -362,7 +372,7 @@ namespace CliFx.Domain
// Enumerable // Enumerable
if (!(defaultValue is string) && defaultValue is IEnumerable defaultValues) if (!(defaultValue is string) && defaultValue is IEnumerable defaultValues)
{ {
var elementType = defaultValues.GetType().GetEnumerableUnderlyingType() ?? typeof(object); var elementType = defaultValues.GetType().TryGetEnumerableUnderlyingType() ?? typeof(object);
// If the ToString() method is not overriden, the default value can't be formatted nicely // If the ToString() method is not overriden, the default value can't be formatted nicely
if (!elementType.IsToStringOverriden()) if (!elementType.IsToStringOverriden())

View File

@@ -114,6 +114,18 @@ namespace CliFx.Domain
nonLastNonScalarParameter nonLastNonScalarParameter
); );
} }
var invalidConverterParameters = command.Parameters
.Where(p => p.ConverterType != null && !p.ConverterType.Implements(typeof(IArgumentValueConverter)))
.ToArray();
if (invalidConverterParameters.Any())
{
throw CliFxException.ParametersWithInvalidConverters(
command,
invalidConverterParameters
);
}
} }
private static void ValidateOptions(CommandSchema command) private static void ValidateOptions(CommandSchema command)
@@ -184,6 +196,18 @@ namespace CliFx.Domain
duplicateEnvironmentVariableNameGroup.ToArray() duplicateEnvironmentVariableNameGroup.ToArray()
); );
} }
var invalidConverterOptions = command.Options
.Where(o => o.ConverterType != null && !o.ConverterType.Implements(typeof(IArgumentValueConverter)))
.ToArray();
if (invalidConverterOptions.Any())
{
throw CliFxException.OptionsWithInvalidConverters(
command,
invalidConverterOptions
);
}
} }
private static void ValidateCommands(IReadOnlyList<CommandSchema> commands) private static void ValidateCommands(IReadOnlyList<CommandSchema> commands)

View File

@@ -172,6 +172,19 @@ If it's not feasible to fit into these constraints, consider using options inste
return new CliFxException(message.Trim()); return new CliFxException(message.Trim());
} }
internal static CliFxException ParametersWithInvalidConverters(
CommandSchema command,
IReadOnlyList<CommandParameterSchema> invalidParameters)
{
var message = $@"
Command '{command.Type.FullName}' is invalid because it contains {invalidParameters.Count} parameter(s) with invalid converters:
{invalidParameters.JoinToString(Environment.NewLine)}
Specified converter must implement {typeof(IArgumentValueConverter).FullName}.";
return new CliFxException(message.Trim());
}
internal static CliFxException OptionsWithNoName( internal static CliFxException OptionsWithNoName(
CommandSchema command, CommandSchema command,
IReadOnlyList<CommandOptionSchema> invalidOptions) IReadOnlyList<CommandOptionSchema> invalidOptions)
@@ -243,6 +256,19 @@ Environment variable names are not case-sensitive.";
return new CliFxException(message.Trim()); return new CliFxException(message.Trim());
} }
internal static CliFxException OptionsWithInvalidConverters(
CommandSchema command,
IReadOnlyList<CommandOptionSchema> invalidOptions)
{
var message = $@"
Command '{command.Type.FullName}' is invalid because it contains {invalidOptions.Count} option(s) with invalid converters:
{invalidOptions.JoinToString(Environment.NewLine)}
Specified converter must implement {typeof(IArgumentValueConverter).FullName}.";
return new CliFxException(message.Trim());
}
} }
// End-user-facing exceptions // End-user-facing exceptions

View File

@@ -1,7 +1,7 @@
namespace CliFx namespace CliFx
{ {
/// <summary> /// <summary>
/// Used as an interface for implementing custom parameter/option converters. /// Implements custom conversion logic that maps an argument value to a domain type.
/// </summary> /// </summary>
public interface IArgumentValueConverter public interface IArgumentValueConverter
{ {

View File

@@ -1,6 +1,7 @@
using System; using System;
using System.IO; using System.IO;
using System.Threading; using System.Threading;
using CliFx.Internal;
namespace CliFx namespace CliFx
{ {
@@ -85,7 +86,10 @@ namespace CliFx
/// <summary> /// <summary>
/// Sets console foreground color, executes specified action, and sets the color back to the original value. /// Sets console foreground color, executes specified action, and sets the color back to the original value.
/// </summary> /// </summary>
public static void WithForegroundColor(this IConsole console, ConsoleColor foregroundColor, Action action) public static void WithForegroundColor(
this IConsole console,
ConsoleColor foregroundColor,
Action action)
{ {
var lastColor = console.ForegroundColor; var lastColor = console.ForegroundColor;
console.ForegroundColor = foregroundColor; console.ForegroundColor = foregroundColor;
@@ -98,7 +102,10 @@ namespace CliFx
/// <summary> /// <summary>
/// Sets console background color, executes specified action, and sets the color back to the original value. /// Sets console background color, executes specified action, and sets the color back to the original value.
/// </summary> /// </summary>
public static void WithBackgroundColor(this IConsole console, ConsoleColor backgroundColor, Action action) public static void WithBackgroundColor(
this IConsole console,
ConsoleColor backgroundColor,
Action action)
{ {
var lastColor = console.BackgroundColor; var lastColor = console.BackgroundColor;
console.BackgroundColor = backgroundColor; console.BackgroundColor = backgroundColor;
@@ -111,7 +118,133 @@ namespace CliFx
/// <summary> /// <summary>
/// Sets console foreground and background colors, executes specified action, and sets the colors back to the original values. /// Sets console foreground and background colors, executes specified action, and sets the colors back to the original values.
/// </summary> /// </summary>
public static void WithColors(this IConsole console, ConsoleColor foregroundColor, ConsoleColor backgroundColor, Action action) => public static void WithColors(
this IConsole console,
ConsoleColor foregroundColor,
ConsoleColor backgroundColor,
Action action) =>
console.WithForegroundColor(foregroundColor, () => console.WithBackgroundColor(backgroundColor, action)); console.WithForegroundColor(foregroundColor, () => console.WithBackgroundColor(backgroundColor, action));
private static void WriteException(
this IConsole console,
Exception exception,
int indentLevel)
{
var exceptionType = exception.GetType();
var indentationShared = new string(' ', 4 * indentLevel);
var indentationLocal = new string(' ', 2);
// Fully qualified exception type
console.Error.Write(indentationShared);
console.WithForegroundColor(ConsoleColor.DarkGray, () =>
console.Error.Write(exceptionType.Namespace + ".")
);
console.WithForegroundColor(ConsoleColor.White, () =>
console.Error.Write(exceptionType.Name)
);
console.Error.Write(": ");
// Exception message
console.WithForegroundColor(ConsoleColor.Red, () => console.Error.WriteLine(exception.Message));
// Recurse into inner exceptions
if (exception.InnerException != null)
{
console.WriteException(exception.InnerException, indentLevel + 1);
}
// Try to parse and pretty-print the stack trace
try
{
foreach (var stackFrame in StackFrame.ParseMany(exception.StackTrace))
{
console.Error.Write(indentationShared + indentationLocal);
console.Error.Write("at ");
// "CliFx.Demo.Commands.BookAddCommand."
console.WithForegroundColor(ConsoleColor.DarkGray, () =>
console.Error.Write(stackFrame.ParentType + ".")
);
// "ExecuteAsync"
console.WithForegroundColor(ConsoleColor.Yellow, () =>
console.Error.Write(stackFrame.MethodName)
);
console.Error.Write("(");
for (var i = 0; i < stackFrame.Parameters.Count; i++)
{
var parameter = stackFrame.Parameters[i];
// Separator
if (i > 0)
{
console.Error.Write(", ");
}
// "IConsole"
console.WithForegroundColor(ConsoleColor.Blue, () =>
console.Error.Write(parameter.Type)
);
if (!string.IsNullOrWhiteSpace(parameter.Name))
{
console.Error.Write(" ");
// "console"
console.WithForegroundColor(ConsoleColor.White, () =>
console.Error.Write(parameter.Name)
);
}
}
console.Error.Write(") ");
// Location
if (!string.IsNullOrWhiteSpace(stackFrame.FilePath))
{
console.Error.Write("in");
console.Error.Write("\n" + indentationShared + indentationLocal + indentationLocal);
// "E:\Projects\Softdev\CliFx\CliFx.Demo\Commands\"
var stackFrameDirectoryPath = Path.GetDirectoryName(stackFrame.FilePath);
console.WithForegroundColor(ConsoleColor.DarkGray, () =>
console.Error.Write(stackFrameDirectoryPath + Path.DirectorySeparatorChar)
);
// "BookAddCommand.cs"
var stackFrameFileName = Path.GetFileName(stackFrame.FilePath);
console.WithForegroundColor(ConsoleColor.Yellow, () =>
console.Error.Write(stackFrameFileName)
);
if (!string.IsNullOrWhiteSpace(stackFrame.LineNumber))
{
console.Error.Write(":");
// "35"
console.WithForegroundColor(ConsoleColor.Blue, () =>
console.Error.Write(stackFrame.LineNumber)
);
}
}
console.Error.WriteLine();
}
}
// If any point of parsing has failed - print the stack trace without any formatting
catch
{
console.Error.WriteLine(exception.StackTrace);
}
}
//Should this be public?
internal static void WriteException(
this IConsole console,
Exception exception) =>
console.WriteException(exception, 0);
} }
} }

View File

@@ -6,6 +6,11 @@ namespace CliFx.Internal.Extensions
{ {
internal static class StringExtensions internal static class StringExtensions
{ {
public static string? NullIfWhiteSpace(this string str) =>
!string.IsNullOrWhiteSpace(str)
? str
: null;
public static string Repeat(this char c, int count) => new string(c, count); public static string Repeat(this char c, int count) => new string(c, count);
public static string AsString(this char c) => c.Repeat(1); public static string AsString(this char c) => c.Repeat(1);

View File

@@ -8,11 +8,15 @@ namespace CliFx.Internal.Extensions
{ {
internal static class TypeExtensions internal static class TypeExtensions
{ {
public static object CreateInstance(this Type type) => Activator.CreateInstance(type);
public static T CreateInstance<T>(this Type type) => (T) type.CreateInstance();
public static bool Implements(this Type type, Type interfaceType) => type.GetInterfaces().Contains(interfaceType); public static bool Implements(this Type type, Type interfaceType) => type.GetInterfaces().Contains(interfaceType);
public static Type? GetNullableUnderlyingType(this Type type) => Nullable.GetUnderlyingType(type); public static Type? TryGetNullableUnderlyingType(this Type type) => Nullable.GetUnderlyingType(type);
public static Type? GetEnumerableUnderlyingType(this Type type) public static Type? TryGetEnumerableUnderlyingType(this Type type)
{ {
if (type.IsPrimitive) if (type.IsPrimitive)
return null; return null;
@@ -25,17 +29,20 @@ namespace CliFx.Internal.Extensions
return type return type
.GetInterfaces() .GetInterfaces()
.Select(GetEnumerableUnderlyingType) .Select(TryGetEnumerableUnderlyingType)
.Where(t => t != null) .Where(t => t != null)
.OrderByDescending(t => t != typeof(object)) // prioritize more specific types .OrderByDescending(t => t != typeof(object)) // prioritize more specific types
.FirstOrDefault(); .FirstOrDefault();
} }
public static MethodInfo GetToStringMethod(this Type type) => type.GetMethod(nameof(ToString), Type.EmptyTypes); public static MethodInfo GetToStringMethod(this Type type) =>
// ToString() with no params always exists
type.GetMethod(nameof(ToString), Type.EmptyTypes)!;
public static bool IsToStringOverriden(this Type type) => type.GetToStringMethod() != typeof(object).GetToStringMethod(); public static bool IsToStringOverriden(this Type type) =>
type.GetToStringMethod() != typeof(object).GetToStringMethod();
public static MethodInfo GetStaticParseMethod(this Type type, bool withFormatProvider = false) public static MethodInfo? TryGetStaticParseMethod(this Type type, bool withFormatProvider = false)
{ {
var argumentTypes = withFormatProvider var argumentTypes = withFormatProvider
? new[] {typeof(string), typeof(IFormatProvider)} ? new[] {typeof(string), typeof(IFormatProvider)}
@@ -56,10 +63,5 @@ namespace CliFx.Internal.Extensions
return array; return array;
} }
public static T InstanceOf<T>(this Type type) =>
type.Implements(typeof(T))
? (T) Activator.CreateInstance(type)
: throw new ArgumentException();
} }
} }

View File

@@ -17,6 +17,9 @@ namespace System
public static bool EndsWith(this string str, char c) => public static bool EndsWith(this string str, char c) =>
str.Length > 0 && str[str.Length - 1] == c; str.Length > 0 && str[str.Length - 1] == c;
public static string[] Split(this string str, char separator, StringSplitOptions splitOptions) =>
str.Split(new[] {separator}, splitOptions);
} }
} }
@@ -31,7 +34,7 @@ namespace System.Collections.Generic
} }
public static TValue GetValueOrDefault<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue> dic, TKey key) => public static TValue GetValueOrDefault<TKey, TValue>(this IReadOnlyDictionary<TKey, TValue> dic, TKey key) =>
dic.TryGetValue(key, out var result) ? result! : default!; dic.TryGetValue(key!, out var result) ? result! : default!;
} }
} }

View File

@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using CliFx.Internal.Extensions;
namespace CliFx.Internal
{
internal class StackFrameParameter
{
public string Type { get; }
public string? Name { get; }
public StackFrameParameter(string type, string? name)
{
Type = type;
Name = name;
}
}
internal partial class StackFrame
{
public string ParentType { get; }
public string MethodName { get; }
public IReadOnlyList<StackFrameParameter> Parameters { get; }
public string? FilePath { get; }
public string? LineNumber { get; }
public StackFrame(
string parentType,
string methodName,
IReadOnlyList<StackFrameParameter> parameters,
string? filePath,
string? lineNumber)
{
ParentType = parentType;
MethodName = methodName;
Parameters = parameters;
FilePath = filePath;
LineNumber = lineNumber;
}
}
internal partial class StackFrame
{
private const string Space = @"[\x20\t]";
private const string NotSpace = @"[^\x20\t]";
// Taken from https://github.com/atifaziz/StackTraceParser
private static readonly Regex Pattern = new Regex(@"
^
" + Space + @"*
\w+ " + Space + @"+
(?<frame>
(?<type> " + NotSpace + @"+ ) \.
(?<method> " + NotSpace + @"+? ) " + Space + @"*
(?<params> \( ( " + Space + @"* \)
| (?<pt> .+?) " + Space + @"+ (?<pn> .+?)
(, " + Space + @"* (?<pt> .+?) " + Space + @"+ (?<pn> .+?) )* \) ) )
( " + Space + @"+
( # Microsoft .NET stack traces
\w+ " + Space + @"+
(?<file> ( [a-z] \: # Windows rooted path starting with a drive letter
| / ) # *nix rooted path starting with a forward-slash
.+? )
\: \w+ " + Space + @"+
(?<line> [0-9]+ ) \p{P}?
| # Mono stack traces
\[0x[0-9a-f]+\] " + Space + @"+ \w+ " + Space + @"+
<(?<file> [^>]+ )>
:(?<line> [0-9]+ )
)
)?
)
\s*
$",
RegexOptions.IgnoreCase |
RegexOptions.Multiline |
RegexOptions.ExplicitCapture |
RegexOptions.CultureInvariant |
RegexOptions.IgnorePatternWhitespace,
TimeSpan.FromSeconds(5)
);
public static IEnumerable<StackFrame> ParseMany(string stackTrace)
{
var matches = Pattern.Matches(stackTrace).Cast<Match>().ToArray();
// Ensure success (all lines should be parsed)
var isSuccess =
matches.Length ==
stackTrace.Split('\n', StringSplitOptions.RemoveEmptyEntries).Length;
if (!isSuccess)
{
throw new FormatException("Could not parse stack trace.");
}
return from m in matches
select m.Groups
into groups
let pt = groups["pt"].Captures
let pn = groups["pn"].Captures
select new StackFrame(
groups["type"].Value,
groups["method"].Value,
(
from i in Enumerable.Range(0, pt.Count)
select new StackFrameParameter(pt[i].Value, pn[i].Value.NullIfWhiteSpace())
).ToArray(),
groups["file"].Value.NullIfWhiteSpace(),
groups["line"].Value.NullIfWhiteSpace()
);
}
}
}

View File

@@ -6,6 +6,8 @@
[![Downloads](https://img.shields.io/nuget/dt/CliFx.svg)](https://nuget.org/packages/CliFx) [![Downloads](https://img.shields.io/nuget/dt/CliFx.svg)](https://nuget.org/packages/CliFx)
[![Donate](https://img.shields.io/badge/donate-$$$-purple.svg)](https://tyrrrz.me/donate) [![Donate](https://img.shields.io/badge/donate-$$$-purple.svg)](https://tyrrrz.me/donate)
**Project status: active**.
CliFx is a simple to use, yet powerful framework for building command line applications. Its primary goal is to completely take over the user input layer, letting you forget about the infrastructure and instead focus on writing your application. This framework uses a declarative class-first approach for defining commands, avoiding excessive boilerplate code and complex configurations. CliFx is a simple to use, yet powerful framework for building command line applications. Its primary goal is to completely take over the user input layer, letting you forget about the infrastructure and instead focus on writing your application. This framework uses a declarative class-first approach for defining commands, avoiding excessive boilerplate code and complex configurations.
## Download ## Download
@@ -249,6 +251,7 @@ Parameters and options can have different underlying types:
- String-initializable types - String-initializable types
- Types with a constructor that accepts a single `string` parameter (`FileInfo`, `DirectoryInfo`, etc.) - Types with a constructor that accepts a single `string` parameter (`FileInfo`, `DirectoryInfo`, etc.)
- Types with a static method `Parse` that accepts a single `string` parameter (and optionally `IFormatProvider`) - Types with a static method `Parse` that accepts a single `string` parameter (and optionally `IFormatProvider`)
- Any other type if a custom converter is specified
- Nullable versions of all above types (`decimal?`, `TimeSpan?`, etc.) - Nullable versions of all above types (`decimal?`, `TimeSpan?`, etc.)
- Collections of all above types - Collections of all above types
- Array types (`T[]`) - Array types (`T[]`)
@@ -257,7 +260,58 @@ Parameters and options can have different underlying types:
When defining a parameter of an enumerable type, keep in mind that it has to be the only such parameter and it must be the last in order. Options, on the other hand, don't have this limitation. When defining a parameter of an enumerable type, keep in mind that it has to be the only such parameter and it must be the last in order. Options, on the other hand, don't have this limitation.
Example command with an array-backed parameter: - Example command with a custom converter:
```c#
// Maps 2D vectors from AxB notation
public class VectorConverter : IArgumentValueConverter
{
public object ConvertFrom(string value)
{
var components = value.Split('x', 'X', ';');
var x = int.Parse(components[0], CultureInfo.InvariantCulture);
var y = int.Parse(components[1], CultureInfo.InvariantCulture);
return new Vector2(x, y);
}
}
[Command]
public class SurfaceCalculatorCommand : ICommand
{
// Custom converter is used to map raw argument values
[CommandParameter(0, Converter = typeof(VectorConverter))]
public Vector2 PointA { get; set; }
[CommandParameter(1, Converter = typeof(VectorConverter))]
public Vector2 PointB { get; set; }
[CommandParameter(2, Converter = typeof(VectorConverter))]
public Vector2 PointC { get; set; }
public ValueTask ExecuteAsync(IConsole console)
{
var a = (PointB - PointA).Length();
var b = (PointC - PointB).Length();
var c = (PointA - PointC).Length();
// Heron's formula
var p = (a + b + c) / 2;
var surface = Math.Sqrt(p * (p - a) * (p - b) * (p - c));
console.Output.WriteLine($"Triangle surface area: {surface}");
return default;
}
}
```
```sh
> myapp.exe 0x0 0x18 24x0
Triangle surface area: 216
```
- Example command with an array-backed parameter:
```c# ```c#
[Command] [Command]