├── .drone.yml ├── .gitignore ├── LICENSE ├── cmd └── envsubst │ └── main.go ├── eval.go ├── eval_test.go ├── funcs.go ├── funcs_test.go ├── go.mod ├── go.sum ├── parse ├── node.go ├── parse.go ├── parse_test.go ├── scan.go └── scan_test.go ├── path └── match.go ├── readme.md └── template.go /.drone.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | name: default 3 | 4 | steps: 5 | - name: build 6 | image: golang:1.11 7 | commands: 8 | - go test -v ./... 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /envsubst 2 | coverage.out 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 drone.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cmd/envsubst/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/drone/envsubst/v2" 10 | ) 11 | 12 | func main() { 13 | stdin := bufio.NewScanner(os.Stdin) 14 | stdout := bufio.NewWriter(os.Stdout) 15 | 16 | for stdin.Scan() { 17 | line, err := envsubst.EvalEnv(stdin.Text()) 18 | if err != nil { 19 | log.Fatalf("Error while envsubst: %v", err) 20 | } 21 | _, err = fmt.Fprintln(stdout, line) 22 | if err != nil { 23 | log.Fatalf("Error while writing to stdout: %v", err) 24 | } 25 | stdout.Flush() 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /eval.go: -------------------------------------------------------------------------------- 1 | package envsubst 2 | 3 | import "os" 4 | 5 | // Eval replaces ${var} in the string based on the mapping function. 6 | func Eval(s string, mapping func(string) string) (string, error) { 7 | t, err := Parse(s) 8 | if err != nil { 9 | return s, err 10 | } 11 | return t.Execute(mapping) 12 | } 13 | 14 | // EvalEnv replaces ${var} in the string according to the values of the 15 | // current environment variables. References to undefined variables are 16 | // replaced by the empty string. 17 | func EvalEnv(s string) (string, error) { 18 | return Eval(s, os.Getenv) 19 | } 20 | -------------------------------------------------------------------------------- /eval_test.go: -------------------------------------------------------------------------------- 1 | package envsubst 2 | 3 | import "testing" 4 | 5 | // test cases sourced from tldp.org 6 | // http://www.tldp.org/LDP/abs/html/parameter-substitution.html 7 | 8 | func TestExpand(t *testing.T) { 9 | var expressions = []struct { 10 | params map[string]string 11 | input string 12 | output string 13 | }{ 14 | // text-only 15 | { 16 | params: map[string]string{}, 17 | input: "abcdEFGH28ij", 18 | output: "abcdEFGH28ij", 19 | }, 20 | // length 21 | { 22 | params: map[string]string{"var01": "abcdEFGH28ij"}, 23 | input: "${#var01}", 24 | output: "12", 25 | }, 26 | // uppercase first 27 | { 28 | params: map[string]string{"var01": "abcdEFGH28ij"}, 29 | input: "${var01^}", 30 | output: "AbcdEFGH28ij", 31 | }, 32 | // uppercase 33 | { 34 | params: map[string]string{"var01": "abcdEFGH28ij"}, 35 | input: "${var01^^}", 36 | output: "ABCDEFGH28IJ", 37 | }, 38 | // lowercase first 39 | { 40 | params: map[string]string{"var01": "ABCDEFGH28IJ"}, 41 | input: "${var01,}", 42 | output: "aBCDEFGH28IJ", 43 | }, 44 | // lowercase 45 | { 46 | params: map[string]string{"var01": "ABCDEFGH28IJ"}, 47 | input: "${var01,,}", 48 | output: "abcdefgh28ij", 49 | }, 50 | // substring with position 51 | { 52 | params: map[string]string{"path_name": "/home/bozo/ideas/thoughts.for.today"}, 53 | input: "${path_name:11}", 54 | output: "ideas/thoughts.for.today", 55 | }, 56 | // substring with position and length 57 | { 58 | params: map[string]string{"path_name": "/home/bozo/ideas/thoughts.for.today"}, 59 | input: "${path_name:11:5}", 60 | output: "ideas", 61 | }, 62 | // default not used 63 | { 64 | params: map[string]string{"var": "abc"}, 65 | input: "${var=xyz}", 66 | output: "abc", 67 | }, 68 | // default used 69 | { 70 | params: map[string]string{}, 71 | input: "${var=xyz}", 72 | output: "xyz", 73 | }, 74 | { 75 | params: map[string]string{"default_var": "foo"}, 76 | input: "something ${var=${default_var}}", 77 | output: "something foo", 78 | }, 79 | { 80 | params: map[string]string{"default_var": "foo1"}, 81 | input: `foo: ${var=${default_var}-suffix}`, 82 | output: "foo: foo1-suffix", 83 | }, 84 | { 85 | params: map[string]string{"default_var": "foo1"}, 86 | input: `foo: ${var=prefix${default_var}-suffix}`, 87 | output: "foo: prefixfoo1-suffix", 88 | }, 89 | { 90 | params: map[string]string{}, 91 | input: "${var:=xyz}", 92 | output: "xyz", 93 | }, 94 | // replace suffix 95 | { 96 | params: map[string]string{"stringZ": "abcABC123ABCabc"}, 97 | input: "${stringZ/%abc/XYZ}", 98 | output: "abcABC123ABCXYZ", 99 | }, 100 | // replace prefix 101 | { 102 | params: map[string]string{"stringZ": "abcABC123ABCabc"}, 103 | input: "${stringZ/#abc/XYZ}", 104 | output: "XYZABC123ABCabc", 105 | }, 106 | // replace all 107 | { 108 | params: map[string]string{"stringZ": "abcABC123ABCabc"}, 109 | input: "${stringZ//abc/xyz}", 110 | output: "xyzABC123ABCxyz", 111 | }, 112 | // replace first 113 | { 114 | params: map[string]string{"stringZ": "abcABC123ABCabc"}, 115 | input: "${stringZ/abc/xyz}", 116 | output: "xyzABC123ABCabc", 117 | }, 118 | // delete shortest match prefix 119 | { 120 | params: map[string]string{"filename": "bash.string.txt"}, 121 | input: "${filename#*.}", 122 | output: "string.txt", 123 | }, 124 | { 125 | params: map[string]string{"filename": "path/to/file"}, 126 | input: "${filename#*/}", 127 | output: "to/file", 128 | }, 129 | { 130 | params: map[string]string{"filename": "/path/to/file"}, 131 | input: "${filename#*/}", 132 | output: "path/to/file", 133 | }, 134 | // delete longest match prefix 135 | { 136 | params: map[string]string{"filename": "bash.string.txt"}, 137 | input: "${filename##*.}", 138 | output: "txt", 139 | }, 140 | { 141 | params: map[string]string{"filename": "path/to/file"}, 142 | input: "${filename##*/}", 143 | output: "file", 144 | }, 145 | { 146 | params: map[string]string{"filename": "/path/to/file"}, 147 | input: "${filename##*/}", 148 | output: "file", 149 | }, 150 | // delete shortest match suffix 151 | { 152 | params: map[string]string{"filename": "bash.string.txt"}, 153 | input: "${filename%.*}", 154 | output: "bash.string", 155 | }, 156 | // delete longest match suffix 157 | { 158 | params: map[string]string{"filename": "bash.string.txt"}, 159 | input: "${filename%%.*}", 160 | output: "bash", 161 | }, 162 | 163 | // nested parameters 164 | { 165 | params: map[string]string{"var01": "abcdEFGH28ij"}, 166 | input: "${var=${var01^^}}", 167 | output: "ABCDEFGH28IJ", 168 | }, 169 | // escaped 170 | { 171 | params: map[string]string{"var01": "abcdEFGH28ij"}, 172 | input: "$${var01}", 173 | output: "${var01}", 174 | }, 175 | { 176 | params: map[string]string{"var01": "abcdEFGH28ij"}, 177 | input: "some text ${var01}$${var$${var01}$var01${var01}", 178 | output: "some text abcdEFGH28ij${var${var01}$var01abcdEFGH28ij", 179 | }, 180 | { 181 | params: map[string]string{"default_var": "foo"}, 182 | input: "something $${var=${default_var}}", 183 | output: "something ${var=foo}", 184 | }, 185 | // some common escaping use cases 186 | { 187 | params: map[string]string{"stringZ": "foo/bar"}, 188 | input: `${stringZ/\//-}`, 189 | output: "foo-bar", 190 | }, 191 | { 192 | params: map[string]string{"stringZ": "foo/bar/baz"}, 193 | input: `${stringZ//\//-}`, 194 | output: "foo-bar-baz", 195 | }, 196 | // escape outside of expansion shouldn't be processed 197 | { 198 | params: map[string]string{"default_var": "foo"}, 199 | input: "\\\\something ${var=${default_var}}", 200 | output: "\\\\something foo", 201 | }, 202 | // substitute with a blank string 203 | { 204 | params: map[string]string{"stringZ": "foo.bar"}, 205 | input: `${stringZ/./}`, 206 | output: "foobar", 207 | }, 208 | } 209 | 210 | for _, expr := range expressions { 211 | t.Run(expr.input, func(t *testing.T) { 212 | t.Logf(expr.input) 213 | output, err := Eval(expr.input, func(s string) string { 214 | return expr.params[s] 215 | }) 216 | if err != nil { 217 | t.Errorf("Want %q expanded but got error %q", expr.input, err) 218 | } 219 | 220 | if output != expr.output { 221 | t.Errorf("Want %q expanded to %q, got %q", 222 | expr.input, 223 | expr.output, 224 | output) 225 | } 226 | }) 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /funcs.go: -------------------------------------------------------------------------------- 1 | package envsubst 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | "unicode" 7 | "unicode/utf8" 8 | 9 | "github.com/drone/envsubst/v2/path" 10 | ) 11 | 12 | // defines a parameter substitution function. 13 | type substituteFunc func(string, ...string) string 14 | 15 | // toLen returns the length of string s. 16 | func toLen(s string, args ...string) string { 17 | return strconv.Itoa(len(s)) 18 | } 19 | 20 | // toLower returns a copy of the string s with all characters 21 | // mapped to their lower case. 22 | func toLower(s string, args ...string) string { 23 | return strings.ToLower(s) 24 | } 25 | 26 | // toUpper returns a copy of the string s with all characters 27 | // mapped to their upper case. 28 | func toUpper(s string, args ...string) string { 29 | return strings.ToUpper(s) 30 | } 31 | 32 | // toLowerFirst returns a copy of the string s with the first 33 | // character mapped to its lower case. 34 | func toLowerFirst(s string, args ...string) string { 35 | if s == "" { 36 | return s 37 | } 38 | r, n := utf8.DecodeRuneInString(s) 39 | return string(unicode.ToLower(r)) + s[n:] 40 | } 41 | 42 | // toUpperFirst returns a copy of the string s with the first 43 | // character mapped to its upper case. 44 | func toUpperFirst(s string, args ...string) string { 45 | if s == "" { 46 | return s 47 | } 48 | r, n := utf8.DecodeRuneInString(s) 49 | return string(unicode.ToUpper(r)) + s[n:] 50 | } 51 | 52 | // toDefault returns a copy of the string s if not empty, else 53 | // returns a concatenation of the args without a separator. 54 | func toDefault(s string, args ...string) string { 55 | if len(s) == 0 && len(args) > 0 { 56 | // don't use any separator 57 | s = strings.Join(args, "") 58 | } 59 | return s 60 | } 61 | 62 | // toSubstr returns a slice of the string s at the specified 63 | // length and position. 64 | func toSubstr(s string, args ...string) string { 65 | if len(args) == 0 { 66 | return s // should never happen 67 | } 68 | 69 | pos, err := strconv.Atoi(args[0]) 70 | if err != nil { 71 | // bash returns the string if the position 72 | // cannot be parsed. 73 | return s 74 | } 75 | 76 | if pos < 0 { 77 | // if pos is negative (counts from the end) add it 78 | // to length to get first character offset 79 | pos = len(s) + pos 80 | 81 | // if negative offset exceeds the length of the string 82 | // start from 0 83 | if pos < 0 { 84 | pos = 0 85 | } 86 | } 87 | 88 | if len(args) == 1 { 89 | if pos < len(s) { 90 | return s[pos:] 91 | } 92 | // if the position exceeds the length of the 93 | // string an empty string is returned 94 | return "" 95 | } 96 | 97 | length, err := strconv.Atoi(args[1]) 98 | if err != nil { 99 | // bash returns the string if the length 100 | // cannot be parsed. 101 | return s 102 | } 103 | 104 | if pos+length >= len(s) { 105 | if pos < len(s) { 106 | // if the position exceeds the length of the 107 | // string just return the rest of it like bash 108 | return s[pos:] 109 | } 110 | // if the position exceeds the length of the 111 | // string an empty string is returned 112 | return "" 113 | } 114 | 115 | return s[pos : pos+length] 116 | } 117 | 118 | // replaceAll returns a copy of the string s with all instances 119 | // of the substring replaced with the replacement string. 120 | func replaceAll(s string, args ...string) string { 121 | switch len(args) { 122 | case 0: 123 | return s 124 | case 1: 125 | return strings.Replace(s, args[0], "", -1) 126 | default: 127 | return strings.Replace(s, args[0], args[1], -1) 128 | } 129 | } 130 | 131 | // replaceFirst returns a copy of the string s with the first 132 | // instance of the substring replaced with the replacement string. 133 | func replaceFirst(s string, args ...string) string { 134 | switch len(args) { 135 | case 0: 136 | return s 137 | case 1: 138 | return strings.Replace(s, args[0], "", 1) 139 | default: 140 | return strings.Replace(s, args[0], args[1], 1) 141 | } 142 | } 143 | 144 | // replacePrefix returns a copy of the string s with the matching 145 | // prefix replaced with the replacement string. 146 | func replacePrefix(s string, args ...string) string { 147 | if len(args) != 2 { 148 | return s 149 | } 150 | if strings.HasPrefix(s, args[0]) { 151 | return strings.Replace(s, args[0], args[1], 1) 152 | } 153 | return s 154 | } 155 | 156 | // replaceSuffix returns a copy of the string s with the matching 157 | // suffix replaced with the replacement string. 158 | func replaceSuffix(s string, args ...string) string { 159 | if len(args) != 2 { 160 | return s 161 | } 162 | if strings.HasSuffix(s, args[0]) { 163 | s = strings.TrimSuffix(s, args[0]) 164 | s = s + args[1] 165 | } 166 | return s 167 | } 168 | 169 | // TODO 170 | 171 | func trimShortestPrefix(s string, args ...string) string { 172 | if len(args) != 0 { 173 | s = trimShortest(s, args[0]) 174 | } 175 | return s 176 | } 177 | 178 | func trimShortestSuffix(s string, args ...string) string { 179 | if len(args) != 0 { 180 | r := reverse(s) 181 | rarg := reverse(args[0]) 182 | s = reverse(trimShortest(r, rarg)) 183 | } 184 | return s 185 | } 186 | 187 | func trimLongestPrefix(s string, args ...string) string { 188 | if len(args) != 0 { 189 | s = trimLongest(s, args[0]) 190 | } 191 | return s 192 | } 193 | 194 | func trimLongestSuffix(s string, args ...string) string { 195 | if len(args) != 0 { 196 | r := reverse(s) 197 | rarg := reverse(args[0]) 198 | s = reverse(trimLongest(r, rarg)) 199 | } 200 | return s 201 | } 202 | 203 | func trimShortest(s, arg string) string { 204 | var shortestMatch string 205 | for i := 0; i < len(s); i++ { 206 | match, err := path.Match(arg, s[0:len(s)-i]) 207 | 208 | if err != nil { 209 | return s 210 | } 211 | 212 | if match { 213 | shortestMatch = s[0 : len(s)-i] 214 | } 215 | } 216 | 217 | if shortestMatch != "" { 218 | return strings.TrimPrefix(s, shortestMatch) 219 | } 220 | 221 | return s 222 | } 223 | 224 | func trimLongest(s, arg string) string { 225 | for i := 0; i < len(s); i++ { 226 | match, err := path.Match(arg, s[0:len(s)-i]) 227 | 228 | if err != nil { 229 | return s 230 | } 231 | 232 | if match { 233 | return strings.TrimPrefix(s, s[0:len(s)-i]) 234 | } 235 | } 236 | 237 | return s 238 | } 239 | 240 | func reverse(s string) string { 241 | r := []rune(s) 242 | for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 { 243 | r[i], r[j] = r[j], r[i] 244 | } 245 | return string(r) 246 | } 247 | -------------------------------------------------------------------------------- /funcs_test.go: -------------------------------------------------------------------------------- 1 | package envsubst 2 | 3 | import "testing" 4 | 5 | func Test_len(t *testing.T) { 6 | got, want := toLen("Hello World"), "11" 7 | if got != want { 8 | t.Errorf("Expect len function to return %s, got %s", want, got) 9 | } 10 | } 11 | 12 | func Test_lower(t *testing.T) { 13 | got, want := toLower("Hello World"), "hello world" 14 | if got != want { 15 | t.Errorf("Expect lower function to return %s, got %s", want, got) 16 | } 17 | } 18 | 19 | func Test_lowerFirst(t *testing.T) { 20 | got, want := toLowerFirst("HELLO WORLD"), "hELLO WORLD" 21 | if got != want { 22 | t.Errorf("Expect lowerFirst function to return %s, got %s", want, got) 23 | } 24 | defer func() { 25 | if recover() != nil { 26 | t.Errorf("Expect empty string does not panic lowerFirst") 27 | } 28 | }() 29 | toLowerFirst("") 30 | } 31 | 32 | func Test_upper(t *testing.T) { 33 | got, want := toUpper("Hello World"), "HELLO WORLD" 34 | if got != want { 35 | t.Errorf("Expect upper function to return %s, got %s", want, got) 36 | } 37 | } 38 | 39 | func Test_upperFirst(t *testing.T) { 40 | got, want := toUpperFirst("hello world"), "Hello world" 41 | if got != want { 42 | t.Errorf("Expect upperFirst function to return %s, got %s", want, got) 43 | } 44 | defer func() { 45 | if recover() != nil { 46 | t.Errorf("Expect empty string does not panic upperFirst") 47 | } 48 | }() 49 | toUpperFirst("") 50 | } 51 | 52 | func Test_default(t *testing.T) { 53 | got, want := toDefault("Hello World", "Hola Mundo"), "Hello World" 54 | if got != want { 55 | t.Errorf("Expect default function uses variable value") 56 | } 57 | 58 | got, want = toDefault("", "Hola Mundo"), "Hola Mundo" 59 | if got != want { 60 | t.Errorf("Expect default function uses default value, when variable empty. Got %s, Want %s", got, want) 61 | } 62 | 63 | got, want = toDefault("", "Hola Mundo", "-Bonjour le monde", "-Halló heimur"), "Hola Mundo-Bonjour le monde-Halló heimur" 64 | if got != want { 65 | t.Errorf("Expect default function to use concatenated args when variable empty. Got %s, Want %s", got, want) 66 | } 67 | } 68 | 69 | func Test_substr(t *testing.T) { 70 | got, want := toSubstr("123456789123456789", "0", "8"), "12345678" 71 | if got != want { 72 | t.Errorf("Expect substr function to cut from beginning to length") 73 | } 74 | 75 | got, want = toSubstr("123456789123456789", "1", "8"), "23456789" 76 | if got != want { 77 | t.Errorf("Expect substr function to cut from offset to length") 78 | } 79 | 80 | got, want = toSubstr("123456789123456789", "9"), "123456789" 81 | if got != want { 82 | t.Errorf("Expect substr function to cut beginnging with offset") 83 | } 84 | 85 | got, want = toSubstr("123456789123456789", "9", "50"), "123456789" 86 | if got != want { 87 | t.Errorf("Expect substr function to ignore length if out of bound") 88 | } 89 | 90 | got, want = toSubstr("123456789123456789", "-3", "2"), "78" 91 | if got != want { 92 | t.Errorf("Expect substr function to count negative offsets from the end") 93 | } 94 | 95 | got, want = toSubstr("123456789123456789", "-300", "3"), "123" 96 | if got != want { 97 | t.Errorf("Expect substr function to cut from the beginning to length for negative offsets exceeding string length") 98 | } 99 | 100 | got, want = toSubstr("12345678", "9", "1"), "" 101 | if got != want { 102 | t.Errorf("Expect substr function to cut entire string if pos is itself out of bound") 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/drone/envsubst/v2 2 | 3 | require github.com/google/go-cmp v0.2.0 4 | 5 | go 1.13 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= 2 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 3 | -------------------------------------------------------------------------------- /parse/node.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | // Node is an element in the parse tree. 4 | type Node interface { 5 | node() 6 | } 7 | 8 | // empty string node 9 | var empty = new(TextNode) 10 | 11 | // a template is represented by a tree consisting of one 12 | // or more of the following nodes. 13 | type ( 14 | // TextNode represents a string of text. 15 | TextNode struct { 16 | Value string 17 | } 18 | 19 | // FuncNode represents a string function. 20 | FuncNode struct { 21 | Param string 22 | Name string 23 | Args []Node 24 | } 25 | 26 | // ListNode represents a list of nodes. 27 | ListNode struct { 28 | Nodes []Node 29 | } 30 | 31 | // ParamNode struct{ 32 | // Name string 33 | // } 34 | // 35 | // CaseNode struct { 36 | // Name string 37 | // First bool 38 | // } 39 | // 40 | // LowerNode struct { 41 | // Name string 42 | // First bool 43 | // } 44 | // 45 | // SubstrNode struct { 46 | // Name string 47 | // Pos Node 48 | // Len Node 49 | // } 50 | // 51 | // ReplaceNode struct { 52 | // Name string 53 | // Substring Node 54 | // Replacement Node 55 | // } 56 | // 57 | // TrimNode struct{ 58 | // 59 | // } 60 | // 61 | // DefaultNode struct { 62 | // Name string 63 | // Default Node 64 | // } 65 | ) 66 | 67 | // newTextNode returns a new TextNode. 68 | func newTextNode(text string) *TextNode { 69 | return &TextNode{Value: text} 70 | } 71 | 72 | // newListNode returns a new ListNode. 73 | func newListNode(nodes ...Node) *ListNode { 74 | return &ListNode{Nodes: nodes} 75 | } 76 | 77 | // newFuncNode returns a new FuncNode. 78 | func newFuncNode(name string) *FuncNode { 79 | return &FuncNode{Param: name} 80 | } 81 | 82 | // node() defines the node in a parse tree 83 | 84 | func (*TextNode) node() {} 85 | func (*ListNode) node() {} 86 | func (*FuncNode) node() {} 87 | -------------------------------------------------------------------------------- /parse/parse.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | // ErrBadSubstitution represents a substitution parsing error. 9 | ErrBadSubstitution = errors.New("bad substitution") 10 | 11 | // ErrMissingClosingBrace represents a missing closing brace "}" error. 12 | ErrMissingClosingBrace = errors.New("missing closing brace") 13 | 14 | // ErrParseVariableName represents the error when unable to parse a 15 | // variable name within a substitution. 16 | ErrParseVariableName = errors.New("unable to parse variable name") 17 | 18 | // ErrParseFuncSubstitution represents the error when unable to parse the 19 | // substitution within a function parameter. 20 | ErrParseFuncSubstitution = errors.New("unable to parse substitution within function") 21 | 22 | // ErrParseDefaultFunction represent the error when unable to parse a 23 | // default function. 24 | ErrParseDefaultFunction = errors.New("unable to parse default function") 25 | ) 26 | 27 | // Tree is the representation of a single parsed SQL statement. 28 | type Tree struct { 29 | Root Node 30 | 31 | // Parsing only; cleared after parse. 32 | scanner *scanner 33 | } 34 | 35 | // Parse parses the string and returns a Tree. 36 | func Parse(buf string) (*Tree, error) { 37 | t := new(Tree) 38 | t.scanner = new(scanner) 39 | return t.Parse(buf) 40 | } 41 | 42 | // Parse parses the string buffer to construct an ast 43 | // representation for expansion. 44 | func (t *Tree) Parse(buf string) (tree *Tree, err error) { 45 | t.scanner.init(buf) 46 | t.Root, err = t.parseAny() 47 | return t, err 48 | } 49 | 50 | func (t *Tree) parseAny() (Node, error) { 51 | t.scanner.accept = acceptRune 52 | t.scanner.mode = scanIdent | scanLbrack | scanEscape 53 | t.scanner.escapeChars = dollar 54 | 55 | switch t.scanner.scan() { 56 | case tokenIdent: 57 | left := newTextNode( 58 | t.scanner.string(), 59 | ) 60 | right, err := t.parseAny() 61 | switch { 62 | case err != nil: 63 | return nil, err 64 | case right == empty: 65 | return left, nil 66 | } 67 | return newListNode(left, right), nil 68 | case tokenEOF: 69 | return empty, nil 70 | case tokenLbrack: 71 | left, err := t.parseFunc() 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | right, err := t.parseAny() 77 | switch { 78 | case err != nil: 79 | return nil, err 80 | case right == empty: 81 | return left, nil 82 | } 83 | return newListNode(left, right), nil 84 | } 85 | 86 | return nil, ErrBadSubstitution 87 | } 88 | 89 | func (t *Tree) parseFunc() (Node, error) { 90 | // Turn on all escape characters 91 | t.scanner.escapeChars = escapeAll 92 | switch t.scanner.peek() { 93 | case '#': 94 | return t.parseLenFunc() 95 | } 96 | 97 | var name string 98 | t.scanner.accept = acceptIdent 99 | t.scanner.mode = scanIdent 100 | 101 | switch t.scanner.scan() { 102 | case tokenIdent: 103 | name = t.scanner.string() 104 | default: 105 | return nil, ErrParseVariableName 106 | } 107 | 108 | switch t.scanner.peek() { 109 | case ':': 110 | return t.parseDefaultOrSubstr(name) 111 | case '=': 112 | return t.parseDefaultFunc(name) 113 | case ',', '^': 114 | return t.parseCasingFunc(name) 115 | case '/': 116 | return t.parseReplaceFunc(name) 117 | case '#': 118 | return t.parseRemoveFunc(name, acceptHashFunc) 119 | case '%': 120 | return t.parseRemoveFunc(name, acceptPercentFunc) 121 | } 122 | 123 | t.scanner.accept = acceptIdent 124 | t.scanner.mode = scanRbrack 125 | switch t.scanner.scan() { 126 | case tokenRbrack: 127 | return newFuncNode(name), nil 128 | default: 129 | return nil, ErrMissingClosingBrace 130 | } 131 | } 132 | 133 | // parse a substitution function parameter. 134 | func (t *Tree) parseParam(accept acceptFunc, mode byte) (Node, error) { 135 | t.scanner.accept = accept 136 | t.scanner.mode = mode | scanLbrack 137 | switch t.scanner.scan() { 138 | case tokenLbrack: 139 | return t.parseFunc() 140 | case tokenIdent: 141 | return newTextNode( 142 | t.scanner.string(), 143 | ), nil 144 | case tokenRbrack: 145 | return newTextNode( 146 | t.scanner.string(), 147 | ), nil 148 | default: 149 | return nil, ErrParseFuncSubstitution 150 | } 151 | } 152 | 153 | // parse either a default or substring substitution function. 154 | func (t *Tree) parseDefaultOrSubstr(name string) (Node, error) { 155 | t.scanner.read() 156 | r := t.scanner.peek() 157 | t.scanner.unread() 158 | switch r { 159 | case '=', '-', '?', '+': 160 | return t.parseDefaultFunc(name) 161 | default: 162 | return t.parseSubstrFunc(name) 163 | } 164 | } 165 | 166 | // parses the ${param:offset} string function 167 | // parses the ${param:offset:length} string function 168 | func (t *Tree) parseSubstrFunc(name string) (Node, error) { 169 | node := new(FuncNode) 170 | node.Param = name 171 | 172 | t.scanner.accept = acceptOneColon 173 | t.scanner.mode = scanIdent 174 | switch t.scanner.scan() { 175 | case tokenIdent: 176 | node.Name = t.scanner.string() 177 | default: 178 | return nil, ErrBadSubstitution 179 | } 180 | 181 | // scan arg[1] 182 | { 183 | param, err := t.parseParam(rejectColonClose, scanIdent) 184 | if err != nil { 185 | return nil, err 186 | } 187 | 188 | // param.Value = t.scanner.string() 189 | node.Args = append(node.Args, param) 190 | } 191 | 192 | // expect delimiter or close 193 | t.scanner.accept = acceptColon 194 | t.scanner.mode = scanIdent | scanRbrack 195 | switch t.scanner.scan() { 196 | case tokenRbrack: 197 | return node, nil 198 | case tokenIdent: 199 | // no-op 200 | default: 201 | return nil, ErrBadSubstitution 202 | } 203 | 204 | // scan arg[2] 205 | { 206 | param, err := t.parseParam(acceptNotClosing, scanIdent) 207 | if err != nil { 208 | return nil, err 209 | } 210 | node.Args = append(node.Args, param) 211 | } 212 | 213 | return node, t.consumeRbrack() 214 | } 215 | 216 | // parses the ${param%word} string function 217 | // parses the ${param%%word} string function 218 | // parses the ${param#word} string function 219 | // parses the ${param##word} string function 220 | func (t *Tree) parseRemoveFunc(name string, accept acceptFunc) (Node, error) { 221 | node := new(FuncNode) 222 | node.Param = name 223 | 224 | t.scanner.accept = accept 225 | t.scanner.mode = scanIdent 226 | switch t.scanner.scan() { 227 | case tokenIdent: 228 | node.Name = t.scanner.string() 229 | default: 230 | return nil, ErrBadSubstitution 231 | } 232 | 233 | // scan arg[1] 234 | { 235 | param, err := t.parseParam(acceptNotClosing, scanIdent) 236 | if err != nil { 237 | return nil, err 238 | } 239 | 240 | // param.Value = t.scanner.string() 241 | node.Args = append(node.Args, param) 242 | } 243 | 244 | return node, t.consumeRbrack() 245 | } 246 | 247 | // parses the ${param/pattern/string} string function 248 | // parses the ${param//pattern/string} string function 249 | // parses the ${param/#pattern/string} string function 250 | // parses the ${param/%pattern/string} string function 251 | func (t *Tree) parseReplaceFunc(name string) (Node, error) { 252 | node := new(FuncNode) 253 | node.Param = name 254 | 255 | t.scanner.accept = acceptReplaceFunc 256 | t.scanner.mode = scanIdent 257 | switch t.scanner.scan() { 258 | case tokenIdent: 259 | node.Name = t.scanner.string() 260 | default: 261 | return nil, ErrBadSubstitution 262 | } 263 | 264 | // scan arg[1] 265 | { 266 | param, err := t.parseParam(acceptNotSlash, scanIdent|scanEscape) 267 | if err != nil { 268 | return nil, err 269 | } 270 | node.Args = append(node.Args, param) 271 | } 272 | 273 | // expect delimiter 274 | t.scanner.accept = acceptSlash 275 | t.scanner.mode = scanIdent 276 | switch t.scanner.scan() { 277 | case tokenIdent: 278 | // no-op 279 | default: 280 | return nil, ErrBadSubstitution 281 | } 282 | 283 | // check for blank string 284 | switch t.scanner.peek() { 285 | case '}': 286 | return node, t.consumeRbrack() 287 | } 288 | 289 | // scan arg[2] 290 | { 291 | param, err := t.parseParam(acceptNotClosing, scanIdent|scanEscape) 292 | if err != nil { 293 | return nil, err 294 | } 295 | node.Args = append(node.Args, param) 296 | } 297 | 298 | return node, t.consumeRbrack() 299 | } 300 | 301 | // parses the ${parameter=word} string function 302 | // parses the ${parameter:=word} string function 303 | // parses the ${parameter:-word} string function 304 | // parses the ${parameter:?word} string function 305 | // parses the ${parameter:+word} string function 306 | func (t *Tree) parseDefaultFunc(name string) (Node, error) { 307 | node := new(FuncNode) 308 | node.Param = name 309 | 310 | t.scanner.accept = acceptDefaultFunc 311 | if t.scanner.peek() == '=' { 312 | t.scanner.accept = acceptOneEqual 313 | } 314 | t.scanner.mode = scanIdent 315 | switch t.scanner.scan() { 316 | case tokenIdent: 317 | node.Name = t.scanner.string() 318 | default: 319 | return nil, ErrParseDefaultFunction 320 | } 321 | 322 | // loop through all possible runes in default param 323 | for { 324 | // this acts as the break condition. Peek to see if we reached the end 325 | switch t.scanner.peek() { 326 | case '}': 327 | return node, t.consumeRbrack() 328 | } 329 | param, err := t.parseParam(acceptNotClosing, scanIdent) 330 | if err != nil { 331 | return nil, err 332 | } 333 | 334 | node.Args = append(node.Args, param) 335 | } 336 | } 337 | 338 | // parses the ${param,} string function 339 | // parses the ${param,,} string function 340 | // parses the ${param^} string function 341 | // parses the ${param^^} string function 342 | func (t *Tree) parseCasingFunc(name string) (Node, error) { 343 | node := new(FuncNode) 344 | node.Param = name 345 | 346 | t.scanner.accept = acceptCasingFunc 347 | t.scanner.mode = scanIdent 348 | switch t.scanner.scan() { 349 | case tokenIdent: 350 | node.Name = t.scanner.string() 351 | default: 352 | return nil, ErrBadSubstitution 353 | } 354 | 355 | return node, t.consumeRbrack() 356 | } 357 | 358 | // parses the ${#param} string function 359 | func (t *Tree) parseLenFunc() (Node, error) { 360 | node := new(FuncNode) 361 | 362 | t.scanner.accept = acceptOneHash 363 | t.scanner.mode = scanIdent 364 | switch t.scanner.scan() { 365 | case tokenIdent: 366 | node.Name = t.scanner.string() 367 | default: 368 | return nil, ErrBadSubstitution 369 | } 370 | 371 | t.scanner.accept = acceptIdent 372 | t.scanner.mode = scanIdent 373 | switch t.scanner.scan() { 374 | case tokenIdent: 375 | node.Param = t.scanner.string() 376 | default: 377 | return nil, ErrBadSubstitution 378 | } 379 | 380 | return node, t.consumeRbrack() 381 | } 382 | 383 | // consumeRbrack consumes a right closing bracket. If a closing 384 | // bracket token is not consumed an ErrBadSubstitution is returned. 385 | func (t *Tree) consumeRbrack() error { 386 | t.scanner.mode = scanRbrack 387 | if t.scanner.scan() != tokenRbrack { 388 | return ErrBadSubstitution 389 | } 390 | return nil 391 | } 392 | 393 | // consumeDelimiter consumes a function argument delimiter. If a 394 | // delimiter is not consumed an ErrBadSubstitution is returned. 395 | // func (t *Tree) consumeDelimiter(accept acceptFunc, mode uint) error { 396 | // t.scanner.accept = accept 397 | // t.scanner.mode = mode 398 | // if t.scanner.scan() != tokenRbrack { 399 | // return ErrBadSubstitution 400 | // } 401 | // return nil 402 | // } 403 | -------------------------------------------------------------------------------- /parse/parse_test.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | ) 8 | 9 | var tests = []struct { 10 | Text string 11 | Node Node 12 | }{ 13 | 14 | // 15 | // text only 16 | // 17 | { 18 | Text: "text", 19 | Node: &TextNode{Value: "text"}, 20 | }, 21 | { 22 | Text: "}text", 23 | Node: &TextNode{Value: "}text"}, 24 | }, 25 | { 26 | Text: "http://github.com", 27 | Node: &TextNode{Value: "http://github.com"}, // should not escape double slash 28 | }, 29 | { 30 | Text: "$${string}", 31 | Node: &TextNode{Value: "${string}"}, // should not escape double dollar 32 | }, 33 | { 34 | Text: "$$string", 35 | Node: &TextNode{Value: "$string"}, // should not escape double dollar 36 | }, 37 | { 38 | Text: `\\.\pipe\pipename`, 39 | Node: &TextNode{Value: `\\.\pipe\pipename`}, 40 | }, 41 | 42 | // 43 | // variable only 44 | // 45 | { 46 | Text: "${string}", 47 | Node: &FuncNode{Param: "string"}, 48 | }, 49 | 50 | // 51 | // text transform functions 52 | // 53 | { 54 | Text: "${string,}", 55 | Node: &FuncNode{ 56 | Param: "string", 57 | Name: ",", 58 | Args: nil, 59 | }, 60 | }, 61 | { 62 | Text: "${string,,}", 63 | Node: &FuncNode{ 64 | Param: "string", 65 | Name: ",,", 66 | Args: nil, 67 | }, 68 | }, 69 | { 70 | Text: "${string^}", 71 | Node: &FuncNode{ 72 | Param: "string", 73 | Name: "^", 74 | Args: nil, 75 | }, 76 | }, 77 | { 78 | Text: "${string^^}", 79 | Node: &FuncNode{ 80 | Param: "string", 81 | Name: "^^", 82 | Args: nil, 83 | }, 84 | }, 85 | 86 | // 87 | // substring functions 88 | // 89 | { 90 | Text: "${string:position}", 91 | Node: &FuncNode{ 92 | Param: "string", 93 | Name: ":", 94 | Args: []Node{ 95 | &TextNode{Value: "position"}, 96 | }, 97 | }, 98 | }, 99 | { 100 | Text: "${string:position:length}", 101 | Node: &FuncNode{ 102 | Param: "string", 103 | Name: ":", 104 | Args: []Node{ 105 | &TextNode{Value: "position"}, 106 | &TextNode{Value: "length"}, 107 | }, 108 | }, 109 | }, 110 | 111 | // 112 | // string removal functions 113 | // 114 | { 115 | Text: "${string#substring}", 116 | Node: &FuncNode{ 117 | Param: "string", 118 | Name: "#", 119 | Args: []Node{ 120 | &TextNode{Value: "substring"}, 121 | }, 122 | }, 123 | }, 124 | { 125 | Text: "${string##substring}", 126 | Node: &FuncNode{ 127 | Param: "string", 128 | Name: "##", 129 | Args: []Node{ 130 | &TextNode{Value: "substring"}, 131 | }, 132 | }, 133 | }, 134 | { 135 | Text: "${string%substring}", 136 | Node: &FuncNode{ 137 | Param: "string", 138 | Name: "%", 139 | Args: []Node{ 140 | &TextNode{Value: "substring"}, 141 | }, 142 | }, 143 | }, 144 | { 145 | Text: "${string%%substring}", 146 | Node: &FuncNode{ 147 | Param: "string", 148 | Name: "%%", 149 | Args: []Node{ 150 | &TextNode{Value: "substring"}, 151 | }, 152 | }, 153 | }, 154 | 155 | // 156 | // string replace functions 157 | // 158 | { 159 | Text: "${string/substring/replacement}", 160 | Node: &FuncNode{ 161 | Param: "string", 162 | Name: "/", 163 | Args: []Node{ 164 | &TextNode{Value: "substring"}, 165 | &TextNode{Value: "replacement"}, 166 | }, 167 | }, 168 | }, 169 | { 170 | Text: "${string//substring/replacement}", 171 | Node: &FuncNode{ 172 | Param: "string", 173 | Name: "//", 174 | Args: []Node{ 175 | &TextNode{Value: "substring"}, 176 | &TextNode{Value: "replacement"}, 177 | }, 178 | }, 179 | }, 180 | { 181 | Text: "${string/#substring/replacement}", 182 | Node: &FuncNode{ 183 | Param: "string", 184 | Name: "/#", 185 | Args: []Node{ 186 | &TextNode{Value: "substring"}, 187 | &TextNode{Value: "replacement"}, 188 | }, 189 | }, 190 | }, 191 | { 192 | Text: "${string/%substring/replacement}", 193 | Node: &FuncNode{ 194 | Param: "string", 195 | Name: "/%", 196 | Args: []Node{ 197 | &TextNode{Value: "substring"}, 198 | &TextNode{Value: "replacement"}, 199 | }, 200 | }, 201 | }, 202 | 203 | // 204 | // default value functions 205 | // 206 | { 207 | Text: "${string=default}", 208 | Node: &FuncNode{ 209 | Param: "string", 210 | Name: "=", 211 | Args: []Node{ 212 | &TextNode{Value: "default"}, 213 | }, 214 | }, 215 | }, 216 | { 217 | Text: "${string:=default}", 218 | Node: &FuncNode{ 219 | Param: "string", 220 | Name: ":=", 221 | Args: []Node{ 222 | &TextNode{Value: "default"}, 223 | }, 224 | }, 225 | }, 226 | { 227 | Text: "${string:-default}", 228 | Node: &FuncNode{ 229 | Param: "string", 230 | Name: ":-", 231 | Args: []Node{ 232 | &TextNode{Value: "default"}, 233 | }, 234 | }, 235 | }, 236 | { 237 | Text: "${string:?default}", 238 | Node: &FuncNode{ 239 | Param: "string", 240 | Name: ":?", 241 | Args: []Node{ 242 | &TextNode{Value: "default"}, 243 | }, 244 | }, 245 | }, 246 | { 247 | Text: "${string:+default}", 248 | Node: &FuncNode{ 249 | Param: "string", 250 | Name: ":+", 251 | Args: []Node{ 252 | &TextNode{Value: "default"}, 253 | }, 254 | }, 255 | }, 256 | 257 | // 258 | // length function 259 | // 260 | { 261 | Text: "${#string}", 262 | Node: &FuncNode{ 263 | Param: "string", 264 | Name: "#", 265 | }, 266 | }, 267 | 268 | // 269 | // special characters in argument 270 | // 271 | { 272 | Text: "${string#$%:*{}", 273 | Node: &FuncNode{ 274 | Param: "string", 275 | Name: "#", 276 | Args: []Node{ 277 | &TextNode{Value: "$%:*{"}, 278 | }, 279 | }, 280 | }, 281 | 282 | // text before and after function 283 | { 284 | Text: "hello ${#string} world", 285 | Node: &ListNode{ 286 | Nodes: []Node{ 287 | &TextNode{ 288 | Value: "hello ", 289 | }, 290 | &ListNode{ 291 | Nodes: []Node{ 292 | &FuncNode{ 293 | Param: "string", 294 | Name: "#", 295 | }, 296 | &TextNode{ 297 | Value: " world", 298 | }, 299 | }, 300 | }, 301 | }, 302 | }, 303 | }, 304 | // text before and after function with \\ outside of function 305 | { 306 | Text: `\\ hello ${#string} world \\`, 307 | Node: &ListNode{ 308 | Nodes: []Node{ 309 | &TextNode{ 310 | Value: `\\ hello `, 311 | }, 312 | &ListNode{ 313 | Nodes: []Node{ 314 | &FuncNode{ 315 | Param: "string", 316 | Name: "#", 317 | }, 318 | &TextNode{ 319 | Value: ` world \\`, 320 | }, 321 | }, 322 | }, 323 | }, 324 | }, 325 | }, 326 | 327 | // escaped function arguments 328 | { 329 | Text: `${string/\/position/length}`, 330 | Node: &FuncNode{ 331 | Param: "string", 332 | Name: "/", 333 | Args: []Node{ 334 | &TextNode{ 335 | Value: "/position", 336 | }, 337 | &TextNode{ 338 | Value: "length", 339 | }, 340 | }, 341 | }, 342 | }, 343 | { 344 | Text: `${string/\/position\\/length}`, 345 | Node: &FuncNode{ 346 | Param: "string", 347 | Name: "/", 348 | Args: []Node{ 349 | &TextNode{ 350 | Value: "/position\\", 351 | }, 352 | &TextNode{ 353 | Value: "length", 354 | }, 355 | }, 356 | }, 357 | }, 358 | { 359 | Text: `${string/position/\/length}`, 360 | Node: &FuncNode{ 361 | Param: "string", 362 | Name: "/", 363 | Args: []Node{ 364 | &TextNode{ 365 | Value: "position", 366 | }, 367 | &TextNode{ 368 | Value: "/length", 369 | }, 370 | }, 371 | }, 372 | }, 373 | { 374 | Text: `${string/position/\/length\\}`, 375 | Node: &FuncNode{ 376 | Param: "string", 377 | Name: "/", 378 | Args: []Node{ 379 | &TextNode{ 380 | Value: "position", 381 | }, 382 | &TextNode{ 383 | Value: "/length\\", 384 | }, 385 | }, 386 | }, 387 | }, 388 | { 389 | Text: `${string/position/\/leng\\th}`, 390 | Node: &FuncNode{ 391 | Param: "string", 392 | Name: "/", 393 | Args: []Node{ 394 | &TextNode{ 395 | Value: "position", 396 | }, 397 | &TextNode{ 398 | Value: "/leng\\th", 399 | }, 400 | }, 401 | }, 402 | }, 403 | 404 | // functions in functions 405 | { 406 | Text: "${string:${position}}", 407 | Node: &FuncNode{ 408 | Param: "string", 409 | Name: ":", 410 | Args: []Node{ 411 | &FuncNode{ 412 | Param: "position", 413 | }, 414 | }, 415 | }, 416 | }, 417 | { 418 | Text: "${string:${stringy:position:length}:${stringz,,}}", 419 | Node: &FuncNode{ 420 | Param: "string", 421 | Name: ":", 422 | Args: []Node{ 423 | &FuncNode{ 424 | Param: "stringy", 425 | Name: ":", 426 | Args: []Node{ 427 | &TextNode{Value: "position"}, 428 | &TextNode{Value: "length"}, 429 | }, 430 | }, 431 | &FuncNode{ 432 | Param: "stringz", 433 | Name: ",,", 434 | }, 435 | }, 436 | }, 437 | }, 438 | { 439 | Text: "${string#${stringz}}", 440 | Node: &FuncNode{ 441 | Param: "string", 442 | Name: "#", 443 | Args: []Node{ 444 | &FuncNode{Param: "stringz"}, 445 | }, 446 | }, 447 | }, 448 | { 449 | Text: "${string=${stringz}}", 450 | Node: &FuncNode{ 451 | Param: "string", 452 | Name: "=", 453 | Args: []Node{ 454 | &FuncNode{Param: "stringz"}, 455 | }, 456 | }, 457 | }, 458 | { 459 | Text: "${string=prefix-${var}}", 460 | Node: &FuncNode{ 461 | Param: "string", 462 | Name: "=", 463 | Args: []Node{ 464 | &TextNode{Value: "prefix-"}, 465 | &FuncNode{Param: "var"}, 466 | }, 467 | }, 468 | }, 469 | { 470 | Text: "${string=${var}-suffix}", 471 | Node: &FuncNode{ 472 | Param: "string", 473 | Name: "=", 474 | Args: []Node{ 475 | &FuncNode{Param: "var"}, 476 | &TextNode{Value: "-suffix"}, 477 | }, 478 | }, 479 | }, 480 | { 481 | Text: "${string=prefix-${var}-suffix}", 482 | Node: &FuncNode{ 483 | Param: "string", 484 | Name: "=", 485 | Args: []Node{ 486 | &TextNode{Value: "prefix-"}, 487 | &FuncNode{Param: "var"}, 488 | &TextNode{Value: "-suffix"}, 489 | }, 490 | }, 491 | }, 492 | { 493 | Text: "${string=prefix${var} suffix}", 494 | Node: &FuncNode{ 495 | Param: "string", 496 | Name: "=", 497 | Args: []Node{ 498 | &TextNode{Value: "prefix"}, 499 | &FuncNode{Param: "var"}, 500 | &TextNode{Value: " suffix"}, 501 | }, 502 | }, 503 | }, 504 | { 505 | Text: "${string//${stringy}/${stringz}}", 506 | Node: &FuncNode{ 507 | Param: "string", 508 | Name: "//", 509 | Args: []Node{ 510 | &FuncNode{Param: "stringy"}, 511 | &FuncNode{Param: "stringz"}, 512 | }, 513 | }, 514 | }, 515 | } 516 | 517 | func TestParse(t *testing.T) { 518 | for _, test := range tests { 519 | t.Log(test.Text) 520 | t.Run(test.Text, func(t *testing.T) { 521 | got, err := Parse(test.Text) 522 | if err != nil { 523 | t.Error(err) 524 | } 525 | 526 | if diff := cmp.Diff(test.Node, got.Root); diff != "" { 527 | t.Errorf(diff) 528 | } 529 | }) 530 | } 531 | } 532 | -------------------------------------------------------------------------------- /parse/scan.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "unicode" 5 | "unicode/utf8" 6 | ) 7 | 8 | // eof rune sent when end of file is reached 9 | var eof = rune(0) 10 | 11 | // token is a lexical token. 12 | type token uint 13 | 14 | // list of lexical tokens. 15 | const ( 16 | // special tokens 17 | tokenIllegal token = iota 18 | tokenEOF 19 | 20 | // identifiers and literals 21 | tokenIdent 22 | 23 | // operators and delimiters 24 | tokenLbrack 25 | tokenRbrack 26 | tokenQuote 27 | ) 28 | 29 | // predefined mode bits to control recognition of tokens. 30 | const ( 31 | scanIdent byte = 1 << iota 32 | scanLbrack 33 | scanRbrack 34 | scanEscape 35 | ) 36 | 37 | // predefined mode bits to control escape tokens. 38 | const ( 39 | dollar byte = 1 << iota 40 | backslash 41 | escapeAll = dollar | backslash 42 | ) 43 | 44 | // returns true if rune is accepted. 45 | type acceptFunc func(r rune, i int) bool 46 | 47 | // scanner implements a lexical scanner that reads unicode 48 | // characters and tokens from a string buffer. 49 | type scanner struct { 50 | buf string 51 | pos int 52 | start int 53 | width int 54 | mode byte 55 | escapeChars byte 56 | 57 | accept acceptFunc 58 | } 59 | 60 | // init initializes a scanner with a new buffer. 61 | func (s *scanner) init(buf string) { 62 | s.buf = buf 63 | s.pos = 0 64 | s.start = 0 65 | s.width = 0 66 | s.accept = nil 67 | } 68 | 69 | // read returns the next unicode character. It returns eof at 70 | // the end of the string buffer. 71 | func (s *scanner) read() rune { 72 | if s.pos >= len(s.buf) { 73 | s.width = 0 74 | return eof 75 | } 76 | r, w := utf8.DecodeRuneInString(s.buf[s.pos:]) 77 | s.width = w 78 | s.pos += s.width 79 | return r 80 | } 81 | 82 | func (s *scanner) unread() { 83 | s.pos -= s.width 84 | } 85 | 86 | // skip skips over the curring unicode character in the buffer 87 | // by slicing and removing from the buffer. 88 | func (s *scanner) skip() { 89 | l := s.buf[:s.pos-1] 90 | r := s.buf[s.pos:] 91 | s.buf = l + r 92 | } 93 | 94 | // peek returns the next unicode character in the buffer without 95 | // advancing the scanner. It returns eof if the scanner's position 96 | // is at the last character of the source. 97 | func (s *scanner) peek() rune { 98 | r := s.read() 99 | s.unread() 100 | return r 101 | } 102 | 103 | // string returns the string corresponding to the most recently 104 | // scanned token. Valid after calling scan(). 105 | func (s *scanner) string() string { 106 | return s.buf[s.start:s.pos] 107 | } 108 | 109 | // tests if the bit exists for a given character bit 110 | func (s *scanner) shouldEscape(character byte) bool { 111 | return s.escapeChars&character != 0 112 | } 113 | 114 | // scan reads the next token or Unicode character from source and 115 | // returns it. It returns EOF at the end of the source. 116 | func (s *scanner) scan() token { 117 | s.start = s.pos 118 | r := s.read() 119 | switch { 120 | case r == eof: 121 | return tokenEOF 122 | case s.scanLbrack(r): 123 | return tokenLbrack 124 | case s.scanRbrack(r): 125 | return tokenRbrack 126 | case s.scanIdent(r): 127 | return tokenIdent 128 | } 129 | return tokenIllegal 130 | } 131 | 132 | // scanIdent reads the next token or Unicode character from source 133 | // and returns true if the Ident character is accepted. 134 | func (s *scanner) scanIdent(r rune) bool { 135 | if s.mode&scanIdent == 0 { 136 | return false 137 | } 138 | if s.scanEscaped(r) { 139 | s.skip() 140 | } else if !s.accept(r, s.pos-s.start) { 141 | return false 142 | } 143 | loop: 144 | for { 145 | r := s.read() 146 | switch { 147 | case r == eof: 148 | s.unread() 149 | break loop 150 | case s.scanLbrack(r): 151 | s.unread() 152 | s.unread() 153 | break loop 154 | } 155 | if s.scanEscaped(r) { 156 | s.skip() 157 | continue 158 | } 159 | if !s.accept(r, s.pos-s.start) { 160 | s.unread() 161 | break loop 162 | } 163 | } 164 | return true 165 | } 166 | 167 | // scanLbrack reads the next token or Unicode character from source 168 | // and returns true if the open bracket is encountered. 169 | func (s *scanner) scanLbrack(r rune) bool { 170 | if s.mode&scanLbrack == 0 { 171 | return false 172 | } 173 | if r == '$' { 174 | if s.read() == '{' { 175 | return true 176 | } 177 | s.unread() 178 | } 179 | return false 180 | } 181 | 182 | // scanRbrack reads the next token or Unicode character from source 183 | // and returns true if the closing bracket is encountered. 184 | func (s *scanner) scanRbrack(r rune) bool { 185 | if s.mode&scanRbrack == 0 { 186 | return false 187 | } 188 | return r == '}' 189 | } 190 | 191 | // scanEscaped reads the next token or Unicode character from source 192 | // and returns true if it being escaped and should be skipped. 193 | func (s *scanner) scanEscaped(r rune) bool { 194 | if s.mode&scanEscape == 0 { 195 | return false 196 | } 197 | if r == '$' && s.shouldEscape(dollar) { 198 | if s.peek() == '$' { 199 | return true 200 | } 201 | } 202 | if r == '\\' && s.shouldEscape(backslash) { 203 | switch s.peek() { 204 | case '/', '\\': 205 | return true 206 | default: 207 | return false 208 | } 209 | } 210 | 211 | return false 212 | } 213 | 214 | // 215 | // scanner functions accept or reject runes. 216 | // 217 | 218 | func acceptRune(r rune, i int) bool { 219 | return true 220 | } 221 | 222 | func acceptIdent(r rune, i int) bool { 223 | return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' 224 | } 225 | 226 | func acceptColon(r rune, i int) bool { 227 | return r == ':' 228 | } 229 | 230 | func acceptOneHash(r rune, i int) bool { 231 | return r == '#' && i == 1 232 | } 233 | 234 | func acceptNone(r rune, i int) bool { 235 | return false 236 | } 237 | 238 | func acceptNotClosing(r rune, i int) bool { 239 | return r != '}' 240 | } 241 | 242 | func acceptHashFunc(r rune, i int) bool { 243 | return r == '#' && i < 3 244 | } 245 | 246 | func acceptPercentFunc(r rune, i int) bool { 247 | return r == '%' && i < 3 248 | } 249 | 250 | func acceptDefaultFunc(r rune, i int) bool { 251 | switch { 252 | case i == 1 && r == ':': 253 | return true 254 | case i == 2 && (r == '=' || r == '-' || r == '?' || r == '+'): 255 | return true 256 | default: 257 | return false 258 | } 259 | } 260 | 261 | func acceptReplaceFunc(r rune, i int) bool { 262 | switch { 263 | case i == 1 && r == '/': 264 | return true 265 | case i == 2 && (r == '/' || r == '#' || r == '%'): 266 | return true 267 | default: 268 | return false 269 | } 270 | } 271 | 272 | func acceptOneEqual(r rune, i int) bool { 273 | return i == 1 && r == '=' 274 | } 275 | 276 | func acceptOneColon(r rune, i int) bool { 277 | return i == 1 && r == ':' 278 | } 279 | 280 | func rejectColonClose(r rune, i int) bool { 281 | return r != ':' && r != '}' 282 | } 283 | 284 | func acceptSlash(r rune, i int) bool { 285 | return r == '/' 286 | } 287 | 288 | func acceptNotSlash(r rune, i int) bool { 289 | return r != '/' 290 | } 291 | 292 | func acceptCasingFunc(r rune, i int) bool { 293 | return (r == ',' || r == '^') && i < 3 294 | } 295 | -------------------------------------------------------------------------------- /parse/scan_test.go: -------------------------------------------------------------------------------- 1 | package parse 2 | -------------------------------------------------------------------------------- /path/match.go: -------------------------------------------------------------------------------- 1 | // Copyright 2010 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package path 6 | 7 | import ( 8 | "errors" 9 | "unicode/utf8" 10 | ) 11 | 12 | // ErrBadPattern indicates a globbing pattern was malformed. 13 | var ErrBadPattern = errors.New("syntax error in pattern") 14 | 15 | // Match reports whether name matches the shell file name pattern. 16 | // The pattern syntax is: 17 | // 18 | // pattern: 19 | // { term } 20 | // term: 21 | // '*' matches any sequence of non-/ characters 22 | // '?' matches any single non-/ character 23 | // '[' [ '^' ] { character-range } ']' 24 | // character class (must be non-empty) 25 | // c matches character c (c != '*', '?', '\\', '[') 26 | // '\\' c matches character c 27 | // 28 | // character-range: 29 | // c matches character c (c != '\\', '-', ']') 30 | // '\\' c matches character c 31 | // lo '-' hi matches character c for lo <= c <= hi 32 | // 33 | // Match requires pattern to match all of name, not just a substring. 34 | // The only possible returned error is ErrBadPattern, when pattern 35 | // is malformed. 36 | // 37 | func Match(pattern, name string) (matched bool, err error) { 38 | Pattern: 39 | for len(pattern) > 0 { 40 | var star bool 41 | var chunk string 42 | star, chunk, pattern = scanChunk(pattern) 43 | if star && chunk == "" { 44 | // Trailing * matches rest of string unless it has a /. 45 | // return !strings.Contains(name, "/"), nil 46 | 47 | // Return rest of string 48 | return true, nil 49 | } 50 | // Look for match at current position. 51 | t, ok, err := matchChunk(chunk, name) 52 | // if we're the last chunk, make sure we've exhausted the name 53 | // otherwise we'll give a false result even if we could still match 54 | // using the star 55 | if ok && (len(t) == 0 || len(pattern) > 0) { 56 | name = t 57 | continue 58 | } 59 | if err != nil { 60 | return false, err 61 | } 62 | if star { 63 | // Look for match skipping i+1 bytes. 64 | for i := 0; i < len(name); i++ { 65 | t, ok, err := matchChunk(chunk, name[i+1:]) 66 | if ok { 67 | // if we're the last chunk, make sure we exhausted the name 68 | if len(pattern) == 0 && len(t) > 0 { 69 | continue 70 | } 71 | name = t 72 | continue Pattern 73 | } 74 | if err != nil { 75 | return false, err 76 | } 77 | } 78 | } 79 | return false, nil 80 | } 81 | return len(name) == 0, nil 82 | } 83 | 84 | // scanChunk gets the next segment of pattern, which is a non-star string 85 | // possibly preceded by a star. 86 | func scanChunk(pattern string) (star bool, chunk, rest string) { 87 | for len(pattern) > 0 && pattern[0] == '*' { 88 | pattern = pattern[1:] 89 | star = true 90 | } 91 | inrange := false 92 | var i int 93 | Scan: 94 | for i = 0; i < len(pattern); i++ { 95 | switch pattern[i] { 96 | case '\\': 97 | // error check handled in matchChunk: bad pattern. 98 | if i+1 < len(pattern) { 99 | i++ 100 | } 101 | case '[': 102 | inrange = true 103 | case ']': 104 | inrange = false 105 | case '*': 106 | if !inrange { 107 | break Scan 108 | } 109 | } 110 | } 111 | return star, pattern[0:i], pattern[i:] 112 | } 113 | 114 | // matchChunk checks whether chunk matches the beginning of s. 115 | // If so, it returns the remainder of s (after the match). 116 | // Chunk is all single-character operators: literals, char classes, and ?. 117 | func matchChunk(chunk, s string) (rest string, ok bool, err error) { 118 | for len(chunk) > 0 { 119 | if len(s) == 0 { 120 | return 121 | } 122 | switch chunk[0] { 123 | case '[': 124 | // character class 125 | r, n := utf8.DecodeRuneInString(s) 126 | s = s[n:] 127 | chunk = chunk[1:] 128 | // possibly negated 129 | notNegated := true 130 | if len(chunk) > 0 && chunk[0] == '^' { 131 | notNegated = false 132 | chunk = chunk[1:] 133 | } 134 | // parse all ranges 135 | match := false 136 | nrange := 0 137 | for { 138 | if len(chunk) > 0 && chunk[0] == ']' && nrange > 0 { 139 | chunk = chunk[1:] 140 | break 141 | } 142 | var lo, hi rune 143 | if lo, chunk, err = getEsc(chunk); err != nil { 144 | return 145 | } 146 | hi = lo 147 | if chunk[0] == '-' { 148 | if hi, chunk, err = getEsc(chunk[1:]); err != nil { 149 | return 150 | } 151 | } 152 | if lo <= r && r <= hi { 153 | match = true 154 | } 155 | nrange++ 156 | } 157 | if match != notNegated { 158 | return 159 | } 160 | 161 | case '?': 162 | _, n := utf8.DecodeRuneInString(s) 163 | s = s[n:] 164 | chunk = chunk[1:] 165 | 166 | case '\\': 167 | chunk = chunk[1:] 168 | if len(chunk) == 0 { 169 | err = ErrBadPattern 170 | return 171 | } 172 | fallthrough 173 | 174 | default: 175 | if chunk[0] != s[0] { 176 | return 177 | } 178 | s = s[1:] 179 | chunk = chunk[1:] 180 | } 181 | } 182 | return s, true, nil 183 | } 184 | 185 | // getEsc gets a possibly-escaped character from chunk, for a character class. 186 | func getEsc(chunk string) (r rune, nchunk string, err error) { 187 | if len(chunk) == 0 || chunk[0] == '-' || chunk[0] == ']' { 188 | err = ErrBadPattern 189 | return 190 | } 191 | if chunk[0] == '\\' { 192 | chunk = chunk[1:] 193 | if len(chunk) == 0 { 194 | err = ErrBadPattern 195 | return 196 | } 197 | } 198 | r, n := utf8.DecodeRuneInString(chunk) 199 | if r == utf8.RuneError && n == 1 { 200 | err = ErrBadPattern 201 | } 202 | nchunk = chunk[n:] 203 | if len(nchunk) == 0 { 204 | err = ErrBadPattern 205 | } 206 | return 207 | } 208 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # envsubst 2 | 3 | `envsubst` is a Go package for expanding variables in a string using `${var}` syntax. 4 | Includes support for bash string replacement functions. 5 | 6 | ## Documentation 7 | 8 | [Documentation can be found on GoDoc][doc]. 9 | 10 | ## Supported Functions 11 | 12 | | __Expression__ | __Meaning__ | 13 | | ----------------- | -------------- | 14 | | `${var}` | Value of `$var` 15 | | `${#var}` | String length of `$var` 16 | | `${var^}` | Uppercase first character of `$var` 17 | | `${var^^}` | Uppercase all characters in `$var` 18 | | `${var,}` | Lowercase first character of `$var` 19 | | `${var,,}` | Lowercase all characters in `$var` 20 | | `${var:n}` | Offset `$var` `n` characters from start 21 | | `${var:n:len}` | Offset `$var` `n` characters with max length of `len` 22 | | `${var#pattern}` | Strip shortest `pattern` match from start 23 | | `${var##pattern}` | Strip longest `pattern` match from start 24 | | `${var%pattern}` | Strip shortest `pattern` match from end 25 | | `${var%%pattern}` | Strip longest `pattern` match from end 26 | | `${var-default` | If `$var` is not set, evaluate expression as `$default` 27 | | `${var:-default` | If `$var` is not set or is empty, evaluate expression as `$default` 28 | | `${var=default` | If `$var` is not set, evaluate expression as `$default` 29 | | `${var:=default` | If `$var` is not set or is empty, evaluate expression as `$default` 30 | | `${var/pattern/replacement}` | Replace as few `pattern` matches as possible with `replacement` 31 | | `${var//pattern/replacement}` | Replace as many `pattern` matches as possible with `replacement` 32 | | `${var/#pattern/replacement}` | Replace `pattern` match with `replacement` from `$var` start 33 | | `${var/%pattern/replacement}` | Replace `pattern` match with `replacement` from `$var` end 34 | 35 | For a deeper reference, see [bash-hackers](https://wiki.bash-hackers.org/syntax/pe#case_modification) or [gnu pattern matching](https://www.gnu.org/software/bash/manual/html_node/Pattern-Matching.html). 36 | 37 | ## Unsupported Functions 38 | 39 | * `${var-default}` 40 | * `${var+default}` 41 | * `${var:?default}` 42 | * `${var:+default}` 43 | 44 | [doc]: http://godoc.org/github.com/drone/envsubst 45 | -------------------------------------------------------------------------------- /template.go: -------------------------------------------------------------------------------- 1 | package envsubst 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "io/ioutil" 7 | 8 | "github.com/drone/envsubst/v2/parse" 9 | ) 10 | 11 | // state represents the state of template execution. It is not part of the 12 | // template so that multiple executions can run in parallel. 13 | type state struct { 14 | template *Template 15 | writer io.Writer 16 | node parse.Node // current node 17 | 18 | // maps variable names to values 19 | mapper func(string) string 20 | } 21 | 22 | // Template is the representation of a parsed shell format string. 23 | type Template struct { 24 | tree *parse.Tree 25 | } 26 | 27 | // Parse creates a new shell format template and parses the template 28 | // definition from string s. 29 | func Parse(s string) (t *Template, err error) { 30 | t = new(Template) 31 | t.tree, err = parse.Parse(s) 32 | if err != nil { 33 | return nil, err 34 | } 35 | return t, nil 36 | } 37 | 38 | // ParseFile creates a new shell format template and parses the template 39 | // definition from the named file. 40 | func ParseFile(path string) (*Template, error) { 41 | b, err := ioutil.ReadFile(path) 42 | if err != nil { 43 | return nil, err 44 | } 45 | return Parse(string(b)) 46 | } 47 | 48 | // Execute applies a parsed template to the specified data mapping. 49 | func (t *Template) Execute(mapping func(string) string) (str string, err error) { 50 | b := new(bytes.Buffer) 51 | s := new(state) 52 | s.node = t.tree.Root 53 | s.mapper = mapping 54 | s.writer = b 55 | err = t.eval(s) 56 | if err != nil { 57 | return 58 | } 59 | return b.String(), nil 60 | } 61 | 62 | func (t *Template) eval(s *state) (err error) { 63 | switch node := s.node.(type) { 64 | case *parse.TextNode: 65 | err = t.evalText(s, node) 66 | case *parse.FuncNode: 67 | err = t.evalFunc(s, node) 68 | case *parse.ListNode: 69 | err = t.evalList(s, node) 70 | } 71 | return err 72 | } 73 | 74 | func (t *Template) evalText(s *state, node *parse.TextNode) error { 75 | _, err := io.WriteString(s.writer, node.Value) 76 | return err 77 | } 78 | 79 | func (t *Template) evalList(s *state, node *parse.ListNode) (err error) { 80 | for _, n := range node.Nodes { 81 | s.node = n 82 | err = t.eval(s) 83 | if err != nil { 84 | return err 85 | } 86 | } 87 | return nil 88 | } 89 | 90 | func (t *Template) evalFunc(s *state, node *parse.FuncNode) error { 91 | var w = s.writer 92 | var buf bytes.Buffer 93 | var args []string 94 | for _, n := range node.Args { 95 | buf.Reset() 96 | s.writer = &buf 97 | s.node = n 98 | err := t.eval(s) 99 | if err != nil { 100 | return err 101 | } 102 | args = append(args, buf.String()) 103 | } 104 | 105 | // restore the origin writer 106 | s.writer = w 107 | s.node = node 108 | 109 | v := s.mapper(node.Param) 110 | 111 | fn := lookupFunc(node.Name, len(args)) 112 | 113 | _, err := io.WriteString(s.writer, fn(v, args...)) 114 | return err 115 | } 116 | 117 | // lookupFunc returns the parameters substitution function by name. If the 118 | // named function does not exists, a default function is returned. 119 | func lookupFunc(name string, args int) substituteFunc { 120 | switch name { 121 | case ",": 122 | return toLowerFirst 123 | case ",,": 124 | return toLower 125 | case "^": 126 | return toUpperFirst 127 | case "^^": 128 | return toUpper 129 | case "#": 130 | if args == 0 { 131 | return toLen 132 | } 133 | return trimShortestPrefix 134 | case "##": 135 | return trimLongestPrefix 136 | case "%": 137 | return trimShortestSuffix 138 | case "%%": 139 | return trimLongestSuffix 140 | case ":": 141 | return toSubstr 142 | case "/#": 143 | return replacePrefix 144 | case "/%": 145 | return replaceSuffix 146 | case "/": 147 | return replaceFirst 148 | case "//": 149 | return replaceAll 150 | case "=", ":=", ":-": 151 | return toDefault 152 | case ":?", ":+", "-", "+": 153 | return toDefault 154 | default: 155 | return toDefault 156 | } 157 | } 158 | --------------------------------------------------------------------------------