├── .gitignore ├── Makefile ├── internal ├── converter │ ├── testdata │ │ └── compfile │ │ │ ├── 4_shorts │ │ │ ├── 4_shorts.fish │ │ │ └── _4_shorts │ │ │ ├── 3_old │ │ │ ├── 3_old.fish │ │ │ └── _3_old │ │ │ ├── 1_long │ │ │ ├── 1_long.fish │ │ │ └── _1_long │ │ │ └── 2_short_long │ │ │ ├── _2_short_long │ │ │ └── 2_short_long.fish │ ├── converter_test.go │ └── converter.go └── util │ └── util.go ├── go.mod ├── .github └── workflows │ └── release.yml ├── go.sum ├── LICENSE ├── .goreleaser.yaml ├── README.md └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | go test -v -cover ./... 3 | -------------------------------------------------------------------------------- /internal/converter/testdata/compfile/4_shorts/4_shorts.fish: -------------------------------------------------------------------------------- 1 | complete -c 4_shorts -s h -s H -l help -d 'help message' 2 | -------------------------------------------------------------------------------- /internal/converter/testdata/compfile/3_old/3_old.fish: -------------------------------------------------------------------------------- 1 | complete -c 3_old -s V -o Ver -o Version -d 'print version' 2 | complete -c 3_old -o help -d 'help message' 3 | -------------------------------------------------------------------------------- /internal/converter/testdata/compfile/4_shorts/_4_shorts: -------------------------------------------------------------------------------- 1 | #compdef 4_shorts 2 | 3 | _arguments \ 4 | '*:file:_files' \ 5 | {-h,-H,--help}'[help message]' 6 | -------------------------------------------------------------------------------- /internal/converter/testdata/compfile/1_long/1_long.fish: -------------------------------------------------------------------------------- 1 | complete -c 1_long -l help -d '(long) help message' 2 | complete -c 1_long -l version -d '(long) output version' 3 | 4 | -------------------------------------------------------------------------------- /internal/converter/testdata/compfile/3_old/_3_old: -------------------------------------------------------------------------------- 1 | #compdef 3_old 2 | 3 | _arguments \ 4 | '*:file:_files' \ 5 | {-V,-Ver,-Version}'[print version]' \ 6 | '-help[help message]' 7 | -------------------------------------------------------------------------------- /internal/converter/testdata/compfile/1_long/_1_long: -------------------------------------------------------------------------------- 1 | #compdef 1_long 2 | 3 | _arguments \ 4 | '*:file:_files' \ 5 | '--help[(long) help message]' \ 6 | '--version[(long) output version]' 7 | -------------------------------------------------------------------------------- /internal/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | func Contains[T comparable](elems []T, v T) bool { 4 | for _, s := range elems { 5 | if v == s { 6 | return true 7 | } 8 | } 9 | return false 10 | } 11 | -------------------------------------------------------------------------------- /internal/converter/testdata/compfile/2_short_long/_2_short_long: -------------------------------------------------------------------------------- 1 | #compdef 2_short_long 2 | 3 | _arguments \ 4 | '*:file:_files' \ 5 | {-v,--version}'[(short and long) show version]' \ 6 | '--help[(long) help message]' \ 7 | '-l[(short) long long]' 8 | -------------------------------------------------------------------------------- /internal/converter/testdata/compfile/2_short_long/2_short_long.fish: -------------------------------------------------------------------------------- 1 | complete -c 2_short_long -s v -l version -d '(short and long) show version' 2 | complete -c 2_short_long -l help -d '(long) help message' 3 | complete -c 2_short_long -s l -d '(short) long long' 4 | 5 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/umlx5h/zsh-manpage-completion-generator 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/google/go-cmp v0.5.9 7 | github.com/stretchr/testify v1.8.4 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/pmezard/go-difflib v1.0.0 // indirect 13 | gopkg.in/yaml.v3 v3.0.1 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | permissions: 7 | contents: write 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version: '1.22' 21 | 22 | - name: Test 23 | run: make test 24 | 25 | - name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v5 27 | with: 28 | distribution: goreleaser 29 | version: latest 30 | args: release --clean 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | # needed by homebrew 34 | TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} 35 | # AUR 36 | AUR_KEY: ${{ secrets.AUR_KEY }} 37 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 4 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 8 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 10 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 11 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 12 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 umlx5h 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 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - env: 9 | - CGO_ENABLED=0 10 | goos: 11 | - linux 12 | - darwin 13 | ldflags: 14 | - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser 15 | flags: 16 | - -trimpath 17 | 18 | archives: 19 | - format: tar.gz 20 | # this name template makes the OS and Arch compatible with the results of `uname`. 21 | name_template: >- 22 | {{ .ProjectName }}_ 23 | {{- title .Os }}_ 24 | {{- if eq .Arch "amd64" }}x86_64 25 | {{- else if eq .Arch "386" }}i386 26 | {{- else }}{{ .Arch }}{{ end }} 27 | {{- if .Arm }}v{{ .Arm }}{{ end }} 28 | # Only include binary in archive 29 | files: 30 | - none* 31 | 32 | changelog: 33 | sort: asc 34 | filters: 35 | exclude: 36 | - "^docs:" 37 | - "^test:" 38 | 39 | brews: 40 | - repository: 41 | owner: umlx5h 42 | name: homebrew-tap 43 | token: "{{ .Env.TAP_GITHUB_TOKEN }}" 44 | homepage: "https://github.com/umlx5h/zsh-manpage-completion-generator" 45 | description: "Automatically generate zsh completions from man page" 46 | license: "MIT" 47 | 48 | aurs: 49 | - 50 | name: zsh-manpage-completion-generator-bin 51 | homepage: "https://github.com/umlx5h/zsh-manpage-completion-generator" 52 | description: "Automatically generate zsh completions from man page" 53 | license: "MIT" 54 | private_key: '{{ .Env.AUR_KEY }}' 55 | git_url: 'ssh://aur@aur.archlinux.org/zsh-manpage-completion-generator-bin.git' 56 | package: |- 57 | # bin 58 | install -Dm755 "./zsh-manpage-completion-generator" "${pkgdir}/usr/bin/zsh-manpage-completion-generator" 59 | -------------------------------------------------------------------------------- /internal/converter/converter_test.go: -------------------------------------------------------------------------------- 1 | package converter 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/google/go-cmp/cmp" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestConverter_Convert(t *testing.T) { 16 | // TODO: add more special cases 17 | tests := []struct { 18 | cmdName string 19 | }{ 20 | {cmdName: "1_long"}, // only long 21 | {cmdName: "2_short_long"}, // short or/and long option 22 | {cmdName: "3_old"}, // old style option (-opt) 23 | {cmdName: "4_shorts"}, // multiple short option 24 | } 25 | for _, tt := range tests { 26 | t.Run(tt.cmdName, func(t *testing.T) { 27 | srcFish, err := os.Open(fmt.Sprintf("./testdata/compfile/%s/%s.fish", tt.cmdName, tt.cmdName)) 28 | require.NoError(t, err) 29 | 30 | dstZsh, err := os.Open(fmt.Sprintf("./testdata/compfile/%s/_%s", tt.cmdName, tt.cmdName)) 31 | require.NoError(t, err) 32 | wantZsh, err := io.ReadAll(dstZsh) 33 | require.NoError(t, err) 34 | 35 | c := NewConverter(srcFish, tt.cmdName) 36 | got, err := c.Convert() 37 | require.NoError(t, err) 38 | 39 | if strings.TrimSpace(got) != strings.TrimSpace(string(wantZsh)) { 40 | t.Errorf("got:\n%s\n\n\nwant:\n%s\n\n\ndiff: %s", got, string(wantZsh), cmp.Diff(strings.TrimSpace(got), strings.TrimSpace(string(wantZsh)))) 41 | } 42 | }) 43 | } 44 | } 45 | 46 | func Test_escapeDescMsg(t *testing.T) { 47 | tests := []struct { 48 | name string 49 | optText string 50 | want string 51 | }{ 52 | { 53 | "'single_quotes", 54 | `don\'t`, 55 | `don'"'"'t`, 56 | }, 57 | { 58 | "[]", 59 | `[MacOS only] hello`, 60 | `\[MacOS only\] hello`, 61 | }, 62 | { 63 | "trim_space", 64 | ` Lorem ipsum dolor sit amet, consectetur adipiscing elit `, 65 | `Lorem ipsum dolor sit amet, consectetur adipiscing elit`, 66 | }, 67 | } 68 | for _, tt := range tests { 69 | t.Run(tt.name, func(t *testing.T) { 70 | assert.Equal(t, tt.want, escapeDescMsg(tt.optText)) 71 | }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zsh-manpage-completion-generator 2 | 3 | Automatically generate zsh completions from man page using fish shell completion files. 4 | This program is inspired from [nevesnunes/sh-manpage-completions](https://github.com/nevesnunes/sh-manpage-completions). 5 | But it supports more completion files and is much simply implemented, faster and easier to use, written in Go. 6 | 7 | ## Requirements 8 | 9 | This program depends on fish shell's manpage converter, [create_manpage_completions.py](https://github.com/fish-shell/fish-shell/blob/master/share/tools/create_manpage_completions.py). 10 | So you must first install fish shell, See below for installation instructions. 11 | 12 | https://github.com/fish-shell/fish-shell 13 | 14 | If you do not want to install fish, you can also manually place and run the conversion python script as an example below. 15 | 16 | ``` 17 | # download script 18 | $ sudo wget --backups=1 -P /usr/local/bin/ https://raw.githubusercontent.com/fish-shell/fish-shell/master/share/tools/{create_manpage_completions,deroff}.py 19 | $ sudo chmod a+x /usr/local/bin/{create_manpage_completions,deroff}.py 20 | 21 | # create arbitrary fish completion folder 22 | $ mkdir ~/fish_generated_completions 23 | 24 | # generate fish completions from manpage 25 | $ create_manpage_completions.py --manpath --cleanup-in ~/fish_generated_completions -d ~/fish_generated_completions --progress 26 | 27 | # and then specify -src option to convert 28 | $ zsh-manpage-completion-generator -src ~/fish_generated_completions 29 | ``` 30 | 31 | 32 | ## Installation 33 | 34 | **From binaries:** 35 | 36 | Download the binary from [GitHub Releases](https://github.com/umlx5h/zsh-manpage-completion-generator/releases/latest) and place it in your `$PATH`. 37 | 38 | Install the latest binary to `/usr/local/bin`: 39 | 40 | ```bash 41 | curl -L "https://github.com/umlx5h/zsh-manpage-completion-generator/releases/latest/download/zsh-manpage-completion-generator_$(uname -s)_$(uname -m).tar.gz" | tar xz 42 | chmod a+x ./zsh-manpage-completion-generator 43 | sudo mv ./zsh-manpage-completion-generator /usr/local/bin/zsh-manpage-completion-generator 44 | ``` 45 | 46 | **Homebrew:** 47 | 48 | ```bash 49 | brew install umlx5h/tap/zsh-manpage-completion-generator 50 | ``` 51 | 52 | **AUR (Arch User Repository):** 53 | 54 | with any AUR helpers 55 | ``` 56 | yay -S zsh-manpage-completion-generator-bin 57 | paru -S zsh-manpage-completion-generator-bin 58 | ``` 59 | 60 | **Go install:** 61 | 62 | ```bash 63 | go install github.com/umlx5h/zsh-manpage-completion-generator@latest 64 | ``` 65 | 66 | ## Usage 67 | 68 | You must first generate fish competion files. 69 | It generates completion files, usually under folder `$XDG_DATA_HOME/.local/share/fish/generated_completions` 70 | 71 | ```console 72 | $ fish -c 'fish_update_completions' 73 | Parsing man pages and writing completions to /home/dummy/.local/share/fish/generated_completions/ 74 | ``` 75 | 76 | Then generate zsh completions from fish completions folder. 77 | By default, it is generated in folder `$XDG_DATA_HOME/.local/share/zsh/generated_man_completions` 78 | 79 | ```console 80 | $ zsh-manpage-completion-generator 81 | Converting fish completions: /home/dummy/.local/share/fish/generated_completions -> /home/dummy/.local/share/zsh/generated_man_completions 82 | Completed. converted: 2579/2581, skipped: 142 83 | ``` 84 | 85 | Then add completions folder to your `fpath` in `.zshrc`. 86 | (It is recommended to place them at the end of the `fpath`, so that human-generated completions such as [zsh-users/zsh-completions](https://github.com/zsh-users/zsh-completions) will be preferred.) 87 | 88 | ``` 89 | fpath=( 90 | $fpath 91 | $HOME/.local/share/zsh/generated_man_completions 92 | ) 93 | compinit # This is not necessary if it is called after this. 94 | ``` 95 | 96 | You may have to force rebuild zcompdump 97 | 98 | ```console 99 | $ rm -f "${ZDOTDIR-~}/.zcompdump" && compinit 100 | ``` 101 | 102 | It is recommended that `compinit` be called only once because of the startup time. 103 | You can check the number of calls to `compinit` by using `zprof`. 104 | 105 | Note that `fpath` must be added before `compinit`. 106 | If you are using any zsh framework, check where it is adding `fpath` and calling `compinit`. 107 | 108 | ## Option 109 | 110 | The following command line options are supported. 111 | 112 | You can change the path to the fish and zsh completions by specifying `-dst` and `-src`. 113 | The `-clean` option can be specified to remove zsh completion folder before generating them. 114 | This is useful for removing completions that are no longer needed. 115 | (Note that the entire `-dst` folder will be deleted.) 116 | 117 | ```console 118 | $ zsh-manpage-completion-generator -h 119 | Usage of zsh-manpage-completion-generator: 120 | -clean 121 | CAUTION: remove destination folder before converting 122 | -dst string 123 | zsh generated_completions destination folder (default "/home/dummy/.local/share/zsh/generated_man_completions") 124 | -src string 125 | fish generated_completions src folder (default "/home/dummy/.local/share/fish/generated_completions") 126 | -verbose 127 | verbose log 128 | -version 129 | show version 130 | ``` 131 | 132 | ## Caveat 133 | 134 | - Some invalid options may be generated, but there is nothing that can be done about this, as this is also the case with fish. 135 | 136 | ## Related 137 | 138 | - [nevesnunes/sh-manpage-completions](https://github.com/nevesnunes/sh-manpage-completions) 139 | - [RobSis/zsh-completion-generator](https://github.com/RobSis/zsh-completion-generator) 140 | 141 | ## License 142 | 143 | MIT 144 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "runtime/debug" 9 | "strings" 10 | 11 | "github.com/umlx5h/zsh-manpage-completion-generator/internal/converter" 12 | "github.com/umlx5h/zsh-manpage-completion-generator/internal/util" 13 | ) 14 | 15 | // These variables are set in build step 16 | var ( 17 | version = "unset" 18 | commit = "unset" 19 | date = "unset" 20 | ) 21 | 22 | var ( 23 | excludeCmds = []string{ 24 | "[", 25 | "]", 26 | "sudo", 27 | } 28 | ) 29 | 30 | func main() { 31 | dataDir := os.Getenv("XDG_DATA_HOME") 32 | if dataDir == "" { 33 | dataDir = filepath.Join(os.Getenv("HOME"), ".local/share") 34 | } 35 | filepath.Join(dataDir) 36 | srcDir := filepath.Join(dataDir, "fish/generated_completions") 37 | dstDir := filepath.Join(dataDir, "zsh/generated_man_completions") 38 | 39 | src := flag.String("src", srcDir, "fish generated_completions src folder") 40 | dst := flag.String("dst", dstDir, "zsh generated_completions destination folder") 41 | clean := flag.Bool("clean", false, "CAUTION: remove destination folder before converting") 42 | verbose := flag.Bool("verbose", false, "verbose log") 43 | version_ := flag.Bool("version", false, "show version") 44 | flag.Parse() 45 | 46 | srcDir = *src 47 | dstDir = *dst 48 | isClean := *clean 49 | isVerbose := *verbose 50 | isVersion := *version_ 51 | 52 | if isVersion { 53 | if commit != "unset" { 54 | fmt.Printf("Version: %s, Commit: %s, Date: %s\n", version, commit, date) 55 | } else if buildInfo, ok := debug.ReadBuildInfo(); ok { 56 | fmt.Printf("Version: %s\n", buildInfo.Main.Version) 57 | } else { 58 | fmt.Printf("Version: %s\n", "(unknown)") 59 | } 60 | 61 | os.Exit(0) 62 | } 63 | 64 | srcDirEntry, err := os.ReadDir(srcDir) 65 | if err != nil { 66 | fmt.Fprintf(os.Stderr, "could not open srcDir, are you sure to install fish?: %s", err) 67 | os.Exit(1) 68 | } 69 | dir, err := os.Stat(dstDir) 70 | if err != nil && !os.IsNotExist(err) { 71 | fmt.Fprintf(os.Stderr, "could not open dstDir: %s", err) 72 | os.Exit(1) 73 | } else if err == nil && !dir.IsDir() { 74 | fmt.Fprintf(os.Stderr, "could not open dstDir as directory: %s", dstDir) 75 | os.Exit(1) 76 | } 77 | 78 | if isClean { 79 | fmt.Printf("Cleaning zsh completions folder: %s\n", dstDir) 80 | if err = os.RemoveAll(dstDir); err != nil { 81 | fmt.Fprintf(os.Stderr, "could not remove dstDir: %s", err) 82 | os.Exit(1) 83 | } 84 | 85 | if err = os.MkdirAll(dstDir, 0777); err != nil { 86 | fmt.Fprintf(os.Stderr, "could not mkdir dstDir: %s", err) 87 | os.Exit(1) 88 | } 89 | } else if os.IsNotExist(err) { 90 | if err = os.MkdirAll(dstDir, 0777); err != nil { 91 | fmt.Fprintf(os.Stderr, "could not mkdir dstDir: %s", err) 92 | os.Exit(1) 93 | } 94 | } 95 | 96 | var ( 97 | // stat 98 | convertNum int 99 | convertedNum int 100 | skippedNum int 101 | ) 102 | 103 | fmt.Printf("Converting fish completions: %s -> %s\n", srcDir, dstDir) 104 | 105 | for _, f := range srcDirEntry { 106 | func() { 107 | if f.Type().IsRegular() { 108 | fileName := f.Name() 109 | if !strings.HasSuffix(fileName, ".fish") { 110 | fmt.Printf("skipped non fish file: %s\n", fileName) 111 | return 112 | } 113 | if strings.HasSuffix(fileName, ".1posix.fish") { 114 | // In zsh, posix and non-posix versions need to be represented as one common command, so skip posix version 115 | // TODO: if only found posix version, then convert it. 116 | if isVerbose { 117 | fmt.Printf("skipped posix version: %s\n", fileName) 118 | } 119 | skippedNum++ 120 | return 121 | } 122 | 123 | cmdName := strings.TrimSuffix(fileName, ".fish") 124 | 125 | // delete unneeded command 126 | if util.Contains(excludeCmds, cmdName) { 127 | if isVerbose { 128 | fmt.Printf("skipped unneeded command: %s\n", fileName) 129 | } 130 | skippedNum++ 131 | return 132 | } 133 | 134 | // NOTE: exclude git commands to prevent weird errors 135 | // _git:8182: bad math expression: operand expected at `/home/user/...' 136 | // _git:8182: bad math expression: operand expected at `/home/user/...' 137 | // _git:8182: math recursion limit exceeded: name-rev 138 | // _git:8182: bad math expression: operator expected at `browse' 139 | if cmdName == "git" || strings.HasPrefix(cmdName, "git-") { 140 | if isVerbose { 141 | fmt.Printf("skipped git command: %s\n", fileName) 142 | } 143 | skippedNum++ 144 | return 145 | } 146 | 147 | srcFilePath := filepath.Join(srcDir, fileName) 148 | srcFile, err := os.Open(filepath.Join(srcFilePath)) 149 | if err != nil { 150 | fmt.Fprintf(os.Stderr, "could not open srcFile: %s: %s", srcFilePath, err) 151 | os.Exit(1) 152 | } 153 | defer srcFile.Close() 154 | convertNum++ 155 | converter := converter.NewConverter(srcFile, cmdName) 156 | fileContent, err := converter.Convert() 157 | if err != nil { 158 | if isVerbose { 159 | fmt.Printf("failed to convert: %s: %s\n", fileName, err) 160 | } 161 | return 162 | } 163 | 164 | dstFilePath := filepath.Join(dstDir, "_"+cmdName) 165 | dstFile, err := os.Create(dstFilePath) 166 | if err != nil { 167 | fmt.Fprintf(os.Stderr, "could not create dstFile: %s: %s", dstFilePath, err) 168 | os.Exit(1) 169 | } 170 | defer dstFile.Close() 171 | 172 | dstFile.WriteString(fileContent) 173 | 174 | if isVerbose { 175 | fmt.Printf("converted: %s\n", f.Name()) 176 | } 177 | 178 | convertedNum++ 179 | } 180 | }() 181 | } 182 | 183 | fmt.Printf("Completed. converted: %d/%d, skipped: %d\n", convertedNum, convertNum, skippedNum) 184 | } 185 | -------------------------------------------------------------------------------- /internal/converter/converter.go: -------------------------------------------------------------------------------- 1 | package converter 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "regexp" 8 | "strings" 9 | ) 10 | 11 | var ( 12 | shortOptReg = regexp.MustCompile(` -s '?([\w#?]+)'?`) // support -?, -# 13 | longOptReg = regexp.MustCompile(` -l '?([\w-]+)'?`) // support aaa-bbb 14 | oldOptReg = regexp.MustCompile(` -o '?([\w+?]+)'?`) // support -??, -??? 15 | descMsgReg = regexp.MustCompile(` -d '(.*)'$`) 16 | ) 17 | 18 | type Converter struct { 19 | r io.Reader 20 | cmdName string 21 | opts []Opt 22 | } 23 | 24 | type Opt struct { 25 | src string // for debug 26 | shortOptNames []string 27 | longOptNames []string 28 | oldOptNames []string 29 | descMsg string 30 | } 31 | 32 | func (o *Opt) getMergeOpts() []string { 33 | // merge short -> old -> long 34 | var mergeOpts []string 35 | appendMergeOpts := func(optNames []string, prefix string, escapeFn func(opt string) string) { 36 | var opts []string 37 | for _, n := range optNames { 38 | opts = append(opts, prefix+escapeFn(n)) 39 | } 40 | mergeOpts = append(mergeOpts, opts...) 41 | } 42 | 43 | escapeNoop := func(opt string) string { 44 | return opt 45 | } 46 | 47 | appendMergeOpts(o.shortOptNames, "-", escapeShortOpt) 48 | appendMergeOpts(o.oldOptNames, "-", escapeOldOpt) 49 | appendMergeOpts(o.longOptNames, "--", escapeNoop) 50 | 51 | return mergeOpts 52 | } 53 | 54 | func NewConverter(r io.Reader, cmdName string) *Converter { 55 | return &Converter{r: r, cmdName: cmdName} 56 | } 57 | 58 | func escapeDescMsg(opt string) string { 59 | e := opt 60 | 61 | // escape: 「\'HUP\'」 -> 「'"'"'HUP'"'"'」 62 | e = strings.ReplaceAll(e, `\'`, `'"'"'`) 63 | 64 | // delete whitespace 65 | e = strings.Join(strings.Fields(e), " ") 66 | 67 | // escape: [] 68 | e = strings.ReplaceAll(e, `[`, `\[`) 69 | e = strings.ReplaceAll(e, `]`, `\]`) 70 | 71 | return e 72 | } 73 | 74 | func escapeCommonOpt(opt string) string { 75 | e := opt 76 | 77 | // support -? 78 | e = strings.ReplaceAll(e, `?`, `\?`) 79 | 80 | return e 81 | } 82 | 83 | func escapeShortOpt(opt string) string { 84 | e := opt 85 | 86 | e = escapeCommonOpt(e) 87 | 88 | // support -# 89 | e = strings.ReplaceAll(e, `#`, `\#`) 90 | 91 | return e 92 | } 93 | 94 | func escapeOldOpt(opt string) string { 95 | e := opt 96 | 97 | e = escapeCommonOpt(e) 98 | 99 | return e 100 | } 101 | 102 | func SplitLines(s string) ([]string, error) { 103 | var lines []string 104 | sc := bufio.NewScanner(strings.NewReader(s)) 105 | for sc.Scan() { 106 | lines = append(lines, sc.Text()) 107 | } 108 | if sc.Err() != nil { 109 | return nil, sc.Err() 110 | } 111 | return lines, nil 112 | } 113 | 114 | func (c *Converter) parse() error { 115 | b, err := io.ReadAll(c.r) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | lines, err := SplitLines(string(b)) 121 | if err != nil { 122 | return err 123 | } 124 | 125 | var optLines []string 126 | for _, l := range lines { 127 | if strings.HasPrefix(l, "complete -c") { 128 | optLines = append(optLines, l) 129 | } 130 | } 131 | if len(optLines) == 0 { 132 | return fmt.Errorf("complete commands don't exist") 133 | } 134 | 135 | var opts []Opt 136 | for _, line := range optLines { 137 | // get only opts portion, deleting -d portion 138 | optLine := descMsgReg.ReplaceAllString(line, "") 139 | 140 | var ( 141 | shortOptNames []string 142 | longOptNames []string 143 | oldOptNames []string 144 | descMsg string 145 | ) 146 | 147 | optParsers := []struct { 148 | optNames *[]string 149 | reg *regexp.Regexp 150 | excludeFilter func(match string) (excluded bool) 151 | }{ 152 | {&shortOptNames, shortOptReg, nil}, 153 | {&longOptNames, longOptReg, func(match string) (excluded bool) { 154 | // Eliminate invalid option that contain consecutive hyphens 155 | return strings.Contains(match, "---") 156 | }}, 157 | {&oldOptNames, oldOptReg, nil}, 158 | } 159 | 160 | for _, p := range optParsers { 161 | for _, matches := range p.reg.FindAllStringSubmatch(optLine, -1) { 162 | for i, match := range matches { 163 | // only one group 164 | if i == 1 { 165 | if p.excludeFilter != nil && p.excludeFilter(match) { 166 | break 167 | } 168 | *p.optNames = append(*p.optNames, match) 169 | } 170 | } 171 | } 172 | } 173 | 174 | if len(shortOptNames)+len(longOptNames)+len(oldOptNames) == 0 { 175 | // pp.Println("no option skipped:", map[string]any{ 176 | // "cmdName": c.cmdName, 177 | // "line": line, 178 | // }) 179 | continue 180 | } 181 | 182 | // get description text 183 | matches := descMsgReg.FindStringSubmatch(line) 184 | if len(matches) >= 1+1 { 185 | // -d must be only one option 186 | descMsg = matches[1] 187 | } 188 | 189 | o := Opt{ 190 | shortOptNames: shortOptNames, 191 | longOptNames: longOptNames, 192 | oldOptNames: oldOptNames, 193 | src: line, 194 | } 195 | 196 | if descMsg != "" { 197 | o.descMsg = descMsg 198 | } 199 | 200 | opts = append(opts, o) 201 | } 202 | 203 | if len(opts) == 0 { 204 | return fmt.Errorf("not found opts") 205 | } 206 | 207 | c.opts = opts 208 | return nil 209 | } 210 | 211 | const zshCompTemplate = `#compdef %s 212 | 213 | _arguments \ 214 | '*:file:_files' \ 215 | ` 216 | 217 | func getZshCompTemplate(commandName string) string { 218 | return fmt.Sprintf(zshCompTemplate, commandName) 219 | } 220 | 221 | func (c *Converter) Convert() (fileContent string, err error) { 222 | if err := c.parse(); err != nil { 223 | return "", fmt.Errorf("convert error: %w", err) 224 | } 225 | 226 | str := strings.Builder{} 227 | str.WriteString(getZshCompTemplate(c.cmdName)) 228 | 229 | for i, opt := range c.opts { 230 | var args string 231 | 232 | allOpts := opt.getMergeOpts() 233 | 234 | // allOpts must be >= 1 235 | if len(allOpts) == 1 { 236 | args = fmt.Sprintf("'%s", allOpts[0]) 237 | } else { 238 | args = fmt.Sprintf("{%s}'", strings.Join(allOpts, ",")) 239 | } 240 | 241 | str.WriteString(fmt.Sprintf("\t\t%s", args)) 242 | if opt.descMsg != "" { 243 | str.WriteString(fmt.Sprintf("[%s]", escapeDescMsg(opt.descMsg))) 244 | } 245 | str.WriteString("'") 246 | 247 | if i+1 < len(c.opts) { 248 | // line break except last option 249 | str.WriteString(" \\\n") 250 | } 251 | } 252 | str.WriteString("\n") 253 | 254 | return str.String(), nil 255 | } 256 | --------------------------------------------------------------------------------