20 Commits
1.5 ... 1.6

Author SHA1 Message Date
Tyrrrz
90a01e729b Update version 2020-12-06 18:42:58 +02:00
Tyrrrz
ac01c2aecb Allow dashes to appear in parameter values 2020-12-06 18:39:03 +02:00
Tyrrrz
4acffe925c Revert help text changes in the command section
Not happy with how it looks right now. Confusing to the user.
2020-12-06 18:24:30 +02:00
Tyrrrz
18f53eeeef Simplify gitignore and remove gitattributes 2020-12-06 17:58:02 +02:00
Tyrrrz
03d6942540 Require options to begin with a letter character
Fixes #88
2020-11-29 17:14:23 +02:00
Tyrrrz
9be811a89a Refactor ArgumentValueConverter into a class 2020-11-18 18:37:20 +02:00
Tyrrrz
f9f5a4696b Add analyzers for invalid validators 2020-11-18 18:26:04 +02:00
Tyrrrz
d6da687170 Refactor recent PRs 2020-11-18 18:00:43 +02:00
Tyrrrz
eba66d0878 Simplify coverage collection 2020-11-11 01:20:08 +02:00
Tyrrrz
8c682766bd Bump target frameworks on peripheral projects 2020-11-10 23:44:51 +02:00
Tyrrrz
39d626c8d8 Update build infra 2020-11-10 23:30:36 +02:00
Alexey Golub
a338ac8ce2 Custom value validators (#87) 2020-11-09 17:21:18 +02:00
Oleksandr Shustov
11637127cb remove redundant space 2020-11-08 20:01:47 +02:00
Oleksandr Shustov
4e12aefafb add tests 2020-11-07 21:46:32 +02:00
Oleksandr Shustov
144d3592fb cleanup after merge 2020-11-07 21:34:12 +02:00
Oleksandr Shustov
6f82c2f0f9 merge branch master into feature/custom-validators 2020-11-07 21:18:34 +02:00
Oleksandr Shustov
b8c60717d5 add a base type for custom validators 2020-11-06 20:37:46 +02:00
Rene Escalante
fec6850c39 Change format for the command section in help text (#83) 2020-10-29 20:31:03 +02:00
Tyrrrz
6a378ad946 Update nuget packages 2020-10-27 17:20:07 +02:00
Tyrrrz
11579f11b1 Update readme 2020-10-25 01:55:12 +03:00
37 changed files with 792 additions and 503 deletions

63
.gitattributes vendored
View File

@@ -1,63 +0,0 @@
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain

View File

@@ -3,23 +3,23 @@ name: CD
on: on:
push: push:
tags: tags:
- '*' - "*"
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2.3.3 uses: actions/checkout@v2.3.3
- name: Install .NET Core - name: Install .NET
uses: actions/setup-dotnet@v1.7.2 uses: actions/setup-dotnet@v1.7.2
with: with:
dotnet-version: 3.1.100 dotnet-version: 5.0.100
- name: Pack - name: Pack
run: dotnet pack CliFx --configuration Release run: dotnet pack CliFx --configuration Release
- name: Deploy - name: Deploy
run: dotnet nuget push CliFx/bin/Release/*.nupkg -s nuget.org -k ${{ secrets.NUGET_TOKEN }} run: dotnet nuget push CliFx/bin/Release/*.nupkg -s nuget.org -k ${{ secrets.NUGET_TOKEN }}

View File

@@ -11,25 +11,18 @@ jobs:
os: [ubuntu-latest, windows-latest, macos-latest] os: [ubuntu-latest, windows-latest, macos-latest]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2.3.3 uses: actions/checkout@v2.3.3
- name: Install .NET Core - name: Install .NET
uses: actions/setup-dotnet@v1.7.2 uses: actions/setup-dotnet@v1.7.2
with: with:
dotnet-version: 3.1.100 dotnet-version: 5.0.100
- name: Build & test - name: Build & test
run: dotnet test --configuration Release --logger GitHubActions run: dotnet test --configuration Release --logger GitHubActions
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v1.0.5 uses: codecov/codecov-action@v1.0.5
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
file: CliFx.Tests/bin/Release/Coverage.xml
- name: Upload coverage (analyzers)
uses: codecov/codecov-action@v1.0.5
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: CliFx.Analyzers.Tests/bin/Release/Coverage.xml

332
.gitignore vendored
View File

@@ -1,341 +1,21 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files # User-specific files
*.rsuser
*.suo *.suo
*.user *.user
*.userosscache *.userosscache
*.sln.docstates *.sln.docstates
.idea/
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Build results # Build results
[Dd]ebug/ [Dd]ebug/
[Dd]ebugPublic/ [Dd]ebugPublic/
[Rr]elease/ [Rr]elease/
[Rr]eleases/ [Rr]eleases/
x64/ [Xx]64/
x86/ [Xx]86/
[Aa][Rr][Mm]/ [Bb]uild/
[Aa][Rr][Mm]64/
bld/ bld/
[Bb]in/ [Bb]in/
[Oo]bj/ [Oo]bj/
[Ll]og/
# Visual Studio 2015/2017 cache/options directory # Coverage
.vs/ *.opencover.xml
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUNIT
*.VisualState.xml
TestResult.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JustCode is a .NET coding add-in
.JustCode
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
.ncrunchsolution
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- Backup*.rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# JetBrains Rider
.idea/
*.sln.iml
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb

View File

@@ -1,3 +1,9 @@
### v1.6 (06-Dec-2020)
- Added support for custom value validators. You can now create a type that inherits from `CliFx.ArgumentValueValidator<T>` to implement reusable validation logic for command arguments. To use a validator, include it in the `Validators` property on the `CommandOption` or `CommandParameter` attribute. (Thanks [@Oleksandr Shustov](https://github.com/AlexandrShustov))
- Added `CliFx.ArgumentValueConverter<T>` class that you can inherit from to implement custom value converters. `CliFx.IArgumentValueConverter` interface is still available, but it is recommended to inherit from the generic class instead, due to the type safety it provides. The interface may become internal or get removed in one of the future major versions.
- Updated requirements for option names and short names: short names now must be letter characters (lowercase or uppercase), while names must now start with a letter character. This means option names can no longer start with a digit or a special character. This change makes it possible to pass negative number values without the need to quote them, i.e. `--my-number -5`.
### v1.5 (23-Oct-2020) ### 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 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))

View File

@@ -2,17 +2,16 @@
<Import Project="../CliFx.props" /> <Import Project="../CliFx.props" />
<PropertyGroup> <PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework> <TargetFramework>net5.0</TargetFramework>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject> <IsTestProject>true</IsTestProject>
<CollectCoverage>true</CollectCoverage> <CollectCoverage>true</CollectCoverage>
<CoverletOutputFormat>opencover</CoverletOutputFormat> <CoverletOutputFormat>opencover</CoverletOutputFormat>
<CoverletOutput>bin/$(Configuration)/Coverage.xml</CoverletOutput>
</PropertyGroup> </PropertyGroup>
<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

@@ -153,9 +153,9 @@ public class MyCommand : ICommand
// language=cs // language=cs
@" @"
public class MyConverter : IArgumentValueConverter public class MyConverter : ArgumentValueConverter<string>
{ {
public object ConvertFrom(string value) => value; public string ConvertFrom(string value) => value;
} }
[Command] [Command]
@@ -164,6 +164,30 @@ public class MyCommand : ICommand
[CommandParameter(0, Converter = typeof(MyConverter))] [CommandParameter(0, Converter = typeof(MyConverter))]
public string Param { get; set; } public string Param { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}"
)
};
yield return new object[]
{
new AnalyzerTestCase(
"Parameter with valid validator",
Analyzer.SupportedDiagnostics,
// language=cs
@"
public class MyValidator : ArgumentValueValidator<string>
{
public ValidationResult Validate(string value) => ValidationResult.Ok();
}
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0, Validators = new[] {typeof(MyValidator)})]
public string Param { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default; public ValueTask ExecuteAsync(IConsole console) => default;
}" }"
) )
@@ -281,9 +305,9 @@ public class MyCommand : ICommand
// language=cs // language=cs
@" @"
public class MyConverter : IArgumentValueConverter public class MyConverter : ArgumentValueConverter<string>
{ {
public object ConvertFrom(string value) => value; public string ConvertFrom(string value) => value;
} }
[Command] [Command]
@@ -292,6 +316,30 @@ public class MyCommand : ICommand
[CommandOption('o', Converter = typeof(MyConverter))] [CommandOption('o', Converter = typeof(MyConverter))]
public string Option { get; set; } public string Option { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}"
)
};
yield return new object[]
{
new AnalyzerTestCase(
"Option with valid validator",
Analyzer.SupportedDiagnostics,
// language=cs
@"
public class MyValidator : ArgumentValueValidator<string>
{
public ValidationResult Validate(string value) => ValidationResult.Ok();
}
[Command]
public class MyCommand : ICommand
{
[CommandOption('o', Validators = new[] {typeof(MyValidator)})]
public string Option { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default; public ValueTask ExecuteAsync(IConsole console) => default;
}" }"
) )
@@ -438,6 +486,30 @@ public class MyCommand : ICommand
[CommandParameter(0, Converter = typeof(MyConverter))] [CommandParameter(0, Converter = typeof(MyConverter))]
public string Param { get; set; } public string Param { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}"
)
};
yield return new object[]
{
new AnalyzerTestCase(
"Parameter with invalid validator",
DiagnosticDescriptors.CliFx0026,
// language=cs
@"
public class MyValidator
{
public ValidationResult Validate(string value) => ValidationResult.Ok();
}
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0, Validators = new[] {typeof(MyValidator)})]
public string Param { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default; public ValueTask ExecuteAsync(IConsole console) => default;
}" }"
) )
@@ -566,6 +638,68 @@ public class MyCommand : ICommand
[CommandOption('o', Converter = typeof(MyConverter))] [CommandOption('o', Converter = typeof(MyConverter))]
public string Option { get; set; } public string Option { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}"
)
};
yield return new object[]
{
new AnalyzerTestCase(
"Option with invalid validator",
DiagnosticDescriptors.CliFx0047,
// language=cs
@"
public class MyValidator
{
public ValidationResult Validate(string value) => ValidationResult.Ok();
}
[Command]
public class MyCommand : ICommand
{
[CommandOption('o', Validators = new[] {typeof(MyValidator)})]
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
{ {
@@ -18,12 +19,16 @@ namespace CliFx.Analyzers
DiagnosticDescriptors.CliFx0023, DiagnosticDescriptors.CliFx0023,
DiagnosticDescriptors.CliFx0024, DiagnosticDescriptors.CliFx0024,
DiagnosticDescriptors.CliFx0025, DiagnosticDescriptors.CliFx0025,
DiagnosticDescriptors.CliFx0026,
DiagnosticDescriptors.CliFx0041, DiagnosticDescriptors.CliFx0041,
DiagnosticDescriptors.CliFx0042, DiagnosticDescriptors.CliFx0042,
DiagnosticDescriptors.CliFx0043, DiagnosticDescriptors.CliFx0043,
DiagnosticDescriptors.CliFx0044, DiagnosticDescriptors.CliFx0044,
DiagnosticDescriptors.CliFx0045, DiagnosticDescriptors.CliFx0045,
DiagnosticDescriptors.CliFx0046 DiagnosticDescriptors.CliFx0046,
DiagnosticDescriptors.CliFx0047,
DiagnosticDescriptors.CliFx0048,
DiagnosticDescriptors.CliFx0049
); );
private static bool IsScalarType(ITypeSymbol typeSymbol) => private static bool IsScalarType(ITypeSymbol typeSymbol) =>
@@ -57,14 +62,24 @@ namespace CliFx.Analyzers
.NamedArguments .NamedArguments
.Where(a => a.Key == "Converter") .Where(a => a.Key == "Converter")
.Select(a => a.Value.Value) .Select(a => a.Value.Value)
.FirstOrDefault() as ITypeSymbol; .Cast<ITypeSymbol?>()
.FirstOrDefault();
var validators = attribute
.NamedArguments
.Where(a => a.Key == "Validators")
.SelectMany(a => a.Value.Values)
.Select(c => c.Value)
.Cast<ITypeSymbol>()
.ToArray();
return new return new
{ {
Property = p, Property = p,
Order = order, Order = order,
Name = name, Name = name,
Converter = converter Converter = converter,
Validators = validators
}; };
}) })
.ToArray(); .ToArray();
@@ -140,6 +155,18 @@ namespace CliFx.Analyzers
DiagnosticDescriptors.CliFx0025, parameter.Property.Locations.First() DiagnosticDescriptors.CliFx0025, parameter.Property.Locations.First()
)); ));
} }
// Invalid validators
var invalidValidatorsParameters = parameters
.Where(p => !p.Validators.All(v => v.AllInterfaces.Any(KnownSymbols.IsArgumentValueValidatorInterface)))
.ToArray();
foreach (var parameter in invalidValidatorsParameters)
{
context.ReportDiagnostic(Diagnostic.Create(
DiagnosticDescriptors.CliFx0026, parameter.Property.Locations.First()
));
}
} }
private static void CheckCommandOptionProperties( private static void CheckCommandOptionProperties(
@@ -175,7 +202,16 @@ namespace CliFx.Analyzers
.NamedArguments .NamedArguments
.Where(a => a.Key == "Converter") .Where(a => a.Key == "Converter")
.Select(a => a.Value.Value) .Select(a => a.Value.Value)
.FirstOrDefault() as ITypeSymbol; .Cast<ITypeSymbol>()
.FirstOrDefault();
var validators = attribute
.NamedArguments
.Where(a => a.Key == "Validators")
.SelectMany(a => a.Value.Values)
.Select(c => c.Value)
.Cast<ITypeSymbol>()
.ToArray();
return new return new
{ {
@@ -183,7 +219,8 @@ namespace CliFx.Analyzers
Name = name, Name = name,
ShortName = shortName, ShortName = shortName,
EnvironmentVariableName = envVarName, EnvironmentVariableName = envVarName,
Converter = converter Converter = converter,
Validators = validators
}; };
}) })
.ToArray(); .ToArray();
@@ -270,6 +307,42 @@ namespace CliFx.Analyzers
DiagnosticDescriptors.CliFx0046, option.Property.Locations.First() DiagnosticDescriptors.CliFx0046, option.Property.Locations.First()
)); ));
} }
// Invalid validators
var invalidValidatorsOptions = options
.Where(o => !o.Validators.All(v => v.AllInterfaces.Any(KnownSymbols.IsArgumentValueValidatorInterface)))
.ToArray();
foreach (var option in invalidValidatorsOptions)
{
context.ReportDiagnostic(Diagnostic.Create(
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

@@ -53,6 +53,13 @@ namespace CliFx.Analyzers
"Usage", DiagnosticSeverity.Error, true "Usage", DiagnosticSeverity.Error, true
); );
public static readonly DiagnosticDescriptor CliFx0026 =
new DiagnosticDescriptor(nameof(CliFx0026),
"Parameter validator must implement 'CliFx.ArgumentValueValidator<T>'",
"Parameter validator must implement 'CliFx.ArgumentValueValidator<T>'",
"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",
@@ -95,6 +102,27 @@ namespace CliFx.Analyzers
"Usage", DiagnosticSeverity.Error, true "Usage", DiagnosticSeverity.Error, true
); );
public static readonly DiagnosticDescriptor CliFx0047 =
new DiagnosticDescriptor(nameof(CliFx0047),
"Option validator must implement 'CliFx.ArgumentValueValidator<T>'",
"Option validator must implement 'CliFx.ArgumentValueValidator<T>'",
"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

@@ -28,6 +28,9 @@ namespace CliFx.Analyzers
public static bool IsArgumentValueConverterInterface(ISymbol symbol) => public static bool IsArgumentValueConverterInterface(ISymbol symbol) =>
symbol.DisplayNameMatches("CliFx.IArgumentValueConverter"); symbol.DisplayNameMatches("CliFx.IArgumentValueConverter");
public static bool IsArgumentValueValidatorInterface(ISymbol symbol) =>
symbol.DisplayNameMatches("CliFx.IArgumentValueValidator");
public static bool IsCommandAttribute(ISymbol symbol) => public static bool IsCommandAttribute(ISymbol symbol) =>
symbol.DisplayNameMatches("CliFx.Attributes.CommandAttribute"); symbol.DisplayNameMatches("CliFx.Attributes.CommandAttribute");

View File

@@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework> <TargetFramework>net5.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -3,11 +3,11 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework> <TargetFramework>net5.0</TargetFramework>
</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

@@ -28,7 +28,7 @@ namespace CliFx.Demo
public static async Task<int> Main() => public static async Task<int> Main() =>
await new CliApplicationBuilder() await new CliApplicationBuilder()
.AddCommandsFromThisAssembly() .AddCommandsFromThisAssembly()
.UseTypeActivator(GetServiceProvider().GetService) .UseTypeActivator(GetServiceProvider().GetRequiredService)
.Build() .Build()
.RunAsync(); .RunAsync();
} }

View File

@@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework> <TargetFramework>net5.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -252,6 +252,26 @@ namespace CliFx.Tests
_output.WriteLine(stdErr.GetString()); _output.WriteLine(stdErr.GetString());
} }
[Fact]
public async Task Command_parameter_custom_validator_must_implement_the_corresponding_interface()
{
var (console, _, stdErr) = VirtualConsole.CreateBuffered();
var application = new CliApplicationBuilder()
.AddCommand<InvalidCustomValidatorParameterCommand>()
.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()
{ {
@@ -411,5 +431,65 @@ namespace CliFx.Tests
_output.WriteLine(stdErr.GetString()); _output.WriteLine(stdErr.GetString());
} }
[Fact]
public async Task Command_option_custom_validator_must_implement_the_corresponding_interface()
{
var (console, _, stdErr) = VirtualConsole.CreateBuffered();
var application = new CliApplicationBuilder()
.AddCommand<InvalidCustomValidatorOptionCommand>()
.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_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

@@ -2,12 +2,11 @@
<Import Project="../CliFx.props" /> <Import Project="../CliFx.props" />
<PropertyGroup> <PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework> <TargetFramework>net5.0</TargetFramework>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject> <IsTestProject>true</IsTestProject>
<CollectCoverage>true</CollectCoverage> <CollectCoverage>true</CollectCoverage>
<CoverletOutputFormat>opencover</CoverletOutputFormat> <CoverletOutputFormat>opencover</CoverletOutputFormat>
<CoverletOutput>bin/$(Configuration)/Coverage.xml</CoverletOutput>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -15,9 +14,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

@@ -0,0 +1,16 @@
using CliFx.Attributes;
namespace CliFx.Tests.Commands.Invalid
{
[Command]
public class InvalidCustomValidatorOptionCommand : SelfSerializeCommandBase
{
[CommandOption('f', Validators = new[] { typeof(Validator) })]
public string? Option { get; set; }
public class Validator
{
public ValidationResult Validate(string value) => ValidationResult.Ok();
}
}
}

View File

@@ -0,0 +1,16 @@
using CliFx.Attributes;
namespace CliFx.Tests.Commands.Invalid
{
[Command]
public class InvalidCustomValidatorParameterCommand : SelfSerializeCommandBase
{
[CommandParameter(0, Validators = new[] { typeof(Validator) })]
public string? Param { get; set; }
public class Validator
{
public ValidationResult Validate(string value) => ValidationResult.Ok();
}
}
}

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

@@ -165,9 +165,9 @@ namespace CliFx.Tests.Commands
public CustomConvertible(int value) => Value = value; public CustomConvertible(int value) => Value = value;
} }
public class CustomConvertibleConverter : IArgumentValueConverter public class CustomConvertibleConverter : ArgumentValueConverter<CustomConvertible>
{ {
public object ConvertFrom(string value) => public override CustomConvertible ConvertFrom(string value) =>
new CustomConvertible(int.Parse(value, CultureInfo.InvariantCulture)); new CustomConvertible(int.Parse(value, CultureInfo.InvariantCulture));
} }
} }

View File

@@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>1.5</Version> <Version>1.6</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

@@ -0,0 +1,30 @@
namespace CliFx
{
/// <summary>
/// Implements custom conversion logic that maps an argument value to a domain type.
/// </summary>
/// <remarks>
/// This type is public for legacy reasons.
/// Please derive from <see cref="ArgumentValueConverter{T}"/> instead.
/// </remarks>
public interface IArgumentValueConverter
{
/// <summary>
/// Converts an input value to object of required type.
/// </summary>
public object ConvertFrom(string value);
}
/// <summary>
/// A base type for custom argument converters.
/// </summary>
public abstract class ArgumentValueConverter<T> : IArgumentValueConverter
{
/// <summary>
/// Converts an input value to object of required type.
/// </summary>
public abstract T ConvertFrom(string value);
object IArgumentValueConverter.ConvertFrom(string value) => ConvertFrom(value)!;
}
}

View File

@@ -0,0 +1,55 @@
namespace CliFx
{
/// <summary>
/// Represents a result of a validation.
/// </summary>
public partial class ValidationResult
{
/// <summary>
/// Whether validation was successful.
/// </summary>
public bool IsValid => ErrorMessage == null;
/// <summary>
/// If validation has failed, contains the associated error, otherwise null.
/// </summary>
public string? ErrorMessage { get; }
/// <summary>
/// Initializes an instance of <see cref="ValidationResult"/>.
/// </summary>
public ValidationResult(string? errorMessage = null) =>
ErrorMessage = errorMessage;
}
public partial class ValidationResult
{
/// <summary>
/// Creates successful result, meaning that the validation has passed.
/// </summary>
public static ValidationResult Ok() => new ValidationResult();
/// <summary>
/// Creates an error result, meaning that the validation has failed.
/// </summary>
public static ValidationResult Error(string message) => new ValidationResult(message);
}
internal interface IArgumentValueValidator
{
ValidationResult Validate(object value);
}
/// <summary>
/// A base type for custom argument validators.
/// </summary>
public abstract class ArgumentValueValidator<T> : IArgumentValueValidator
{
/// <summary>
/// Validates the value.
/// </summary>
public abstract ValidationResult Validate(T value);
ValidationResult IArgumentValueValidator.Validate(object value) => Validate((T) value);
}
}

View File

@@ -0,0 +1,28 @@
using System;
namespace CliFx.Attributes
{
/// <summary>
/// Properties shared by parameter and option arguments.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public abstract class CommandArgumentAttribute : Attribute
{
/// <summary>
/// Option description, which is used in help text.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Type of converter to use when mapping the argument value.
/// Converter must derive from <see cref="ArgumentValueConverter{T}"/>.
/// </summary>
public Type? Converter { get; set; }
/// <summary>
/// Types of validators to use when mapping the argument value.
/// Validators must derive from <see cref="ArgumentValueValidator{T}"/>.
/// </summary>
public Type[] Validators { get; set; } = Array.Empty<Type>();
}
}

View File

@@ -6,7 +6,7 @@ namespace CliFx.Attributes
/// Annotates a property that defines a command option. /// Annotates a property that defines a command option.
/// </summary> /// </summary>
[AttributeUsage(AttributeTargets.Property)] [AttributeUsage(AttributeTargets.Property)]
public class CommandOptionAttribute : Attribute public class CommandOptionAttribute : CommandArgumentAttribute
{ {
/// <summary> /// <summary>
/// Option name (must be longer than a single character). /// Option name (must be longer than a single character).
@@ -27,22 +27,11 @@ namespace CliFx.Attributes
/// </summary> /// </summary>
public bool IsRequired { get; set; } public bool IsRequired { get; set; }
/// <summary>
/// Option description, which is used in help text.
/// </summary>
public string? Description { get; set; }
/// <summary> /// <summary>
/// Environment variable that will be used as fallback if no option value is specified. /// Environment variable that will be used as fallback if no option value is specified.
/// </summary> /// </summary>
public string? EnvironmentVariableName { get; set; } public string? EnvironmentVariableName { get; set; }
/// <summary>
/// Type of converter to use when mapping the argument value.
/// Converter must implement <see cref="IArgumentValueConverter"/>.
/// </summary>
public Type? Converter { get; set; }
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CommandOptionAttribute"/>. /// Initializes an instance of <see cref="CommandOptionAttribute"/>.
/// </summary> /// </summary>

View File

@@ -6,7 +6,7 @@ namespace CliFx.Attributes
/// Annotates a property that defines a command parameter. /// Annotates a property that defines a command parameter.
/// </summary> /// </summary>
[AttributeUsage(AttributeTargets.Property)] [AttributeUsage(AttributeTargets.Property)]
public class CommandParameterAttribute : Attribute public class CommandParameterAttribute : CommandArgumentAttribute
{ {
/// <summary> /// <summary>
/// Order of this parameter compared to other parameters. /// Order of this parameter compared to other parameters.
@@ -21,17 +21,6 @@ namespace CliFx.Attributes
/// </summary> /// </summary>
public string? Name { get; set; } public string? Name { get; set; }
/// <summary>
/// Parameter description, which is used in help text.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Type of converter to use when mapping the argument value.
/// Converter must implement <see cref="IArgumentValueConverter"/>.
/// </summary>
public Type? Converter { get; set; }
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CommandParameterAttribute"/>. /// Initializes an instance of <see cref="CommandParameterAttribute"/>.
/// </summary> /// </summary>

View File

@@ -28,7 +28,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="all" /> <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="all" />
<PackageReference Include="Nullable" Version="1.3.0" PrivateAssets="all" /> <PackageReference Include="Nullable" Version="1.3.0" PrivateAssets="all" />
</ItemGroup> </ItemGroup>

View File

@@ -19,11 +19,18 @@ namespace CliFx.Domain
public Type? ConverterType { get; } public Type? ConverterType { get; }
protected CommandArgumentSchema(PropertyInfo? property, string? description, Type? converterType) public Type[] ValidatorTypes { get; }
protected CommandArgumentSchema(
PropertyInfo? property,
string? description,
Type? converterType,
Type[] validatorTypes)
{ {
Property = property; Property = property;
Description = description; Description = description;
ConverterType = converterType; ConverterType = converterType;
ValidatorTypes = validatorTypes;
} }
private Type? TryGetEnumerableArgumentUnderlyingType() => private Type? TryGetEnumerableArgumentUnderlyingType() =>
@@ -132,8 +139,31 @@ namespace CliFx.Domain
} }
} }
public void BindOn(ICommand command, IReadOnlyList<string> values) => private void Validate(object? value)
Property?.SetValue(command, Convert(values)); {
if (value is null)
return;
var validators = ValidatorTypes
.Select(t => t.CreateInstance<IArgumentValueValidator>())
.ToArray();
var failedValidations = validators
.Select(v => v.Validate(value))
.Where(result => !result.IsValid)
.ToArray();
if (failedValidations.Any())
throw CliFxException.ValidationFailed(this, failedValidations);
}
public void BindOn(ICommand command, IReadOnlyList<string> values)
{
var value = Convert(values);
Validate(value);
Property?.SetValue(command, value);
}
public void BindOn(ICommand command, params string[] values) => public void BindOn(ICommand command, params string[] values) =>
BindOn(command, (IReadOnlyList<string>) values); BindOn(command, (IReadOnlyList<string>) values);

View File

@@ -138,7 +138,16 @@ namespace CliFx.Domain
{ {
var argument = commandLineArguments[index]; var argument = commandLineArguments[index];
if (argument.StartsWith('-')) var isOptionArgument =
argument.StartsWith("--", StringComparison.OrdinalIgnoreCase) &&
argument.Length > 2 &&
char.IsLetter(argument[2]) ||
argument.StartsWith('-') &&
argument.Length > 1 &&
char.IsLetter(argument[1]);
// Break on the first encountered option
if (isOptionArgument)
break; break;
result.Add(new CommandParameterInput(argument)); result.Add(new CommandParameterInput(argument));
@@ -161,7 +170,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 +182,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

@@ -24,8 +24,9 @@ namespace CliFx.Domain
string? environmentVariableName, string? environmentVariableName,
bool isRequired, bool isRequired,
string? description, string? description,
Type? converterType) Type? converterType,
: base(property, description, converterType) Type[] validatorTypes)
: base(property, description, converterType, validatorTypes)
{ {
Name = name; Name = name;
ShortName = shortName; ShortName = shortName;
@@ -99,17 +100,34 @@ namespace CliFx.Domain
attribute.EnvironmentVariableName, attribute.EnvironmentVariableName,
attribute.IsRequired, attribute.IsRequired,
attribute.Description, attribute.Description,
attribute.Converter attribute.Converter,
attribute.Validators
); );
} }
} }
internal partial class CommandOptionSchema internal partial class CommandOptionSchema
{ {
public static CommandOptionSchema HelpOption { get; } = public static CommandOptionSchema HelpOption { get; } = new CommandOptionSchema(
new CommandOptionSchema(null, "help", 'h', null, false, "Shows help text.", null); null,
"help",
'h',
null,
false,
"Shows help text.",
null,
Array.Empty<Type>()
);
public static CommandOptionSchema VersionOption { get; } = public static CommandOptionSchema VersionOption { get; } = new CommandOptionSchema(
new CommandOptionSchema(null, "version", null, null, false, "Shows version information.", null); null,
"version",
null,
null,
false,
"Shows version information.",
null,
Array.Empty<Type>()
);
} }
} }

View File

@@ -17,8 +17,9 @@ namespace CliFx.Domain
int order, int order,
string name, string name,
string? description, string? description,
Type? converterType) Type? converterType,
: base(property, description, converterType) Type[] validatorTypes)
: base(property, description, converterType, validatorTypes)
{ {
Order = order; Order = order;
Name = name; Name = name;
@@ -57,7 +58,8 @@ namespace CliFx.Domain
attribute.Order, attribute.Order,
name, name,
attribute.Description, attribute.Description,
attribute.Converter attribute.Converter,
attribute.Validators
); );
} }
} }

View File

@@ -126,6 +126,18 @@ namespace CliFx.Domain
invalidConverterParameters invalidConverterParameters
); );
} }
var invalidValidatorParameters = command.Parameters
.Where(p => !p.ValidatorTypes.All(x => x.Implements(typeof(IArgumentValueValidator))))
.ToArray();
if (invalidValidatorParameters.Any())
{
throw CliFxException.ParametersWithInvalidValidators(
command,
invalidValidatorParameters
);
}
} }
private static void ValidateOptions(CommandSchema command) private static void ValidateOptions(CommandSchema command)
@@ -208,6 +220,42 @@ namespace CliFx.Domain
invalidConverterOptions invalidConverterOptions
); );
} }
var invalidValidatorOptions = command.Options
.Where(o => !o.ValidatorTypes.All(x => x.Implements(typeof(IArgumentValueValidator))))
.ToArray();
if (invalidValidatorOptions.Any())
{
throw CliFxException.OptionsWithInvalidValidators(
command,
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

@@ -36,7 +36,8 @@ namespace CliFx.Exceptions
{ {
internal static CliFxException DefaultActivatorFailed(Type type, Exception? innerException = null) internal static CliFxException DefaultActivatorFailed(Type type, Exception? innerException = null)
{ {
var configureActivatorMethodName = $"{nameof(CliApplicationBuilder)}.{nameof(CliApplicationBuilder.UseTypeActivator)}(...)"; var configureActivatorMethodName =
$"{nameof(CliApplicationBuilder)}.{nameof(CliApplicationBuilder.UseTypeActivator)}(...)";
var message = $@" var message = $@"
Failed to create an instance of type '{type.FullName}'. Failed to create an instance of type '{type.FullName}'.
@@ -180,7 +181,20 @@ If it's not feasible to fit into these constraints, consider using options inste
Command '{command.Type.FullName}' is invalid because it contains {invalidParameters.Count} parameter(s) with invalid converters: Command '{command.Type.FullName}' is invalid because it contains {invalidParameters.Count} parameter(s) with invalid converters:
{invalidParameters.JoinToString(Environment.NewLine)} {invalidParameters.JoinToString(Environment.NewLine)}
Specified converter must implement {typeof(IArgumentValueConverter).FullName}."; Specified converter must implement {typeof(ArgumentValueConverter<>).FullName}.";
return new CliFxException(message.Trim());
}
internal static CliFxException ParametersWithInvalidValidators(
CommandSchema command,
IReadOnlyList<CommandParameterSchema> invalidParameters)
{
var message = $@"
Command '{command.Type.FullName}' is invalid because it contains {invalidParameters.Count} parameter(s) with invalid value validators:
{invalidParameters.JoinToString(Environment.NewLine)}
Specified validators must inherit from {typeof(ArgumentValueValidator<>).FullName}.";
return new CliFxException(message.Trim()); return new CliFxException(message.Trim());
} }
@@ -265,7 +279,46 @@ Environment variable names are not case-sensitive.";
Command '{command.Type.FullName}' is invalid because it contains {invalidOptions.Count} option(s) with invalid converters: Command '{command.Type.FullName}' is invalid because it contains {invalidOptions.Count} option(s) with invalid converters:
{invalidOptions.JoinToString(Environment.NewLine)} {invalidOptions.JoinToString(Environment.NewLine)}
Specified converter must implement {typeof(IArgumentValueConverter).FullName}."; Specified converter must implement {typeof(ArgumentValueConverter<>).FullName}.";
return new CliFxException(message.Trim());
}
internal static CliFxException OptionsWithInvalidValidators(
CommandSchema command,
IReadOnlyList<CommandOptionSchema> invalidOptions)
{
var message = $@"
Command '{command.Type.FullName}' is invalid because it contains {invalidOptions.Count} option(s) with invalid validators:
{invalidOptions.JoinToString(Environment.NewLine)}
Specified validators must inherit from {typeof(ArgumentValueValidator<>).FullName}.";
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()); return new CliFxException(message.Trim());
} }
@@ -398,7 +451,8 @@ Missing values for one or more required options:
return new CliFxException(message.Trim()); return new CliFxException(message.Trim());
} }
internal static CliFxException UnrecognizedParametersProvided(IReadOnlyList<CommandParameterInput> parameterInputs) internal static CliFxException UnrecognizedParametersProvided(
IReadOnlyList<CommandParameterInput> parameterInputs)
{ {
var message = $@" var message = $@"
Unrecognized parameters provided: Unrecognized parameters provided:
@@ -415,5 +469,36 @@ Unrecognized options provided:
return new CliFxException(message.Trim()); return new CliFxException(message.Trim());
} }
internal static CliFxException ValidationFailed(
CommandParameterSchema parameter,
IReadOnlyList<ValidationResult> failedResults)
{
var message = $@"
Value provided for parameter {parameter.GetUserFacingDisplayString()}:
{failedResults.Select(r => r.ErrorMessage).JoinToString(Environment.NewLine)}";
return new CliFxException(message.Trim());
}
internal static CliFxException ValidationFailed(
CommandOptionSchema option,
IReadOnlyList<ValidationResult> failedResults)
{
var message = $@"
Value provided for option {option.GetUserFacingDisplayString()}:
{failedResults.Select(r => r.ErrorMessage).JoinToString(Environment.NewLine)}";
return new CliFxException(message.Trim());
}
internal static CliFxException ValidationFailed(
CommandArgumentSchema argument,
IReadOnlyList<ValidationResult> failedResults) => argument switch
{
CommandParameterSchema parameter => ValidationFailed(parameter, failedResults),
CommandOptionSchema option => ValidationFailed(option, failedResults),
_ => throw new ArgumentOutOfRangeException(nameof(argument))
};
} }
} }

View File

@@ -1,13 +0,0 @@
namespace CliFx
{
/// <summary>
/// Implements custom conversion logic that maps an argument value to a domain type.
/// </summary>
public interface IArgumentValueConverter
{
/// <summary>
/// Converts an input value to object of required type.
/// </summary>
public object ConvertFrom(string value);
}
}

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
@@ -262,9 +264,9 @@ When defining a parameter of an enumerable type, keep in mind that it has to be
```c# ```c#
// Maps 2D vectors from AxB notation // Maps 2D vectors from AxB notation
public class VectorConverter : IArgumentValueConverter public class VectorConverter : ArgumentValueConverter<Vector2>
{ {
public object ConvertFrom(string value) public override Vector2 ConvertFrom(string value)
{ {
var components = value.Split('x', 'X', ';'); var components = value.Split('x', 'X', ';');
var x = int.Parse(components[0], CultureInfo.InvariantCulture); var x = int.Parse(components[0], CultureInfo.InvariantCulture);