Require options to begin with a letter character

Fixes #88
This commit is contained in:
Tyrrrz
2020-11-29 17:14:23 +02:00
parent 9be811a89a
commit 03d6942540
10 changed files with 227 additions and 4 deletions

View File

@@ -662,6 +662,44 @@ public class MyCommand : ICommand
[CommandOption('o', Validators = new[] {typeof(MyValidator)})] [CommandOption('o', Validators = new[] {typeof(MyValidator)})]
public string Option { get; set; } public string Option { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}"
)
};
yield return new object[]
{
new AnalyzerTestCase(
"Option with a name that doesn't start with a letter character",
DiagnosticDescriptors.CliFx0048,
// language=cs
@"
[Command]
public class MyCommand : ICommand
{
[CommandOption(""0foo"")]
public string Option { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}"
)
};
yield return new object[]
{
new AnalyzerTestCase(
"Option with a short name that isn't a letter character",
DiagnosticDescriptors.CliFx0049,
// language=cs
@"
[Command]
public class MyCommand : ICommand
{
[CommandOption('0')]
public string Option { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default; public ValueTask ExecuteAsync(IConsole console) => default;
}" }"
) )

View File

@@ -7,6 +7,7 @@ using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers namespace CliFx.Analyzers
{ {
// TODO: split into multiple analyzers
[DiagnosticAnalyzer(LanguageNames.CSharp)] [DiagnosticAnalyzer(LanguageNames.CSharp)]
public class CommandSchemaAnalyzer : DiagnosticAnalyzer public class CommandSchemaAnalyzer : DiagnosticAnalyzer
{ {
@@ -25,7 +26,9 @@ namespace CliFx.Analyzers
DiagnosticDescriptors.CliFx0044, DiagnosticDescriptors.CliFx0044,
DiagnosticDescriptors.CliFx0045, DiagnosticDescriptors.CliFx0045,
DiagnosticDescriptors.CliFx0046, DiagnosticDescriptors.CliFx0046,
DiagnosticDescriptors.CliFx0047 DiagnosticDescriptors.CliFx0047,
DiagnosticDescriptors.CliFx0048,
DiagnosticDescriptors.CliFx0049
); );
private static bool IsScalarType(ITypeSymbol typeSymbol) => private static bool IsScalarType(ITypeSymbol typeSymbol) =>
@@ -307,7 +310,7 @@ namespace CliFx.Analyzers
// Invalid validators // Invalid validators
var invalidValidatorsOptions = options var invalidValidatorsOptions = options
.Where(p => !p.Validators.All(v => v.AllInterfaces.Any(KnownSymbols.IsArgumentValueValidatorInterface))) .Where(o => !o.Validators.All(v => v.AllInterfaces.Any(KnownSymbols.IsArgumentValueValidatorInterface)))
.ToArray(); .ToArray();
foreach (var option in invalidValidatorsOptions) foreach (var option in invalidValidatorsOptions)
@@ -316,6 +319,30 @@ namespace CliFx.Analyzers
DiagnosticDescriptors.CliFx0047, option.Property.Locations.First() DiagnosticDescriptors.CliFx0047, option.Property.Locations.First()
)); ));
} }
// Non-letter first character in name
var nonLetterFirstCharacterInNameOptions = options
.Where(o => !string.IsNullOrWhiteSpace(o.Name) && !char.IsLetter(o.Name[0]))
.ToArray();
foreach (var option in nonLetterFirstCharacterInNameOptions)
{
context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.CliFx0048, option.Property.Locations.First()
));
}
// Non-letter short name
var nonLetterShortNameOptions = options
.Where(o => o.ShortName != null && !char.IsLetter(o.ShortName.Value))
.ToArray();
foreach (var option in nonLetterShortNameOptions)
{
context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.CliFx0049, option.Property.Locations.First()
));
}
} }
private static void CheckCommandType(SymbolAnalysisContext context) private static void CheckCommandType(SymbolAnalysisContext context)

View File

