From d04d844e76d9a9b192725e4eb8dfc1cd7a057ab6 Mon Sep 17 00:00:00 2001 From: Immo Landwerth Date: Mon, 28 Sep 2015 12:20:31 -0700 Subject: [PATCH 1/3] Initial import of a command line parser The purpose of this library is to make command line tools first class by providing a command line parser. * Designed for cross-platform usage * Lightweight with minimal configuration * Optional but built-in support for help, validation, and response files * Support for multiple commands, like version control tools Sample code: static class Program { static void Main(string[] args) { var addressee = "world"; ArgumentSyntax.Parse(args, syntax => { syntax.DefineOption("n|name", ref addressee, "The addressee to greet"); }); Console.WriteLine("Hello {0}!", addressee); } } --- src/System.CommandLine/README.md | 496 +++++++++ src/System.CommandLine/System.CommandLine.sln | 28 + .../src/System.CommandLine.csproj | 48 + .../src/System/CommandLine/Argument.cs | 108 ++ .../src/System/CommandLine/ArgumentCommand.cs | 40 + .../System/CommandLine/ArgumentCommand`1.cs | 23 + .../src/System/CommandLine/ArgumentLexer.cs | 202 ++++ .../src/System/CommandLine/ArgumentList`1.cs | 60 ++ .../src/System/CommandLine/ArgumentParser.cs | 242 +++++ .../src/System/CommandLine/ArgumentSyntax.cs | 442 ++++++++ .../CommandLine/ArgumentSyntaxException.cs | 24 + .../CommandLine/ArgumentSyntax_Definers.cs | 183 ++++ .../src/System/CommandLine/ArgumentToken.cs | 82 ++ .../src/System/CommandLine/Argument`1.cs | 50 + .../System/CommandLine/HelpTextGenerator.cs | 261 +++++ .../System/CommandLine/InternalsVisibleTo.cs | 6 + .../src/System/Strings.Designer.cs | 262 +++++ .../src/System/Strings.resx | 186 ++++ src/System.CommandLine/src/project.json | 20 + .../tests/System.CommandLine.Tests.csproj | 33 + .../tests/System/CommandLine/Splitter.cs | 77 ++ .../CommandLine/Tests/ArgumentLexerTests.cs | 148 +++ .../CommandLine/Tests/ArgumentSyntaxTests.cs | 993 ++++++++++++++++++ .../Tests/HelpTextGeneratorTests.cs | 511 +++++++++ src/System.CommandLine/tests/project.json | 21 + 25 files changed, 4546 insertions(+) create mode 100644 src/System.CommandLine/README.md create mode 100644 src/System.CommandLine/System.CommandLine.sln create mode 100644 src/System.CommandLine/src/System.CommandLine.csproj create mode 100644 src/System.CommandLine/src/System/CommandLine/Argument.cs create mode 100644 src/System.CommandLine/src/System/CommandLine/ArgumentCommand.cs create mode 100644 src/System.CommandLine/src/System/CommandLine/ArgumentCommand`1.cs create mode 100644 src/System.CommandLine/src/System/CommandLine/ArgumentLexer.cs create mode 100644 src/System.CommandLine/src/System/CommandLine/ArgumentList`1.cs create mode 100644 src/System.CommandLine/src/System/CommandLine/ArgumentParser.cs create mode 100644 src/System.CommandLine/src/System/CommandLine/ArgumentSyntax.cs create mode 100644 src/System.CommandLine/src/System/CommandLine/ArgumentSyntaxException.cs create mode 100644 src/System.CommandLine/src/System/CommandLine/ArgumentSyntax_Definers.cs create mode 100644 src/System.CommandLine/src/System/CommandLine/ArgumentToken.cs create mode 100644 src/System.CommandLine/src/System/CommandLine/Argument`1.cs create mode 100644 src/System.CommandLine/src/System/CommandLine/HelpTextGenerator.cs create mode 100644 src/System.CommandLine/src/System/CommandLine/InternalsVisibleTo.cs create mode 100644 src/System.CommandLine/src/System/Strings.Designer.cs create mode 100644 src/System.CommandLine/src/System/Strings.resx create mode 100644 src/System.CommandLine/src/project.json create mode 100644 src/System.CommandLine/tests/System.CommandLine.Tests.csproj create mode 100644 src/System.CommandLine/tests/System/CommandLine/Splitter.cs create mode 100644 src/System.CommandLine/tests/System/CommandLine/Tests/ArgumentLexerTests.cs create mode 100644 src/System.CommandLine/tests/System/CommandLine/Tests/ArgumentSyntaxTests.cs create mode 100644 src/System.CommandLine/tests/System/CommandLine/Tests/HelpTextGeneratorTests.cs create mode 100644 src/System.CommandLine/tests/project.json diff --git a/src/System.CommandLine/README.md b/src/System.CommandLine/README.md new file mode 100644 index 00000000000..f4d39384f2d --- /dev/null +++ b/src/System.CommandLine/README.md @@ -0,0 +1,496 @@ +# System.CommandLine + +The purpose of this library is to make command line tools first class by +providing a command line parser. We've already made an attempt in 2009 but +that wasn't a design we (or the community) was +[happy with](http://tirania.org/blog/archive/2009/Feb-21.html). + +Here are the goals: + +* Designed for cross-platform usage +* Lightweight with minimal configuration +* Optional but built-in support for help, validation, and response files +* Support for multiple commands, like version control tools + +[Syntax](#syntax) and [API samples](#api-samples) are below. + +## Why a new library? + +There is already a set of libraries available for command line parsing, such as: + +* [Mono.Options][Mono.Options] (also known as `NDesk.Options`) +* [CommandLine][CommandLine] +* An [internal one][vance] that Vance Morrison wrote many years ago. + +[Mono.Options]: http://tirania.org/blog/archive/2008/Oct-14.html +[CommandLine]: https://github.com/gsscoder/commandline +[vance]: https://github.com/dotnet/buildtools/blob/master/src/common/CommandLine.cs + +So the question is: why a new one? There are a couple of reasons: + +1. We want to support a syntax that feels natural when used across platforms. In + particular, we want to be very close to the Unix- and GNU style. + +2. We need something that is quite low level. In particular we don't want to + have a library that requires reflection for attribute discovery or for + setting properties. + +3. We want an experience that achieves an extremely minimal setup in terms of + lines of required for parsing. + +While some of the libraries solve some of the aspects none of them solve the +combination. + +Of course, providing a command line parser isn't just providing a parsing +mechanism: in order to be usable, the library has to be opinionated in both the +supported syntax as well as in the shape of the APIs. In the BCL, we've always +taken the stance that we want to provide a layered experience that allows +getting the 80% scenario done, while allowing to be extensible for a potential +long tail of additional scenarios. If that means we've to make policy decisions +so be it because not making those forces all of our consumers to come up with +their own policy. + +That being said, the goal isn't to provide the final command line parser +library. In fact, I'm not aware of any platform that gets away with having a +single one. If you're happy with the one you're already using or if you even +wrote your own: that's perfectly fine. But after one and a half decades it's +time for the BCL to provide a built-in experience as well. + +## Work in progress + +* Should we disable slash syntax when running on non-Windows operating systems? +* Should we even support slash syntax at all? +* Should we support a case insensitive mode? +* Should we have a way to name option arguments, e.g. `DefineOption("n=name")`? +* Should provide a string based approach to define usage? +* Should we provide an error handler? +* Should we provide a help request handler? +* Should we expose the response file hander? + +## Syntax + +The syntax conventions are heavily inspired by the following existing +conventions: + +* [Unix History][Unix-History] +* [POSIX Conventions][POSIX-Conventions] +* [GNU Standards for Command Line Interfaces][GNU] +* [GNU List of standard option names][GNU-Options] + +[Unix-History]: http://catb.org/~esr/writings/taoup/html/ch10s05.html +[POSIX-Conventions]: http://www.cs.unicam.it/piergallini/home/materiale/gc/java/essential/attributes/_posix.html +[GNU]: http://www.gnu.org/prep/standards/html_node/Command_002dLine-Interfaces.html +[GNU-Options]: http://www.gnu.org/prep/standards/html_node/Option-Table.html#Option-Table + +In general, all strings are treated in a case sensitive way. This allows +supporting options that only differ by case, which is pretty common on +Unix systems, e.g. + + # This reverses the output + $ ls -r *.txt + # This does a recursive search + $ ls -R *.txt + +### Single character options + +Single character options are delimited by a single dash, e.g. + + $ tool -x -d -f + +They can be *bundled* together, such as + + $ tool -xdf + +You can also use a slash, e.g. + + $ tool /x /d /f + +However, slashes don't support bundling. For example, the following isn't +recognized: + + # This is not equivalent to -xdf + $ tool /xdf + +### Keyword options + +Keyword options are delimited by two dashes, such as: + + $ tool --verbose + +Alternatively, you can use a slash: + + $ tool /verbose + +Using two dashes avoids any ambiguity with bundled forms -- which is why +slashes don't support bundling. + +### Option arguments + +Both, the single letter form, as well as the long forms, support arguments. +Arguments must be separated by either a space, an equal sign or a colon: + + # All three forms are identical: + $ tool /out result.exe + $ tool /out=result.exe + $ tool /out:result.exe + +Multiple spaces are allowed as well: + + $ tool /out result.exe + $ tool /out = result.exe + $ tool /out : result.exe + +This even works when combined with bundling, but in that case only the last +option can have an argument. So this: + + $ tool -am "hello" + +is equivalent to: + + $ tool -a -m "hello" + +### Parameters + +Parameters, sometimes also called non-option arguments, can be anywhere in the +input: + + # Both forms equivalent: + $ tool input1.ext input2.ext -o result.ext + $ tool input1.ext -o result.ext input2.ext + +### Commands + +Very often, tools have multiple commands with independent options. Good example +are version control tools, e.g. + + $ tool fetch origin --prune + $ tool commit -m 'Message' + +### Response files + +It's common practice to allow passing command line arguments via response files. +This can look as follows: + + $ tool -f -r @D:\src\defaults.rsp --additional + +The API supports multiple response files being passed in. It will simply expand +those in-place, i.e. it's valid to have additional options and parameters +before, as well as after the response file reference. + +## API Samples + +### Hello world + +```C# +using System; +using System.CommandLine; + +static class Program +{ + static void Main(string[] args) + { + var addressee = "world"; + + ArgumentSyntax.Parse(args, syntax => + { + syntax.DefineOption("n|name", ref addressee, "The addressee to greet"); + }); + + Console.WriteLine("Hello {0}!", addressee); + } +} +``` + +Usage looks as follows: + +``` +$ ./hello -h +usage: hello [-n ] + + -n, --name The addressee to greet + +$ ./hello +Hello world! +$ ./hello -n Jennifer +Hello Jennifer! +$ ./hello --name Tom +Hello Tom! +$ ./hello -x +error: invalid option -x +``` + +### Defining options + +The `ArgumentSyntax` class allows defining options and parameters for any data +type. In order to parse the value, you need to supply a `Func` that +performs the parsing. So if you want to use a guid, you could define an option +like this: + +```C# +Guid guid = Guid.Empty; +syntax.DefineOption("g|guid", ref guid, Guid.Parse, "The GUID to use"); +``` + +The library provides overloads that handle the most common types, such as +`string`, `int`, and `bool`, so that you don't have to pass in parsers for +those. + +Boolean options are specially handled in that they are considered flags, i.e. +they don't require an argument -- they are simply considered `true` if they are +specified. However you can still explicitly pass in `true` or `false`. So this + + $ tool -x + +Is equivalent to + + $ tool -x:true + +The syntax used to define the option supports multiple names by separating them +using a pipe. All names are aliases for the same option. For diagnostic +purposes, the first name will be used. By convention that should be the short +name, but it's really up to you. + +### Defining parameters + +Parameters work in a very similar way: + +```C# +Guid guid = Guid.Empty; +syntax.DefineParameter("guid", ref guid, Guid.Parse, "The GUID to use"); +``` + +However, since parameters are matched by position and not by using a named +option, the name is only used for diagnostic purposes and to render a readable +syntax. Hence, they don't support the pipe syntax because having multiple names +wouldn't make any sense there. + +Please note that parameters must be specified after options. The reason being +that the parser needs to know all options before it can match parameters. +Otherwise parsing this command would be ambiguous: + + $ tool -x one two + +Without knowing whether `-x` takes an argument, it's not clear whether `one` +will be an argument or the first parameter. + +### Defining option and parameter lists + +Both, options and parameters, support the notion of lists. For example, consider +the C# compiler CSC: + + $ csc /r:mscorlib.dll /r:system.dll source1.cs source2.cs /out:hello.exe /t:exe + +You would define the options and parameters as follows: + +```C# +string target = "exe"; +string output = string.Empty; +IReadOnlyList references = Array.Empty(); +IReadOnlyList sources = Array.Empty(); + +syntax.DefineOption("t|target", ref target, "Type of app (exe, dll, winexe)"); +syntax.DefineOption("out", ref output, "Output name"); +syntax.DefineOptionList("r|reference", ref references, "References an assembly"); +syntax.DefineParameterList("source", ref sources, "The source files to compile"); +``` + +In general, you can define multiple option lists but only one parameter list. +The reason being that a parameter list will consume all remaining parameters. +You can define individual parameters and a parameter list but the parameter list +must be after the individual parameters otherwise the individual ones will never +be matched. + +### Defining commands + +Commands are defined in a similar way to options and parameters. The way they +are associated with options and commands is by order: + +```C# +var command = string.Empty; +var prune = false; +var message = string.Empty; +var amend = false; + +syntax.DefineCommand("pull", ref command, "Pull from another repo"); +syntax.DefineOption("p|prune", ref prune, "Prune branches"); + +syntax.DefineCommand("commit", ref command, "Committing changes"); +syntax.DefineOption("m|message", ref message, "The message to use"); +syntax.DefineOption("amend", ref amend, "Amend existing commit"); +``` + +In this case the `pull` command has a `-p` option and the `commit` command has +`-m` and `--amend` options. It's worth noting that you can use the same option +name between different commands as they are logically in different scopes. + +You can also have global options, i.e. options that are defined before the first +command. Global options are available to each command. + +In order to check which command was used you've two options. You can either +use the version we used above in which case the `ref` variable passed to +`DefineCommand` will contain the name of the command that was specified. But +you're not limited to just plain strings. For example, this will work as well: + +```C# +enum Command { Pull, Commit } + +// ... + +Command command = Command.Pull; +syntax.DefineCommand("pull", ref command, Command.Pull, "Pull from another repo"); +syntax.DefineCommand("commit", ref command, Command.Commit, "Committing changes"); +``` + +### Custom help + +By default, `ArgumentSyntax` will display the help and exit when `-?`, `-h` or +`--help` is specified. Some tools perform different actions, for instance `git`, +which displays the help on the command line when `-h` is used but opens the web +browser when `--help` is used. + +You can support this by handling help yourself: + +```C# +ArgumentSyntax.Parse(args, syntax => +{ + // Turn off built-in help processing + + syntax.HandleHelp = false; + + // Define your own options + + syntax.DefineOption("n|name", ref addressee, "The addressee to greet"); + + // Define custom help options. Optionally, you can hide those options + // from the help text. + + var longHelp = syntax.DefineOption("help", false); + longHelp.IsHidden = true; + + var quickHelp = syntax.DefineOption("h", false); + quickHelp.IsHidden = true; + + if (longHelp.Value) + { + // Open a browser + var url = "https://en.wikipedia.org/wiki/%22Hello,_World!%22_program"; + Process.Start(url); + Environment.Exit(0); + } + else if (quickHelp.Value) + { + // Show help text. Even if you disable built-in help processing + // you can still use the built-in help page generator. Optionally, + // you can ask it to word wrap it according to some maximum, such + // as the width of the console window. + var maxWidth = Console.WindowWidth - 2; + var helpText = syntax.GetHelpText(maxWidth); + Console.WriteLine(helpText); + Environment.Exit(0); + } +}); +``` + +### Additional validation + +Let's say you want to perform additional validation, such as that supplied +arguments point to valid files or that certain options aren't used in +combination. You can do this by simply adding a bit of validation code at +the end of the `Parse` method that calls `ReportError`. + +```C# +ArgumentSyntax.Parse(args, syntax => +{ + syntax.DefineOption("n|name", ref addressee, "The addressee to greet"); + + if (addressee.Any(char.IsWhiteSpace)) + syntax.ReportError("addressee cannot contain whitespace"); +}); +``` + +Usage will look like this: + +``` +$ ./hello -n "Immo Landwerth" +error: addressee cannot contain whitespace +``` + +### Custom error handling + +There are cases where you want to use `ArgumentSyntax` in such a way that user +errors shouldn't result in the process being terminated. You can do this by +disabling the built-in error handling. In that case, the `ReportError` method -- +and thus the `Parse` method -- will throw `ArgumentSyntaxException`. + +```C# +try +{ + ArgumentSyntax.Parse(args, syntax => + { + syntax.HandleErrors = false; + syntax.DefineOption("n|name", ref addressee); + }); +} +catch (ArgumentSyntaxException ex) +{ + Console.WriteLine("Ooops... something didn't go well."); + Console.WriteLine(ex.Message); + return 1; +} +``` + +### Accessing options and parameters + +The `ArgumentSyntax` object also provides access to the defined options and +parameters. The `Parse` method returns the used instance, so you can use that +to access them. You can either access all of the defined ones or you can access +only the ones that are relevant to the currently active command. + +```C# +static void Main(string[] args) +{ + var addressee = "world"; + + var result = ArgumentSyntax.Parse(args, syntax => + { + syntax.DefineOption("n|name", ref addressee, "The addressee to greet"); + + if (addressee.Any(char.IsWhiteSpace)) + syntax.ReportError("adressee cannot contain whitespace"); + }); + + foreach (var argument in result.GetActiveArguments()) + { + if (argument.IsOption) + { + var names = string.Join(", ", argument.GetDisplayNames()); + Console.WriteLine("Option {0}", names); + } + else + { + Console.WriteLine("Parameter {0}", argument.GetDisplayName()); + } + + Console.WriteLine("Help : {0}", argument.Help); + Console.WriteLine("IsHidden : {0}", argument.IsHidden); + Console.WriteLine("Value : {0}", argument.Value); + Console.WriteLine("DefaultValue : {0}", argument.DefaultValue); + Console.WriteLine("IsSpecified : {0}", argument.IsSpecified); + } +} +``` + +### Turning off response files + +In case you don't want consumers to use response files or you need to process +parameters that could be prefixed with an `@`-sign, you can disable response +file expansion: + +```C# +ArgumentSyntax.Parse(args, syntax => +{ + syntax.HandleResponseFiles = false; + + // ... +}); +``` diff --git a/src/System.CommandLine/System.CommandLine.sln b/src/System.CommandLine/System.CommandLine.sln new file mode 100644 index 00000000000..70731ed892c --- /dev/null +++ b/src/System.CommandLine/System.CommandLine.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 14 +VisualStudioVersion = 14.0.23107.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "System.CommandLine", "src\System.CommandLine.csproj", "{0A365F6D-AF33-4DD1-ABF3-BE45A5F8E642}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "System.CommandLine.Tests", "tests\System.CommandLine.Tests.csproj", "{F48BE89B-4F3E-4AB3-B10A-D6AE47869190}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {0A365F6D-AF33-4DD1-ABF3-BE45A5F8E642}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0A365F6D-AF33-4DD1-ABF3-BE45A5F8E642}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0A365F6D-AF33-4DD1-ABF3-BE45A5F8E642}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0A365F6D-AF33-4DD1-ABF3-BE45A5F8E642}.Release|Any CPU.Build.0 = Release|Any CPU + {F48BE89B-4F3E-4AB3-B10A-D6AE47869190}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F48BE89B-4F3E-4AB3-B10A-D6AE47869190}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F48BE89B-4F3E-4AB3-B10A-D6AE47869190}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F48BE89B-4F3E-4AB3-B10A-D6AE47869190}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/src/System.CommandLine/src/System.CommandLine.csproj b/src/System.CommandLine/src/System.CommandLine.csproj new file mode 100644 index 00000000000..4c0177de343 --- /dev/null +++ b/src/System.CommandLine/src/System.CommandLine.csproj @@ -0,0 +1,48 @@ + + + + + Debug + AnyCPU + Library + + + System.CommandLine + 4.0.0.0 + {0A365F6D-AF33-4DD1-ABF3-BE45A5F8E642} + + + + + + + + + + + + + + + + + + + + + + + + True + True + Strings.resx + + + + + ResXFileCodeGenerator + Strings.Designer.cs + + + + \ No newline at end of file diff --git a/src/System.CommandLine/src/System/CommandLine/Argument.cs b/src/System.CommandLine/src/System/CommandLine/Argument.cs new file mode 100644 index 00000000000..2dc9f1138f4 --- /dev/null +++ b/src/System.CommandLine/src/System/CommandLine/Argument.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace System.CommandLine +{ + public abstract class Argument + { + internal Argument(ArgumentCommand command, IEnumerable names, bool isOption) + { + var nameArray = names.ToArray(); + Command = command; + Name = nameArray.First(); + Names = new ReadOnlyCollection(nameArray); + IsOption = isOption; + } + + public ArgumentCommand Command { get; private set; } + + public string Name { get; private set; } + + public ReadOnlyCollection Names { get; private set; } + + public string Help { get; set; } + + public bool IsOption { get; private set; } + + public bool IsParameter + { + get { return !IsOption; } + } + + public bool IsSpecified { get; private set; } + + public bool IsHidden { get; set; } + + public virtual bool IsList + { + get { return false; } + } + + public object Value + { + get { return GetValue(); } + } + + public object DefaultValue + { + get { return GetDefaultValue(); } + } + + public bool IsActive + { + get { return Command == null || Command.IsActive; } + } + + public abstract bool IsFlag { get; } + + internal abstract object GetValue(); + + internal abstract object GetDefaultValue(); + + internal void MarkSpecified() + { + IsSpecified = true; + } + + public string GetDisplayName() + { + return GetDisplayName(Name); + } + + public IEnumerable GetDisplayNames() + { + return Names.Select(GetDisplayName); + } + + private string GetDisplayName(string name) + { + return IsOption ? GetOptionDisplayName(name) : GetParameterDisplayName(name); + } + + private static string GetOptionDisplayName(string name) + { + var modifier = name.Length == 1 ? @"-" : @"--"; + return modifier + name; + } + + private static string GetParameterDisplayName(string name) + { + return @"<" + name + @">"; + } + + public virtual string GetDisplayValue() + { + return Value == null ? string.Empty : Value.ToString(); + } + + public override string ToString() + { + return GetDisplayName(); + } + } +} \ No newline at end of file diff --git a/src/System.CommandLine/src/System/CommandLine/ArgumentCommand.cs b/src/System.CommandLine/src/System/CommandLine/ArgumentCommand.cs new file mode 100644 index 00000000000..5ce0d64b32b --- /dev/null +++ b/src/System.CommandLine/src/System/CommandLine/ArgumentCommand.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; + +namespace System.CommandLine +{ + public abstract class ArgumentCommand + { + internal ArgumentCommand(string name) + { + Name = name; + } + + public string Name { get; private set; } + + public string Help { get; set; } + + public object Value + { + get { return GetValue(); } + } + + public bool IsHidden { get; set; } + + public bool IsActive { get; private set; } + + internal abstract object GetValue(); + + internal void MarkActive() + { + IsActive = true; + } + + public override string ToString() + { + return Name; + } + } +} \ No newline at end of file diff --git a/src/System.CommandLine/src/System/CommandLine/ArgumentCommand`1.cs b/src/System.CommandLine/src/System/CommandLine/ArgumentCommand`1.cs new file mode 100644 index 00000000000..5f67332ed34 --- /dev/null +++ b/src/System.CommandLine/src/System/CommandLine/ArgumentCommand`1.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; + +namespace System.CommandLine +{ + public sealed class ArgumentCommand : ArgumentCommand + { + internal ArgumentCommand(string name, T value) + : base(name) + { + Value = value; + } + + public new T Value { get; private set; } + + internal override object GetValue() + { + return Value; + } + } +} \ No newline at end of file diff --git a/src/System.CommandLine/src/System/CommandLine/ArgumentLexer.cs b/src/System.CommandLine/src/System/CommandLine/ArgumentLexer.cs new file mode 100644 index 00000000000..62ab1f1d979 --- /dev/null +++ b/src/System.CommandLine/src/System/CommandLine/ArgumentLexer.cs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; + +namespace System.CommandLine +{ + internal static class ArgumentLexer + { + public static IReadOnlyList Lex(IEnumerable args, Func> responseFileReader = null) + { + var result = new List(); + + // We'll split the arguments into tokens. + // + // A token combines the modifier (/, -, --), the option name, and the option + // value. + // + // Please note that this code doesn't combine arguments. It only provides + // some pre-processing over the arguments to split out the modifier, + // option, and value: + // + // { "--out", "out.exe" } ==> { new ArgumentToken("--", "out", null), + // new ArgumentToken(null, null, "out.exe") } + // + // {"--out:out.exe" } ==> { new ArgumentToken("--", "out", "out.exe") } + // + // The reason it doesn't combine arguments is because it depends on the actual + // definition. For example, if --out is a flag (meaning it's of type bool) then + // out.exe in the first example wouldn't be considered its value. + // + // The code also handles the special -- token which indicates that the following + // arguments shouldn't be considered options. + // + // Finally, this code will also expand any reponse file entries, assuming the caller + // gave us a non-null reader. + + var hasSeenDashDash = false; + + foreach (var arg in ExpandReponseFiles(args, responseFileReader)) + { + // If we've seen a -- already, then we'll treat one as a plain name, that is + // without a modifier or value. + + if (!hasSeenDashDash && arg == @"--") + { + hasSeenDashDash = true; + continue; + } + + string modifier; + string name; + string value; + + if (hasSeenDashDash) + { + modifier = null; + name = arg; + value = null; + } + else + { + // If we haven't seen the -- separator, we're looking for options. + // Options have leading modifiers, i.e. /, -, or --. + // + // Options can also have values, such as: + // + // -f:false + // --name=hello + + string nameAndValue; + + if (!TryExtractOption(arg, out modifier, out nameAndValue)) + { + name = arg; + value = null; + } + else if (!TrySplitNameValue(nameAndValue, out name, out value)) + { + name = nameAndValue; + value = null; + } + } + + var token = new ArgumentToken(modifier, name, value); + result.Add(token); + } + + // Single letter options can be combined, for example the following two + // forms are considered equivalent: + // + // (1) -xdf + // (2) -x -d -f + // + // In order to free later phases from handling this case, we simply expand + // single letter options to the second form. + + for (var i = result.Count - 1; i >= 0; i--) + { + if (IsOptionBundle(result[i])) + ExpandOptionBundle(result, i); + } + + return result.ToArray(); + } + + private static IEnumerable ExpandReponseFiles(IEnumerable args, Func> responseFileReader) + { + foreach (var arg in args) + { + if (responseFileReader == null || !arg.StartsWith(@"@")) + { + yield return arg; + } + else + { + var fileName = arg.Substring(1); + + var responseFileArguments = responseFileReader(fileName); + + // The reader can suppress expanding this response file by + // returning null. In that case, we'll treat the response + // file token as a regular argument. + + if (responseFileArguments == null) + { + yield return arg; + } + else + { + foreach (var responseFileArgument in responseFileArguments) + yield return responseFileArgument.Trim(); + } + } + } + } + + private static bool IsOptionBundle(ArgumentToken token) + { + return token.IsOption && + token.Modifier == @"-" && + token.Name.Length > 1; + } + + private static void ExpandOptionBundle(IList receiver, int index) + { + var options = receiver[index].Name; + receiver.RemoveAt(index); + + foreach (var c in options) + { + var name = char.ToString(c); + var expandedToken = new ArgumentToken(@"-", name, null); + receiver.Insert(index, expandedToken); + index++; + } + } + + private static bool TryExtractOption(string text, out string modifier, out string remainder) + { + return TryExtractOption(text, @"--", out modifier, out remainder) || + TryExtractOption(text, @"-", out modifier, out remainder) || + TryExtractOption(text, @"/", out modifier, out remainder); + } + + private static bool TryExtractOption(string text, string prefix, out string modifier, out string remainder) + { + if (text.StartsWith(prefix)) + { + remainder = text.Substring(prefix.Length); + modifier = prefix; + return true; + } + + remainder = null; + modifier = null; + return false; + } + + private static bool TrySplitNameValue(string text, out string name, out string value) + { + return TrySplitNameValue(text, ':', out name, out value) || + TrySplitNameValue(text, '=', out name, out value); + } + + private static bool TrySplitNameValue(string text, char separator, out string name, out string value) + { + var i = text.IndexOf(separator); + if (i >= 0) + { + name = text.Substring(0, i); + value = text.Substring(i + 1); + return true; + } + + name = null; + value = null; + return false; + } + } +} \ No newline at end of file diff --git a/src/System.CommandLine/src/System/CommandLine/ArgumentList`1.cs b/src/System.CommandLine/src/System/CommandLine/ArgumentList`1.cs new file mode 100644 index 00000000000..efafec581d8 --- /dev/null +++ b/src/System.CommandLine/src/System/CommandLine/ArgumentList`1.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; + +namespace System.CommandLine +{ + public sealed class ArgumentList : Argument + { + internal ArgumentList(ArgumentCommand command, IEnumerable names, IReadOnlyList defaultValue) + : base(command, names, true) + { + Value = defaultValue; + DefaultValue = defaultValue; + } + + internal ArgumentList(ArgumentCommand command, string name, IReadOnlyList defaultValue) + : base(command, new[] { name }, false) + { + Value = defaultValue; + DefaultValue = defaultValue; + } + + public override bool IsList + { + get { return true; } + } + + public new IReadOnlyList Value { get; private set; } + + public new IReadOnlyList DefaultValue { get; private set; } + + public override bool IsFlag + { + get { return typeof(T) == typeof(bool); } + } + + internal override object GetValue() + { + return Value; + } + + internal override object GetDefaultValue() + { + return DefaultValue; + } + + internal void SetValue(IReadOnlyList value) + { + Value = value; + MarkSpecified(); + } + + public override string GetDisplayValue() + { + return string.Join(@", ", Value); + } + } +} \ No newline at end of file diff --git a/src/System.CommandLine/src/System/CommandLine/ArgumentParser.cs b/src/System.CommandLine/src/System/CommandLine/ArgumentParser.cs new file mode 100644 index 00000000000..314f6add759 --- /dev/null +++ b/src/System.CommandLine/src/System/CommandLine/ArgumentParser.cs @@ -0,0 +1,242 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace System.CommandLine +{ + internal sealed class ArgumentParser + { + private readonly IReadOnlyList _tokens; + + public ArgumentParser(IEnumerable arguments) + : this(arguments, null) + { + } + + public ArgumentParser(IEnumerable arguments, Func> responseFileReader) + { + if (arguments == null) + throw new ArgumentNullException("arguments"); + + _tokens = ArgumentLexer.Lex(arguments, responseFileReader); + } + + public bool TryParseCommand(string name) + { + var token = _tokens.FirstOrDefault(); + if (token == null || token.IsOption || token.IsSeparator) + return false; + + if (!string.Equals(token.Name, name, StringComparison.Ordinal)) + return false; + + token.MarkMatched(); + return true; + } + + public bool TryParseOption(string diagnosticName, IReadOnlyCollection names, Func valueConverter, out T value) + { + IReadOnlyList values; + if (!TryParseOptionList(diagnosticName, names, valueConverter, out values)) + { + value = default(T); + return false; + } + + if (values.Count > 1) + { + var message = string.Format(Strings.OptionSpecifiedMultipleTimesFmt, diagnosticName); + throw new ArgumentSyntaxException(message); + } + + value = values.Single(); + return true; + } + + public bool TryParseOptionList(string diagnosticName, IReadOnlyCollection names, Func valueConverter, out IReadOnlyList values) + { + var result = new List(); + var tokenIndex = 0; + var isBoolean = typeof(T) == typeof(bool); + + while (tokenIndex < _tokens.Count) + { + if (TryParseOption(ref tokenIndex, names)) + { + string valueText; + if (TryParseOptionArgument(ref tokenIndex, isBoolean, out valueText)) + { + var value = ParseValue(diagnosticName, valueConverter, valueText); + result.Add(value); + } + else if (isBoolean) + { + var value = (T)(object)true; + result.Add(value); + } + else + { + var message = string.Format(Strings.OptionRequiresValueFmt, diagnosticName); + throw new ArgumentSyntaxException(message); + } + } + + tokenIndex++; + } + + if (!result.Any()) + { + values = null; + return false; + } + + values = result.ToArray(); + return true; + } + + public bool TryParseParameter(string diagnosticName, Func valueConverter, out T value) + { + foreach (var token in _tokens) + { + if (token.IsMatched || token.IsOption || token.IsSeparator) + continue; + + token.MarkMatched(); + + var valueText = token.Name; + value = ParseValue(diagnosticName, valueConverter, valueText); + return true; + } + + value = default(T); + return false; + } + + public bool TryParseParameterList(string diagnosticName, Func valueConverter, out IReadOnlyList values) + { + var result = new List(); + + T value; + while (TryParseParameter(diagnosticName, valueConverter, out value)) + { + result.Add(value); + } + + if (!result.Any()) + { + values = null; + return false; + } + + values = result.ToArray(); + return true; + } + + private bool TryParseOption(ref int tokenIndex, IReadOnlyCollection names) + { + while (tokenIndex < _tokens.Count) + { + var a = _tokens[tokenIndex]; + + if (a.IsOption) + { + if (names.Any(n => string.Equals(a.Name, n, StringComparison.Ordinal))) + { + a.MarkMatched(); + return true; + } + } + + tokenIndex++; + } + + return false; + } + + private bool TryParseOptionArgument(ref int tokenIndex, bool requiresSeparator, out string argument) + { + var a = _tokens[tokenIndex]; + if (a.HasValue) + { + a.MarkMatched(); + argument = a.Value; + return true; + } + + tokenIndex++; + if (tokenIndex < _tokens.Count) + { + var b = _tokens[tokenIndex]; + if (!b.IsOption) + { + // This might an argument or a separator + if (!b.IsSeparator) + { + // If we require a separator we can't consume this. + if (requiresSeparator) + goto noResult; + + b.MarkMatched(); + argument = b.Name; + return true; + } + + // Skip separator + b.MarkMatched(); + tokenIndex++; + + if (tokenIndex < _tokens.Count) + { + var c = _tokens[tokenIndex]; + if (!c.IsOption) + { + c.MarkMatched(); + argument = c.Name; + return true; + } + } + } + } + + noResult: + argument = null; + return false; + } + + private static T ParseValue(string diagnosticName, Func valueConverter, string valueText) + { + try + { + return valueConverter(valueText); + } + catch (FormatException ex) + { + var message = string.Format(Strings.CannotParseValueFmt, valueText, diagnosticName, ex.Message); + throw new ArgumentSyntaxException(message); + } + } + + public string GetUnreadCommand() + { + return _tokens.Where(t => !t.IsOption && !t.IsSeparator).Select(t => t.Name).FirstOrDefault(); + } + + public IReadOnlyList GetUnreadOptionNames() + { + return _tokens.Where(t => !t.IsMatched && t.IsOption).Select(t => t.Modifier + t.Name).ToArray(); + } + + public IReadOnlyList GetUnreadParameters() + { + return _tokens.Where(t => !t.IsMatched && !t.IsOption).Select(t => t.ToString()).ToArray(); + } + + public IReadOnlyList GetUnreadArguments() + { + return _tokens.Where(t => !t.IsMatched).Select(t => t.ToString()).ToArray(); + } + } +} \ No newline at end of file diff --git a/src/System.CommandLine/src/System/CommandLine/ArgumentSyntax.cs b/src/System.CommandLine/src/System/CommandLine/ArgumentSyntax.cs new file mode 100644 index 00000000000..5854ada9c96 --- /dev/null +++ b/src/System.CommandLine/src/System/CommandLine/ArgumentSyntax.cs @@ -0,0 +1,442 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace System.CommandLine +{ + public sealed partial class ArgumentSyntax + { + private readonly IEnumerable _arguments; + private readonly List _commands = new List(); + private readonly List _options = new List(); + private readonly List _parameters = new List(); + + private ArgumentParser _parser; + private ArgumentCommand _definedCommand; + private ArgumentCommand _activeCommand; + + internal ArgumentSyntax(IEnumerable arguments) + { + _arguments = arguments; + + ApplicationName = GetApplicationName(); + HandleErrors = true; + HandleHelp = true; + HandleResponseFiles = true; + } + + public static ArgumentSyntax Parse(IEnumerable arguments, Action defineAction) + { + if (arguments == null) + throw new ArgumentNullException("arguments"); + + if (defineAction == null) + throw new ArgumentNullException("defineAction"); + + var syntax = new ArgumentSyntax(arguments); + defineAction(syntax); + syntax.Validate(); + return syntax; + } + + private void Validate() + { + // Check whether help is requested + + if (HandleHelp && IsHelpRequested()) + { + var helpText = GetHelpText(); + Console.Error.Write(helpText); + + // TODO: This should use Environment.Exit(0) but this API isn't available yet. +#if NET_FX + Environment.Exit(0); +#else + Environment.FailFast(string.Empty); +#endif + } + + // Check for invalid or missing command + + if (_activeCommand == null && _commands.Any()) + { + var unreadCommand = Parser.GetUnreadCommand(); + var message = unreadCommand == null + ? Strings.MissingCommand + : string.Format(Strings.UnknownCommandFmt, unreadCommand); + ReportError(message); + } + + // Check for invalid options and extra parameters + + foreach (var option in Parser.GetUnreadOptionNames()) + { + var message = string.Format(Strings.InvalidOptionFmt, option); + ReportError(message); + } + + foreach (var parameter in Parser.GetUnreadParameters()) + { + var message = string.Format(Strings.ExtraParameterFmt, parameter); + ReportError(message); + } + } + + private bool IsHelpRequested() + { + return Parser.GetUnreadOptionNames() + .Any(a => string.Equals(a, @"-?", StringComparison.Ordinal) || + string.Equals(a, @"-h", StringComparison.Ordinal) || + string.Equals(a, @"--help", StringComparison.Ordinal)); + } + + public void ReportError(string message) + { + if (HandleErrors) + { + Console.Error.WriteLine(Strings.ErrorWithMessageFmt, message); + + // TODO: This should use Environment.Exit(1) but this API isn't available yet. +#if NET_FX + Environment.Exit(1); +#else + Environment.FailFast(string.Empty); +#endif + } + + throw new ArgumentSyntaxException(message); + } + + public ArgumentCommand DefineCommand(string name, T value) + { + if (string.IsNullOrEmpty(name)) + throw new ArgumentException(Strings.NameMissing, "name"); + + if (!IsValidName(name)) + { + var message = string.Format(Strings.CommandNameIsNotValidFmt, name); + throw new ArgumentException(message, "name"); + } + + if (_commands.Any(c => string.Equals(c.Name, name, StringComparison.Ordinal))) + { + var message = string.Format(Strings.CommandAlreadyDefinedFmt, name); + throw new InvalidOperationException(message); + } + + if (_parameters.Any(c => c.Command == null)) + throw new InvalidOperationException(Strings.CannotDefineCommandsIfGlobalParametersExist); + + var definedCommand = new ArgumentCommand(name, value); + _commands.Add(definedCommand); + _definedCommand = definedCommand; + + if (_activeCommand != null) + return definedCommand; + + if (!Parser.TryParseCommand(name)) + return definedCommand; + + _activeCommand = _definedCommand; + _activeCommand.MarkActive(); + + return definedCommand; + } + + public Argument DefineOption(string name, T defaultValue, Func valueConverter) + { + if (string.IsNullOrEmpty(name)) + throw new ArgumentException(Strings.NameMissing, "name"); + + if (DefinedParameters.Any()) + throw new InvalidOperationException(Strings.OptionsMustBeDefinedBeforeParameters); + + var names = ParseOptionNameList(name); + var option = new Argument(_definedCommand, names, defaultValue); + _options.Add(option); + + if (_activeCommand != _definedCommand) + return option; + + try + { + T value; + if (Parser.TryParseOption(option.GetDisplayName(), option.Names, valueConverter, out value)) + option.SetValue(value); + } + catch (ArgumentSyntaxException ex) + { + ReportError(ex.Message); + } + + return option; + } + + public ArgumentList DefineOptionList(string name, IReadOnlyList defaultValue, Func valueConverter) + { + if (string.IsNullOrEmpty(name)) + throw new ArgumentException(Strings.NameMissing, "name"); + + if (DefinedParameters.Any()) + throw new InvalidOperationException(Strings.OptionsMustBeDefinedBeforeParameters); + + var names = ParseOptionNameList(name); + var optionList = new ArgumentList(_definedCommand, names, defaultValue); + _options.Add(optionList); + + if (_activeCommand != _definedCommand) + return optionList; + + try + { + IReadOnlyList value; + if (Parser.TryParseOptionList(optionList.GetDisplayName(), optionList.Names, valueConverter, out value)) + optionList.SetValue(value); + } + catch (ArgumentSyntaxException ex) + { + ReportError(ex.Message); + } + + return optionList; + } + + public Argument DefineParameter(string name, T defaultValue, Func valueConverter) + { + if (string.IsNullOrEmpty(name)) + throw new ArgumentException(Strings.NameMissing, "name"); + + if (!IsValidName(name)) + { + var message = string.Format(Strings.ParameterNameIsNotValidFmt, name); + throw new ArgumentException(message, "name"); + } + + if (DefinedParameters.Any(p => p.IsList)) + throw new InvalidOperationException(Strings.ParametersCannotBeDefinedAfterLists); + + if (DefinedParameters.Any(p => string.Equals(name, p.Name, StringComparison.OrdinalIgnoreCase))) + { + var message = string.Format(Strings.ParameterAlreadyDefinedFmt, name); + throw new InvalidOperationException(message); + } + + var parameter = new Argument(_definedCommand, name, defaultValue); + _parameters.Add(parameter); + + if (_activeCommand != _definedCommand) + return parameter; + + try + { + T value; + if (Parser.TryParseParameter(parameter.GetDisplayName(), valueConverter, out value)) + parameter.SetValue(value); + } + catch (ArgumentSyntaxException ex) + { + ReportError(ex.Message); + } + + return parameter; + } + + public ArgumentList DefineParameterList(string name, IReadOnlyList defaultValue, Func valueConverter) + { + if (string.IsNullOrEmpty(name)) + throw new ArgumentException(Strings.NameMissing, "name"); + + if (!IsValidName(name)) + { + var message = string.Format(Strings.ParameterNameIsNotValidFmt, name); + throw new ArgumentException(message, "name"); + } + + if (DefinedParameters.Any(p => p.IsList)) + throw new InvalidOperationException(Strings.CannotDefineMultipleParameterLists); + + var parameterList = new ArgumentList(_definedCommand, name, defaultValue); + _parameters.Add(parameterList); + + if (_activeCommand != _definedCommand) + return parameterList; + + try + { + IReadOnlyList values; + if (Parser.TryParseParameterList(parameterList.GetDisplayName(), valueConverter, out values)) + parameterList.SetValue(values); + } + catch (ArgumentSyntaxException ex) + { + ReportError(ex.Message); + } + + return parameterList; + } + + private static bool IsValidName(string name) + { + if (string.IsNullOrEmpty(name)) + return false; + + if (name[0] == '-') + return false; + + return name.All(c => char.IsLetterOrDigit(c) || + c == '-' || + c == '_'); + } + + private IEnumerable ParseOptionNameList(string name) + { + var names = name.Split('|').Select(n => n.Trim()).ToArray(); + + foreach (var alias in names) + { + if (!IsValidName(alias)) + { + var message = string.Format(Strings.OptionNameIsNotValidFmt, alias); + throw new ArgumentException(message, "name"); + } + + foreach (var option in DefinedOptions) + { + if (option.Names.Any(n => string.Equals(n, alias, StringComparison.Ordinal))) + { + var message = string.Format(Strings.OptionAlreadyDefinedFmt, alias); + throw new InvalidOperationException(message); + } + } + } + + return names; + } + + private IEnumerable ParseResponseFile(string fileName) + { + if (!HandleResponseFiles) + return null; + + if (!File.Exists(fileName)) + { + var message = string.Format(Strings.ResponseFileDoesNotExistFmt, fileName); + ReportError(message); + } + + return File.ReadLines(fileName); + } + + private static string GetApplicationName() + { + // TODO: This should use Environment.GetCommandLineArgs() but this API isn't available yet. +#if NET_FX + var processPath = Environment.GetCommandLineArgs()[0]; + var processName = Path.GetFileNameWithoutExtension(processPath); + return processName; +#else + return string.Empty; +#endif + } + + public string ApplicationName { get; set; } + + public bool HandleErrors { get; set; } + + public bool HandleHelp { get; set; } + + public bool HandleResponseFiles { get; set; } + + private ArgumentParser Parser + { + get + { + if (_parser == null) + _parser = new ArgumentParser(_arguments, ParseResponseFile); + + return _parser; + } + } + + private IEnumerable DefinedOptions + { + get { return _options.Where(o => o.Command == null || o.Command == _definedCommand); } + } + + private IEnumerable DefinedParameters + { + get { return _parameters.Where(p => p.Command == null || p.Command == _definedCommand); } + } + + public ArgumentCommand ActiveCommand + { + get { return _activeCommand; } + } + + public IReadOnlyList Commands + { + get { return _commands; } + } + + public IEnumerable GetArguments() + { + return _options.Concat(_parameters); + } + + public IEnumerable GetArguments(ArgumentCommand command) + { + return GetArguments().Where(c => c.Command == null || c.Command == command); + } + + public IEnumerable GetOptions() + { + return _options; + } + + public IEnumerable GetOptions(ArgumentCommand command) + { + return _options.Where(c => c.Command == null || c.Command == command); + } + + public IEnumerable GetParameters() + { + return _parameters; + } + + public IEnumerable GetParameters(ArgumentCommand command) + { + return _parameters.Where(c => c.Command == null || c.Command == command); + } + + public IEnumerable GetActiveArguments() + { + return GetArguments(ActiveCommand); + } + + public IEnumerable GetActiveOptions() + { + return GetOptions(ActiveCommand); + } + + public IEnumerable GetActiveParameters() + { + return GetParameters(ActiveCommand); + } + + public string GetHelpText() + { + // TODO: This should use Console.WindowWidth but this API isn't available yet. + // return GetHelpText(Console.WindowWidth - 2); + return GetHelpText(72); + } + + public string GetHelpText(int maxWidth) + { + return HelpTextGenerator.Generate(this, maxWidth); + } + } +} \ No newline at end of file diff --git a/src/System.CommandLine/src/System/CommandLine/ArgumentSyntaxException.cs b/src/System.CommandLine/src/System/CommandLine/ArgumentSyntaxException.cs new file mode 100644 index 00000000000..3ccb51c949c --- /dev/null +++ b/src/System.CommandLine/src/System/CommandLine/ArgumentSyntaxException.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; + +namespace System.CommandLine +{ + public sealed class ArgumentSyntaxException : Exception + { + public ArgumentSyntaxException() + { + } + + public ArgumentSyntaxException(string message) + : base(message) + { + } + + public ArgumentSyntaxException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} \ No newline at end of file diff --git a/src/System.CommandLine/src/System/CommandLine/ArgumentSyntax_Definers.cs b/src/System.CommandLine/src/System/CommandLine/ArgumentSyntax_Definers.cs new file mode 100644 index 00000000000..745e4d05e5f --- /dev/null +++ b/src/System.CommandLine/src/System/CommandLine/ArgumentSyntax_Definers.cs @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace System.CommandLine +{ + public partial class ArgumentSyntax + { + private static readonly Func s_stringParser = v => v; + private static readonly Func s_booleanParser = v => bool.Parse(v); + private static readonly Func s_int32Parser = v => int.Parse(v, CultureInfo.InvariantCulture); + + // Commands + + public ArgumentCommand DefineCommand(string name) + { + return DefineCommand(name, name); + } + + public ArgumentCommand DefineCommand(string name, ref T command, T value, string help) + { + var result = DefineCommand(name, value); + result.Help = help; + + if (_activeCommand == result) + command = value; + + return result; + } + + public ArgumentCommand DefineCommand(string name, ref string value, string help) + { + return DefineCommand(name, ref value, name, help); + } + + // Options + + public Argument DefineOption(string name, string defaultValue) + { + return DefineOption(name, defaultValue, s_stringParser); + } + + public Argument DefineOption(string name, bool defaultValue) + { + return DefineOption(name, defaultValue, s_booleanParser); + } + + public Argument DefineOption(string name, int defaultValue) + { + return DefineOption(name, defaultValue, s_int32Parser); + } + + public Argument DefineOption(string name, ref T value, Func valueConverter, string help) + { + var option = DefineOption(name, value, valueConverter); + option.Help = help; + + value = option.Value; + return option; + } + + public Argument DefineOption(string name, ref string value, string help) + { + return DefineOption(name, ref value, s_stringParser, help); + } + + public Argument DefineOption(string name, ref bool value, string help) + { + return DefineOption(name, ref value, s_booleanParser, help); + } + + public Argument DefineOption(string name, ref int value, string help) + { + return DefineOption(name, ref value, s_int32Parser, help); + } + + // Option lists + + public ArgumentList DefineOptionList(string name, IReadOnlyList defaultValue) + { + return DefineOptionList(name, defaultValue, s_stringParser); + } + + public ArgumentList DefineOptionList(string name, IReadOnlyList defaultValue) + { + return DefineOptionList(name, defaultValue, s_int32Parser); + } + + public ArgumentList DefineOptionList(string name, ref IReadOnlyList value, Func valueConverter, string help) + { + var optionList = DefineOptionList(name, value, valueConverter); + optionList.Help = help; + + value = optionList.Value; + return optionList; + } + + public ArgumentList DefineOptionList(string name, ref IReadOnlyList value, string help) + { + return DefineOptionList(name, ref value, s_stringParser, help); + } + + public ArgumentList DefineOptionList(string name, ref IReadOnlyList value, string help) + { + return DefineOptionList(name, ref value, s_int32Parser, help); + } + + // Parameters + + public Argument DefineParameter(string name, string defaultValue) + { + return DefineParameter(name, defaultValue, s_stringParser); + } + + public Argument DefineParameter(string name, bool defaultValue) + { + return DefineParameter(name, defaultValue, s_booleanParser); + } + + public Argument DefineParameter(string name, int defaultValue) + { + return DefineParameter(name, defaultValue, s_int32Parser); + } + + public Argument DefineParameter(string name, ref T value, Func valueConverter, string help) + { + var parameter = DefineParameter(name, value, valueConverter); + parameter.Help = help; + + value = parameter.Value; + return parameter; + } + + public Argument DefineParameter(string name, ref string value, string help) + { + return DefineParameter(name, ref value, s_stringParser, help); + } + + public Argument DefineParameter(string name, ref bool value, string help) + { + return DefineParameter(name, ref value, s_booleanParser, help); + } + + public Argument DefineParameter(string name, ref int value, string help) + { + return DefineParameter(name, ref value, s_int32Parser, help); + } + + // Parameter list + + public ArgumentList DefineParameterList(string name, IReadOnlyList defaultValue) + { + return DefineParameterList(name, defaultValue, s_stringParser); + } + + public ArgumentList DefineParameterList(string name, IReadOnlyList defaultValue) + { + return DefineParameterList(name, defaultValue, s_int32Parser); + } + + public ArgumentList DefineParameterList(string name, ref IReadOnlyList value, Func valueConverter, string help) + { + var parameterList = DefineParameterList(name, value, valueConverter); + parameterList.Help = help; + + value = parameterList.Value; + return parameterList; + } + + public ArgumentList DefineParameterList(string name, ref IReadOnlyList value, string help) + { + return DefineParameterList(name, ref value, s_stringParser, help); + } + + public ArgumentList DefineParameterList(string name, ref IReadOnlyList value, string help) + { + return DefineParameterList(name, ref value, s_int32Parser, help); + } + } +} \ No newline at end of file diff --git a/src/System.CommandLine/src/System/CommandLine/ArgumentToken.cs b/src/System.CommandLine/src/System/CommandLine/ArgumentToken.cs new file mode 100644 index 00000000000..191bdd84fc9 --- /dev/null +++ b/src/System.CommandLine/src/System/CommandLine/ArgumentToken.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; + +namespace System.CommandLine +{ + internal sealed class ArgumentToken + { + internal ArgumentToken(string modifier, string name, string value) + { + Modifier = modifier; + Name = name; + Value = value; + } + + public string Modifier { get; private set; } + + public string Name { get; private set; } + + public string Value { get; private set; } + + public bool IsOption + { + get { return !string.IsNullOrEmpty(Modifier); } + } + + public bool IsSeparator + { + get { return Name == @":" || Name == @"="; } + } + + public bool HasValue + { + get { return !string.IsNullOrEmpty(Value); } + } + + public bool IsMatched { get; private set; } + + public void MarkMatched() + { + IsMatched = true; + } + + private bool Equals(ArgumentToken other) + { + return string.Equals(Modifier, other.Modifier) && + string.Equals(Name, other.Name) && + string.Equals(Value, other.Value); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(obj, null)) + return false; + + if (ReferenceEquals(obj, this)) + return true; + + var other = obj as ArgumentToken; + return !ReferenceEquals(other, null) && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = (Modifier != null ? Modifier.GetHashCode() : 0); + hashCode = (hashCode * 397) ^ (Name != null ? Name.GetHashCode() : 0); + hashCode = (hashCode * 397) ^ (Value != null ? Value.GetHashCode() : 0); + return hashCode; + } + } + + public override string ToString() + { + return HasValue + ? string.Format(@"{0}{1}:{2}", Modifier, Name, Value) + : string.Format(@"{0}{1}", Modifier, Name); + } + } +} \ No newline at end of file diff --git a/src/System.CommandLine/src/System/CommandLine/Argument`1.cs b/src/System.CommandLine/src/System/CommandLine/Argument`1.cs new file mode 100644 index 00000000000..96f1d682790 --- /dev/null +++ b/src/System.CommandLine/src/System/CommandLine/Argument`1.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; + +namespace System.CommandLine +{ + public sealed class Argument : Argument + { + internal Argument(ArgumentCommand command, IEnumerable names, T defaultValue) + : base(command, names, true) + { + Value = defaultValue; + Value = DefaultValue = defaultValue; + } + + internal Argument(ArgumentCommand command, string name, T defaultValue) + : base(command, new[] { name }, false) + { + Value = defaultValue; + DefaultValue = defaultValue; + } + + public new T Value { get; private set; } + + public new T DefaultValue { get; private set; } + + public override bool IsFlag + { + get { return typeof(T) == typeof(bool); } + } + + internal override object GetValue() + { + return Value; + } + + internal override object GetDefaultValue() + { + return DefaultValue; + } + + internal void SetValue(T value) + { + Value = value; + MarkSpecified(); + } + } +} \ No newline at end of file diff --git a/src/System.CommandLine/src/System/CommandLine/HelpTextGenerator.cs b/src/System.CommandLine/src/System/CommandLine/HelpTextGenerator.cs new file mode 100644 index 00000000000..c0600fe53ad --- /dev/null +++ b/src/System.CommandLine/src/System/CommandLine/HelpTextGenerator.cs @@ -0,0 +1,261 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace System.CommandLine +{ + internal static class HelpTextGenerator + { + public static string Generate(ArgumentSyntax argumentSyntax, int maxWidth) + { + var forCommandList = argumentSyntax.ActiveCommand == null && + argumentSyntax.Commands.Any(); + + var page = forCommandList + ? GetCommandListHelp(argumentSyntax) + : GetCommandHelp(argumentSyntax, argumentSyntax.ActiveCommand); + + var sb = new StringBuilder(); + sb.WriteHelpPage(page, maxWidth); + return sb.ToString(); + } + + private struct HelpPage + { + public string ApplicationName; + public IEnumerable SyntaxElements; + public IReadOnlyList Rows; + } + + private struct HelpRow + { + public string Header; + public string Text; + } + + private static void WriteHelpPage(this StringBuilder sb, HelpPage page, int maxWidth) + { + sb.WriteUsage(page.ApplicationName, page.SyntaxElements, maxWidth); + + if (!page.Rows.Any()) + return; + + sb.AppendLine(); + + sb.WriteRows(page.Rows, maxWidth); + + sb.AppendLine(); + } + + private static void WriteUsage(this StringBuilder sb, string applicationName, IEnumerable syntaxElements, int maxWidth) + { + var usageHeader = string.Format(Strings.HelpUsageOfApplicationFmt, applicationName); + sb.Append(usageHeader); + + if (syntaxElements.Any()) + sb.Append(@" "); + + var syntaxIndent = usageHeader.Length + 1; + var syntaxMaxWidth = maxWidth - syntaxIndent; + + sb.WriteWordWrapped(syntaxElements, syntaxIndent, syntaxMaxWidth); + } + + private static void WriteRows(this StringBuilder sb, IReadOnlyList rows, int maxWidth) + { + const int indent = 4; + var maxColumnWidth = rows.Select(r => r.Header.Length).Max(); + var helpStartColumn = maxColumnWidth + 2 * indent; + + var maxHelpWidth = maxWidth - helpStartColumn; + if (maxHelpWidth < 0) + maxHelpWidth = maxWidth; + + foreach (var row in rows) + { + var headerStart = sb.Length; + + sb.Append(' ', indent); + sb.Append(row.Header); + + var headerLength = sb.Length - headerStart; + var requiredSpaces = helpStartColumn - headerLength; + + sb.Append(' ', requiredSpaces); + + var words = SplitWords(row.Text); + sb.WriteWordWrapped(words, helpStartColumn, maxHelpWidth); + } + } + + private static void WriteWordWrapped(this StringBuilder sb, IEnumerable words, int indent, int maxidth) + { + var helpLines = WordWrapLines(words, maxidth); + var isFirstHelpLine = true; + + foreach (var helpLine in helpLines) + { + if (isFirstHelpLine) + isFirstHelpLine = false; + else + sb.Append(' ', indent); + + sb.AppendLine(helpLine); + } + + if (isFirstHelpLine) + sb.AppendLine(); + } + + private static HelpPage GetCommandListHelp(ArgumentSyntax argumentSyntax) + { + return new HelpPage + { + ApplicationName = argumentSyntax.ApplicationName, + SyntaxElements = GetGlobalSyntax(), + Rows = GetCommandRows(argumentSyntax).ToArray() + }; + } + + private static HelpPage GetCommandHelp(ArgumentSyntax argumentSyntax, ArgumentCommand command) + { + return new HelpPage + { + ApplicationName = argumentSyntax.ApplicationName, + SyntaxElements = GetCommandSyntax(argumentSyntax, command), + Rows = GetArgumentRows(argumentSyntax, command).ToArray() + }; + } + + private static IEnumerable GetGlobalSyntax() + { + yield return @""; + yield return @"[]"; + } + + private static IEnumerable GetCommandSyntax(ArgumentSyntax argumentSyntax, ArgumentCommand command) + { + if (command != null) + yield return command.Name; + + foreach (var option in argumentSyntax.GetOptions(command).Where(o => !o.IsHidden)) + yield return GetOptionSyntax(option); + + if (argumentSyntax.GetParameters(command).All(p => p.IsHidden)) + yield break; + + if (argumentSyntax.GetOptions(command).Any(o => !o.IsHidden)) + yield return @"[--]"; + + foreach (var parameter in argumentSyntax.GetParameters(command).Where(o => !o.IsHidden)) + yield return GetParameterSyntax(parameter); + } + + private static string GetOptionSyntax(Argument option) + { + var sb = new StringBuilder(); + + sb.Append(@"["); + sb.Append(option.GetDisplayName()); + + if (!option.IsFlag) + sb.Append(@" "); + + if (option.IsList) + sb.Append(@"..."); + + sb.Append(@"]"); + + return sb.ToString(); + } + + private static string GetParameterSyntax(Argument parameter) + { + var sb = new StringBuilder(); + + sb.Append(parameter.GetDisplayName()); + if (parameter.IsList) + sb.Append(@"..."); + + return sb.ToString(); + } + + private static IEnumerable GetCommandRows(ArgumentSyntax argumentSyntax) + { + return argumentSyntax.Commands + .Where(c => !c.IsHidden) + .Select(c => new HelpRow { Header = c.Name, Text = c.Help }); + } + + private static IEnumerable GetArgumentRows(ArgumentSyntax argumentSyntax, ArgumentCommand command) + { + return argumentSyntax.GetArguments(command) + .Where(a => !a.IsHidden) + .Select(a => new HelpRow { Header = GetArgumentRowHeader(a), Text = a.Help }); + } + + private static string GetArgumentRowHeader(Argument argument) + { + var sb = new StringBuilder(); + + foreach (var displayName in argument.GetDisplayNames()) + { + if (sb.Length > 0) + sb.Append(@", "); + + sb.Append(displayName); + } + + if (argument.IsOption && !argument.IsFlag) + sb.Append(@" "); + + if (argument.IsList) + sb.Append(@"..."); + + return sb.ToString(); + } + + private static IEnumerable WordWrapLines(IEnumerable tokens, int maxWidth) + { + var sb = new StringBuilder(); + + foreach (var token in tokens) + { + var newLength = sb.Length == 0 + ? token.Length + : sb.Length + 1 + token.Length; + + if (newLength > maxWidth) + { + if (sb.Length == 0) + { + yield return token; + continue; + } + + yield return sb.ToString(); + sb.Clear(); + } + + if (sb.Length > 0) + sb.Append(@" "); + + sb.Append(token); + } + + if (sb.Length > 0) + yield return sb.ToString(); + } + + private static IEnumerable SplitWords(string text) + { + return string.IsNullOrEmpty(text) + ? Enumerable.Empty() + : text.Split(' '); + } + } +} \ No newline at end of file diff --git a/src/System.CommandLine/src/System/CommandLine/InternalsVisibleTo.cs b/src/System.CommandLine/src/System/CommandLine/InternalsVisibleTo.cs new file mode 100644 index 00000000000..460a0dc3572 --- /dev/null +++ b/src/System.CommandLine/src/System/CommandLine/InternalsVisibleTo.cs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("System.CommandLine.Tests, PublicKey=002400000480000094000000060200000024000052534131000400000100010007d1fa57c4aed9f0a32e84aa0faefd0de9e8fd6aec8f87fb03766c834c99921eb23be79ad9d5dcc1dd9ad236132102900b723cf980957fc4e177108fc607774f29e8320e92ea05ece4e821c0a5efe8f1645c4c0c93c1ab99285d622caa652c1dfad63d745d6f2de5f17e5eaf0fc4963d261c8a12436518206dc093344d5ad293")] \ No newline at end of file diff --git a/src/System.CommandLine/src/System/Strings.Designer.cs b/src/System.CommandLine/src/System/Strings.Designer.cs new file mode 100644 index 00000000000..73681e7b0ed --- /dev/null +++ b/src/System.CommandLine/src/System/Strings.Designer.cs @@ -0,0 +1,262 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace System { + using System; + using System.Reflection; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Strings { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Strings() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("System.Strings", typeof(Strings).GetTypeInfo().Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Cannot define commands if global parameters exist.. + /// + internal static string CannotDefineCommandsIfGlobalParametersExist { + get { + return ResourceManager.GetString("CannotDefineCommandsIfGlobalParametersExist", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cannot define multiple parameter lists.. + /// + internal static string CannotDefineMultipleParameterLists { + get { + return ResourceManager.GetString("CannotDefineMultipleParameterLists", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to value '{0}' isn't valid for {1}: {2}. + /// + internal static string CannotParseValueFmt { + get { + return ResourceManager.GetString("CannotParseValueFmt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Command '{0}' is already defined.. + /// + internal static string CommandAlreadyDefinedFmt { + get { + return ResourceManager.GetString("CommandAlreadyDefinedFmt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Name '{0}' cannot be used for a command.. + /// + internal static string CommandNameIsNotValidFmt { + get { + return ResourceManager.GetString("CommandNameIsNotValidFmt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to error: {0}. + /// + internal static string ErrorWithMessageFmt { + get { + return ResourceManager.GetString("ErrorWithMessageFmt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to extra parameter '{0}'. + /// + internal static string ExtraParameterFmt { + get { + return ResourceManager.GetString("ExtraParameterFmt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to usage: {0}. + /// + internal static string HelpUsageOfApplicationFmt { + get { + return ResourceManager.GetString("HelpUsageOfApplicationFmt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to invalid option {0}{1}. + /// + internal static string InvalidOptionFmt { + get { + return ResourceManager.GetString("InvalidOptionFmt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to missing command. + /// + internal static string MissingCommand { + get { + return ResourceManager.GetString("MissingCommand", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You must specify a name.. + /// + internal static string NameMissing { + get { + return ResourceManager.GetString("NameMissing", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Option '{0}' is already defined.. + /// + internal static string OptionAlreadyDefinedFmt { + get { + return ResourceManager.GetString("OptionAlreadyDefinedFmt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Name '{0}' cannot be used for an option.. + /// + internal static string OptionNameIsNotValidFmt { + get { + return ResourceManager.GetString("OptionNameIsNotValidFmt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to option {0} requires a value. + /// + internal static string OptionRequiresValueFmt { + get { + return ResourceManager.GetString("OptionRequiresValueFmt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Options must be defined before any parameters.. + /// + internal static string OptionsMustBeDefinedBeforeParameters { + get { + return ResourceManager.GetString("OptionsMustBeDefinedBeforeParameters", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to option {0} is specified multiple times. + /// + internal static string OptionSpecifiedMultipleTimesFmt { + get { + return ResourceManager.GetString("OptionSpecifiedMultipleTimesFmt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Parameter '{0}' is already defined.. + /// + internal static string ParameterAlreadyDefinedFmt { + get { + return ResourceManager.GetString("ParameterAlreadyDefinedFmt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Name '{0}' cannot be used for a parameter.. + /// + internal static string ParameterNameIsNotValidFmt { + get { + return ResourceManager.GetString("ParameterNameIsNotValidFmt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Parameters cannot be defined after parameter lists.. + /// + internal static string ParametersCannotBeDefinedAfterLists { + get { + return ResourceManager.GetString("ParametersCannotBeDefinedAfterLists", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Response file '{0}' doesn't exist.. + /// + internal static string ResponseFileDoesNotExistFmt { + get { + return ResourceManager.GetString("ResponseFileDoesNotExistFmt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to unknown command '{0}'. + /// + internal static string UnknownCommandFmt { + get { + return ResourceManager.GetString("UnknownCommandFmt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unmatched quote at position {0}.. + /// + internal static string UnmatchedQuoteFmt { + get { + return ResourceManager.GetString("UnmatchedQuoteFmt", resourceCulture); + } + } + } +} diff --git a/src/System.CommandLine/src/System/Strings.resx b/src/System.CommandLine/src/System/Strings.resx new file mode 100644 index 00000000000..17b78e7c77a --- /dev/null +++ b/src/System.CommandLine/src/System/Strings.resx @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Cannot define commands if global parameters exist. + + + value '{0}' isn't valid for {1}: {2} + + + Command '{0}' is already defined. + + + error: {0} + + + extra parameter '{0}' + + + usage: {0} + + + invalid option {0} + + + missing command + + + You must specify a name. + + + Option '{0}' is already defined. + + + Options must be defined before any parameters. + + + option {0} is specified multiple times + + + Response file '{0}' doesn't exist. + + + unknown command '{0}' + + + Unmatched quote at position {0}. + + + option {0} requires a value + + + Parameter '{0}' is already defined. + + + Cannot define multiple parameter lists. + + + Parameters cannot be defined after parameter lists. + + + Name '{0}' cannot be used for a command. + + + Name '{0}' cannot be used for an option. + + + Name '{0}' cannot be used for a parameter. + + \ No newline at end of file diff --git a/src/System.CommandLine/src/project.json b/src/System.CommandLine/src/project.json new file mode 100644 index 00000000000..b68ebbd6501 --- /dev/null +++ b/src/System.CommandLine/src/project.json @@ -0,0 +1,20 @@ +{ + "dependencies": { + "System.Collections": "4.0.10-*", + "System.Console": "4.0.0-*", + "System.Diagnostics.Debug": "4.0.10-*", + "System.Diagnostics.Tools": "4.0.1-beta-*", + "System.IO": "4.0.10", + "System.IO.FileSystem": "4.0.0", + "System.Linq": "4.0.0-*", + "System.Reflection.Primitives": "4.0.0", + "System.Resources.ResourceManager": "4.0.0-*", + "System.Runtime": "4.0.20", + "System.Runtime.Extensions": "4.0.10", + "System.Runtime.InteropServices": "4.0.20-*", + "System.Threading": "4.0.10" + }, + "frameworks": { + "dnxcore50": {} + } +} \ No newline at end of file diff --git a/src/System.CommandLine/tests/System.CommandLine.Tests.csproj b/src/System.CommandLine/tests/System.CommandLine.Tests.csproj new file mode 100644 index 00000000000..fd8701cfb39 --- /dev/null +++ b/src/System.CommandLine/tests/System.CommandLine.Tests.csproj @@ -0,0 +1,33 @@ + + + + + Debug + AnyCPU + Library + + + System.CommandLine.Tests + {F48BE89B-4F3E-4AB3-B10A-D6AE47869190} + + + + + + + + + + + + + + + + + {0a365f6d-af33-4dd1-abf3-be45a5f8e642} + System.CommandLine + + + + \ No newline at end of file diff --git a/src/System.CommandLine/tests/System/CommandLine/Splitter.cs b/src/System.CommandLine/tests/System/CommandLine/Splitter.cs new file mode 100644 index 00000000000..5f5679c8178 --- /dev/null +++ b/src/System.CommandLine/tests/System/CommandLine/Splitter.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Text; + +namespace System.CommandLine +{ + internal static class Splitter + { + internal static IReadOnlyList Split(string commandLine) + { + var result = new List(); + var sb = new StringBuilder(); + var pos = 0; + + while (pos < commandLine.Length) + { + var c = commandLine[pos]; + + if (c == ' ') + { + AddArgument(result, sb); + } + else if (c == '"') + { + var openingQuote = pos++; + + while (pos < commandLine.Length) + { + if (commandLine[pos] == '"') + { + // Check if quote is escaped + if (pos + 1 < commandLine.Length && commandLine[pos + 1] == '"') + pos++; + else + break; + } + + // Check for backslash quote + if (commandLine[pos] == '\\' && pos + 1 < commandLine.Length && commandLine[pos + 1] == '"') + pos++; + + sb.Append(commandLine[pos]); + pos++; + } + + if (pos >= commandLine.Length) + throw new FormatException(string.Format(Strings.UnmatchedQuoteFmt, openingQuote)); + } + else + { + sb.Append(commandLine[pos]); + } + + pos++; + } + + AddArgument(result, sb); + + return result.ToArray(); + } + + private static void AddArgument(ICollection receiver, StringBuilder sb) + { + if (sb.Length == 0) + return; + + var token = sb.ToString().Trim(); + if (token.Length > 0) + receiver.Add(token); + + sb.Clear(); + } + } +} \ No newline at end of file diff --git a/src/System.CommandLine/tests/System/CommandLine/Tests/ArgumentLexerTests.cs b/src/System.CommandLine/tests/System/CommandLine/Tests/ArgumentLexerTests.cs new file mode 100644 index 00000000000..685084faad2 --- /dev/null +++ b/src/System.CommandLine/tests/System/CommandLine/Tests/ArgumentLexerTests.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; + +using Xunit; + +namespace System.CommandLine.Tests +{ + public class ArgumentLexerTests + { + [Fact] + public void Lex_Parameters() + { + var text = "abc def ghi"; + var actual = Lex(text); + var expected = new[] { + new ArgumentToken(null, "abc", null), + new ArgumentToken(null, "def", null), + new ArgumentToken(null, "ghi", null), + }; + + Assert.Equal(expected, actual); + } + + [Fact] + public void Lex_Options() + { + var text = "-a /b --c"; + var actual = Lex(text); + var expected = new[] { + new ArgumentToken("-", "a", null), + new ArgumentToken("/", "b", null), + new ArgumentToken("--", "c", null) + }; + + Assert.Equal(expected, actual); + } + + [Fact] + public void Lex_OptionArguments() + { + var text = "-a:va /b=vb --c vc"; + var actual = Lex(text); + var expected = new[] { + new ArgumentToken("-", "a", "va"), + new ArgumentToken("/", "b", "vb"), + new ArgumentToken("--", "c", null), + new ArgumentToken(null, "vc", null) + }; + + Assert.Equal(expected, actual); + } + + [Fact] + public void Lex_ExpandsBundles() + { + var text = "-xdf"; + var actual = Lex(text); + var expected = new[] { + new ArgumentToken("-", "x", null), + new ArgumentToken("-", "d", null), + new ArgumentToken("-", "f", null) + }; + + Assert.Equal(expected, actual); + } + + [Fact] + public void Lex_ExpandsBundles_UnlessUsingSlash() + { + var text = "/xdf"; + var actual = Lex(text); + var expected = new[] { + new ArgumentToken("/", "xdf", null) + }; + + Assert.Equal(expected, actual); + } + + [Fact] + public void Lex_ExpandsReponseFile() + { + var responseFileName = @"C:\src\magic.rsp"; + var responseFileContents = new[] + { + "-xdf", + "--out:out.exe", + "/responseFileReader", + @"C:\Reference Assemblies\system.dll" + }; + + var responseFileReader = CreateMokeReponseFileReader(responseFileName, responseFileContents); + var text = "--before @\"" + responseFileName + "\" --after"; + var actual = Lex(text, responseFileReader); + var expected = new[] { + new ArgumentToken("--", "before", null), + new ArgumentToken("-", "x", null), + new ArgumentToken("-", "d", null), + new ArgumentToken("-", "f", null), + new ArgumentToken("--", "out", "out.exe"), + new ArgumentToken("/", "responseFileReader", null), + new ArgumentToken(null, @"C:\Reference Assemblies\system.dll", null), + new ArgumentToken("--", "after", null) + }; + + Assert.Equal(expected, actual); + } + + [Fact] + public void Lex_ExpandsReponseFile_UnlessNoReader() + { + var text = "--before @somefile --after"; + var actual = Lex(text); + var expected = new[] { + new ArgumentToken("--", "before", null), + new ArgumentToken(null, "@somefile", null), + new ArgumentToken("--", "after", null) + }; + + Assert.Equal(expected, actual); + } + + private static IReadOnlyList Lex(string commandLine) + { + var args = Splitter.Split(commandLine); + return ArgumentLexer.Lex(args); + } + + private static IReadOnlyList Lex(string commandLine, Func> responseFileReader) + { + var args = Splitter.Split(commandLine); + return ArgumentLexer.Lex(args, responseFileReader); + } + + private static Func> CreateMokeReponseFileReader(string expectedFileName, string[] response) + { + return fileName => + { + if (fileName == expectedFileName) + return response; + + throw new Exception("Unexpeced response file request:" + fileName); + }; + } + } +} \ No newline at end of file diff --git a/src/System.CommandLine/tests/System/CommandLine/Tests/ArgumentSyntaxTests.cs b/src/System.CommandLine/tests/System/CommandLine/Tests/ArgumentSyntaxTests.cs new file mode 100644 index 00000000000..46f8d4cb2de --- /dev/null +++ b/src/System.CommandLine/tests/System/CommandLine/Tests/ArgumentSyntaxTests.cs @@ -0,0 +1,993 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; + +using Xunit; + +namespace System.CommandLine.Tests +{ + public class ArgumentSyntaxTests + { + [Fact] + public void Parse_Throws_IfArgumentsIsNull() + { + var ex = Assert.Throws(() => + { + ArgumentSyntax.Parse(null, syntax => { }); + }); + + Assert.Equal("arguments", ex.ParamName); + } + + [Fact] + public void Parse_Throws_IfDefineActionIsNull() + { + var ex = Assert.Throws(() => + { + ArgumentSyntax.Parse(Array.Empty(), null); + }); + + Assert.Equal("defineAction", ex.ParamName); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Command_Definition_Error_NoName(string name) + { + var ex = Assert.Throws(() => + { + Parse(string.Empty, syntax => + { + syntax.DefineCommand(name, string.Empty); + }); + }); + + Assert.Equal("You must specify a name." + Environment.NewLine + "Parameter name: name", ex.Message); + } + + [Theory] + [InlineData("-c")] + [InlineData("/c")] + [InlineData("--c")] + [InlineData("")] + [InlineData("c|d")] + public void Command_Definition_Error_IllegalName(string name) + { + var ex = Assert.Throws(() => + { + Parse(string.Empty, syntax => + { + syntax.DefineCommand(name, string.Empty); + }); + }); + + Assert.Equal("Name '" + name + "' cannot be used for a command." + Environment.NewLine + "Parameter name: name", ex.Message); + } + + [Fact] + public void Command_Definition_Error_AfterParameter() + { + var ex = Assert.Throws(() => + { + Parse(string.Empty, syntax => + { + syntax.DefineParameter("p", string.Empty); + syntax.DefineCommand("c", string.Empty); + }); + }); + + Assert.Equal("Cannot define commands if global parameters exist.", ex.Message); + } + + [Fact] + public void Command_Definition_Error_Duplicate() + { + var ex = Assert.Throws(() => + { + Parse(string.Empty, syntax => + { + syntax.DefineCommand("c", string.Empty); + syntax.DefineCommand("c", string.Empty); + }); + }); + + Assert.Equal("Command 'c' is already defined.", ex.Message); + } + + [Fact] + public void Command_Definition() + { + var c1 = (ArgumentCommand)null; + var c2 = (ArgumentCommand)null; + var c3 = (ArgumentCommand)null; + var c = string.Empty; + + var result = Parse("c2", syntax => + { + c1 = syntax.DefineCommand("c1", ref c, string.Empty); + c2 = syntax.DefineCommand("c2", ref c, string.Empty); + c3 = syntax.DefineCommand("c3", ref c, string.Empty); + }); + + Assert.Equal("c2", c); + Assert.Equal(c2, result.ActiveCommand); + + Assert.Equal("c1", c1.Value); + Assert.Equal("c2", c2.Value); + Assert.Equal("c3", c3.Value); + } + + [Fact] + public void Command_Definition_WithCustomValue() + { + var c1 = (ArgumentCommand)null; + var c2 = (ArgumentCommand)null; + var c3 = (ArgumentCommand)null; + var c = 0; + + var result = Parse("c2", syntax => + { + c1 = syntax.DefineCommand("c1", ref c, 1, string.Empty); + c2 = syntax.DefineCommand("c2", ref c, 2, string.Empty); + c3 = syntax.DefineCommand("c3", ref c, 3, string.Empty); + }); + + Assert.Equal(2, c); + Assert.Equal(c2, result.ActiveCommand); + + Assert.Equal(1, c1.Value); + Assert.Equal(2, c2.Value); + Assert.Equal(3, c3.Value); + } + + [Fact] + public void Command_Usage_Error_Missing() + { + var ex = Assert.Throws(() => + { + Parse(string.Empty, syntax => + { + syntax.DefineCommand("c", string.Empty); + }); + }); + + Assert.Equal("missing command", ex.Message); + } + + [Fact] + public void Command_Usage_Error_Invalid() + { + var ex = Assert.Throws(() => + { + Parse("d", syntax => + { + syntax.DefineCommand("c", string.Empty); + }); + }); + + Assert.Equal("unknown command 'd'", ex.Message); + } + + [Fact] + public void Command_Usage_GobalOption_IsShared() + { + var o = string.Empty; + + Parse("c2 -o x", syntax => + { + syntax.DefineOption("o", ref o, string.Empty); + + syntax.DefineCommand("c1", string.Empty); + syntax.DefineCommand("c2", string.Empty); + }); + + Assert.Equal("x", o); + } + + [Fact] + public void Command_Usage_GobalOption_List_IsShared() + { + var o = (IReadOnlyList)null; + + Parse("c2 -o x -o y", syntax => + { + syntax.DefineOptionList("o", ref o, string.Empty); + + syntax.DefineCommand("c1", string.Empty); + syntax.DefineCommand("c2", string.Empty); + }); + + Assert.Equal(new[] { "x", "y" }.AsEnumerable(), o); + } + + [Theory] + [MemberData("GetNonLetterNames")] + public void Command_Usage_ViaNonLetterName(string name) + { + var o = string.Empty; + + Parse(name, syntax => + { + syntax.DefineCommand(name, ref o, string.Empty); + }); + + Assert.Equal(name, o); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Option_Definition_Error_NoName(string name) + { + var ex = Assert.Throws(() => + { + Parse(string.Empty, syntax => + { + syntax.DefineOption(name, string.Empty); + }); + }); + + Assert.Equal("You must specify a name." + Environment.NewLine + "Parameter name: name", ex.Message); + } + + [Theory] + [InlineData("-o")] + [InlineData("/o")] + [InlineData("--o")] + [InlineData("")] + public void Option_Definition_Error_IllegalName(string name) + { + var ex = Assert.Throws(() => + { + Parse(string.Empty, syntax => + { + syntax.DefineOption(name, string.Empty); + }); + }); + + Assert.Equal("Name '" + name + "' cannot be used for an option." + Environment.NewLine + "Parameter name: name", ex.Message); + } + + [Fact] + public void Option_Definition_MultipleNames() + { + var a = "standard-a"; + var b = "standard-b"; + var d = "standard-c"; + + Parse("--opta arga -c argb -e argd", syntax => + { + syntax.DefineOption("a|opta", ref a, string.Empty); + syntax.DefineOption("b|optb|c", ref b, string.Empty); + syntax.DefineOption("d|optd|e", ref d, string.Empty); + }); + + Assert.Equal("arga", a); + Assert.Equal("argb", b); + Assert.Equal("argd", d); + } + + [Fact] + public void Option_Definition_AfterParameters() + { + Parse("c1 p", syntax => + { + var c1 = syntax.DefineCommand("c1"); + var p = syntax.DefineParameter("p", string.Empty); + + var c2 = syntax.DefineCommand("c2"); + var o = syntax.DefineOption("o", string.Empty); + + Assert.Equal(c1, p.Command); + Assert.Equal(c2, o.Command); + }); + } + + [Fact] + public void Option_Definition_SameNameInAnotherCommand() + { + var o1 = "o1"; + var o2 = "o2"; + + Parse("c1 -o x", syntax => + { + syntax.DefineCommand("c1"); + syntax.DefineOption("o", ref o1, string.Empty); + + syntax.DefineCommand("c2"); + syntax.DefineOption("o", ref o2, string.Empty); + + Assert.Equal("x", o1); + Assert.Equal("o2", o2); + }); + } + + [Fact] + public void Option_Definition_Error_AfterParameter() + { + var ex = Assert.Throws(() => + { + Parse(string.Empty, syntax => + { + syntax.DefineParameter("p", string.Empty); + syntax.DefineOption("o", string.Empty); + }); + }); + + Assert.Equal("Options must be defined before any parameters.", ex.Message); + } + + [Fact] + public void Option_Definition_Error_AfterParameter_WithCommand() + { + var ex = Assert.Throws(() => + { + Parse("c", syntax => + { + syntax.DefineCommand("c"); + syntax.DefineParameter("p", string.Empty); + syntax.DefineOption("o", string.Empty); + }); + }); + + Assert.Equal("Options must be defined before any parameters.", ex.Message); + } + + [Fact] + public void Option_Definition_Error_Duplicate() + { + var ex = Assert.Throws(() => + { + Parse(string.Empty, syntax => + { + syntax.DefineOption("o", string.Empty); + syntax.DefineOption("o", string.Empty); + }); + }); + + Assert.Equal("Option 'o' is already defined.", ex.Message); + } + + [Fact] + public void Option_Definition_HasDefault_IfNotSpecified() + { + var o = "standard"; + + Parse(string.Empty, syntax => + { + syntax.DefineOption("o", ref o, string.Empty); + }); + + Assert.Equal("standard", o); + } + + [Fact] + public void Option_Usage_Error_Invalid() + { + var ex = Assert.Throws(() => + { + Parse("-e -d", syntax => + { + var exists = false; + syntax.DefineOption("e", ref exists, "Some qualifier"); + }); + }); + + Assert.Equal("invalid option -d", ex.Message); + } + + [Fact] + public void Option_Usage_Error_Conversion_Int32() + { + var ex = Assert.Throws(() => + { + Parse("-o abc", syntax => + { + var o = 0; + syntax.DefineOption("o", ref o, string.Empty); + }); + }); + + Assert.Equal("value 'abc' isn't valid for -o: Input string was not in a correct format.", ex.Message); + } + + [Fact] + public void Option_Usage_Error_Conversion_Boolean() + { + var ex = Assert.Throws(() => + { + Parse("-o:abc", syntax => + { + var o = false; + syntax.DefineOption("o", ref o, string.Empty); + }); + }); + + Assert.Equal("value 'abc' isn't valid for -o: String was not recognized as a valid Boolean.", ex.Message); + } + + [Fact] + public void Option_Usage_Error_Conversion_CustomConverter() + { + Func converter = s => + { + throw new FormatException("invalid format"); + }; + + var ex = Assert.Throws(() => + { + Parse("-o abc", syntax => + { + var o = Guid.Empty; + syntax.DefineOption("o", ref o, converter, string.Empty); + }); + }); + + Assert.Equal("value 'abc' isn't valid for -o: invalid format", ex.Message); + } + + [Fact] + public void Option_Usage_Error_Duplicate() + { + var ex = Assert.Throws(() => + { + Parse("-a -b -a", syntax => + { + var arg1 = false; + var arg2 = false; + syntax.DefineOption("a", ref arg1, string.Empty); + syntax.DefineOption("b", ref arg2, string.Empty); + }); + }); + + Assert.Equal("option -a is specified multiple times", ex.Message); + } + + [Theory] + [InlineData("-a")] + [InlineData("-a:")] + [InlineData("-a : ")] + [InlineData("-a : -b")] + public void Option_Usage_Error_RequiresValue(string commandLine) + { + var ex = Assert.Throws(() => + { + Parse(commandLine, syntax => + { + var arg1 = string.Empty; + syntax.DefineOption("a", ref arg1, string.Empty); + }); + }); + + Assert.Equal("option -a requires a value", ex.Message); + } + + [Theory] + [InlineData("/")] + [InlineData("--")] + public void Option_Usage_Error_Flag_Bundle_ViaModifier(string modifier) + { + var ex = Assert.Throws(() => + { + Parse(modifier + "opq", syntax => + { + syntax.DefineOption("o", false); + syntax.DefineOption("p", false); + syntax.DefineOption("q", false); + }); + }); + + Assert.Equal("invalid option " + modifier + "opq", ex.Message); + } + + [Theory] + [MemberData("GetNonLetterNames")] + public void Option_Usage_ViaNonLetterName(string name) + { + var o = false; + + Parse("--" + name, syntax => + { + syntax.DefineOption(name, ref o, ""); + }); + + Assert.True(o); + } + + [Theory] + [InlineData("/")] + [InlineData("-")] + [InlineData("--")] + public void Option_Usage_ViaModifer(string modifier) + { + var o = false; + + Parse(modifier + "o", syntax => + { + syntax.DefineOption("o", ref o, ""); + }); + + Assert.True(o); + } + + [Fact] + public void Option_Usage_Flag_DoesNotRequireValue() + { + var f1 = false; + var f2 = true; + + Parse("-f --flag", syntax => + { + syntax.DefineOption("f", ref f1, string.Empty); + syntax.DefineOption("flag", ref f2, string.Empty); + }); + + Assert.True(f1); + Assert.True(f2); + } + + [Fact] + public void Option_Usage_Flag_AcceptsValue() + { + var f1 = false; + var f2 = false; + var f3 = true; + + Parse("--f1 --f2:true --f3 = false", syntax => + { + syntax.DefineOption("f1", ref f1, string.Empty); + syntax.DefineOption("f2", ref f2, string.Empty); + syntax.DefineOption("f3", ref f3, string.Empty); + }); + + Assert.True(f1); + Assert.True(f2); + Assert.False(f3); + } + + [Fact] + public void Option_Usage_Flag_Bundled() + { + var a = false; + var b = false; + var c = false; + var d = false; + var e = false; + var f = false; + + Parse("-bdf", syntax => + { + syntax.DefineOption("a", ref a, string.Empty); + syntax.DefineOption("b", ref b, string.Empty); + syntax.DefineOption("c", ref c, string.Empty); + syntax.DefineOption("d", ref d, string.Empty); + syntax.DefineOption("e", ref e, string.Empty); + syntax.DefineOption("f", ref f, string.Empty); + }); + + Assert.False(a); + Assert.True(b); + Assert.False(c); + Assert.True(d); + Assert.False(e); + Assert.True(f); + } + + [Fact] + public void Option_Usage_Flag_Bundled_WithValue() + { + var a = false; + var b = ""; + + Parse("-ab message", syntax => + { + syntax.DefineOption("a", ref a, string.Empty); + syntax.DefineOption("b", ref b, string.Empty); + }); + + Assert.True(a); + Assert.Equal("message", b); + } + + [Fact] + public void Option_Usage_Flag_DoesNotConsumeParameter() + { + var a = false; + var b = ""; + + Parse("-a false", syntax => + { + syntax.DefineOption("a", ref a, string.Empty); + syntax.DefineParameter("b", ref b, string.Empty); + }); + + Assert.True(a); + Assert.Equal("false", b); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Option_List_Definition_Error_NoName(string name) + { + var ex = Assert.Throws(() => + { + Parse(string.Empty, syntax => + { + syntax.DefineOptionList(name, Array.Empty()); + }); + }); + + Assert.Equal("You must specify a name." + Environment.NewLine + "Parameter name: name", ex.Message); + } + + [Theory] + [InlineData("-o")] + [InlineData("/o")] + [InlineData("--o")] + [InlineData("")] + public void Option_List_Definition_Error_IllegalName(string name) + { + var ex = Assert.Throws(() => + { + Parse(string.Empty, syntax => + { + syntax.DefineOptionList(name, Array.Empty()); + }); + }); + + Assert.Equal("Name '" + name + "' cannot be used for an option." + Environment.NewLine + "Parameter name: name", ex.Message); + } + + [Fact] + public void Option_List_Definition_Error_AfterParameter() + { + var ex = Assert.Throws(() => + { + Parse(string.Empty, syntax => + { + syntax.DefineParameter("p", string.Empty); + syntax.DefineOptionList("o", Array.Empty()); + }); + }); + + Assert.Equal("Options must be defined before any parameters.", ex.Message); + } + + [Fact] + public void Option_List_Definition() + { + var arg1 = (IReadOnlyList)Array.Empty(); + var arg2 = false; + + Parse("-a x -b -a y", syntax => + { + syntax.DefineOptionList("a", ref arg1, string.Empty); + syntax.DefineOption("b", ref arg2, string.Empty); + }); + + Assert.Equal(new[] { "x", "y" }, arg1); + Assert.True(arg2); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Parameter_Definition_Error_NoName(string name) + { + var ex = Assert.Throws(() => + { + Parse(string.Empty, syntax => + { + syntax.DefineParameter(name, string.Empty); + }); + }); + + Assert.Equal("You must specify a name." + Environment.NewLine + "Parameter name: name", ex.Message); + } + + [Theory] + [InlineData("-p")] + [InlineData("/p")] + [InlineData("--p")] + [InlineData("p|para")] + [InlineData("

