From 50a6c76587dd99e94e66caa12888d19cafefde65 Mon Sep 17 00:00:00 2001 From: Dennis Haney Date: Mon, 31 Aug 2015 16:04:21 +0700 Subject: [PATCH] Fix #232 --- src/CommandLine/CommandLine.csproj | 18 +- .../CSharpStyleCommandLineParser.cs | 194 +++++++++++++++ .../DefaultWindowsCommandLineParser.cs | 79 ++++++ .../StringToCommandLineParserBase.cs | 18 ++ .../CommandLine.Tests.csproj | 227 +++--------------- .../CSharpStyleCommandLineParserTests.cs | 44 ++++ ...indowsCommandLineWithEscapesParserTests.cs | 44 ++++ 7 files changed, 429 insertions(+), 195 deletions(-) create mode 100644 src/CommandLine/StringToCommandLine/CSharpStyleCommandLineParser.cs create mode 100644 src/CommandLine/StringToCommandLine/DefaultWindowsCommandLineParser.cs create mode 100644 src/CommandLine/StringToCommandLine/StringToCommandLineParserBase.cs create mode 100644 tests/CommandLine.Tests/Unit/StringToCommandLine/CSharpStyleCommandLineParserTests.cs create mode 100644 tests/CommandLine.Tests/Unit/StringToCommandLine/WindowsCommandLineWithEscapesParserTests.cs diff --git a/src/CommandLine/CommandLine.csproj b/src/CommandLine/CommandLine.csproj index c2605663..ff0d2f1c 100644 --- a/src/CommandLine/CommandLine.csproj +++ b/src/CommandLine/CommandLine.csproj @@ -119,6 +119,9 @@ + + + Code @@ -167,7 +170,16 @@ - + + + + ..\..\packages\FSharp.Core\lib\net40\FSharp.Core.dll + True + True + + + + ..\..\packages\FSharp.Core\lib\portable-net45+netcore45\FSharp.Core.dll @@ -176,10 +188,10 @@ - + - ..\..\packages\FSharp.Core\lib\net40\FSharp.Core.dll + ..\..\packages\FSharp.Core\lib\portable-net45+monoandroid10+monotouch10+xamarinios10\FSharp.Core.dll True True diff --git a/src/CommandLine/StringToCommandLine/CSharpStyleCommandLineParser.cs b/src/CommandLine/StringToCommandLine/CSharpStyleCommandLineParser.cs new file mode 100644 index 00000000..2eb5e7b1 --- /dev/null +++ b/src/CommandLine/StringToCommandLine/CSharpStyleCommandLineParser.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace CommandLine.StringToCommandLine +{ + /// + /// Parse commandlines like C# would parse a string, splitting at each unquoted space: + /// * "" -> + /// * "abc" -> abc + /// * abc abc -> abc, abc + /// * "\"" -> " + /// * asd"asd -> error + /// * "asd -> error unterminated string + /// * \ -> error unterminated escape + /// * \['"\0abfnrtUuvx] -> https://msdn.microsoft.com/en-us/library/ms228362.aspx?f=255&MSPPError=-2147217396 + /// * \other -> error + /// + public class CSharpStyleCommandLineParser : StringToCommandLineParserBase + { + public override IEnumerable Parse(string commandLine) + { + if (string.IsNullOrWhiteSpace(commandLine)) + yield break; + var currentArg = new StringBuilder(); + var quoting = false; + + var pos = 0; + while (pos < commandLine.Length) + { + var c = commandLine[pos]; + if (c == '\\') + { + // --- Handle escape sequences + pos++; + if (pos >= commandLine.Length) throw new UnterminatedEscapeException(); + switch (commandLine[pos]) + { + case '\'': + c = '\''; + break; + case '\"': + c = '\"'; + break; + case '\\': + c = '\\'; + break; + case '0': + c = '\0'; + break; + case 'a': + c = '\a'; + break; + case 'b': + c = '\b'; + break; + case 'f': + c = '\f'; + break; + case 'n': + c = ' '; + break; + case 'r': + c = ' '; + break; + case 't': + c = '\t'; + break; + case 'v': + c = '\v'; + break; + case 'x': + // --- Hexa escape (1-4 digits) + var hexa = new StringBuilder(10); + pos++; + if (pos >= commandLine.Length) + throw new UnterminatedEscapeException(); + c = commandLine[pos]; + if (char.IsDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) + { + hexa.Append(c); + pos++; + if (pos < commandLine.Length) + { + c = commandLine[pos]; + if (char.IsDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) + { + hexa.Append(c); + pos++; + if (pos < commandLine.Length) + { + c = commandLine[pos]; + if (char.IsDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) + { + hexa.Append(c); + pos++; + if (pos < commandLine.Length) + { + c = commandLine[pos]; + if (char.IsDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) + { + hexa.Append(c); + pos++; + } + } + } + } + } + } + } + c = (char) int.Parse(hexa.ToString(), NumberStyles.HexNumber); + pos--; + break; + case 'u': + // Unicode hexa escape (exactly 4 digits) + pos++; + if (pos + 3 >= commandLine.Length) + throw new UnterminatedEscapeException(); + try + { + var charValue = uint.Parse(commandLine.Substring(pos, 4), NumberStyles.HexNumber); + c = (char) charValue; + pos += 3; + } + catch (SystemException) + { + throw new UnrecognizedEscapeSequenceException(); + } + break; + case 'U': + // Unicode hexa escape (exactly 8 digits, first four must be 0000) + pos++; + if (pos + 7 >= commandLine.Length) + throw new UnterminatedEscapeException(); + try + { + var charValue = uint.Parse(commandLine.Substring(pos, 8), NumberStyles.HexNumber); + if (charValue > 0xffff) + throw new UnrecognizedEscapeSequenceException(); + c = (char) charValue; + pos += 7; + } + catch (SystemException) + { + throw new UnrecognizedEscapeSequenceException(); + } + break; + default: + throw new UnrecognizedEscapeSequenceException(); + } + pos++; + currentArg.Append(c); + continue; + } + if (c == '"') + { + if (quoting) + { + pos++; //skip space + //check that it actually IS a space or EOF + if (pos < commandLine.Length && !char.IsWhiteSpace(commandLine[pos])) + throw new UnquotedQuoteException(); + yield return currentArg.ToString(); + currentArg.Clear(); + quoting = false; + } + else + { + if (currentArg.Length > 0) + throw new UnquotedQuoteException(); + quoting = true; + } + pos++; + continue; + } + if (char.IsWhiteSpace(c) && !quoting) + { + if (currentArg.Length > 0) + yield return currentArg.ToString(); + currentArg.Clear(); + pos++; + continue; + } + pos++; + currentArg.Append(c); + } + if (quoting && currentArg.Length > 0) + throw new UnterminatedStringException(); + if (currentArg.Length > 0) + yield return currentArg.ToString(); + } + } +} \ No newline at end of file diff --git a/src/CommandLine/StringToCommandLine/DefaultWindowsCommandLineParser.cs b/src/CommandLine/StringToCommandLine/DefaultWindowsCommandLineParser.cs new file mode 100644 index 00000000..3c66548a --- /dev/null +++ b/src/CommandLine/StringToCommandLine/DefaultWindowsCommandLineParser.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Text; + +namespace CommandLine.StringToCommandLine +{ + /// + /// Parse commandlines like CommandLineToArgvW: + /// * 2n backslashes followed by a quotation mark produce n backslashes followed by a quotation mark. + /// * (2n) + 1 backslashes followed by a quotation mark again produce n backslashes followed by a quotation mark. + /// * n backslashes not followed by a quotation mark simply produce n backslashes. + /// * Unterminated quoted strings at the end of the line ignores the missing quote. + /// + public class DefaultWindowsCommandLineParser : StringToCommandLineParserBase + { + public override IEnumerable Parse(string commandLine) + { + if (string.IsNullOrWhiteSpace(commandLine)) + yield break; + var currentArg = new StringBuilder(); + var quoting = false; + var emptyIsAnArgument = false; + var lastC = '\0'; + // Iterate all characters from the input string + foreach (var c in commandLine) + { + if (c == '"') + { + var nrbackslash = 0; + for (var i = currentArg.Length - 1; i >= 0; i--) + { + if (currentArg[i] != '\\') break; + nrbackslash++; + } + //* 2n backslashes followed by a quotation mark produce n backslashes followed by a quotation mark. + //also cover nrbackslack == 0 + if (nrbackslash%2 == 0) + { + if (nrbackslash > 0) + currentArg.Length = currentArg.Length - nrbackslash/2; + // Toggle quoted range + quoting = !quoting; + emptyIsAnArgument = true; + if (quoting && lastC == '"') + { + // Doubled quote within a quoted range is like escaping + currentArg.Append(c); + lastC = '\0'; //prevent double quoting + continue; + } + } + else + { + // * (2n) + 1 backslashes followed by a quotation mark again produce n backslashes followed by a quotation mark. + currentArg.Length = currentArg.Length - nrbackslash/2 - 1; + currentArg.Append(c); + } + } + else if (!quoting && char.IsWhiteSpace(c)) + { + // Accept empty arguments only if they are quoted + if (currentArg.Length > 0 || emptyIsAnArgument) + yield return currentArg.ToString(); + // Reset for next argument + currentArg.Clear(); + emptyIsAnArgument = false; + } + else + { + // Copy character from input, no special meaning + currentArg.Append(c); + } + lastC = c; + } + // Save last argument + if (currentArg.Length > 0 || emptyIsAnArgument) + yield return currentArg.ToString(); + } + } +} \ No newline at end of file diff --git a/src/CommandLine/StringToCommandLine/StringToCommandLineParserBase.cs b/src/CommandLine/StringToCommandLine/StringToCommandLineParserBase.cs new file mode 100644 index 00000000..138c57f0 --- /dev/null +++ b/src/CommandLine/StringToCommandLine/StringToCommandLineParserBase.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; + +namespace CommandLine.StringToCommandLine +{ + public abstract class StringToCommandLineParserBase + { + public abstract IEnumerable Parse(string commandLine); + + public class UnterminatedStringException : ArgumentException {} + + public class UnrecognizedEscapeSequenceException : ArgumentException {} + + public class UnquotedQuoteException : ArgumentException {} + + public class UnterminatedEscapeException : ArgumentException {} + } +} \ No newline at end of file diff --git a/tests/CommandLine.Tests/CommandLine.Tests.csproj b/tests/CommandLine.Tests/CommandLine.Tests.csproj index d9a9d62b..da3a53f3 100644 --- a/tests/CommandLine.Tests/CommandLine.Tests.csproj +++ b/tests/CommandLine.Tests/CommandLine.Tests.csproj @@ -17,7 +17,7 @@ ..\..\ true - f62f3018 + 3dcbc90d true @@ -45,6 +45,12 @@ ..\..\CommandLine.snk + + ..\..\packages\FluentAssertions.4.0.0\lib\net45\FluentAssertions.dll + + + ..\..\packages\FluentAssertions.4.0.0\lib\net45\FluentAssertions.Core.dll + @@ -52,6 +58,15 @@ + + ..\..\packages\xunit.abstractions.2.0.0\lib\net35\xunit.abstractions.dll + + + ..\..\packages\xunit.assert.2.0.0\lib\portable-net45+win+wpa81+wp80+monotouch+monoandroid+Xamarin.iOS\xunit.assert.dll + + + ..\..\packages\xunit.extensibility.core.2.0.0\lib\portable-net45+win+wpa81+wp80+monotouch+monoandroid+Xamarin.iOS\xunit.core.dll + @@ -114,6 +129,8 @@ + + @@ -138,64 +155,8 @@ --> - - - - ..\..\packages\FluentAssertions\lib\net40\FluentAssertions.Core.dll - True - True - - - ..\..\packages\FluentAssertions\lib\net40\FluentAssertions.dll - True - True - - - - - - - ..\..\packages\FluentAssertions\lib\net45\FluentAssertions.Core.dll - True - True - - - ..\..\packages\FluentAssertions\lib\net45\FluentAssertions.dll - True - True - - - - - - - ..\..\packages\FluentAssertions\lib\monoandroid\FluentAssertions.Core.dll - True - True - - - - - - - ..\..\packages\FluentAssertions\lib\monotouch\FluentAssertions.Core.dll - True - True - - - - - ..\..\packages\FluentAssertions\lib\sl5\FluentAssertions.Core.dll - True - True - - - ..\..\packages\FluentAssertions\lib\sl5\FluentAssertions.dll - True - True - ..\..\packages\FluentAssertions\lib\sl5\Microsoft.VisualStudio.QualityTools.UnitTesting.Silverlight.dll True @@ -203,60 +164,27 @@ - - - - ..\..\packages\FluentAssertions\lib\wp8\FluentAssertions.Core.dll - True - True - - - ..\..\packages\FluentAssertions\lib\wp8\FluentAssertions.dll - True - True - - - - + + + - - ..\..\packages\FluentAssertions\lib\portable-net40+sl5+win8+wp8+wpa81\FluentAssertions.Core.dll - True - True - - - ..\..\packages\FluentAssertions\lib\portable-net40+sl5+win8+wp8+wpa81\FluentAssertions.dll + + ..\..\packages\FSharp.Core\lib\net20\FSharp.Core.dll True True - - - - ..\..\packages\FluentAssertions\lib\portable-win81+wpa81\FluentAssertions.Core.dll - True - True - - - ..\..\packages\FluentAssertions\lib\portable-win81+wpa81\FluentAssertions.dll - True - True - - - - - - + - ..\..\packages\FSharp.Core\lib\net20\FSharp.Core.dll + ..\..\packages\FSharp.Core\lib\net40\FSharp.Core.dll True True - + ..\..\packages\FSharp.Core\lib\portable-net45+netcore45\FSharp.Core.dll @@ -265,10 +193,10 @@ - + - ..\..\packages\FSharp.Core\lib\net40\FSharp.Core.dll + ..\..\packages\FSharp.Core\lib\portable-net45+monoandroid10+monotouch10+xamarinios10\FSharp.Core.dll True True @@ -315,8 +243,6 @@ - - @@ -331,8 +257,6 @@ - - @@ -347,93 +271,17 @@ - - - - - - - - ..\..\packages\xunit.abstractions\lib\net35\xunit.abstractions.dll - True - True - - - - - - - ..\..\packages\xunit.abstractions\lib\portable-net45+win+wpa81+wp80+monotouch+monoandroid+Xamarin.iOS\xunit.abstractions.dll - True - True - - - - - - - - - ..\..\packages\xunit.assert\lib\portable-net45+win+wpa81+wp80+monotouch+monoandroid+Xamarin.iOS\xunit.assert.dll - True - True - - - - <__paket__xunit_core_props>win8\xunit.core - <__paket__xunit_core_targets>win8\xunit.core - - - - - <__paket__xunit_core_props>monoandroid\xunit.core - - - - - <__paket__xunit_core_props>monotouch\xunit.core - - - - - <__paket__xunit_core_props>wp8\xunit.core - <__paket__xunit_core_targets>wp8\xunit.core - - - - - <__paket__xunit_core_props>portable-net45+win+wpa81+wp80+monotouch+monoandroid+Xamarin.iOS\xunit.core + <__paket__xunit_runner_visualstudio_props>win8\xunit.runner.visualstudio + <__paket__xunit_runner_visualstudio_targets>win8\xunit.runner.visualstudio - + - <__paket__xunit_core_props>portable-win81+wpa81\xunit.core - <__paket__xunit_core_targets>portable-win81+wpa81\xunit.core - - - - - - - - - - ..\..\packages\xunit.extensibility.core\lib\portable-net45+win+wpa81+wp80+monotouch+monoandroid+Xamarin.iOS\xunit.core.dll - True - True - - - - - - - - <__paket__xunit_runner_visualstudio_props>win8\xunit.runner.visualstudio - <__paket__xunit_runner_visualstudio_targets>win8\xunit.runner.visualstudio + <__paket__xunit_runner_visualstudio_props>net20\xunit.runner.visualstudio @@ -442,17 +290,12 @@ <__paket__xunit_runner_visualstudio_targets>wpa81\xunit.runner.visualstudio - - - <__paket__xunit_runner_visualstudio_props>net20\xunit.runner.visualstudio - - - + <__paket__xunit_runner_visualstudio_props>portable-net45+aspnetcore50+win+wpa81+wp80+monotouch+monoandroid\xunit.runner.visualstudio - - + + \ No newline at end of file diff --git a/tests/CommandLine.Tests/Unit/StringToCommandLine/CSharpStyleCommandLineParserTests.cs b/tests/CommandLine.Tests/Unit/StringToCommandLine/CSharpStyleCommandLineParserTests.cs new file mode 100644 index 00000000..af91e1f4 --- /dev/null +++ b/tests/CommandLine.Tests/Unit/StringToCommandLine/CSharpStyleCommandLineParserTests.cs @@ -0,0 +1,44 @@ +using System.Collections; +using System.Linq; +using CommandLine.StringToCommandLine; +using Xunit; + +namespace CommandLine.Tests.Unit.StringToCommandLine +{ + public class CSharpStyleCommandLineParserTests + { + [Fact] + public void TestMethod1() { RunTest("test", new[] {"test"}); } + + [Fact] + public void TestMethod2() { RunTest("test test", new[] {"test", "test"}); } + + [Fact] + public void TestMethod3() { RunTest("test \"test\"", new[] {"test", "test"}); } + + [Fact] + public void TestMethod4() { RunTest("test \"te\\\"s\\\"t\"", new[] {"test", "te\"s\"t"}); } + + [Fact] + public void TestMethod4B() { RunTest("test \"te\\\"\\\"\\\"\\\"s\\\"t\"", new[] {"test", "te\"\"\"\"s\"t"}); } + + [Fact] + public void TestMethod5() { Assert.Throws(() => RunTest("\"abc d e", new[] {""})); } + + [Fact] + public void TestMethod6() { Assert.Throws(() => RunTest("asd\\", new[] {""})); } + + [Fact] + public void TestMethod7() { RunTest("\\\\\\a\\b\\'\\\"\\0\\f\\t\\v", new[] {"\\\a\b\'\"\0\f\t\v"}); } + + [Fact] + public void TestMethod8() { RunTest("Hello\\x1\\x12\\x123\\x1234", new[] {"Hello\x1\x12\x123\x1234"}); } + + private static void RunTest(string commandLine, ICollection expected) + { + var parser = new CSharpStyleCommandLineParser(); + var enumerable = parser.Parse(commandLine); + Assert.Equal(expected, enumerable.ToArray()); + } + } +} diff --git a/tests/CommandLine.Tests/Unit/StringToCommandLine/WindowsCommandLineWithEscapesParserTests.cs b/tests/CommandLine.Tests/Unit/StringToCommandLine/WindowsCommandLineWithEscapesParserTests.cs new file mode 100644 index 00000000..a3e6dcfb --- /dev/null +++ b/tests/CommandLine.Tests/Unit/StringToCommandLine/WindowsCommandLineWithEscapesParserTests.cs @@ -0,0 +1,44 @@ +using System.Collections; +using System.Linq; +using CommandLine.StringToCommandLine; +using Xunit; + +namespace CommandLine.Tests.Unit.StringToCommandLine +{ + public class DefaultWindowsCommandLineParserTests + { + [Fact] + public void TestMethod1() { RunTest("test", new[] {"test"}); } + + [Fact] + public void TestMethod2() { RunTest("test test", new[] {"test", "test"}); } + + [Fact] + public void TestMethod3() { RunTest("test \"test\"", new[] {"test", "test"}); } + + [Fact] + public void TestMethod4() { RunTest("test \"te\"s\"t\"", new[] {"test", "test"}); } + + [Fact] + public void TestMethod4B() { RunTest("test \"te\"\"\"\"s\"t\"", new[] {"test", "te\"\"st"}); } + + [Fact] + public void TestMethod5() { RunTest("\"abc\" d e", new[] {"abc", "d", "e"}); } + + [Fact] + public void TestMethod6() { RunTest("a\\\\b d\"e f\"g h", new[] {"a\\\\b", "de fg", "h"}); } + + [Fact] + public void TestMethod7() { RunTest("a\\\\\\\"b c d", new[] {"a\\\"b", "c", "d"}); } + + [Fact] + public void TestMethod8() { RunTest("a\\\\\\\\\"b c\" d e", new[] {"a\\\\b c", "d", "e"}); } + + private static void RunTest(string commandLine, ICollection expected) + { + var parser = new DefaultWindowsCommandLineParser(); + var enumerable = parser.Parse(commandLine); + Assert.Equal(expected, enumerable.ToArray()); + } + } +}