@@ -109,6 +109,20 @@ namespace CliFx.Analyzers
"Usage", DiagnosticSeverity.Error, true "Usage", DiagnosticSeverity.Error, true
); );
public static readonly DiagnosticDescriptor CliFx0048 =
new DiagnosticDescriptor(nameof(CliFx0048),
"Option name must begin with a letter character.",
"Option name must begin with a letter character.",
"Usage", DiagnosticSeverity.Error, true
);
public static readonly DiagnosticDescriptor CliFx0049 =
new DiagnosticDescriptor(nameof(CliFx0049),
"Option short name must be a letter character.",
"Option short name must be a letter character.",
"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",

View File

@@ -451,5 +451,45 @@ namespace CliFx.Tests
_output.WriteLine(stdErr.GetString()); _output.WriteLine(stdErr.GetString());
} }
[Fact]
public async Task Command_options_must_have_names_that_start_with_a_letter_character()
{
var (console, _, stdErr) = VirtualConsole.CreateBuffered();
var application = new CliApplicationBuilder()
.AddCommand<NonLetterCharacterNameCommand>()
.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]
public async Task Command_options_must_have_short_names_that_are_letter_characters()
{
var (console, _, stdErr) = VirtualConsole.CreateBuffered();
var application = new CliApplicationBuilder()
.AddCommand<NonLetterCharacterShortNameCommand>()
.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

@@ -191,6 +191,34 @@ namespace CliFx.Tests
_output.WriteLine(stdErr.GetString()); _output.WriteLine(stdErr.GetString());
} }
[Fact]
public async Task Argument_that_begins_with_a_dash_is_not_parsed_as_option_name_if_it_does_not_start_with_a_letter_character()
{
// Arrange
var (console, stdOut, _) = VirtualConsole.CreateBuffered();
var application = new CliApplicationBuilder()
.AddCommand<SupportedArgumentTypesCommand>()
.UseConsole(console)
.Build();
// Act
var exitCode = await application.RunAsync(new[]
{
"cmd", "--int", "-13"
});
var commandInstance = stdOut.GetString().DeserializeJson<SupportedArgumentTypesCommand>();
// Assert
exitCode.Should().Be(0);
commandInstance.Should().BeEquivalentTo(new SupportedArgumentTypesCommand
{
Int = -13
});
}
[Fact] [Fact]
public async Task All_provided_option_arguments_must_be_bound_to_corresponding_properties() public async Task All_provided_option_arguments_must_be_bound_to_corresponding_properties()
{ {

View File

@@ -0,0 +1,11 @@
using CliFx.Attributes;
namespace CliFx.Tests.Commands.Invalid
{
[Command("cmd")]
public class NonLetterCharacterNameCommand : SelfSerializeCommandBase
{
[CommandOption("0foo")]
public string? Apples { get; set; }
}
}

View File

@@ -0,0 +1,11 @@
using CliFx.Attributes;
namespace CliFx.Tests.Commands.Invalid
{
[Command("cmd")]
public class NonLetterCharacterShortNameCommand : SelfSerializeCommandBase
{
[CommandOption('0')]
public string? Apples { get; set; }
}
}

View File

@@ -161,7 +161,9 @@ namespace CliFx.Domain
var argument = commandLineArguments[index]; var argument = commandLineArguments[index];
// Name // Name
if (argument.StartsWith("--", StringComparison.Ordinal)) if (argument.StartsWith("--", StringComparison.Ordinal) &&
argument.Length > 2 &&
char.IsLetter(argument[2]))
{ {
// Flush previous // Flush previous
if (!string.IsNullOrWhiteSpace(currentOptionAlias)) if (!string.IsNullOrWhiteSpace(currentOptionAlias))
@@ -171,7 +173,9 @@ namespace CliFx.Domain
currentOptionValues = new List<string>(); currentOptionValues = new List<string>();
} }
// Short name // Short name
else if (argument.StartsWith('-')) else if (argument.StartsWith('-') &&
argument.Length > 1 &&
char.IsLetter(argument[1]))
{ {
foreach (var alias in argument.Substring(1)) foreach (var alias in argument.Substring(1))
{ {

View File

@@ -232,6 +232,30 @@ namespace CliFx.Domain
invalidValidatorOptions invalidValidatorOptions
); );
} }
var nonLetterFirstCharacterInNameOptions = command.Options
.Where(o => !string.IsNullOrWhiteSpace(o.Name) && !char.IsLetter(o.Name[0]))
.ToArray();
if (nonLetterFirstCharacterInNameOptions.Any())
{
throw CliFxException.OptionsWithNonLetterCharacterName(
command,
nonLetterFirstCharacterInNameOptions
);
}
var nonLetterShortNameOptions = command.Options
.Where(o => o.ShortName != null && !char.IsLetter(o.ShortName.Value))
.ToArray();
if (nonLetterShortNameOptions.Any())
{
throw CliFxException.OptionsWithNonLetterCharacterShortName(
command,
nonLetterShortNameOptions
);
}
} }
private static void ValidateCommands(IReadOnlyList<CommandSchema> commands) private static void ValidateCommands(IReadOnlyList<CommandSchema> commands)

View File

@@ -296,6 +296,32 @@ Specified validators must inherit from {typeof(ArgumentValueValidator<>).FullNam
return new CliFxException(message.Trim()); return new CliFxException(message.Trim());
} }
internal static CliFxException OptionsWithNonLetterCharacterName(
CommandSchema command,
IReadOnlyList<CommandOptionSchema> invalidOptions)
{
var message = $@"
Command '{command.Type.FullName}' is invalid because it contains one or more options whose names don't start with a letter character:
{invalidOptions.JoinToString(Environment.NewLine)}
Option names must start with a letter character (i.e. not a digit and not a special character).";
return new CliFxException(message.Trim());
}
internal static CliFxException OptionsWithNonLetterCharacterShortName(
CommandSchema command,
IReadOnlyList<CommandOptionSchema> invalidOptions)
{
var message = $@"
Command '{command.Type.FullName}' is invalid because it contains one or more options whose short names are not letter characters:
{invalidOptions.JoinToString(Environment.NewLine)}
Option short names must be letter characters (i.e. not digits and not special characters).";
return new CliFxException(message.Trim());
}
} }
// End-user-facing exceptions // End-user-facing exceptions