")] + public void Parameter_Definition_Error_IllegalName(string name) + { + var ex = Assert.Throws(() => + { + Parse(string.Empty, syntax => + { + syntax.DefineParameter(name, string.Empty); + }); + }); + + Assert.Equal("Name '" + name + "' cannot be used for a parameter." + Environment.NewLine + "Parameter name: name", ex.Message); + } + + [Fact] + public void Parameter_Definition_Error_Duplicate() + { + var ex = Assert.Throws(() => + { + Parse(string.Empty, syntax => + { + syntax.DefineParameter("p", string.Empty); + syntax.DefineParameter("p", string.Empty); + }); + }); + + Assert.Equal("Parameter 'p' is already defined.", ex.Message); + } + + [Fact] + public void Parameter_Definition_Error_AfterList() + { + var ex = Assert.Throws(() => + { + Parse(string.Empty, syntax => + { + syntax.DefineParameterList("a", Array.Empty()); + syntax.DefineParameter("b", string.Empty); + }); + }); + + Assert.Equal("Parameters cannot be defined after parameter lists.", ex.Message); + } + + [Fact] + public void Parameter_Definition_SameNameAsOption() + { + var o1 = "o1"; + var o2 = "o2"; + + Parse("-o x y", syntax => + { + syntax.DefineOption("o", ref o1, string.Empty); + syntax.DefineParameter("o", ref o2, string.Empty); + + Assert.Equal("x", o1); + Assert.Equal("y", o2); + }); + } + + [Fact] + public void Parameter_Usage_Error_Extra() + { + var ex = Assert.Throws(() => + { + Parse("-a a -b b c", syntax => + { + var arg1 = string.Empty; + var arg2 = string.Empty; + syntax.DefineOption("a", ref arg1, string.Empty); + syntax.DefineOption("b", ref arg2, string.Empty); + }); + }); + + Assert.Equal("extra parameter 'c'", ex.Message); + } + + [Fact] + public void Parameter_Usage_Error_Conversion_Int32() + { + var ex = Assert.Throws(() => + { + Parse("abc", syntax => + { + var o = 0; + syntax.DefineParameter("p", ref o, string.Empty); + }); + }); + + Assert.Equal("value 'abc' isn't valid for

