shellwords 0.1.3.2 → 0.1.4.0
raw patch · 8 files changed
+404/−246 lines, 8 files
Files
- CHANGELOG.md +5/−1
- shellwords.cabal +5/−2
- src/ShellWords.hs +17/−68
- src/ShellWords/Parse.hs +78/−0
- src/ShellWords/Quote.hs +81/−0
- test/ShellWords/ParseSpec.hs +175/−0
- test/ShellWords/QuoteSpec.hs +43/−0
- test/ShellWordsSpec.hs +0/−175
CHANGELOG.md view
@@ -1,4 +1,8 @@-## [*Unreleased*](https://github.com/pbrisbin/hs-shellwords/compare/v0.1.3.2...main)+## [*Unreleased*](https://github.com/pbrisbin/hs-shellwords/compare/v0.1.4.0...main)++## [v0.1.4.0](https://github.com/pbrisbin/hs-shellwords/compare/v0.1.3.2...v0.1.4.0)++- Add `quote` and `join` ([@999years](https://github.com/pbrisbin/hs-shellwords/pull/11)) ## [v0.1.3.2](https://github.com/pbrisbin/hs-shellwords/compare/v0.1.3.1...v0.1.3.2)
shellwords.cabal view
@@ -1,6 +1,6 @@ cabal-version: 1.18 name: shellwords-version: 0.1.3.2+version: 0.1.4.0 license: MIT license-file: LICENSE copyright: 2018 Patrick Brisbin@@ -23,6 +23,8 @@ library exposed-modules: ShellWords+ ShellWords.Parse+ ShellWords.Quote Text.Megaparsec.Compat hs-source-dirs: src@@ -52,7 +54,8 @@ main-is: Spec.hs hs-source-dirs: test other-modules:- ShellWordsSpec+ ShellWords.ParseSpec+ ShellWords.QuoteSpec Paths_shellwords default-language: Haskell2010
src/ShellWords.hs view
@@ -1,77 +1,26 @@-{-# LANGUAGE OverloadedStrings #-}- module ShellWords- ( parse+ ( -- * Splitting shell words+ parse , parseText + -- * Quoting for shells+ , quote+ , join+ -- * Low-level parser , Parser , runParser , parser ) where -import Prelude--import Data.Bifunctor (first)-import Data.Char-import Data.Text (Text, pack, unpack)-import Data.Void (Void)-import qualified Text.Megaparsec as Megaparsec-import Text.Megaparsec.Char-import Text.Megaparsec.Compat hiding (parse, runParser)--type Parser = Parsec Void String--parse :: String -> Either String [String]-parse = runParser parser--runParser :: Parser a -> String -> Either String a-runParser p = first errorBundlePretty . Megaparsec.parse (p <* eof) "<input>"---- | Parse and return @'Text'@ values-parseText :: Text -> Either String [Text]-parseText = fmap (map pack) . parse . unpack--parser :: Parser [String]-parser = space *> shellword `sepEndBy1` space1 <* space--shellword :: Parser String-shellword = fmap concat $ some $ bare <|> quoted---- | A plain value, here till an (unescaped) space or quote-bare :: Parser String-bare = some go- where- go =- escaped '\\'- <|> escapedSpace- <|> escapedAnyOf (reserved <> quotes)- <|> satisfy ((&&) <$> not . isSpace <*> (`notElem` (reserved <> quotes)))- <?> "non white space / non reserved character / non quote"---- | A balanced, single- or double-quoted string-quoted :: Parser String-quoted = do- q <- oneOf quotes- manyTill (escaped '\\' <|> escaped q <|> anyToken) $ char q--escaped :: Char -> Parser Char-escaped c = c <$ (escapedSatisfy (== c) <?> "escaped" <> show c)--escapedSpace :: Parser Char-escapedSpace = escapedSatisfy isSpace <?> "escaped white space"--escapedAnyOf :: [Char] -> Parser Char-escapedAnyOf cs = escapedSatisfy (`elem` cs) <?> "escaped one of " <> cs--escapedSatisfy :: (Char -> Bool) -> Parser Char-escapedSatisfy p = try $ string "\\" *> satisfy p--anyToken :: Parser Char-anyToken = satisfy $ const True--reserved :: [Char]-reserved = "();"--quotes :: [Char]-quotes = "\'\""+import ShellWords.Parse+ ( Parser+ , parse+ , parseText+ , parser+ , runParser+ )+import ShellWords.Quote+ ( join+ , quote+ )
+ src/ShellWords/Parse.hs view
@@ -0,0 +1,78 @@+{-# LANGUAGE OverloadedStrings #-}++module ShellWords.Parse+ ( -- * Splitting shell words+ parse+ , parseText++ -- * Low-level parser+ , Parser+ , runParser+ , parser+ ) where++import Prelude++import Data.Bifunctor (first)+import Data.Char+import Data.Text (Text, pack, unpack)+import Data.Void (Void)+import qualified Text.Megaparsec as Megaparsec+import Text.Megaparsec.Char+import Text.Megaparsec.Compat hiding (parse, runParser)++type Parser = Parsec Void String++parse :: String -> Either String [String]+parse = runParser parser++runParser :: Parser a -> String -> Either String a+runParser p = first errorBundlePretty . Megaparsec.parse (p <* eof) "<input>"++-- | Parse and return @'Text'@ values+parseText :: Text -> Either String [Text]+parseText = fmap (map pack) . parse . unpack++parser :: Parser [String]+parser = space *> shellword `sepEndBy1` space1 <* space++shellword :: Parser String+shellword = fmap concat $ some $ bare <|> quoted++-- | A plain value, here till an (unescaped) space or quote+bare :: Parser String+bare = some go+ where+ go =+ escaped '\\'+ <|> escapedSpace+ <|> escapedAnyOf (reserved <> quotes)+ <|> satisfy ((&&) <$> not . isSpace <*> (`notElem` (reserved <> quotes)))+ <?> "non white space / non reserved character / non quote"++-- | A balanced, single- or double-quoted string+quoted :: Parser String+quoted = do+ q <- oneOf quotes+ manyTill (escaped '\\' <|> escaped q <|> anyToken) $ char q++escaped :: Char -> Parser Char+escaped c = c <$ (escapedSatisfy (== c) <?> "escaped" <> show c)++escapedSpace :: Parser Char+escapedSpace = escapedSatisfy isSpace <?> "escaped white space"++escapedAnyOf :: [Char] -> Parser Char+escapedAnyOf cs = escapedSatisfy (`elem` cs) <?> "escaped one of " <> cs++escapedSatisfy :: (Char -> Bool) -> Parser Char+escapedSatisfy p = try $ string "\\" *> satisfy p++anyToken :: Parser Char+anyToken = satisfy $ const True++reserved :: [Char]+reserved = "();"++quotes :: [Char]+quotes = "\'\""
+ src/ShellWords/Quote.hs view
@@ -0,0 +1,81 @@+{-# LANGUAGE NamedFieldPuns #-}++module ShellWords.Quote+ ( -- * Quoting for shells+ quote+ , join+ ) where++import Prelude++-- | How to escape a `String` for @sh(1)@.+data EscapeStyle+ = -- | No escaping.+ NoEscaping+ | -- | Wrapped in single quotes.+ SingleQuoted+ | -- | Wrapped in single quotes+ Mixed++-- | Internal state for `escapeStyle`.+data EscapeStyleState+ = EscapeStyleState+ { hasSpecial :: Bool+ , hasNewline :: Bool+ , hasSingleQuote :: Bool+ }++-- | Determine how to escape a `String`.+escapeStyle :: String -> EscapeStyle+escapeStyle "" = SingleQuoted+escapeStyle str =+ let+ isOtherSpecial :: Char -> Bool+ isOtherSpecial c = c `elem` ("|&;<>()$`\\\" \t*?[#~=%" :: String)++ helper :: EscapeStyleState -> String -> EscapeStyle+ helper state (c : s)+ | c == '\n' = helper state {hasNewline = True, hasSpecial = True} s+ | c == '\'' = helper state {hasSingleQuote = True, hasSpecial = True} s+ | isOtherSpecial c = helper state {hasSpecial = True} s+ | otherwise = helper state s+ helper EscapeStyleState {hasSpecial, hasNewline, hasSingleQuote} []+ | not hasSpecial = NoEscaping+ | hasNewline && not hasSingleQuote = SingleQuoted+ | otherwise = Mixed+ in+ helper+ EscapeStyleState+ { hasSpecial = False+ , hasNewline = False+ , hasSingleQuote = False+ }+ str++-- | Escape special characters in a string, so that it will retain its literal+-- meaning when used as a part of command in a Unix shell.+--+-- It tries to avoid introducing any unnecessary quotes or escape characters,+-- but specifics regarding quoting style are left unspecified.+quote :: String -> String+quote str =+ case escapeStyle str of+ NoEscaping -> str+ SingleQuoted -> "'" <> str <> "'"+ Mixed ->+ let+ quoteMixed :: String -> String+ quoteMixed [] = []+ quoteMixed ('\'' : s) = "'\\''" <> quoteMixed s+ quoteMixed (c : s) = c : quoteMixed s+ in+ "'" <> quoteMixed str <> "'"++-- | Joins arguments into a single command line suitable for execution in a Unix shell.+--+-- Each argument is quoted using `quote` to preserve its literal meaning when+-- parsed by Unix shell.+--+-- Note: This function is essentially an (infallible) inverse of `parse`.+join :: [String] -> String+join = unwords . map quote
+ test/ShellWords/ParseSpec.hs view
@@ -0,0 +1,175 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}++-- |+--+-- Case-for-case port from Python:+--+-- <https://github.com/mozillazg/python-shellwords/blob/master/tests/test_shellwords.py>+module ShellWords.ParseSpec+ ( spec+ ) where++import Prelude hiding (truncate)++import Data.Foldable (for_, traverse_)+import ShellWords.Parse (parser, runParser)+import Test.Hspec+import Text.Megaparsec.Char (string)+import Text.Megaparsec.Compat (between)++testCases :: [(String, [String])]+testCases =+ [ ("var --bar=baz", ["var", "--bar=baz"])+ , ("var --bar=\"baz\"", ["var", "--bar=baz"])+ , ("var \"--bar=baz\"", ["var", "--bar=baz"])+ , ("var \"--bar='baz'\"", ["var", "--bar='baz'"])+ , ("var --bar=`baz`", ["var", "--bar=`baz`"])+ , ("var \"--bar=\\\"baz'\"", ["var", "--bar=\"baz'"])+ , ("var \"--bar=\\'baz\\'\"", ["var", "--bar=\\'baz\\'"])+ , ("var \"--bar baz\"", ["var", "--bar baz"])+ , ("var --\"bar baz\"", ["var", "--bar baz"])+ , ("var --\"bar baz\"", ["var", "--bar baz"])+ , -- Additional test cases for whitespace+ ("var --bar=baz ", ["var", "--bar=baz"])+ , (" var --bar=baz ", ["var", "--bar=baz"])+ , (" var --bar=baz ", ["var", "--bar=baz"])+ , -- Additional test cases for escaped spaces+ ("var --bar\\ baz", ["var", "--bar baz"])+ , ("var --bar=baz\\ bat", ["var", "--bar=baz bat"])+ , ("var --bar baz\\ bat", ["var", "--bar", "baz bat"])+ , -- N.B. sh preserves escapes in quoted values, python-shellwords does not.+ -- we behave like sh in this regard.+ ("var --bar 'baz\\ bat'", ["var", "--bar", "baz\\ bat"])+ , ("var --bar \"baz\\ bat\"", ["var", "--bar", "baz\\ bat"])+ , ("var \"--bar=\\\\'baz\\\\'\"", ["var", "--bar=\\'baz\\'"])+ , ("var --bar='\\\\'", ["var", "--bar=\\"])+ ]++errorCases :: [String]+errorCases =+ [ "foo '"+ , "foo \""+ --+ -- Doesn't need to error since we don't have parse_backtick either.+ --+ -- , "foo `"+ ]++runTestCase :: HasCallStack => String -> [String] -> Spec+runTestCase input expected =+ it ("parses |" <> truncate 50 input <> "| correctly") $ do+ runParser parser input `shouldBeParsed` expected+ where+ truncate n x+ | length x > n = take n x <> "..."+ | otherwise = x++spec :: Spec+spec = describe "parser" $ do+ traverse_ (uncurry runTestCase) testCases++ for_ errorCases $ \input -> do+ it ("errors on |" <> input <> "|") $ do+ expectParseError $ runParser parser input++ context "Issue #3" $ do+ runTestCase+ "-LC:/Users/Vitor\\ Coimbra/AppData/Local/Programs/stack/x86_64-windows/msys2-20150512/mingw64/lib -ltag\n"+ [ "-LC:/Users/Vitor Coimbra/AppData/Local/Programs/stack/x86_64-windows/msys2-20150512/mingw64/lib"+ , "-ltag"+ ]++ context "Issue #7" $ do+ runTestCase "foo=123 bar" ["foo=123", "bar"]+ runTestCase "foo bar=123 cow" ["foo", "bar=123", "cow"]+ runTestCase "foo bar'();='bar cow" ["foo", "bar();=bar", "cow"]+ runTestCase "foo 'ba'\"r\" cow" ["foo", "bar", "cow"]++ context "As part of a larger Parser" $ do+ let parseDelimited input =+ runParser (between (string "FOO=$(") (string ")") parser) $+ "FOO=$("+ <> input+ <> ")"++ it "parses within delimiters" $ do+ parseDelimited "echo \"hi\"" `shouldBeParsed` ["echo", "hi"]++ it "handles quoted delimiter" $ do+ parseDelimited "echo \"hi (quietly)\""+ `shouldBeParsed` ["echo", "hi (quietly)"]++ it "works with white space" $ do+ parseDelimited " echo \n\"hi\" " `shouldBeParsed` ["echo", "hi"]++ it "works with newlines" $ do+ parseDelimited "echo \n\"hi\"" `shouldBeParsed` ["echo", "hi"]++ it "works with final newline" $ do+ parseDelimited "echo \n\"hi\"\n" `shouldBeParsed` ["echo", "hi"]++expectParseError :: (Show a, HasCallStack) => Either String a -> Expectation+expectParseError = \case+ Left {} -> pure ()+ Right a' -> expectationFailure $ "Expected parse error, got:" <> show a'++shouldBeParsed+ :: (Show a, Eq a, HasCallStack)+ => Either String a+ -> a+ -> Expectation+a `shouldBeParsed` b = case a of+ Left e -> expectationFailure e+ Right a' -> a' `shouldBe` b++{- Features I'm not sure I'll be porting, certainly not yet.++def test_backtick():+ goversion, err = shell_run("go version")+ assert not err++ s = ShellWords(parse_backtick=True)+ args = s.parse("echo `go version`")++ expected = ["echo", goversion.strip('\n')]+ assert args == expected++def test_backtick_error():+ s = ShellWords(parse_backtick=True)+ try:+ s.parse("echo `go Version`")+ except:+ pass+ else:+ raise Exception("Should be an error")++def test_env():+ os.environ["FOO"] = "bar"++ s = ShellWords(parse_env=True)+ args = s.parse("echo $FOO")++ expected = ["echo", "bar"]+ assert args == expected++def test_no_env():+ s = ShellWords(parse_env=True)+ args = s.parse("echo $BAR")++ expected = ["echo", ""]+ assert args == expected++def test_dup_env():+ os.environ["FOO"] = "bar"+ os.environ["FOO_BAR"] = "baz"++ s = ShellWords(parse_env=True)+ args = s.parse("echo $$FOO$")+ expected = ["echo", "$bar$"]+ assert args == expected++ args = s.parse("echo $${FOO_BAR}$")+ expected = ["echo", "$baz$"]+ assert args == expected+-}
+ test/ShellWords/QuoteSpec.hs view
@@ -0,0 +1,43 @@+{-# LANGUAGE OverloadedStrings #-}++-- |+--+-- Some test cases ported from Rust:+--+-- <https://github.com/tmiasko/shell-words/blob/045e4dccd2478ccc8bfa91bd0fe449dfe5473496/src/lib.rs#L475-L488>+module ShellWords.QuoteSpec+ ( spec+ ) where++import Prelude hiding (truncate)++import ShellWords.Quote (join, quote)+import Test.Hspec++spec :: Spec+spec = do+ describe "quote" $ do+ it "single-quotes the empty string" $ do+ quote "" `shouldBe` "''"++ it "escapes a single quote" $ do+ quote "'" `shouldBe` "''\\'''"++ it "leaves un-special characters alone" $ do+ quote "abc" `shouldBe` "abc"++ it "quotes newlines" $ do+ quote "a \n b" `shouldBe` "'a \n b'"++ it "quotes newlines and escapes single-quotes" $ do+ quote "X'\nY" `shouldBe` "'X'\\''\nY'"++ it "quotes tildes correctly" $ do+ quote "~root" `shouldBe` "'~root'"++ describe "join" $ do+ it "joins arguments" $ do+ join ["a", "b", "c"] `shouldBe` "a b c"++ it "quotes arguments" $ do+ join [" ", "$", "\n"] `shouldBe` "' ' '$' '\n'"
− test/ShellWordsSpec.hs
@@ -1,175 +0,0 @@-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE OverloadedStrings #-}---- |------ Case-for-case port from Python:------ <https://github.com/mozillazg/python-shellwords/blob/master/tests/test_shellwords.py>-module ShellWordsSpec- ( spec- ) where--import Prelude hiding (truncate)--import Data.Foldable (for_, traverse_)-import ShellWords-import Test.Hspec-import Text.Megaparsec.Char (string)-import Text.Megaparsec.Compat (between)--testCases :: [(String, [String])]-testCases =- [ ("var --bar=baz", ["var", "--bar=baz"])- , ("var --bar=\"baz\"", ["var", "--bar=baz"])- , ("var \"--bar=baz\"", ["var", "--bar=baz"])- , ("var \"--bar='baz'\"", ["var", "--bar='baz'"])- , ("var --bar=`baz`", ["var", "--bar=`baz`"])- , ("var \"--bar=\\\"baz'\"", ["var", "--bar=\"baz'"])- , ("var \"--bar=\\'baz\\'\"", ["var", "--bar=\\'baz\\'"])- , ("var \"--bar baz\"", ["var", "--bar baz"])- , ("var --\"bar baz\"", ["var", "--bar baz"])- , ("var --\"bar baz\"", ["var", "--bar baz"])- , -- Additional test cases for whitespace- ("var --bar=baz ", ["var", "--bar=baz"])- , (" var --bar=baz ", ["var", "--bar=baz"])- , (" var --bar=baz ", ["var", "--bar=baz"])- , -- Additional test cases for escaped spaces- ("var --bar\\ baz", ["var", "--bar baz"])- , ("var --bar=baz\\ bat", ["var", "--bar=baz bat"])- , ("var --bar baz\\ bat", ["var", "--bar", "baz bat"])- , -- N.B. sh preserves escapes in quoted values, python-shellwords does not.- -- we behave like sh in this regard.- ("var --bar 'baz\\ bat'", ["var", "--bar", "baz\\ bat"])- , ("var --bar \"baz\\ bat\"", ["var", "--bar", "baz\\ bat"])- , ("var \"--bar=\\\\'baz\\\\'\"", ["var", "--bar=\\'baz\\'"])- , ("var --bar='\\\\'", ["var", "--bar=\\"])- ]--errorCases :: [String]-errorCases =- [ "foo '"- , "foo \""- --- -- Doesn't need to error since we don't have parse_backtick either.- --- -- , "foo `"- ]--runTestCase :: HasCallStack => String -> [String] -> Spec-runTestCase input expected =- it ("parses |" <> truncate 50 input <> "| correctly") $ do- runParser parser input `shouldBeParsed` expected- where- truncate n x- | length x > n = take n x <> "..."- | otherwise = x--spec :: Spec-spec = describe "parser" $ do- traverse_ (uncurry runTestCase) testCases-- for_ errorCases $ \input -> do- it ("errors on |" <> input <> "|") $ do- expectParseError $ runParser parser input-- context "Issue #3" $ do- runTestCase- "-LC:/Users/Vitor\\ Coimbra/AppData/Local/Programs/stack/x86_64-windows/msys2-20150512/mingw64/lib -ltag\n"- [ "-LC:/Users/Vitor Coimbra/AppData/Local/Programs/stack/x86_64-windows/msys2-20150512/mingw64/lib"- , "-ltag"- ]-- context "Issue #7" $ do- runTestCase "foo=123 bar" ["foo=123", "bar"]- runTestCase "foo bar=123 cow" ["foo", "bar=123", "cow"]- runTestCase "foo bar'();='bar cow" ["foo", "bar();=bar", "cow"]- runTestCase "foo 'ba'\"r\" cow" ["foo", "bar", "cow"]-- context "As part of a larger Parser" $ do- let parseDelimited input =- runParser (between (string "FOO=$(") (string ")") parser) $- "FOO=$("- <> input- <> ")"-- it "parses within delimiters" $ do- parseDelimited "echo \"hi\"" `shouldBeParsed` ["echo", "hi"]-- it "handles quoted delimiter" $ do- parseDelimited "echo \"hi (quietly)\""- `shouldBeParsed` ["echo", "hi (quietly)"]-- it "works with white space" $ do- parseDelimited " echo \n\"hi\" " `shouldBeParsed` ["echo", "hi"]-- it "works with newlines" $ do- parseDelimited "echo \n\"hi\"" `shouldBeParsed` ["echo", "hi"]-- it "works with final newline" $ do- parseDelimited "echo \n\"hi\"\n" `shouldBeParsed` ["echo", "hi"]--expectParseError :: (Show a, HasCallStack) => Either String a -> Expectation-expectParseError = \case- Left {} -> pure ()- Right a' -> expectationFailure $ "Expected parse error, got:" <> show a'--shouldBeParsed- :: (Show a, Eq a, HasCallStack)- => Either String a- -> a- -> Expectation-a `shouldBeParsed` b = case a of- Left e -> expectationFailure e- Right a' -> a' `shouldBe` b--{- Features I'm not sure I'll be porting, certainly not yet.--def test_backtick():- goversion, err = shell_run("go version")- assert not err-- s = ShellWords(parse_backtick=True)- args = s.parse("echo `go version`")-- expected = ["echo", goversion.strip('\n')]- assert args == expected--def test_backtick_error():- s = ShellWords(parse_backtick=True)- try:- s.parse("echo `go Version`")- except:- pass- else:- raise Exception("Should be an error")--def test_env():- os.environ["FOO"] = "bar"-- s = ShellWords(parse_env=True)- args = s.parse("echo $FOO")-- expected = ["echo", "bar"]- assert args == expected--def test_no_env():- s = ShellWords(parse_env=True)- args = s.parse("echo $BAR")-- expected = ["echo", ""]- assert args == expected--def test_dup_env():- os.environ["FOO"] = "bar"- os.environ["FOO_BAR"] = "baz"-- s = ShellWords(parse_env=True)- args = s.parse("echo $$FOO$")- expected = ["echo", "$bar$"]- assert args == expected-- args = s.parse("echo $${FOO_BAR}$")- expected = ["echo", "$baz$"]- assert args == expected--}