├── .github └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── config.go ├── config_test.go ├── exec.go ├── exec_posix.go ├── exec_windows.go ├── fillin.go ├── fillin_test.go ├── go.mod ├── go.sum ├── identifier.go ├── identifier_test.go ├── main.go ├── prompt.go ├── prompt_liner.go ├── prompt_test.go ├── resolve.go ├── run.go └── run_test.go /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, macos-latest, windows-latest] 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v2 19 | - name: Setup Go 20 | uses: actions/setup-go@v2 21 | with: 22 | go-version: 1.x 23 | - name: Test 24 | run: make test 25 | - name: Lint 26 | run: make lint 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | - name: Setup Go 16 | uses: actions/setup-go@v2 17 | with: 18 | go-version: 1.x 19 | - name: Cross build 20 | run: make cross 21 | - name: Create Release 22 | id: create_release 23 | uses: actions/create-release@v1 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | with: 27 | tag_name: ${{ github.ref }} 28 | release_name: Release ${{ github.ref }} 29 | - name: Upload 30 | run: make upload 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /fillin 2 | /goxz 3 | /CREDITS 4 | *.exe 5 | *.test 6 | *.out 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2021 itchyny 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN := fillin 2 | VERSION := $$(make -s show-version) 3 | VERSION_PATH := . 4 | BUILD_LDFLAGS := "-s -w" 5 | GOBIN ?= $(shell go env GOPATH)/bin 6 | 7 | .PHONY: all 8 | all: build 9 | 10 | .PHONY: build 11 | build: 12 | go build -ldflags=$(BUILD_LDFLAGS) -o $(BIN) . 13 | 14 | .PHONY: install 15 | install: 16 | go install -ldflags=$(BUILD_LDFLAGS) ./... 17 | 18 | .PHONY: show-version 19 | show-version: $(GOBIN)/gobump 20 | @gobump show -r $(VERSION_PATH) 21 | 22 | $(GOBIN)/gobump: 23 | @go install github.com/x-motemen/gobump/cmd/gobump@latest 24 | 25 | .PHONY: cross 26 | cross: $(GOBIN)/goxz CREDITS 27 | goxz -n $(BIN) -pv=v$(VERSION) -arch=amd64,arm64 \ 28 | -build-ldflags=$(BUILD_LDFLAGS) . 29 | 30 | $(GOBIN)/goxz: 31 | go install github.com/Songmu/goxz/cmd/goxz@latest 32 | 33 | CREDITS: $(GOBIN)/gocredits go.sum 34 | go mod tidy 35 | gocredits -w . 36 | 37 | $(GOBIN)/gocredits: 38 | go install github.com/Songmu/gocredits/cmd/gocredits@latest 39 | 40 | .PHONY: test 41 | test: build 42 | go test -v -race ./... 43 | 44 | .PHONY: lint 45 | lint: $(GOBIN)/staticcheck 46 | go vet ./... 47 | staticcheck ./... 48 | 49 | $(GOBIN)/staticcheck: 50 | go install honnef.co/go/tools/cmd/staticcheck@latest 51 | 52 | .PHONY: clean 53 | clean: 54 | rm -rf $(BIN) goxz CREDITS 55 | go clean 56 | 57 | .PHONY: bump 58 | bump: $(GOBIN)/gobump 59 | ifneq ($(shell git status --porcelain),) 60 | $(error git workspace is dirty) 61 | endif 62 | ifneq ($(shell git rev-parse --abbrev-ref HEAD),main) 63 | $(error current branch is not main) 64 | endif 65 | @gobump up -w "$(VERSION_PATH)" 66 | git commit -am "bump up version to $(VERSION)" 67 | git tag "v$(VERSION)" 68 | git push origin main 69 | git push origin "refs/tags/v$(VERSION)" 70 | 71 | .PHONY: upload 72 | upload: $(GOBIN)/ghr 73 | ghr "v$(VERSION)" goxz 74 | 75 | $(GOBIN)/ghr: 76 | go install github.com/tcnksm/ghr@latest 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fillin 2 | [![CI Status](https://github.com/itchyny/fillin/workflows/CI/badge.svg)](https://github.com/itchyny/fillin/actions) 3 | [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/itchyny/fillin/blob/main/LICENSE) 4 | [![release](https://img.shields.io/github/release/itchyny/fillin/all.svg)](https://github.com/itchyny/fillin/releases) 5 | 6 | ### fill-in your command and execute 7 | #### ― _separate action and environment of your command!_ ― 8 | 9 | ## Motivation 10 | We rely on shell history in our terminal operation. 11 | We search from our shell history and execute commands dozens times in a day. 12 | 13 | Some programmers execute same commands switching servers. 14 | We do not just login with `ssh {{hostname}}`, we also connect to the database with `psql -h {{psql:hostname}} -U {{psql:username}} -d {{psql:dbname}}` and to Redis server with `redis-cli -h {{redis:hostname}} -p {{redis:port}}`. 15 | We switch the host argument from the localhost (you may omit this), staging and production servers. 16 | 17 | Some command line tools allow us to login cloud services and retrieve data from our terminal. 18 | Most of such cli tools accept an option to switching between our accounts. 19 | For example, AWS command line tool has `--profile` option. 20 | Other typical names of options are `--region`, `--conf` and `--account`. 21 | When we specify these options directly, there are quadratic number of commands; the number of accounts times the number of actions. 22 | The `fillin` allows us to save the command something like `aws --profile {{aws:profile}} ec2 describe-instances` so we'll not be bothered by the quadratic combinations of commands while searching through the shell history. 23 | 24 | The core concept of `fillin` lies in separating the action (do what) and the environment (to where) in the command. 25 | With this `fillin` command line tool, you can 26 | 27 | - make your commands reusable and it will make incremental shell history searching easy. 28 | - fill in the template variables interactively and their history will be stored locally. 29 | - invoke the same action switching multiple environment (local, staging and production servers, configuration paths, cloud service accounts or whatever) 30 | 31 | ## Installation 32 | ### Homebrew 33 | ```sh 34 | brew install itchyny/tap/fillin 35 | ``` 36 | 37 | ### Build from source 38 | ```sh 39 | go install github.com/itchyny/fillin@latest 40 | ``` 41 | 42 | ## Usage 43 | The interface of the `fillin` command is very simple. 44 | Prepend `fillin` to the command and create template variables with `{{...}}`. 45 | So the hello world for the `fillin` command is as follows. 46 | ```sh 47 | $ fillin echo {{message}} 48 | message: Hello, world! # you type here 49 | Hello, world! # fillin executes: echo 'Hello, world!' 50 | ``` 51 | The value of `message` variable is stored locally (in `~/.config/fillin/fillin.json`; you can configure the directory by `FILLIN_CONFIG_DIR`). 52 | You can use the recently used value with the upwards key. 53 | Note that in fish shell you can use square brackets like `fillin echo [[message]]`. 54 | 55 | The `{{message}}` (or `[[message]]` in fish shell) is a template part of the command. 56 | As the identifier, you can use alphabets, numbers, underscore and hyphen. 57 | Thus `{{sample-id}}`, `{{SAMPLE_ID}}`, `{{X01}}` and `{{FOO_example-identifier0123}}` are all valid template parts. 58 | 59 | One of the important features of `fillin` is variable scope grouping. 60 | Let's look into more practical example. 61 | When you connect to a PostgreSQL server, you can use: 62 | ```sh 63 | $ fillin psql -h {{psql:hostname}} -U {{psql:username}} -d {{psql:dbname}} 64 | [psql] hostname: localhost 65 | [psql] username: example-user 66 | [psql] dbname: example-db 67 | ``` 68 | What's the benefit of `psql:` prefix? 69 | You'll notice the answer when you execute the command again: 70 | ```sh 71 | $ fillin psql -h {{psql:hostname}} -U {{psql:username}} -d {{psql:dbname}} 72 | [psql] hostname, username, dbname: localhost, example-user, example-db # you can select the most recently used entry with the upwards key 73 | ``` 74 | The identifiers with the same scope name (`psql` scope here) can be selected as pairs. 75 | You can input individual values to create a new pair after skipping the multi input prompt. 76 | ```sh 77 | $ fillin psql -h {{psql:hostname}} -U {{psql:username}} -d {{psql:dbname}} 78 | [psql] hostname, username, dbname: # just type enter to input values for each identifiers 79 | [psql] hostname: example.org 80 | [psql] username: example-org-user 81 | [psql] dbname: example-org-db 82 | ``` 83 | 84 | The scope grouping behaviour is useful with some authorization keys. 85 | ```sh 86 | $ fillin curl {{example-api:base-url}}/api/1/example/info -H 'Authorization: Bearer {{example-api:access-token}}' 87 | [example-api] base-url, access-token: example.com, accesstokenabcde012345 88 | ``` 89 | The `base-url` and `access-token` are stored in pairs so you can easily switch between local, staging and production environment authorization. 90 | Without the grouping behaviour, variable history searching will lead you to an unmatched pair of `base-url` and `access-token`. 91 | Since the curl endpoint are stored in the shell history and authorization keys are stored in `fillin` history, we'll not be bothered by the quadratic number of the command history. 92 | 93 | In order to have the benefit of this grouping behaviour, it's strongly recommended to prepend the scope name. 94 | The `psql:` prefix on connecting to PostgreSQL database server, `redis:` prefix for Redis server are useful best practice in my opinion. 95 | 96 | ## Disclaimer 97 | This tool is not an encryption tool. 98 | The command saves the inputted values in a JSON file with no encryption. 99 | Do not use this tool for security reason. 100 | 101 | ## Bug Tracker 102 | Report bug at [Issues・itchyny/fillin - GitHub](https://github.com/itchyny/fillin/issues). 103 | 104 | ## Author 105 | itchyny (https://github.com/itchyny) 106 | 107 | ## License 108 | This software is released under the MIT License, see LICENSE. 109 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "strings" 4 | 5 | // Config for fillin 6 | type Config struct { 7 | Scopes map[string]*Scope `json:"scopes"` 8 | } 9 | 10 | // Scope holds pairs of values 11 | type Scope struct { 12 | Values []map[string]string `json:"values"` 13 | } 14 | 15 | func (config *Config) collectHistory(id *Identifier) []string { 16 | values := []string{} 17 | added := make(map[string]bool) 18 | if _, ok := config.Scopes[id.scope]; ok { 19 | for _, value := range config.Scopes[id.scope].Values { 20 | if v, ok := value[id.key]; ok && !added[v] { 21 | values = append(values, v) 22 | added[v] = true 23 | } 24 | } 25 | } 26 | return values 27 | } 28 | 29 | func (config *Config) collectScopedPairHistory(idg *IdentifierGroup) []string { 30 | values := []string{} 31 | added := make(map[string]bool) 32 | if _, ok := config.Scopes[idg.scope]; ok { 33 | for _, value := range config.Scopes[idg.scope].Values { 34 | contained := true 35 | var vs []string 36 | for _, key := range idg.keys { 37 | if v, ok := value[key]; ok { 38 | vs = append(vs, strings.Replace(v, ", ", ",\\ ", -1)) 39 | } else { 40 | contained = false 41 | break 42 | } 43 | } 44 | if contained { 45 | v := strings.Join(vs, ", ") 46 | if !added[v] { 47 | values = append(values, v) 48 | added[v] = true 49 | } 50 | } 51 | } 52 | } 53 | return values 54 | } 55 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | var configForTest = &Config{ 9 | Scopes: map[string]*Scope{ 10 | "": { 11 | Values: []map[string]string{ 12 | { 13 | "baz": "world!", 14 | "foo": "Hello 1", 15 | }, 16 | { 17 | "baz": "world!", 18 | "foo": "Hello 2", 19 | }, 20 | }, 21 | }, 22 | "sample": { 23 | Values: []map[string]string{ 24 | { 25 | "foo": "Test1, world!", 26 | "bar": "test1, test", 27 | "baz": "baz", 28 | }, 29 | { 30 | "foo": "Test2, world!", 31 | "bar": "test2, test", 32 | }, 33 | { 34 | "foo": "Test1, world!", 35 | "bar": "test1, test", 36 | "qux": "qux", 37 | }, 38 | }, 39 | }, 40 | }, 41 | } 42 | 43 | func Test_collectHistory(t *testing.T) { 44 | testCases := []struct { 45 | identifier *Identifier 46 | expected []string 47 | }{ 48 | { 49 | identifier: &Identifier{key: "foo"}, 50 | expected: []string{"Hello 1", "Hello 2"}, 51 | }, 52 | { 53 | identifier: &Identifier{key: "baz"}, 54 | expected: []string{"world!"}, 55 | }, 56 | { 57 | &Identifier{scope: "sample", key: "foo"}, 58 | []string{"Test1, world!", "Test2, world!"}, 59 | }, 60 | { 61 | &Identifier{scope: "foo", key: "test"}, 62 | []string{}, 63 | }, 64 | } 65 | for _, tc := range testCases { 66 | got := configForTest.collectHistory(tc.identifier) 67 | if !reflect.DeepEqual(tc.expected, got) { 68 | t.Errorf("collectHistory incorrect (expected: %+v, got: %+v)", tc.expected, got) 69 | } 70 | } 71 | } 72 | 73 | func Test_collectScopedPairHistory(t *testing.T) { 74 | testCases := []struct { 75 | identifier *IdentifierGroup 76 | expected []string 77 | }{ 78 | { 79 | identifier: &IdentifierGroup{keys: []string{"foo", "baz"}}, 80 | expected: []string{"Hello 1, world!", "Hello 2, world!"}, 81 | }, 82 | { 83 | identifier: &IdentifierGroup{scope: "sample", keys: []string{"foo", "bar"}}, 84 | expected: []string{"Test1,\\ world!, test1,\\ test", "Test2,\\ world!, test2,\\ test"}, 85 | }, 86 | { 87 | identifier: &IdentifierGroup{scope: "sample", keys: []string{"foo", "bar", "baz"}}, 88 | expected: []string{"Test1,\\ world!, test1,\\ test, baz"}, 89 | }, 90 | { 91 | identifier: &IdentifierGroup{scope: "foo", keys: []string{"test"}}, 92 | expected: []string{}, 93 | }, 94 | } 95 | for _, tc := range testCases { 96 | got := configForTest.collectScopedPairHistory(tc.identifier) 97 | if !reflect.DeepEqual(tc.expected, got) { 98 | t.Errorf("collectScopedPairHistory incorrect (expected: %+v, got: %+v)", tc.expected, got) 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /exec.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "runtime" 9 | ) 10 | 11 | var cmdBase = []string{"sh", "-c"} 12 | 13 | func init() { 14 | if runtime.GOOS == "windows" { 15 | cmdBase = []string{"cmd", "/c"} 16 | } 17 | } 18 | 19 | // Exec fillin 20 | func Exec() error { 21 | if len(os.Args) >= 2 { 22 | switch os.Args[1] { 23 | case "-v", "version", "-version", "--version": 24 | printVersion() 25 | return nil 26 | case "-h", "help", "-help", "--help": 27 | printHelp() 28 | return nil 29 | } 30 | } 31 | sh, err := exec.LookPath(cmdBase[0]) 32 | if err != nil { 33 | return err 34 | } 35 | configDir, err := getConfigDir() 36 | if err != nil { 37 | return err 38 | } 39 | cmd, err := Run(configDir, os.Args[1:], newPrompt()) 40 | if err != nil { 41 | return err 42 | } 43 | if err := syscallExec(sh, append(cmdBase, cmd), os.Environ()); err != nil { 44 | return err 45 | } 46 | return nil 47 | } 48 | 49 | func getConfigDir() (string, error) { 50 | if dir := os.Getenv("FILLIN_CONFIG_DIR"); dir != "" { 51 | return dir, nil 52 | } 53 | if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { 54 | return filepath.Join(dir, name), nil 55 | } 56 | home, err := os.UserHomeDir() 57 | if err != nil { 58 | return "", err 59 | } 60 | return filepath.Join(home, ".config", name), nil 61 | } 62 | 63 | func printVersion() { 64 | fmt.Printf("%s %s\n", name, version) 65 | } 66 | 67 | func printHelp() { 68 | fmt.Printf(`NAME: 69 | %[1]s - %[2]s 70 | 71 | USAGE: 72 | %[1]s command... 73 | 74 | EXAMPLES: 75 | %[1]s echo {{message}} # in bash/zsh shell 76 | %[1]s echo [[message]] # in fish shell 77 | %[1]s psql -h {{psql:hostname}} -U {{psql:username}} -d {{psql:dbname}} 78 | %[1]s curl {{example-api:base-url}}/api/1/example/info -H 'Authorization: Bearer {{example-api:access-token}}' 79 | 80 | VERSION: 81 | %[3]s 82 | 83 | AUTHOR: 84 | %[4]s 85 | `, name, description, version, author) 86 | } 87 | -------------------------------------------------------------------------------- /exec_posix.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package main 4 | 5 | import "syscall" 6 | 7 | func syscallExec(argv0 string, argv []string, envv []string) error { 8 | return syscall.Exec(argv0, argv, envv) 9 | } 10 | -------------------------------------------------------------------------------- /exec_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | "os/exec" 8 | "syscall" 9 | ) 10 | 11 | func syscallExec(argv0 string, argv []string, envv []string) error { 12 | cmd := exec.Command(argv0, argv[1:]...) 13 | cmd.Env = envv 14 | cmd.Stdin = os.Stdin 15 | cmd.Stdout = os.Stdout 16 | cmd.Stderr = os.Stderr 17 | err := cmd.Run() 18 | if err != nil && cmd.ProcessState == nil { 19 | return err 20 | } 21 | os.Exit(cmd.ProcessState.Sys().(syscall.WaitStatus).ExitStatus()) 22 | panic("unreachable") 23 | } 24 | -------------------------------------------------------------------------------- /fillin.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | ) 7 | 8 | var fillinPattern = regexp.MustCompile( 9 | `{{[A-Za-z][-0-9A-Za-z_]*(:[A-Za-z][-0-9A-Za-z_]*)?}}|\[\[[A-Za-z][-0-9A-Za-z_]*(:[A-Za-z][-0-9A-Za-z_]*)?\]\]`, 10 | ) 11 | 12 | func collectIdentifiers(args []string) []*Identifier { 13 | var identifiers []*Identifier 14 | for _, arg := range args { 15 | matches := fillinPattern.FindAllString(arg, -1) 16 | for _, match := range matches { 17 | identifiers = append(identifiers, identifierFromMatch(match)) 18 | } 19 | } 20 | return identifiers 21 | } 22 | 23 | // Fillin fills in the command arguments 24 | func Fillin(args []string, config *Config, p prompt) ([]string, error) { 25 | ret := make([]string, len(args)) 26 | if config.Scopes == nil { 27 | config.Scopes = make(map[string]*Scope) 28 | } 29 | values, err := Resolve(collectIdentifiers(args), config, p) 30 | if err != nil { 31 | return nil, err 32 | } 33 | if !empty(values) { 34 | insertValues(config.Scopes, values) 35 | } 36 | for i, arg := range args { 37 | ret[i] = fillinPattern.ReplaceAllStringFunc(arg, func(match string) string { 38 | return lookup(values, identifierFromMatch(match)) 39 | }) 40 | } 41 | return ret, nil 42 | } 43 | 44 | func identifierFromMatch(match string) *Identifier { 45 | match = match[2 : len(match)-2] 46 | var scope, key string 47 | if strings.ContainsRune(match, ':') { 48 | xs := strings.Split(match, ":") 49 | scope = strings.TrimSpace(xs[0]) 50 | key = strings.TrimSpace(xs[1]) 51 | } else { 52 | key = strings.TrimSpace(match) 53 | } 54 | return &Identifier{scope, key} 55 | } 56 | 57 | func insertValues(scopes map[string]*Scope, values map[string]map[string]string) { 58 | for scope := range values { 59 | if _, ok := scopes[scope]; !ok { 60 | scopes[scope] = &Scope{} 61 | } 62 | newValues := make([]map[string]string, 1, len(scopes[scope].Values)+1) 63 | newValues[0] = values[scope] 64 | for _, v := range scopes[scope].Values { 65 | var skip bool 66 | L: 67 | for i, w := range newValues { 68 | switch mapCompare(w, v) { 69 | case mapCompareSubset: 70 | newValues[i] = v 71 | fallthrough 72 | case mapCompareEqual, mapCompareSuperset: 73 | skip = true 74 | break L 75 | } 76 | } 77 | if !skip { 78 | newValues = append(newValues, v) 79 | } 80 | } 81 | scopes[scope].Values = newValues 82 | } 83 | } 84 | 85 | const ( 86 | mapCompareEqual = iota 87 | mapCompareSuperset 88 | mapCompareSubset 89 | mapCompareDiff 90 | ) 91 | 92 | func mapCompare(m1, m2 map[string]string) int { 93 | ret := mapCompareEqual 94 | for v, k := range m1 { 95 | if l, ok := m2[v]; ok { 96 | if k != l { 97 | return mapCompareDiff 98 | } 99 | } else { 100 | ret = mapCompareSuperset 101 | } 102 | } 103 | for v, k := range m2 { 104 | if l, ok := m1[v]; ok { 105 | if k != l { 106 | return mapCompareDiff 107 | } 108 | } else { 109 | if ret == mapCompareSuperset { 110 | return mapCompareDiff 111 | } 112 | ret = mapCompareSubset 113 | } 114 | } 115 | return ret 116 | } 117 | -------------------------------------------------------------------------------- /fillin_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | var collectIdentifiersTests = []struct { 9 | args []string 10 | ids []*Identifier 11 | }{ 12 | { 13 | args: []string{"{{foo}}", "{{bar}}", "{{baz}}"}, 14 | ids: []*Identifier{ 15 | {key: "foo"}, 16 | {key: "bar"}, 17 | {key: "baz"}, 18 | }, 19 | }, 20 | { 21 | args: []string{"{{foo:bar}}", "{{foo:baz}}", "{{foo}}"}, 22 | ids: []*Identifier{ 23 | {scope: "foo", key: "bar"}, 24 | {scope: "foo", key: "baz"}, 25 | {scope: "", key: "foo"}, 26 | }, 27 | }, 28 | { 29 | args: []string{"[[foo]]", "{{bar}}", "[[baz]]"}, 30 | ids: []*Identifier{ 31 | {key: "foo"}, 32 | {key: "bar"}, 33 | {key: "baz"}, 34 | }, 35 | }, 36 | { 37 | args: []string{"[[foo:bar]]", "[[foo:baz]]", "{{foo}}"}, 38 | ids: []*Identifier{ 39 | {scope: "foo", key: "bar"}, 40 | {scope: "foo", key: "baz"}, 41 | {scope: "", key: "foo"}, 42 | }, 43 | }, 44 | } 45 | 46 | func Test_collectIdentifiers(t *testing.T) { 47 | for _, test := range collectIdentifiersTests { 48 | got := collectIdentifiers(test.args) 49 | if !reflect.DeepEqual(got, test.ids) { 50 | t.Errorf("collectIdentifiers failed for %+v", test.args) 51 | } 52 | } 53 | } 54 | 55 | var mapCompareTests = []struct { 56 | m1, m2 map[string]string 57 | expected int 58 | }{ 59 | { 60 | m1: map[string]string{}, 61 | m2: map[string]string{}, 62 | expected: mapCompareEqual, 63 | }, 64 | { 65 | m1: map[string]string{"x": "1"}, 66 | m2: map[string]string{}, 67 | expected: mapCompareSuperset, 68 | }, 69 | { 70 | m1: map[string]string{"x": "1", "y": "2"}, 71 | m2: map[string]string{}, 72 | expected: mapCompareSuperset, 73 | }, 74 | { 75 | m1: map[string]string{}, 76 | m2: map[string]string{"x": "1"}, 77 | expected: mapCompareSubset, 78 | }, 79 | { 80 | m1: map[string]string{"x": "1"}, 81 | m2: map[string]string{"x": "1", "y": "2"}, 82 | expected: mapCompareSubset, 83 | }, 84 | { 85 | m1: map[string]string{"x": "1", "y": "2"}, 86 | m2: map[string]string{"x": "1"}, 87 | expected: mapCompareSuperset, 88 | }, 89 | { 90 | m1: map[string]string{"x": "1"}, 91 | m2: map[string]string{"y": "2"}, 92 | expected: mapCompareDiff, 93 | }, 94 | { 95 | m1: map[string]string{"x": "1"}, 96 | m2: map[string]string{"x": "2"}, 97 | expected: mapCompareDiff, 98 | }, 99 | { 100 | m1: map[string]string{"x": "1", "y": "2", "z": "3"}, 101 | m2: map[string]string{"x": "1", "w": "2", "z": "3"}, 102 | expected: mapCompareDiff, 103 | }, 104 | { 105 | m1: map[string]string{"x": "1"}, 106 | m2: map[string]string{"x": "1", "y": "2", "z": "3"}, 107 | expected: mapCompareSubset, 108 | }, 109 | { 110 | m1: map[string]string{"x": "1", "y": "2", "z": "3"}, 111 | m2: map[string]string{"x": "1", "y": "2", "z": "3"}, 112 | expected: mapCompareEqual, 113 | }, 114 | } 115 | 116 | func Test_mapCompare(t *testing.T) { 117 | for _, test := range mapCompareTests { 118 | got := mapCompare(test.m1, test.m2) 119 | if got != test.expected { 120 | t.Errorf("mapCompare failed for %+v", test) 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/itchyny/fillin 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/itchyny/liner v1.2.1 7 | github.com/itchyny/zshhist-go v0.0.0-20190322142727-8204cadf336d 8 | github.com/mattn/go-tty v0.0.3 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/itchyny/liner v1.2.1 h1:aH8RzEAqIaSaKCrX5TKJxOX/9rjxUfuVoz4PTTSrT1k= 2 | github.com/itchyny/liner v1.2.1/go.mod h1:R0tlHteWaPB+maxKCadJ7TWfhQscdw3Te2JPDXfZ2G4= 3 | github.com/itchyny/zshhist-go v0.0.0-20190322142727-8204cadf336d h1:VgxzaKQwTOQY0cAPr0Pfiid7e5JfjmmnfarPaXQAw60= 4 | github.com/itchyny/zshhist-go v0.0.0-20190322142727-8204cadf336d/go.mod h1:rDsW+gmI0bb+BUFv510pJeMmAfOB+LiWUwnFOhgdV8U= 5 | github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 6 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 7 | github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= 8 | github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= 9 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 10 | github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 11 | github.com/mattn/go-runewidth v0.0.8 h1:3tS41NlGYSmhhe/8fhGRzc+z3AYCw1Fe1WAyLuujKs0= 12 | github.com/mattn/go-runewidth v0.0.8/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 13 | github.com/mattn/go-tty v0.0.3 h1:5OfyWorkyO7xP52Mq7tB36ajHDG5OHrmBGIS/DtakQI= 14 | github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0= 15 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 16 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 17 | golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 18 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 19 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 20 | golang.org/x/sys v0.0.0-20200122134326-e047566fdf82 h1:ywK/j/KkyTHcdyYSZNXGjMwgmDSfjglYZ3vStQ/gSCU= 21 | golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 22 | -------------------------------------------------------------------------------- /identifier.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // Identifier ... 9 | type Identifier struct { 10 | scope string 11 | key string 12 | } 13 | 14 | func (id *Identifier) prompt() string { 15 | if id.scope == "" { 16 | return fmt.Sprintf("%s: ", id.key) 17 | } 18 | return fmt.Sprintf("[%s] %s: ", id.scope, id.key) 19 | } 20 | 21 | // IdentifierGroup ... 22 | type IdentifierGroup struct { 23 | scope string 24 | keys []string 25 | } 26 | 27 | func (idg *IdentifierGroup) prompt() string { 28 | return fmt.Sprintf("[%s] %s: ", idg.scope, strings.Join(idg.keys, ", ")) 29 | } 30 | 31 | func found(values map[string]map[string]string, id *Identifier) bool { 32 | if v, ok := values[id.scope]; ok { 33 | if _, ok := v[id.key]; ok { 34 | return true 35 | } 36 | } 37 | return false 38 | } 39 | 40 | func collect(identifiers []*Identifier, scope string) *IdentifierGroup { 41 | var keys []string 42 | added := make(map[string]bool) 43 | for _, id := range identifiers { 44 | if scope == id.scope && !added[id.key] { 45 | keys = append(keys, id.key) 46 | added[id.key] = true 47 | } 48 | } 49 | return &IdentifierGroup{scope: scope, keys: keys} 50 | } 51 | 52 | func insert(values map[string]map[string]string, id *Identifier, value string) { 53 | if _, ok := values[id.scope]; !ok { 54 | values[id.scope] = make(map[string]string) 55 | } 56 | values[id.scope][id.key] = value 57 | } 58 | 59 | func empty(values map[string]map[string]string) bool { 60 | for scope := range values { 61 | for key := range values[scope] { 62 | if values[scope][key] != "" { 63 | return false 64 | } 65 | } 66 | } 67 | return true 68 | } 69 | 70 | func lookup(values map[string]map[string]string, id *Identifier) string { 71 | if v, ok := values[id.scope]; ok { 72 | if v, ok := v[id.key]; ok { 73 | return v 74 | } 75 | } 76 | return "" 77 | } 78 | -------------------------------------------------------------------------------- /identifier_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | var identifierTests = []struct { 9 | values map[string]map[string]string 10 | id *Identifier 11 | found bool 12 | value string 13 | }{ 14 | { 15 | values: nil, 16 | id: &Identifier{key: "foo"}, 17 | found: false, 18 | }, 19 | { 20 | values: map[string]map[string]string{"": {"foo": "example"}}, 21 | id: &Identifier{key: "foo"}, 22 | found: true, 23 | value: "example", 24 | }, 25 | { 26 | values: map[string]map[string]string{"": {"foo": "example"}}, 27 | id: &Identifier{key: "bar"}, 28 | found: false, 29 | }, 30 | { 31 | values: map[string]map[string]string{"example": {"foo": "example"}}, 32 | id: &Identifier{scope: "example", key: "foo"}, 33 | found: true, 34 | value: "example", 35 | }, 36 | { 37 | values: map[string]map[string]string{"": {"foo": "example"}}, 38 | id: &Identifier{scope: "example", key: "foo"}, 39 | found: false, 40 | }, 41 | { 42 | values: map[string]map[string]string{"example": {"foo": "example"}}, 43 | id: &Identifier{scope: "example", key: "bar"}, 44 | found: false, 45 | }, 46 | } 47 | 48 | func Test_prompt(t *testing.T) { 49 | id1 := &Identifier{key: "foo"} 50 | got := id1.prompt() 51 | if got != "foo: " { 52 | t.Errorf("prompt is not correct for %+v (found: %+v, got: %+v)", id1, "foo: ", got) 53 | } 54 | id2 := &Identifier{scope: "foo", key: "bar"} 55 | got = id2.prompt() 56 | if got != "[foo] bar: " { 57 | t.Errorf("prompt is not correct for %+v (found: %+v, got: %+v)", id2, "[foo] bar: ", got) 58 | } 59 | idg := &IdentifierGroup{scope: "foo", keys: []string{"bar", "baz", "qux"}} 60 | got = idg.prompt() 61 | if got != "[foo] bar, baz, qux: " { 62 | t.Errorf("prompt is not correct for %+v (found: %+v, got: %+v)", idg, "[foo] bar, baz, qux: ", got) 63 | } 64 | } 65 | 66 | func Test_found(t *testing.T) { 67 | for _, test := range identifierTests { 68 | got := found(test.values, test.id) 69 | if got != test.found { 70 | t.Errorf("found not correct for %+v (found: %+v, got: %+v)", test.id, test.found, got) 71 | } 72 | } 73 | } 74 | 75 | func Test_collect(t *testing.T) { 76 | ids := []*Identifier{ 77 | {scope: "foo", key: "foo"}, 78 | {scope: "foo", key: "bar"}, 79 | {scope: "zoo", key: "foo"}, 80 | {scope: "foo", key: "foo"}, 81 | {scope: "foo", key: "baz"}, 82 | {scope: "qux", key: "bar"}, 83 | } 84 | expectedFoo := &IdentifierGroup{ 85 | scope: "foo", 86 | keys: []string{"foo", "bar", "baz"}, 87 | } 88 | expectedBar := &IdentifierGroup{ 89 | scope: "bar", 90 | keys: nil, 91 | } 92 | idgFoo := collect(ids, "foo") 93 | if !reflect.DeepEqual(idgFoo, expectedFoo) { 94 | t.Errorf("collect not correct (expected: %+v, got: %+v)", expectedFoo, idgFoo) 95 | } 96 | idgBar := collect(ids, "bar") 97 | if !reflect.DeepEqual(idgBar, expectedBar) { 98 | t.Errorf("collect not correct (expected: %+v, got: %+v)", expectedBar, idgBar) 99 | } 100 | } 101 | 102 | func Test_insert(t *testing.T) { 103 | values := make(map[string]map[string]string) 104 | id := &Identifier{key: "foo"} 105 | value := "bar" 106 | insert(values, id, value) 107 | v, ok := values[""]["foo"] 108 | if !ok { 109 | t.Errorf("insert failed for %+v", id) 110 | } 111 | if v != value { 112 | t.Errorf("insert not correctly for %+v (found: %+v, got: %+v)", id, v, value) 113 | } 114 | id = &Identifier{scope: "foo", key: "bar"} 115 | value = "example" 116 | insert(values, id, value) 117 | v, ok = values["foo"]["bar"] 118 | if !ok { 119 | t.Errorf("insert failed for %+v", id) 120 | } 121 | if v != value { 122 | t.Errorf("insert not correctly for %+v (found: %+v, got: %+v)", id, v, value) 123 | } 124 | } 125 | 126 | func Test_empty(t *testing.T) { 127 | tests := []struct { 128 | values map[string]map[string]string 129 | expected bool 130 | }{ 131 | { 132 | values: nil, 133 | expected: true, 134 | }, 135 | { 136 | values: map[string]map[string]string{ 137 | "foo": {}, 138 | }, 139 | expected: true, 140 | }, 141 | { 142 | values: map[string]map[string]string{ 143 | "foo": { 144 | "bar": "", 145 | "baz": "", 146 | }, 147 | }, 148 | expected: true, 149 | }, 150 | { 151 | values: map[string]map[string]string{ 152 | "foo": { 153 | "bar": "", 154 | "baz": "", 155 | }, 156 | "bar": { 157 | "qux": "quux", 158 | }, 159 | }, 160 | expected: false, 161 | }, 162 | } 163 | for _, test := range tests { 164 | got := empty(test.values) 165 | if got != test.expected { 166 | t.Errorf("empty not correctly for %+v (expected: %+v, got: %+v)", test.values, test.expected, got) 167 | } 168 | } 169 | } 170 | 171 | func Test_lookup(t *testing.T) { 172 | for _, test := range identifierTests { 173 | got := lookup(test.values, test.id) 174 | if got != test.value { 175 | t.Errorf("lookup not correct for %+v (expected: %+v, got: %+v)", test.id, test.value, got) 176 | } 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | ) 8 | 9 | var ( 10 | name = "fillin" 11 | version = "0.3.3" 12 | description = "fill-in your command and execute" 13 | author = "itchyny" 14 | ) 15 | 16 | func main() { 17 | if err := Exec(); err != nil { 18 | if err != io.EOF { 19 | fmt.Fprintf(os.Stderr, "%s: %v\n", name, err) 20 | } 21 | os.Exit(1) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /prompt.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type prompt interface { 4 | start() error 5 | prompt(string) (string, error) 6 | setHistory([]string) 7 | close() 8 | } 9 | -------------------------------------------------------------------------------- /prompt_liner.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/itchyny/liner" 7 | "github.com/mattn/go-tty" 8 | ) 9 | 10 | type realPrompt struct { 11 | state *liner.State 12 | } 13 | 14 | func newPrompt() *realPrompt { 15 | return &realPrompt{} 16 | } 17 | 18 | func (p *realPrompt) start() error { 19 | tty, err := tty.Open() 20 | if err != nil { 21 | return err 22 | } 23 | p.state = liner.NewLinerTTY(tty) 24 | p.state.SetCtrlCAborts(true) 25 | return nil 26 | } 27 | 28 | func (p *realPrompt) prompt(message string) (string, error) { 29 | input, err := p.state.Prompt(message) 30 | if err != nil { 31 | if err == liner.ErrPromptAborted { 32 | return "", io.EOF 33 | } 34 | return "", err 35 | } 36 | return input, nil 37 | } 38 | 39 | func (p *realPrompt) setHistory(history []string) { 40 | p.state.ClearHistory() 41 | for i := len(history) - 1; i >= 0; i-- { 42 | p.state.AppendHistory(history[i]) 43 | } 44 | } 45 | 46 | func (p *realPrompt) close() { 47 | p.state.Close() 48 | } 49 | -------------------------------------------------------------------------------- /prompt_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | "strings" 8 | ) 9 | 10 | type testPrompt struct { 11 | reader *bufio.Reader 12 | writer io.Writer 13 | } 14 | 15 | func newTestPrompt(input string) *testPrompt { 16 | return &testPrompt{ 17 | reader: bufio.NewReader(strings.NewReader(input)), 18 | writer: new(bytes.Buffer), 19 | } 20 | } 21 | 22 | func (p *testPrompt) start() error { 23 | return nil 24 | } 25 | 26 | func (p *testPrompt) prompt(message string) (string, error) { 27 | p.writer.Write([]byte(message)) 28 | return p.reader.ReadString('\n') 29 | } 30 | 31 | func (p *testPrompt) setHistory([]string) { 32 | } 33 | 34 | func (p *testPrompt) close() { 35 | } 36 | -------------------------------------------------------------------------------- /resolve.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "strings" 4 | 5 | // Resolve asks the user to resolve the identifiers 6 | func Resolve(identifiers []*Identifier, config *Config, p prompt) (map[string]map[string]string, error) { 7 | if err := p.start(); err != nil { 8 | return nil, err 9 | } 10 | defer p.close() 11 | 12 | values := make(map[string]map[string]string) 13 | scopeAsked := make(map[string]bool) 14 | for _, id := range identifiers { 15 | if found(values, id) || id.scope == "" || scopeAsked[id.scope] { 16 | continue 17 | } 18 | scopeAsked[id.scope] = true 19 | idg := collect(identifiers, id.scope) 20 | if len(idg.keys) == 0 { 21 | continue 22 | } 23 | history := config.collectScopedPairHistory(idg) 24 | if len(history) == 0 { 25 | continue 26 | } 27 | p.setHistory(history) 28 | text, err := p.prompt(idg.prompt()) 29 | if err != nil { 30 | return nil, err 31 | } 32 | xs := strings.Split(strings.TrimSuffix(text, "\n"), ", ") 33 | if len(xs) == len(idg.keys) { 34 | for i, key := range idg.keys { 35 | insert(values, &Identifier{scope: id.scope, key: key}, strings.Replace(xs[i], ",\\ ", ", ", -1)) 36 | } 37 | } 38 | } 39 | 40 | for _, id := range identifiers { 41 | if found(values, id) { 42 | continue 43 | } 44 | p.setHistory(config.collectHistory(id)) 45 | text, err := p.prompt(id.prompt()) 46 | if err != nil { 47 | return nil, err 48 | } 49 | insert(values, id, strings.TrimSuffix(text, "\n")) 50 | } 51 | 52 | return values, nil 53 | } 54 | -------------------------------------------------------------------------------- /run.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/itchyny/zshhist-go" 14 | ) 15 | 16 | // Run fillin 17 | func Run(dir string, args []string, p prompt) (string, error) { 18 | if err := os.MkdirAll(dir, 0o700); err != nil { 19 | return "", err 20 | } 21 | if err := os.Chmod(dir, 0o700); err != nil { 22 | return "", err 23 | } 24 | path := filepath.Join(dir, "fillin.json") 25 | config, err := readConfig(path) 26 | if err != nil { 27 | return "", err 28 | } 29 | filled, err := Fillin(args, config, p) 30 | if err != nil { 31 | return "", err 32 | } 33 | cmd := escapeJoin(filled) 34 | if err := writeConfig(path, config); err != nil { 35 | return "", err 36 | } 37 | if err := appendHistory(dir, cmd); err != nil { 38 | return "", err 39 | } 40 | return cmd, nil 41 | } 42 | 43 | func readConfig(path string) (*Config, error) { 44 | f, err := os.OpenFile(path, os.O_RDONLY, 0o600) 45 | if err != nil { 46 | if os.IsNotExist(err) { 47 | return &Config{}, nil 48 | } 49 | return nil, err 50 | } 51 | defer f.Close() 52 | var config Config 53 | if err := json.NewDecoder(f).Decode(&config); err != nil { 54 | return nil, err 55 | } 56 | return &config, nil 57 | } 58 | 59 | func writeConfig(path string, config *Config) error { 60 | tmp, err := ioutil.TempFile(filepath.Dir(path), "fillin-*.json") 61 | if err != nil { 62 | return err 63 | } 64 | defer func() { 65 | tmp.Close() 66 | os.Remove(tmp.Name()) 67 | }() 68 | enc := json.NewEncoder(tmp) 69 | enc.SetIndent("", " ") 70 | if err := enc.Encode(config); err != nil { 71 | return err 72 | } 73 | if err := tmp.Sync(); err != nil { 74 | return err 75 | } 76 | tmp.Close() 77 | return os.Rename(tmp.Name(), path) 78 | } 79 | 80 | func escapeJoin(args []string) string { 81 | if len(args) == 1 { 82 | return args[0] 83 | } 84 | for i, arg := range args { 85 | args[i] = escape(arg) 86 | } 87 | return strings.Join(args, " ") 88 | } 89 | 90 | var redirectPattern = regexp.MustCompile(`^\s*[012]?\s*[<>]`) 91 | 92 | func escape(arg string) string { 93 | switch arg { 94 | case "|", "||", "&&", ">", ">>", "<": 95 | return arg 96 | } 97 | if redirectPattern.MatchString(arg) { 98 | return arg 99 | } 100 | if !strings.ContainsAny(arg, "|&><[?! \"'\a\b\f\n\r\t\v\\") { 101 | return arg 102 | } 103 | return strconv.Quote(arg) 104 | } 105 | 106 | func appendHistory(dir, cmd string) error { 107 | if cmd == "" { 108 | return nil 109 | } 110 | histfile := filepath.Join(dir, ".fillin.histfile") 111 | f, err := os.OpenFile(histfile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600) 112 | if err != nil { 113 | return err 114 | } 115 | defer func() { 116 | f.Chmod(0o600) 117 | f.Close() 118 | }() 119 | zshhist.NewWriter(f).Write( 120 | zshhist.History{Time: int(time.Now().Unix()), Elapsed: 0, Command: cmd}, 121 | ) 122 | return f.Sync() 123 | } 124 | -------------------------------------------------------------------------------- /run_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "reflect" 7 | "runtime" 8 | "sync" 9 | "testing" 10 | ) 11 | 12 | var runTests = []struct { 13 | args []string 14 | in string 15 | expected string 16 | }{ 17 | { 18 | args: []string{"echo", "Hello,", "world!"}, 19 | in: ``, 20 | expected: `echo Hello, "world!"`, 21 | }, 22 | { 23 | args: []string{"echo", "{{foo}},", "{{bar}}"}, 24 | in: `Hello test 25 | world test! 26 | `, 27 | expected: `echo "Hello test," "world test!"`, 28 | }, 29 | { 30 | args: []string{"echo", "{{foo-bar_baz}}", "{{FOO-9:BAR_2}}", "{{X}}"}, 31 | in: `Foo bar 32 | FOO BAR 33 | X 34 | `, 35 | expected: `echo "Foo bar" "FOO BAR" X`, 36 | }, 37 | { 38 | args: []string{"echo", "{{foo}},", "{{bar}},", "{{foo}}-{{bar}}-{{baz}}"}, 39 | in: `Hello 40 | wonderful 41 | world! 42 | `, 43 | expected: `echo Hello, wonderful, "Hello-wonderful-world!"`, 44 | }, 45 | { 46 | args: []string{"echo", "{{foo:bar}},", "{{foo:bar}},", "{{foo:baz}}"}, 47 | in: `Hello 48 | example world! 49 | 50 | `, 51 | expected: `echo Hello, Hello, "example world!"`, 52 | }, 53 | { 54 | args: []string{"echo", "{{foo:bar}}", "{{foo:baz}}", "{{foo:baz}}"}, 55 | in: `Hello, world! 56 | `, 57 | expected: `echo Hello "world!" "world!"`, 58 | }, 59 | { 60 | args: []string{"echo", "{{foo:bar}}", "{{foo:bar}}", "{{foo:baz}}"}, 61 | in: `Hello,\ world!, test,\ for,\ comma! 62 | `, 63 | expected: `echo "Hello, world!" "Hello, world!" "test, for, comma!"`, 64 | }, 65 | { 66 | args: []string{"echo", "[[foo:bar]]", "[[foo:bar]]", "[[foo:baz]]"}, 67 | in: `Hello, world, oops! 68 | Hello, 69 | world? 70 | `, 71 | expected: `echo Hello, Hello, "world?"`, 72 | }, 73 | { 74 | args: []string{"echo", "{{foo}},", "[[bar]]", "{{baz}}"}, 75 | in: `こんにちは 76 | 世界 77 | +。:.゚٩(๑>◡<๑)۶:.。+゚ 78 | `, 79 | expected: `echo こんにちは, 世界 "+。:.゚٩(๑>◡<๑)۶:.。+゚"`, 80 | }, 81 | { 82 | args: []string{"echo", "{{foo}}", "|", "echo", "||", "echo", "&&", "echo", ">", "/dev/null", "&1", "1", ">&2", ">>", "foo", ">>/dev/null"}, 83 | in: `Hello world! 84 | `, 85 | expected: `echo "Hello world!" | echo || echo && echo > /dev/null &1 1 >&2 >> foo >>/dev/null`, 86 | }, 87 | { 88 | args: []string{"echo", "{{foo}}", "{{bar}}"}, 89 | in: `\'"${[]}|&;<>()*?! 90 | foo bar baz 91 | `, 92 | expected: `echo "\\'\"${[]}|&;<>()*?!" "\tfoo bar baz"`, 93 | }, 94 | { 95 | args: []string{"echo", "{{foo}}"}, 96 | in: "\a\b\f'\r\t\v\\\n", 97 | expected: `echo "\a\b\f'\r\t\v\\"`, 98 | }, 99 | { 100 | args: []string{"echo $(cat {{foo}} {{bar}})"}, 101 | in: `sample1.txt 102 | sample2.txt 103 | `, 104 | expected: `echo $(cat sample1.txt sample2.txt)`, 105 | }, 106 | } 107 | 108 | func TestRun(t *testing.T) { 109 | dir, err := ioutil.TempDir("", "fillin-") 110 | if err != nil { 111 | t.Fatal(err) 112 | } 113 | defer os.RemoveAll(dir) 114 | for _, test := range runTests { 115 | cmd, err := Run(dir, test.args, newTestPrompt(test.in)) 116 | if err != nil { 117 | t.Errorf("error occurred unexpectedly: %+v", err) 118 | } 119 | if !reflect.DeepEqual(cmd, test.expected) { 120 | t.Errorf("command not correct (expected: %+v, got: %+v)", test.expected, cmd) 121 | } 122 | } 123 | } 124 | 125 | func TestRun_concurrently(t *testing.T) { 126 | if runtime.GOOS == "windows" { 127 | t.Skip("skip test on Windows") 128 | } 129 | dir, err := ioutil.TempDir("", "fillin-") 130 | if err != nil { 131 | t.Fatal(err) 132 | } 133 | defer os.RemoveAll(dir) 134 | test := runTests[1] 135 | var wg sync.WaitGroup 136 | for i := 0; i < 20; i++ { 137 | wg.Add(1) 138 | go func() { 139 | defer wg.Done() 140 | cmd, err := Run(dir, test.args, newTestPrompt(test.in)) 141 | if err != nil { 142 | t.Errorf("error occurred unexpectedly: %+v", err) 143 | } 144 | if !reflect.DeepEqual(cmd, test.expected) { 145 | t.Errorf("command not correct (expected: %+v, got: %+v)", test.expected, cmd) 146 | } 147 | }() 148 | } 149 | wg.Wait() 150 | } 151 | --------------------------------------------------------------------------------