: Input string was not in a correct format.", ex.Message); + } + + [Fact] + public void Parameter_Usage_Error_Conversion_Boolean() + { + var ex = Assert.Throws(() => + { + Parse("abc", syntax => + { + var o = false; + syntax.DefineParameter("p", ref o, string.Empty); + }); + }); + + Assert.Equal("value 'abc' isn't valid for

: String was not recognized as a valid Boolean.", ex.Message); + } + + [Fact] + public void Parameter_Usage_Error_Conversion_CustomConverter() + { + Func converter = s => + { + throw new FormatException("invalid format"); + }; + + var ex = Assert.Throws(() => + { + Parse("abc", syntax => + { + var o = Guid.Empty; + syntax.DefineParameter("p", ref o, converter, string.Empty); + }); + }); + + Assert.Equal("value 'abc' isn't valid for

: invalid format", ex.Message); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Parameter_Definition_List_Error_NoName(string name) + { + var ex = Assert.Throws(() => + { + Parse(string.Empty, syntax => + { + syntax.DefineParameterList(name, Array.Empty()); + }); + }); + + Assert.Equal("You must specify a name." + Environment.NewLine + "Parameter name: name", ex.Message); + } + + [Theory] + [InlineData("-p")] + [InlineData("/p")] + [InlineData("--p")] + [InlineData("p|para")] + [InlineData("

