9 "github.com/pkg/errors"
12 // ShellLex performs shell word splitting and variable expansion.
14 // ShellLex takes a string and an array of env variables and
15 // process all quotes (" and ') as well as $xxx and ${xxx} env variable
16 // tokens. Tries to mimic bash shell process.
17 // It doesn't support all flavors of ${xx:...} formats but new ones can
18 // be added by adding code to the "special ${} format processing" section
19 type ShellLex struct {
23 // NewShellLex creates a new ShellLex which uses escapeToken to escape quotes.
24 func NewShellLex(escapeToken rune) *ShellLex {
25 return &ShellLex{escapeToken: escapeToken}
28 // ProcessWord will use the 'env' list of environment variables,
29 // and replace any env var references in 'word'.
30 func (s *ShellLex) ProcessWord(word string, env []string) (string, error) {
31 word, _, err := s.process(word, env)
35 // ProcessWords will use the 'env' list of environment variables,
36 // and replace any env var references in 'word' then it will also
37 // return a slice of strings which represents the 'word'
38 // split up based on spaces - taking into account quotes. Note that
39 // this splitting is done **after** the env var substitutions are done.
40 // Note, each one is trimmed to remove leading and trailing spaces (unless
41 // they are quoted", but ProcessWord retains spaces between words.
42 func (s *ShellLex) ProcessWords(word string, env []string) ([]string, error) {
43 _, words, err := s.process(word, env)
47 func (s *ShellLex) process(word string, env []string) (string, []string, error) {
50 escapeToken: s.escapeToken,
52 sw.scanner.Init(strings.NewReader(word))
53 return sw.process(word)
56 type shellWord struct {
57 scanner scanner.Scanner
62 func (sw *shellWord) process(source string) (string, []string, error) {
63 word, words, err := sw.processStopOn(scanner.EOF)
65 err = errors.Wrapf(err, "failed to process %q", source)
67 return word, words, err
70 type wordsStruct struct {
76 func (w *wordsStruct) addChar(ch rune) {
77 if unicode.IsSpace(ch) && w.inWord {
79 w.words = append(w.words, w.word)
83 } else if !unicode.IsSpace(ch) {
88 func (w *wordsStruct) addRawChar(ch rune) {
93 func (w *wordsStruct) addString(str string) {
94 var scan scanner.Scanner
95 scan.Init(strings.NewReader(str))
96 for scan.Peek() != scanner.EOF {
97 w.addChar(scan.Next())
101 func (w *wordsStruct) addRawString(str string) {
106 func (w *wordsStruct) getWords() []string {
108 w.words = append(w.words, w.word)
110 // Just in case we're called again by mistake
117 // Process the word, starting at 'pos', and stop when we get to the
118 // end of the word or the 'stopChar' character
119 func (sw *shellWord) processStopOn(stopChar rune) (string, []string, error) {
120 var result bytes.Buffer
121 var words wordsStruct
123 var charFuncMapping = map[rune]func() (string, error){
124 '\'': sw.processSingleQuote,
125 '"': sw.processDoubleQuote,
126 '$': sw.processDollar,
129 for sw.scanner.Peek() != scanner.EOF {
130 ch := sw.scanner.Peek()
132 if stopChar != scanner.EOF && ch == stopChar {
136 if fn, ok := charFuncMapping[ch]; ok {
137 // Call special processing func for certain chars
140 return "", []string{}, err
142 result.WriteString(tmp)
147 words.addRawString(tmp)
150 // Not special, just add it to the result
151 ch = sw.scanner.Next()
153 if ch == sw.escapeToken {
154 // '\' (default escape token, but ` allowed) escapes, except end of line
155 ch = sw.scanner.Next()
157 if ch == scanner.EOF {
170 return result.String(), words.getWords(), nil
173 func (sw *shellWord) processSingleQuote() (string, error) {
174 // All chars between single quotes are taken as-is
175 // Note, you can't escape '
177 // From the "sh" man page:
179 // Enclosing characters in single quotes preserves the literal meaning of
180 // all the characters (except single quotes, making it impossible to put
181 // single-quotes in a single-quoted string).
183 var result bytes.Buffer
188 ch := sw.scanner.Next()
191 return "", errors.New("unexpected end of statement while looking for matching single-quote")
193 return result.String(), nil
199 func (sw *shellWord) processDoubleQuote() (string, error) {
200 // All chars up to the next " are taken as-is, even ', except any $ chars
201 // But you can escape " with a \ (or ` if escape token set accordingly)
203 // From the "sh" man page:
205 // Enclosing characters within double quotes preserves the literal meaning
206 // of all characters except dollarsign ($), backquote (`), and backslash
207 // (\). The backslash inside double quotes is historically weird, and
208 // serves to quote only the following characters:
209 // $ ` " \ <newline>.
210 // Otherwise it remains literal.
212 var result bytes.Buffer
217 switch sw.scanner.Peek() {
219 return "", errors.New("unexpected end of statement while looking for matching double-quote")
222 return result.String(), nil
224 value, err := sw.processDollar()
228 result.WriteString(value)
230 ch := sw.scanner.Next()
231 if ch == sw.escapeToken {
232 switch sw.scanner.Peek() {
234 // Ignore \ at end of word
236 case '"', '$', sw.escapeToken:
237 // These chars can be escaped, all other \'s are left as-is
238 // Note: for now don't do anything special with ` chars.
239 // Not sure what to do with them anyway since we're not going
240 // to execute the text in there (not now anyway).
241 ch = sw.scanner.Next()
249 func (sw *shellWord) processDollar() (string, error) {
253 if sw.scanner.Peek() != '{' {
254 name := sw.processName()
258 return sw.getEnv(name), nil
262 name := sw.processName()
263 ch := sw.scanner.Peek()
267 return sw.getEnv(name), nil
270 // Special ${xx:...} format processing
271 // Yes it allows for recursive $'s in the ... spot
273 sw.scanner.Next() // skip over :
274 modifier := sw.scanner.Next()
276 word, _, err := sw.processStopOn('}')
281 // Grab the current value of the variable in question so we
282 // can use to to determine what to do based on the modifier
283 newValue := sw.getEnv(name)
299 return "", errors.Errorf("unsupported modifier (%c) in substitution", modifier)
302 return "", errors.Errorf("missing ':' in substitution")
305 func (sw *shellWord) processName() string {
306 // Read in a name (alphanumeric or _)
307 // If it starts with a numeric then just return $#
308 var name bytes.Buffer
310 for sw.scanner.Peek() != scanner.EOF {
311 ch := sw.scanner.Peek()
312 if name.Len() == 0 && unicode.IsDigit(ch) {
313 ch = sw.scanner.Next()
316 if !unicode.IsLetter(ch) && !unicode.IsDigit(ch) && ch != '_' {
319 ch = sw.scanner.Next()
326 func (sw *shellWord) getEnv(name string) string {
327 for _, env := range sw.envs {
328 i := strings.Index(env, "=")
330 if equalEnvKeys(name, env) {
331 // Should probably never get here, but just in case treat
332 // it like "var" and "var=" are the same
337 compareName := env[:i]
338 if !equalEnvKeys(name, compareName) {