├── LICENSE ├── README ├── both_test.go ├── doc.go ├── quote.go ├── quote_test.go ├── unquote.go └── unquote_test.go /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2014 Kevin Ballard 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included 11 | in all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 15 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 18 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE 19 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | PACKAGE 2 | 3 | package shellquote 4 | import "github.com/kballard/go-shellquote" 5 | 6 | Shellquote provides utilities for joining/splitting strings using sh's 7 | word-splitting rules. 8 | 9 | VARIABLES 10 | 11 | var ( 12 | UnterminatedSingleQuoteError = errors.New("Unterminated single-quoted string") 13 | UnterminatedDoubleQuoteError = errors.New("Unterminated double-quoted string") 14 | UnterminatedEscapeError = errors.New("Unterminated backslash-escape") 15 | ) 16 | 17 | 18 | FUNCTIONS 19 | 20 | func Join(args ...string) string 21 | Join quotes each argument and joins them with a space. If passed to 22 | /bin/sh, the resulting string will be split back into the original 23 | arguments. 24 | 25 | func Split(input string) (words []string, err error) 26 | Split splits a string according to /bin/sh's word-splitting rules. It 27 | supports backslash-escapes, single-quotes, and double-quotes. Notably it 28 | does not support the $'' style of quoting. It also doesn't attempt to 29 | perform any other sort of expansion, including brace expansion, shell 30 | expansion, or pathname expansion. 31 | 32 | If the given input has an unterminated quoted string or ends in a 33 | backslash-escape, one of UnterminatedSingleQuoteError, 34 | UnterminatedDoubleQuoteError, or UnterminatedEscapeError is returned. 35 | 36 | 37 | -------------------------------------------------------------------------------- /both_test.go: -------------------------------------------------------------------------------- 1 | package shellquote 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | "testing/quick" 7 | ) 8 | 9 | // this is called bothtest because it tests Split and Join together 10 | 11 | func TestJoinSplit(t *testing.T) { 12 | f := func(strs []string) bool { 13 | // Join, then split, the input 14 | combined := Join(strs...) 15 | split, err := Split(combined) 16 | if err != nil { 17 | t.Logf("Error splitting %#v: %v", combined, err) 18 | return false 19 | } 20 | if !reflect.DeepEqual(strs, split) { 21 | t.Logf("Input %q did not match output %q", strs, split) 22 | return false 23 | } 24 | return true 25 | } 26 | if err := quick.Check(f, nil); err != nil { 27 | t.Error(err) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Shellquote provides utilities for joining/splitting strings using sh's 2 | // word-splitting rules. 3 | package shellquote 4 | -------------------------------------------------------------------------------- /quote.go: -------------------------------------------------------------------------------- 1 | package shellquote 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "unicode/utf8" 7 | ) 8 | 9 | // Join quotes each argument and joins them with a space. 10 | // If passed to /bin/sh, the resulting string will be split back into the 11 | // original arguments. 12 | func Join(args ...string) string { 13 | var buf bytes.Buffer 14 | for i, arg := range args { 15 | if i != 0 { 16 | buf.WriteByte(' ') 17 | } 18 | quote(arg, &buf) 19 | } 20 | return buf.String() 21 | } 22 | 23 | const ( 24 | specialChars = "\\'\"`${[|&;<>()*?!" 25 | extraSpecialChars = " \t\n" 26 | prefixChars = "~" 27 | ) 28 | 29 | func quote(word string, buf *bytes.Buffer) { 30 | // We want to try to produce a "nice" output. As such, we will 31 | // backslash-escape most characters, but if we encounter a space, or if we 32 | // encounter an extra-special char (which doesn't work with 33 | // backslash-escaping) we switch over to quoting the whole word. We do this 34 | // with a space because it's typically easier for people to read multi-word 35 | // arguments when quoted with a space rather than with ugly backslashes 36 | // everywhere. 37 | origLen := buf.Len() 38 | 39 | if len(word) == 0 { 40 | // oops, no content 41 | buf.WriteString("''") 42 | return 43 | } 44 | 45 | cur, prev := word, word 46 | atStart := true 47 | for len(cur) > 0 { 48 | c, l := utf8.DecodeRuneInString(cur) 49 | cur = cur[l:] 50 | if strings.ContainsRune(specialChars, c) || (atStart && strings.ContainsRune(prefixChars, c)) { 51 | // copy the non-special chars up to this point 52 | if len(cur) < len(prev) { 53 | buf.WriteString(prev[0 : len(prev)-len(cur)-l]) 54 | } 55 | buf.WriteByte('\\') 56 | buf.WriteRune(c) 57 | prev = cur 58 | } else if strings.ContainsRune(extraSpecialChars, c) { 59 | // start over in quote mode 60 | buf.Truncate(origLen) 61 | goto quote 62 | } 63 | atStart = false 64 | } 65 | if len(prev) > 0 { 66 | buf.WriteString(prev) 67 | } 68 | return 69 | 70 | quote: 71 | // quote mode 72 | // Use single-quotes, but if we find a single-quote in the word, we need 73 | // to terminate the string, emit an escaped quote, and start the string up 74 | // again 75 | inQuote := false 76 | for len(word) > 0 { 77 | i := strings.IndexRune(word, '\'') 78 | if i == -1 { 79 | break 80 | } 81 | if i > 0 { 82 | if !inQuote { 83 | buf.WriteByte('\'') 84 | inQuote = true 85 | } 86 | buf.WriteString(word[0:i]) 87 | } 88 | word = word[i+1:] 89 | if inQuote { 90 | buf.WriteByte('\'') 91 | inQuote = false 92 | } 93 | buf.WriteString("\\'") 94 | } 95 | if len(word) > 0 { 96 | if !inQuote { 97 | buf.WriteByte('\'') 98 | } 99 | buf.WriteString(word) 100 | buf.WriteByte('\'') 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /quote_test.go: -------------------------------------------------------------------------------- 1 | package shellquote 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestSimpleJoin(t *testing.T) { 8 | for _, elem := range simpleJoinTest { 9 | output := Join(elem.input...) 10 | if output != elem.output { 11 | t.Errorf("Input %q, got %q, expected %q", elem.input, output, elem.output) 12 | } 13 | } 14 | } 15 | 16 | var simpleJoinTest = []struct { 17 | input []string 18 | output string 19 | }{ 20 | {[]string{"test"}, "test"}, 21 | {[]string{"hello goodbye"}, "'hello goodbye'"}, 22 | {[]string{"hello", "goodbye"}, "hello goodbye"}, 23 | {[]string{"don't you know the dewey decimal system?"}, "'don'\\''t you know the dewey decimal system?'"}, 24 | {[]string{"don't", "you", "know", "the", "dewey", "decimal", "system?"}, "don\\'t you know the dewey decimal system\\?"}, 25 | {[]string{"~user", "u~ser", " ~user", "!~user"}, "\\~user u~ser ' ~user' \\!~user"}, 26 | {[]string{"foo*", "M{ovies,usic}", "ab[cd]", "%3"}, "foo\\* M\\{ovies,usic} ab\\[cd] %3"}, 27 | {[]string{"one", "", "three"}, "one '' three"}, 28 | {[]string{"some(parentheses)"}, "some\\(parentheses\\)"}, 29 | {[]string{"$some_ot~her_)spe!cial_*_characters"}, "\\$some_ot~her_\\)spe\\!cial_\\*_characters"}, 30 | {[]string{"' "}, "\\'' '"}, 31 | } 32 | -------------------------------------------------------------------------------- /unquote.go: -------------------------------------------------------------------------------- 1 | package shellquote 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "strings" 7 | "unicode/utf8" 8 | ) 9 | 10 | var ( 11 | UnterminatedSingleQuoteError = errors.New("Unterminated single-quoted string") 12 | UnterminatedDoubleQuoteError = errors.New("Unterminated double-quoted string") 13 | UnterminatedEscapeError = errors.New("Unterminated backslash-escape") 14 | ) 15 | 16 | var ( 17 | splitChars = " \n\t" 18 | singleChar = '\'' 19 | doubleChar = '"' 20 | escapeChar = '\\' 21 | doubleEscapeChars = "$`\"\n\\" 22 | ) 23 | 24 | // Split splits a string according to /bin/sh's word-splitting rules. It 25 | // supports backslash-escapes, single-quotes, and double-quotes. Notably it does 26 | // not support the $'' style of quoting. It also doesn't attempt to perform any 27 | // other sort of expansion, including brace expansion, shell expansion, or 28 | // pathname expansion. 29 | // 30 | // If the given input has an unterminated quoted string or ends in a 31 | // backslash-escape, one of UnterminatedSingleQuoteError, 32 | // UnterminatedDoubleQuoteError, or UnterminatedEscapeError is returned. 33 | func Split(input string) (words []string, err error) { 34 | var buf bytes.Buffer 35 | words = make([]string, 0) 36 | 37 | for len(input) > 0 { 38 | // skip any splitChars at the start 39 | c, l := utf8.DecodeRuneInString(input) 40 | if strings.ContainsRune(splitChars, c) { 41 | input = input[l:] 42 | continue 43 | } else if c == escapeChar { 44 | // Look ahead for escaped newline so we can skip over it 45 | next := input[l:] 46 | if len(next) == 0 { 47 | err = UnterminatedEscapeError 48 | return 49 | } 50 | c2, l2 := utf8.DecodeRuneInString(next) 51 | if c2 == '\n' { 52 | input = next[l2:] 53 | continue 54 | } 55 | } 56 | 57 | var word string 58 | word, input, err = splitWord(input, &buf) 59 | if err != nil { 60 | return 61 | } 62 | words = append(words, word) 63 | } 64 | return 65 | } 66 | 67 | func splitWord(input string, buf *bytes.Buffer) (word string, remainder string, err error) { 68 | buf.Reset() 69 | 70 | raw: 71 | { 72 | cur := input 73 | for len(cur) > 0 { 74 | c, l := utf8.DecodeRuneInString(cur) 75 | cur = cur[l:] 76 | if c == singleChar { 77 | buf.WriteString(input[0 : len(input)-len(cur)-l]) 78 | input = cur 79 | goto single 80 | } else if c == doubleChar { 81 | buf.WriteString(input[0 : len(input)-len(cur)-l]) 82 | input = cur 83 | goto double 84 | } else if c == escapeChar { 85 | buf.WriteString(input[0 : len(input)-len(cur)-l]) 86 | input = cur 87 | goto escape 88 | } else if strings.ContainsRune(splitChars, c) { 89 | buf.WriteString(input[0 : len(input)-len(cur)-l]) 90 | return buf.String(), cur, nil 91 | } 92 | } 93 | if len(input) > 0 { 94 | buf.WriteString(input) 95 | input = "" 96 | } 97 | goto done 98 | } 99 | 100 | escape: 101 | { 102 | if len(input) == 0 { 103 | return "", "", UnterminatedEscapeError 104 | } 105 | c, l := utf8.DecodeRuneInString(input) 106 | if c == '\n' { 107 | // a backslash-escaped newline is elided from the output entirely 108 | } else { 109 | buf.WriteString(input[:l]) 110 | } 111 | input = input[l:] 112 | } 113 | goto raw 114 | 115 | single: 116 | { 117 | i := strings.IndexRune(input, singleChar) 118 | if i == -1 { 119 | return "", "", UnterminatedSingleQuoteError 120 | } 121 | buf.WriteString(input[0:i]) 122 | input = input[i+1:] 123 | goto raw 124 | } 125 | 126 | double: 127 | { 128 | cur := input 129 | for len(cur) > 0 { 130 | c, l := utf8.DecodeRuneInString(cur) 131 | cur = cur[l:] 132 | if c == doubleChar { 133 | buf.WriteString(input[0 : len(input)-len(cur)-l]) 134 | input = cur 135 | goto raw 136 | } else if c == escapeChar { 137 | // bash only supports certain escapes in double-quoted strings 138 | c2, l2 := utf8.DecodeRuneInString(cur) 139 | cur = cur[l2:] 140 | if strings.ContainsRune(doubleEscapeChars, c2) { 141 | buf.WriteString(input[0 : len(input)-len(cur)-l-l2]) 142 | if c2 == '\n' { 143 | // newline is special, skip the backslash entirely 144 | } else { 145 | buf.WriteRune(c2) 146 | } 147 | input = cur 148 | } 149 | } 150 | } 151 | return "", "", UnterminatedDoubleQuoteError 152 | } 153 | 154 | done: 155 | return buf.String(), input, nil 156 | } 157 | -------------------------------------------------------------------------------- /unquote_test.go: -------------------------------------------------------------------------------- 1 | package shellquote 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestSimpleSplit(t *testing.T) { 9 | for _, elem := range simpleSplitTest { 10 | output, err := Split(elem.input) 11 | if err != nil { 12 | t.Errorf("Input %q, got error %#v", elem.input, err) 13 | } else if !reflect.DeepEqual(output, elem.output) { 14 | t.Errorf("Input %q, got %q, expected %q", elem.input, output, elem.output) 15 | } 16 | } 17 | } 18 | 19 | func TestErrorSplit(t *testing.T) { 20 | for _, elem := range errorSplitTest { 21 | _, err := Split(elem.input) 22 | if err != elem.error { 23 | t.Errorf("Input %q, got error %#v, expected error %#v", elem.input, err, elem.error) 24 | } 25 | } 26 | } 27 | 28 | var simpleSplitTest = []struct { 29 | input string 30 | output []string 31 | }{ 32 | {"hello", []string{"hello"}}, 33 | {"hello goodbye", []string{"hello", "goodbye"}}, 34 | {"hello goodbye", []string{"hello", "goodbye"}}, 35 | {"glob* test?", []string{"glob*", "test?"}}, 36 | {"don\\'t you know the dewey decimal system\\?", []string{"don't", "you", "know", "the", "dewey", "decimal", "system?"}}, 37 | {"'don'\\''t you know the dewey decimal system?'", []string{"don't you know the dewey decimal system?"}}, 38 | {"one '' two", []string{"one", "", "two"}}, 39 | {"text with\\\na backslash-escaped newline", []string{"text", "witha", "backslash-escaped", "newline"}}, 40 | {"text \"with\na\" quoted newline", []string{"text", "with\na", "quoted", "newline"}}, 41 | {"\"quoted\\d\\\\\\\" text with\\\na backslash-escaped newline\"", []string{"quoted\\d\\\" text witha backslash-escaped newline"}}, 42 | {"text with an escaped \\\n newline in the middle", []string{"text", "with", "an", "escaped", "newline", "in", "the", "middle"}}, 43 | {"foo\"bar\"baz", []string{"foobarbaz"}}, 44 | } 45 | 46 | var errorSplitTest = []struct { 47 | input string 48 | error error 49 | }{ 50 | {"don't worry", UnterminatedSingleQuoteError}, 51 | {"'test'\\''ing", UnterminatedSingleQuoteError}, 52 | {"\"foo'bar", UnterminatedDoubleQuoteError}, 53 | {"foo\\", UnterminatedEscapeError}, 54 | {" \\", UnterminatedEscapeError}, 55 | } 56 | --------------------------------------------------------------------------------