")] + public void Parameter_Definition_List_Error_IllegalName(string name) + { + var ex = Assert.Throws(() => + { + Parse(string.Empty, syntax => + { + syntax.DefineParameterList(name, Array.Empty()); + }); + }); + + Assert.Equal("Name '" + name + "' cannot be used for a parameter." + Environment.NewLine + "Parameter name: name", ex.Message); + } + + [Fact] + public void Parameter_List_Definition_Error_Duplicate_WithCommand() + { + var ex = Assert.Throws(() => + { + Parse("c", syntax => + { + var p1 = (IReadOnlyList)null; + var p2 = (IReadOnlyList)null; + syntax.DefineCommand("c"); + syntax.DefineParameterList("p1", ref p1, string.Empty); + syntax.DefineParameterList("p2", ref p2, string.Empty); + }); + }); + + Assert.Equal("Cannot define multiple parameter lists.", ex.Message); + } + + [Fact] + public void Parameter_List_Definition_Error_Duplicate() + { + var ex = Assert.Throws(() => + { + Parse(string.Empty, syntax => + { + var p1 = (IReadOnlyList)null; + var p2 = (IReadOnlyList)null; + syntax.DefineParameterList("p1", ref p1, string.Empty); + syntax.DefineParameterList("p2", ref p2, string.Empty); + }); + }); + + Assert.Equal("Cannot define multiple parameter lists.", ex.Message); + } + + [Fact] + public void Parameter_List_Definition() + { + var sources = (IReadOnlyList)Array.Empty(); + + Parse("source1.cs source2.cs", syntax => + { + syntax.DefineParameterList("sources", ref sources, string.Empty); + }); + + var expected = new[] { "source1.cs", "source2.cs" }; + var actual = sources; + Assert.Equal(expected.AsEnumerable(), actual); + } + + [Fact] + public void Parameter_List_Definition_AfterParamater() + { + var p = string.Empty; + var ps = (IReadOnlyList)Array.Empty(); + + Parse("single p1 p2", syntax => + { + syntax.DefineParameter("p", ref p, string.Empty); + syntax.DefineParameterList("ps", ref ps, string.Empty); + }); + + Assert.Equal("single", p); + Assert.Equal(new[] { "p1", "p2" }.AsEnumerable(), ps); + } + + [Fact] + public void Parameter_List_Definition_AnotherListInAnotherCommand() + { + var p1 = (IReadOnlyList)null; + var p2 = (IReadOnlyList)null; + + Parse("c2 a1 a2", syntax => + { + syntax.DefineCommand("c1"); + syntax.DefineParameterList("p1", ref p1, string.Empty); + + syntax.DefineCommand("c2"); + syntax.DefineParameterList("p2", ref p2, string.Empty); + }); + + Assert.Null(p1); + Assert.Equal(new[] { "a1", "a2" }, p2.AsEnumerable()); + } + + [Fact] + public void ResponseFiles_Error_DoesNotExist() + { + var ex = Assert.Throws(() => + { + Parse("@6FF54573-5066-46BA-8457-0CF8AA402561", syntax => { }); + }); + + Assert.Equal("Response file '6FF54573-5066-46BA-8457-0CF8AA402561' doesn't exist.", ex.Message); + } + + [Fact] + public void ResponseFiles_CanBeDisabled() + { + var p = string.Empty; + + Parse("@foo", syntax => + { + syntax.HandleResponseFiles = false; + syntax.DefineParameter("p", ref p, string.Empty); + }); + + Assert.Equal("@foo", p); + } + + private static ArgumentSyntax Parse(string commandLine, Action defineAction) + { + var args = Splitter.Split(commandLine); + + return ArgumentSyntax.Parse(args, syntax => + { + syntax.HandleHelp = false; + syntax.HandleErrors = false; + defineAction(syntax); + }); + } + + public static IEnumerable GetNonLetterNames() + { + return new[] + { + new[] {"do-stuff"}, + new[] {"do123"}, + new[] {"123_do"}, + new[] {"_test"} + }; + } + } +} diff --git a/src/System.CommandLine/tests/System/CommandLine/Tests/HelpTextGeneratorTests.cs b/src/System.CommandLine/tests/System/CommandLine/Tests/HelpTextGeneratorTests.cs new file mode 100644 index 00000000000..c2d62fa1c13 --- /dev/null +++ b/src/System.CommandLine/tests/System/CommandLine/Tests/HelpTextGeneratorTests.cs @@ -0,0 +1,511 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using Xunit; + +namespace System.CommandLine.Tests +{ + public class HelpTextGeneratorTests + { + [Fact] + public void Help_Empty() + { + var expectedHelp = @" + usage: tool + "; + + var actualHelp = GetHelp(syntax => { }); + + AssertMatch(expectedHelp, actualHelp); + } + + [Fact] + public void Help_Command_Overview() + { + var expectedHelp = @" + usage: tool [] + + pull Gets commits from another repo + commit Adds commits to the current repo + push Transfers commits to another repo + "; + + var actualHelp = GetHelp(string.Empty, syntax => + { + var command = string.Empty; + syntax.DefineCommand("pull", ref command, "Gets commits from another repo"); + syntax.DefineCommand("commit", ref command, "Adds commits to the current repo"); + syntax.DefineCommand("push", ref command, "Transfers commits to another repo"); + }); + + AssertMatch(expectedHelp, actualHelp); + } + + [Fact] + public void Help_Command_Details() + { + var expectedHelp = @" + usage: tool commit [-o ] + + -o Some option + "; + + var actualHelp = GetHelp("commit", syntax => + { + var o = string.Empty; + syntax.DefineCommand("pull"); + syntax.DefineCommand("commit"); + syntax.DefineOption("o", ref o, "Some option"); + syntax.DefineCommand("push"); + }); + + AssertMatch(expectedHelp, actualHelp); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Help_Option(bool includeHidden) + { + var expectedHelp = @" + usage: tool [-s] [--keyword] [-f] [-o ] [--option ] + + -s Single character flag + --keyword Keyword flag + -f, --flag A flag with with short and long name + -o Single character option with value + --option Keyword option with value + "; + + var actualHelp = GetHelp(syntax => + { + var s = false; + var keyword = false; + var flag = false; + var o = string.Empty; + var option = string.Empty; + syntax.DefineOption("s", ref s, "Single character flag"); + syntax.DefineOption("keyword", ref keyword, "Keyword flag"); + syntax.DefineOption("f|flag", ref flag, "A flag with with short and long name"); + syntax.DefineOption("o", ref o, "Single character option with value"); + syntax.DefineOption("option", ref option, "Keyword option with value"); + + if (includeHidden) + { + var h = syntax.DefineParameter("h", string.Empty); + h.IsHidden = true; + } + }); + + AssertMatch(expectedHelp, actualHelp); + } + + [Fact] + public void Help_Option_List() + { + var expectedHelp = @" + usage: tool [-r ...] [--color ...] + + -r ... The references + --color, -c ... The colors to use + "; + + var actualHelp = GetHelp(syntax => + { + var r = (IReadOnlyList)null; + var c = (IReadOnlyList)null; + + syntax.DefineOptionList("r", ref r, "The references"); + syntax.DefineOptionList("color|c", ref c, "The colors to use"); + }); + + AssertMatch(expectedHelp, actualHelp); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Help_Parameter(bool includeHidden) + { + var expectedHelp = @" + usage: tool + + Parameter One + Parameter Two + "; + + var actualHelp = GetHelp(syntax => + { + if (includeHidden) + { + var h = syntax.DefineOption("h", string.Empty); + h.IsHidden = true; + } + + var p1 = string.Empty; + var p2 = false; + syntax.DefineParameter("p1", ref p1, "Parameter One"); + syntax.DefineParameter("p2", ref p2, "Parameter Two"); + }); + + AssertMatch(expectedHelp, actualHelp); + } + + [Fact] + public void Help_Parameter_List() + { + var expectedHelp = @" + usage: tool ... + + The first + ... The second + "; + + var actualHelp = GetHelp(syntax => + { + var r = string.Empty; + var c = (IReadOnlyList)null; + + syntax.DefineParameter("first", ref r, "The first"); + syntax.DefineParameterList("second", ref c, "The second"); + }); + + AssertMatch(expectedHelp, actualHelp); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Help_OptionAndParameter(bool includeHidden) + { + var expectedHelp = @" + usage: tool [-o] [-x ] [--] + + -o Option #1 + -x, --option2 Option #2 + Parameter One + Parameter Two + "; + + var actualHelp = GetHelp(syntax => + { + var o1 = false; + var o2 = string.Empty; + var p1 = string.Empty; + var p2 = false; + + syntax.DefineOption("o", ref o1, "Option #1"); + syntax.DefineOption("x|option2", ref o2, "Option #2"); + + if (includeHidden) + { + var h = syntax.DefineOption("h", string.Empty); + h.IsHidden = true; + } + + syntax.DefineParameter("p1", ref p1, "Parameter One"); + syntax.DefineParameter("p2", ref p2, "Parameter Two"); + + if (includeHidden) + { + var h = syntax.DefineParameter("h", string.Empty); + h.IsHidden = true; + } + }); + + AssertMatch(expectedHelp, actualHelp); + } + + [Fact] + public void Help_Wrapping_Usage() + { + var expectedHelp = @" + usage: tool [-a] [-b] [-c] [-d] [-e] [-f] [-g] [-h] + [-i] [-j] [-k] [-l] [-m] [-n] [-o] [-p] + [-q] + + -a a + -b b + -c c + -d d + -e e + -f f + -g g + -h h + -i i + -j j + -k k + -l l + -m m + -n n + -o o + -p p + -q q + "; + + var actualHelp = GetHelp(55, syntax => + { + var f = false; + for (var c = 'a'; c <= 'q'; c++) + { + var cText = c.ToString(); + syntax.DefineOption(cText, ref f, cText); + } + }); + + AssertMatch(expectedHelp, actualHelp); + } + + [Fact] + public void Help_Wrapping_Usage_LongToken_First() + { + var expectedHelp = @" + usage: tool [--Lorem_ipsum_dolor_sit_amet_consectetur_adipiscing_elit_sed_do_eiusmod_tempor] + [-a] [-b] [-c] [-d] + + --Lorem_ipsum_dolor_sit_amet_consectetur_adipiscing_elit_sed_do_eiusmod_tempor Lorem ipsum flag + -a a + -b b + -c c + -d d + "; + + var actualHelp = GetHelp(55, syntax => + { + var f = false; + + syntax.DefineOption("Lorem_ipsum_dolor_sit_amet_consectetur_adipiscing_elit_sed_do_eiusmod_tempor", ref f, "Lorem ipsum flag"); + + for (var c = 'a'; c <= 'd'; c++) + { + var cText = c.ToString(); + syntax.DefineOption(cText, ref f, cText); + } + }); + + AssertMatch(expectedHelp, actualHelp); + } + + [Fact] + public void Help_Wrapping_Usage_LongToken_Second() + { + var expectedHelp = @" + usage: tool [-a] + [--Lorem_ipsum_dolor_sit_amet_consectetur_adipiscing_elit_sed_do_eiusmod_tempor] + [-b] [-c] [-d] + + -a a + --Lorem_ipsum_dolor_sit_amet_consectetur_adipiscing_elit_sed_do_eiusmod_tempor Lorem ipsum flag + -b b + -c c + -d d + "; + + var actualHelp = GetHelp(55, syntax => + { + var f = false; + + for (var c = 'a'; c <= 'd'; c++) + { + var cText = c.ToString(); + syntax.DefineOption(cText, ref f, cText); + + if (c == 'a') + syntax.DefineOption("Lorem_ipsum_dolor_sit_amet_consectetur_adipiscing_elit_sed_do_eiusmod_tempor", ref f, "Lorem ipsum flag"); + } + }); + + AssertMatch(expectedHelp, actualHelp); + } + + [Fact] + public void Help_Wrapping_Text() + { + var expectedHelp = @" + usage: tool [-s ] [-f] + + -s Lorem ipsum dolor sit amet, consectetur + adipiscing elit, sed do eiusmod tempor + incididunt ut labore et dolore magna + aliqua. Ut enim ad minim veniam, quis + nostrud exercitation ullamco laboris + nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse + cillum dolore eu fugiat nulla pariatur. + Excepteur sint occaecat cupidatat non + proident, sunt in culpa qui officia + deserunt mollit anim id est laborum. + -f Lorem ipsum dolor sit amet. + "; + + var actualHelp = GetHelp(55, syntax => + { + var s = string.Empty; + var f = false; + syntax.DefineOption("s", ref s, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."); + syntax.DefineOption("f", ref f, "Lorem ipsum dolor sit amet."); + }); + + AssertMatch(expectedHelp, actualHelp); + } + + [Fact] + public void Help_Wrapping_Text_LongToken_First() + { + var expectedHelp = @" + usage: tool [-s ] [-f] + + -s Lorem_ipsum_dolor_sit_amet_consectetur_adipiscing_elit_sed_do_eiusmod_tempor + incididunt ut labore et dolore magna + aliqua. Ut enim ad minim veniam, quis + nostrud exercitation ullamco laboris + nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse + cillum dolore eu fugiat nulla pariatur. + Excepteur sint occaecat cupidatat non + proident, sunt in culpa qui officia + deserunt mollit anim id est laborum. + -f Lorem ipsum dolor sit amet. + "; + + var actualHelp = GetHelp(55, syntax => + { + var s = string.Empty; + var f = false; + syntax.DefineOption("s", ref s, "Lorem_ipsum_dolor_sit_amet_consectetur_adipiscing_elit_sed_do_eiusmod_tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."); + syntax.DefineOption("f", ref f, "Lorem ipsum dolor sit amet."); + }); + + AssertMatch(expectedHelp, actualHelp); + } + + [Fact] + public void Help_Wrapping_Text_LongToken_Second() + { + var expectedHelp = @" + usage: tool [-s ] [-f] + + -s Lorem + ipsum_dolor_sit_amet_consectetur_adipiscing_elit_sed_do_eiusmod_tempor + incididunt ut labore et dolore magna + aliqua. Ut enim ad minim veniam, quis + nostrud exercitation ullamco laboris + nisi ut aliquip ex ea commodo + consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse + cillum dolore eu fugiat nulla pariatur. + Excepteur sint occaecat cupidatat non + proident, sunt in culpa qui officia + deserunt mollit anim id est laborum. + -f Lorem ipsum dolor sit amet. + "; + + var actualHelp = GetHelp(55, syntax => + { + var s = string.Empty; + var f = false; + syntax.DefineOption("s", ref s, "Lorem ipsum_dolor_sit_amet_consectetur_adipiscing_elit_sed_do_eiusmod_tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."); + syntax.DefineOption("f", ref f, "Lorem ipsum dolor sit amet."); + }); + + AssertMatch(expectedHelp, actualHelp); + } + + private static void AssertMatch(string expectedText, string actualText) + { + var expectedLines = SplitLines(expectedText); + RemoveLeadingBlankLines(expectedLines); + RemoveTrailingBlankLines(expectedLines); + UnindentLines(expectedLines); + + var actualLines = SplitLines(actualText); + RemoveLeadingBlankLines(actualLines); + RemoveTrailingBlankLines(actualLines); + + Assert.Equal(expectedLines.Count, actualLines.Count); + + for (var i = 0; i < expectedLines.Count; i++) + Assert.Equal(expectedLines[i], actualLines[i]); + } + + private static List SplitLines(string expectedHelp) + { + var lines = new List(); + + // Get lines + + using (var stringReader = new StringReader(expectedHelp)) + { + string line; + while ((line = stringReader.ReadLine()) != null) + lines.Add(line); + } + return lines; + } + + private static void RemoveLeadingBlankLines(List lines) + { + while (lines.Count > 0 && lines.First().Trim().Length == 0) + lines.RemoveAt(0); + } + + private static void RemoveTrailingBlankLines(List lines) + { + while (lines.Count > 0 && lines.Last().Trim().Length == 0) + lines.RemoveAt(lines.Count - 1); + } + + private static void UnindentLines(List lines) + { + var minIndent = lines.Where(l => l.Length > 0) + .Min(l => l.Length - l.TrimStart().Length); + + for (var i = 0; i < lines.Count; i++) + { + if (lines[i].Length >= minIndent) + lines[i] = lines[i].Substring(minIndent); + } + } + + private static string GetHelp(Action defineAction) + { + return CreateSyntax(defineAction).GetHelpText(); + } + + private static string GetHelp(string commandLine, Action defineAction) + { + return CreateSyntax(commandLine, defineAction).GetHelpText(); + } + + private static string GetHelp(int maxWidth, Action defineAction) + { + return CreateSyntax(defineAction).GetHelpText(maxWidth); + } + + private static ArgumentSyntax CreateSyntax(Action defineAction) + { + return CreateSyntax(Array.Empty(), defineAction); + } + + private static ArgumentSyntax CreateSyntax(string commandLine, Action defineAction) + { + var args = Splitter.Split(commandLine); + return CreateSyntax(args, defineAction); + } + + private static ArgumentSyntax CreateSyntax(IEnumerable arguments, Action defineAction) + { + var syntax = new ArgumentSyntax(arguments); + syntax.ApplicationName = "tool"; + defineAction(syntax); + return syntax; + } + } +} \ No newline at end of file diff --git a/src/System.CommandLine/tests/project.json b/src/System.CommandLine/tests/project.json new file mode 100644 index 00000000000..1f190c3836e --- /dev/null +++ b/src/System.CommandLine/tests/project.json @@ -0,0 +1,21 @@ +{ + "dependencies": { + "System.Collections": "4.0.10-*", + "System.Console": "4.0.0-*", + "System.Diagnostics.Debug": "4.0.10-*", + "System.Diagnostics.Tools": "4.0.1-beta-*", + "System.IO": "4.0.10", + "System.IO.FileSystem": "4.0.0", + "System.Linq": "4.0.0-*", + "System.Reflection.Primitives": "4.0.0", + "System.Resources.ResourceManager": "4.0.0-*", + "System.Runtime": "4.0.20", + "System.Runtime.Extensions": "4.0.10", + "System.Runtime.InteropServices": "4.0.20-*", + "System.Threading": "4.0.10", + "xunit": "2.0.0-beta5-build2785" + }, + "frameworks": { + "dnxcore50": {} + } +} From 6bea8016b906a72b416e9a8f129c6ab59190813f Mon Sep 17 00:00:00 2001 From: Immo Landwerth Date: Wed, 30 Sep 2015 09:23:19 -0700 Subject: [PATCH 2/3] Remove support for slash options Supporting slashes seems problematic for cross-platform usage. When running on non-Windows platform, we'd have to disallow it anyways in order to avoid parsing issues. On Windows there is already enough prior-art to justify dropping support for it. For instance, PowerShell doesn't slashes either. Also, Windows users have started to use many ports of Unix tools, such as Git, that also don't support slashes. The only downside of removing support for slashes is that existing tools that support slashes can't start to depend on this library. Until we've anybody asking for it, it seems better to start clean. --- src/System.CommandLine/README.md | 31 +++++-------------- .../src/System/CommandLine/ArgumentLexer.cs | 3 +- .../CommandLine/Tests/ArgumentLexerTests.cs | 19 ++++++------ .../CommandLine/Tests/ArgumentSyntaxTests.cs | 11 +++---- 4 files changed, 22 insertions(+), 42 deletions(-) diff --git a/src/System.CommandLine/README.md b/src/System.CommandLine/README.md index f4d39384f2d..8c9b2f5f7f7 100644 --- a/src/System.CommandLine/README.md +++ b/src/System.CommandLine/README.md @@ -101,15 +101,7 @@ They can be *bundled* together, such as $ tool -xdf -You can also use a slash, e.g. - - $ tool /x /d /f - -However, slashes don't support bundling. For example, the following isn't -recognized: - - # This is not equivalent to -xdf - $ tool /xdf +Please note that slashes aren't supported. ### Keyword options @@ -117,28 +109,21 @@ Keyword options are delimited by two dashes, such as: $ tool --verbose -Alternatively, you can use a slash: - - $ tool /verbose - -Using two dashes avoids any ambiguity with bundled forms -- which is why -slashes don't support bundling. - ### Option arguments Both, the single letter form, as well as the long forms, support arguments. Arguments must be separated by either a space, an equal sign or a colon: # All three forms are identical: - $ tool /out result.exe - $ tool /out=result.exe - $ tool /out:result.exe + $ tool --out result.exe + $ tool --out=result.exe + $ tool --out:result.exe Multiple spaces are allowed as well: - $ tool /out result.exe - $ tool /out = result.exe - $ tool /out : result.exe + $ tool --out result.exe + $ tool --out = result.exe + $ tool --out : result.exe This even works when combined with bundling, but in that case only the last option can have an argument. So this: @@ -278,7 +263,7 @@ will be an argument or the first parameter. Both, options and parameters, support the notion of lists. For example, consider the C# compiler CSC: - $ csc /r:mscorlib.dll /r:system.dll source1.cs source2.cs /out:hello.exe /t:exe + $ csc -r:mscorlib.dll -r:system.dll source1.cs source2.cs -out:hello.exe -t:exe You would define the options and parameters as follows: diff --git a/src/System.CommandLine/src/System/CommandLine/ArgumentLexer.cs b/src/System.CommandLine/src/System/CommandLine/ArgumentLexer.cs index 62ab1f1d979..6f2dca9a33a 100644 --- a/src/System.CommandLine/src/System/CommandLine/ArgumentLexer.cs +++ b/src/System.CommandLine/src/System/CommandLine/ArgumentLexer.cs @@ -160,8 +160,7 @@ private static void ExpandOptionBundle(IList receiver, int index) private static bool TryExtractOption(string text, out string modifier, out string remainder) { return TryExtractOption(text, @"--", out modifier, out remainder) || - TryExtractOption(text, @"-", out modifier, out remainder) || - TryExtractOption(text, @"/", out modifier, out remainder); + TryExtractOption(text, @"-", out modifier, out remainder); } private static bool TryExtractOption(string text, string prefix, out string modifier, out string remainder) diff --git a/src/System.CommandLine/tests/System/CommandLine/Tests/ArgumentLexerTests.cs b/src/System.CommandLine/tests/System/CommandLine/Tests/ArgumentLexerTests.cs index 685084faad2..93aa1b696c7 100644 --- a/src/System.CommandLine/tests/System/CommandLine/Tests/ArgumentLexerTests.cs +++ b/src/System.CommandLine/tests/System/CommandLine/Tests/ArgumentLexerTests.cs @@ -27,12 +27,11 @@ public void Lex_Parameters() [Fact] public void Lex_Options() { - var text = "-a /b --c"; + var text = "-a --b"; var actual = Lex(text); var expected = new[] { new ArgumentToken("-", "a", null), - new ArgumentToken("/", "b", null), - new ArgumentToken("--", "c", null) + new ArgumentToken("--", "b", null) }; Assert.Equal(expected, actual); @@ -41,11 +40,11 @@ public void Lex_Options() [Fact] public void Lex_OptionArguments() { - var text = "-a:va /b=vb --c vc"; + var text = "-a:va -b=vb --c vc"; var actual = Lex(text); var expected = new[] { new ArgumentToken("-", "a", "va"), - new ArgumentToken("/", "b", "vb"), + new ArgumentToken("-", "b", "vb"), new ArgumentToken("--", "c", null), new ArgumentToken(null, "vc", null) }; @@ -68,12 +67,12 @@ public void Lex_ExpandsBundles() } [Fact] - public void Lex_ExpandsBundles_UnlessUsingSlash() + public void Lex_ExpandsBundles_UnlessUsingDashDash() { - var text = "/xdf"; + var text = "--xdf"; var actual = Lex(text); var expected = new[] { - new ArgumentToken("/", "xdf", null) + new ArgumentToken("--", "xdf", null) }; Assert.Equal(expected, actual); @@ -87,7 +86,7 @@ public void Lex_ExpandsReponseFile() { "-xdf", "--out:out.exe", - "/responseFileReader", + "--responseFileReader", @"C:\Reference Assemblies\system.dll" }; @@ -100,7 +99,7 @@ public void Lex_ExpandsReponseFile() new ArgumentToken("-", "d", null), new ArgumentToken("-", "f", null), new ArgumentToken("--", "out", "out.exe"), - new ArgumentToken("/", "responseFileReader", null), + new ArgumentToken("--", "responseFileReader", null), new ArgumentToken(null, @"C:\Reference Assemblies\system.dll", null), new ArgumentToken("--", "after", null) }; diff --git a/src/System.CommandLine/tests/System/CommandLine/Tests/ArgumentSyntaxTests.cs b/src/System.CommandLine/tests/System/CommandLine/Tests/ArgumentSyntaxTests.cs index 46f8d4cb2de..7ad8601ea64 100644 --- a/src/System.CommandLine/tests/System/CommandLine/Tests/ArgumentSyntaxTests.cs +++ b/src/System.CommandLine/tests/System/CommandLine/Tests/ArgumentSyntaxTests.cs @@ -466,14 +466,12 @@ public void Option_Usage_Error_RequiresValue(string commandLine) Assert.Equal("option -a requires a value", ex.Message); } - [Theory] - [InlineData("/")] - [InlineData("--")] - public void Option_Usage_Error_Flag_Bundle_ViaModifier(string modifier) + [Fact] + public void Option_Usage_Error_Flag_Bundle_ViaDashDash() { var ex = Assert.Throws(() => { - Parse(modifier + "opq", syntax => + Parse("--opq", syntax => { syntax.DefineOption("o", false); syntax.DefineOption("p", false); @@ -481,7 +479,7 @@ public void Option_Usage_Error_Flag_Bundle_ViaModifier(string modifier) }); }); - Assert.Equal("invalid option " + modifier + "opq", ex.Message); + Assert.Equal("invalid option --opq", ex.Message); } [Theory] @@ -499,7 +497,6 @@ public void Option_Usage_ViaNonLetterName(string name) } [Theory] - [InlineData("/")] [InlineData("-")] [InlineData("--")] public void Option_Usage_ViaModifer(string modifier) From dade26987fc7126ec172cb1020c30201f31bd870 Mon Sep 17 00:00:00 2001 From: Immo Landwerth Date: Wed, 30 Sep 2015 09:40:38 -0700 Subject: [PATCH 3/3] Allow options to appear more than once Unix has a strong tradition for scripting. In order to make it easier to forward arguments to scripts, it's common practice to allow options to appear more than once. The semantics are that the last one wins. So this: $ tool -a this -b -a that should be equivalent to $ tool -b -a that --- src/System.CommandLine/README.md | 12 +++++++ .../src/System/CommandLine/ArgumentParser.cs | 11 +++---- .../src/System/Strings.Designer.cs | 11 +------ .../src/System/Strings.resx | 3 -- .../CommandLine/Tests/ArgumentSyntaxTests.cs | 33 +++++++++---------- 5 files changed, 34 insertions(+), 36 deletions(-) diff --git a/src/System.CommandLine/README.md b/src/System.CommandLine/README.md index 8c9b2f5f7f7..4281de24d2c 100644 --- a/src/System.CommandLine/README.md +++ b/src/System.CommandLine/README.md @@ -134,6 +134,18 @@ is equivalent to: $ tool -a -m "hello" +### Multiple occurrences + +Unix has a strong tradition for scripting. In order to make it easier to forward +arguments to scripts, it's common practice to allow options to appear more than +once. The semantics are that the last one wins. So this: + + $ tool -a this -b -a that + +has the same effect as that: + + $ tool -b -a that + ### Parameters Parameters, sometimes also called non-option arguments, can be anywhere in the diff --git a/src/System.CommandLine/src/System/CommandLine/ArgumentParser.cs b/src/System.CommandLine/src/System/CommandLine/ArgumentParser.cs index 314f6add759..36d66ffe0e2 100644 --- a/src/System.CommandLine/src/System/CommandLine/ArgumentParser.cs +++ b/src/System.CommandLine/src/System/CommandLine/ArgumentParser.cs @@ -46,13 +46,12 @@ public bool TryParseOption(string diagnosticName, IReadOnlyCollection return false; } - if (values.Count > 1) - { - var message = string.Format(Strings.OptionSpecifiedMultipleTimesFmt, diagnosticName); - throw new ArgumentSyntaxException(message); - } + // Please note that we don't verify that the option is only specified once. + // It's tradition on Unix to allow single options to occur more than once, + // with 'last once wins' semantics. This simplifies scripting because you + // easily combine arguments. - value = values.Single(); + value = values.Last(); return true; } diff --git a/src/System.CommandLine/src/System/Strings.Designer.cs b/src/System.CommandLine/src/System/Strings.Designer.cs index 73681e7b0ed..f423456e8c1 100644 --- a/src/System.CommandLine/src/System/Strings.Designer.cs +++ b/src/System.CommandLine/src/System/Strings.Designer.cs @@ -134,7 +134,7 @@ internal static string HelpUsageOfApplicationFmt { } ///

- /// Looks up a localized string similar to invalid option {0}{1}. + /// Looks up a localized string similar to invalid option {0}. /// internal static string InvalidOptionFmt { get { @@ -196,15 +196,6 @@ internal static string OptionsMustBeDefinedBeforeParameters { } } - /// - /// Looks up a localized string similar to option {0} is specified multiple times. - /// - internal static string OptionSpecifiedMultipleTimesFmt { - get { - return ResourceManager.GetString("OptionSpecifiedMultipleTimesFmt", resourceCulture); - } - } - /// /// Looks up a localized string similar to Parameter '{0}' is already defined.. /// diff --git a/src/System.CommandLine/src/System/Strings.resx b/src/System.CommandLine/src/System/Strings.resx index 17b78e7c77a..6f4c89b939f 100644 --- a/src/System.CommandLine/src/System/Strings.resx +++ b/src/System.CommandLine/src/System/Strings.resx @@ -150,9 +150,6 @@ Options must be defined before any parameters. - - option {0} is specified multiple times - Response file '{0}' doesn't exist. diff --git a/src/System.CommandLine/tests/System/CommandLine/Tests/ArgumentSyntaxTests.cs b/src/System.CommandLine/tests/System/CommandLine/Tests/ArgumentSyntaxTests.cs index 7ad8601ea64..b0eaa6f2d0c 100644 --- a/src/System.CommandLine/tests/System/CommandLine/Tests/ArgumentSyntaxTests.cs +++ b/src/System.CommandLine/tests/System/CommandLine/Tests/ArgumentSyntaxTests.cs @@ -430,23 +430,6 @@ public void Option_Usage_Error_Conversion_CustomConverter() Assert.Equal("value 'abc' isn't valid for -o: invalid format", ex.Message); } - [Fact] - public void Option_Usage_Error_Duplicate() - { - var ex = Assert.Throws(() => - { - Parse("-a -b -a", syntax => - { - var arg1 = false; - var arg2 = false; - syntax.DefineOption("a", ref arg1, string.Empty); - syntax.DefineOption("b", ref arg2, string.Empty); - }); - }); - - Assert.Equal("option -a is specified multiple times", ex.Message); - } - [Theory] [InlineData("-a")] [InlineData("-a:")] @@ -511,6 +494,22 @@ public void Option_Usage_ViaModifer(string modifier) Assert.True(o); } + [Fact] + public void Option_Usage_LastOneWins() + { + var a = string.Empty; + var b = false; + + Parse("-a x -b -a y -b:false", syntax => + { + syntax.DefineOption("a", ref a, string.Empty); + syntax.DefineOption("b", ref b, string.Empty); + }); + + Assert.Equal("y", a); + Assert.False(b); + } + [Fact] public void Option_Usage_Flag_DoesNotRequireValue() {