├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── tag.gif └── tag.go /.gitignore: -------------------------------------------------------------------------------- 1 | tag 2 | release 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) [2014] [github.com/aykamko/tag] 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 | SHELL:=/bin/bash 2 | 3 | GOBUILD=go build -v github.com/aykamko/tag >/dev/null 4 | 5 | build: 6 | go build 7 | 8 | generate_release_binaries: 9 | mkdir -p release; \ 10 | cd release; \ 11 | export GOOS=darwin; export GOARCH=386; \ 12 | ${GOBUILD} && zip tag_$${GOOS}_$${GOARCH} tag; \ 13 | export GOOS=darwin; export GOARCH=amd64; \ 14 | ${GOBUILD} && zip tag_$${GOOS}_$${GOARCH} tag; \ 15 | export GOOS=linux; export GOARCH=386; \ 16 | ${GOBUILD} && tar -cvzf tag_$${GOOS}_$${GOARCH}.tar.gz tag; \ 17 | export GOOS=linux; export GOARCH=amd64; \ 18 | ${GOBUILD} && tar -cvzf tag_$${GOOS}_$${GOARCH}.tar.gz tag; \ 19 | export GOOS=linux; export GOARCH=arm; \ 20 | ${GOBUILD} && tar -cvzf tag_$${GOOS}_$${GOARCH}.tar.gz tag; \ 21 | export GOOS=windows; export GOARCH=386; \ 22 | ${GOBUILD} && tar -cvzf tag_$${GOOS}_$${GOARCH}.tar.gz tag; \ 23 | export GOOS=windows; export GOARCH=amd64; \ 24 | ${GOBUILD} && tar -cvzf tag_$${GOOS}_$${GOARCH}.tar.gz tag; 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | tag - Tag your ag and ripgrep matches 2 | ==== 3 | ![revolv++](tag.gif) 4 | 5 | **tag** is a lightweight wrapper around **[ag](https://github.com/ggreer/the_silver_searcher)** and **[ripgrep](https://github.com/BurntSushi/ripgrep)** that generates shell aliases for your search matches. tag is a very fast Golang reimagining of [sack](https://github.com/sampson-chen/sack). 6 | 7 | tag supports `ag` and `ripgrep (rg)`. There are no plans to support ack or grep. If you'd like to add support for more search backends, I encourage you to contribute! 8 | 9 | ## Why should I use tag? 10 | 11 | tag makes it easy to _immediately_ jump to a search match in your favorite editor. It eliminates the tedious task of typing `vim foo/bar/baz.qux +42` to jump to a match by automatically generating these commands for you as shell aliases. 12 | 13 | Inside vim, [vim-grepper](https://github.com/mhinz/vim-grepper) or [ag.vim](https://github.com/rking/ag.vim) is probably the way to go. Outside vim (or inside a Neovim `:terminal`), tag is your best friend. 14 | 15 | Finally, tag is unobtrusive. It should behave exactly like `ag` or `ripgrep` under most circumstances. 16 | 17 | ## Performance Benchmarks 18 | 19 | tag processes ag's output on-the-fly with Golang using pipes so the performance loss is neglible. In other words, **tag is just as fast as ag**! 20 | 21 | ``` 22 | $ cd ~/github/torvalds/linux 23 | $ time ( for _ in {1..10}; do ag EXPORT_SYMBOL_GPL >/dev/null 2>&1; done ) 24 | 16.66s user 16.54s system 347% cpu 9.562 total 25 | $ time ( for _ in {1..10}; do tag EXPORT_SYMBOL_GPL >/dev/null 2>&1; done ) 26 | 16.84s user 16.90s system 356% cpu 9.454 total 27 | ``` 28 | 29 | # Installation 30 | 31 | 1. Update to the latest versions of [`ag`](https://github.com/ggreer/the_silver_searcher) or [`ripgrep`](https://github.com/BurntSushi/ripgrep). `ag` in particular must be version `>= 0.25.0`. 32 | 33 | 1. Install the `tag` binary using one of the following methods. 34 | - Homebrew (OSX) 35 | ``` 36 | $ brew tap aykamko/tag-ag 37 | $ brew install tag-ag 38 | ``` 39 | 40 | - AUR (Arch Linux) 41 | 42 | Using your favorite [AUR helper](https://wiki.archlinux.org/index.php/AUR_helpers), install the [tag-ag AUR package](https://aur.archlinux.org/packages/tag-ag/) like this: 43 | ``` 44 | $ aura -A tag-ag 45 | ``` 46 | 47 | - [Download a compressed binary for your platform](https://github.com/aykamko/tag/releases) 48 | 49 | - Developers and other platforms 50 | ``` 51 | $ go get -u github.com/aykamko/tag/... 52 | $ go install github.com/aykamko/tag 53 | ``` 54 | 55 | 1. By default, `tag` uses `ag` as its search backend. To use `ripgrep` instead, set the environment variable `TAG_SEARCH_PROG=rg`. (To persist this setting, put it in your `bashrc`/`zshrc`.) 56 | 57 | 1. Since tag generates a file with command aliases for your shell, you'll have to drop the following in your `bashrc`/`zshrc` to actually pick up those aliases. 58 | - `bash` 59 | ```bash 60 | if hash ag 2>/dev/null; then 61 | export TAG_SEARCH_PROG=ag # replace with rg for ripgrep 62 | tag() { command tag "$@"; source ${TAG_ALIAS_FILE:-/tmp/tag_aliases} 2>/dev/null; } 63 | alias ag=tag # replace with rg for ripgrep 64 | fi 65 | ``` 66 | 67 | - `zsh` 68 | ```zsh 69 | if (( $+commands[tag] )); then 70 | export TAG_SEARCH_PROG=ag # replace with rg for ripgrep 71 | tag() { command tag "$@"; source ${TAG_ALIAS_FILE:-/tmp/tag_aliases} 2>/dev/null } 72 | alias ag=tag # replace with rg for ripgrep 73 | fi 74 | ``` 75 | 76 | - `fish - ~/.config/fish/functions/tag.fish` 77 | ```fish 78 | function tag 79 | set -x TAG_SEARCH_PROG ag # replace with rg for ripgrep 80 | set -q TAG_ALIAS_FILE; or set -l TAG_ALIAS_FILE /tmp/tag_aliases 81 | command tag $argv; and source $TAG_ALIAS_FILE ^/dev/null 82 | alias ag tag # replace with rg for ripgrep 83 | end 84 | ``` 85 | 86 | # Configuration 87 | 88 | `tag` exposes the following configuration options via environment variables: 89 | 90 | - `TAG_SEARCH_PROG` 91 | - Determines whether to use `ag` or `ripgrep` as the search backend. Must be one of `ag` or `rg`. 92 | - Default: `ag` 93 | - `TAG_ALIAS_FILE` 94 | - Path where shortcut alias file will be generated. 95 | - Default: `/tmp/tag_aliases` 96 | - `TAG_ALIAS_PREFIX` 97 | - Prefix for alias commands, e.g. the `e` in generated alias `e42`. 98 | - Default: `e` 99 | - `TAG_CMD_FMT_STRING` 100 | - Format string for alias commands. Must contain `{{.Filename}}`, `{{.LineNumber}}`, and `{{.ColumnNumber}}` for proper substitution. 101 | - Default: `vim -c 'call cursor({{.LineNumber}}, {{.ColumnNumber}})' '{{.Filename}}'` 102 | 103 | # License 104 | 105 | [MIT](LICENSE) 106 | 107 | # Author 108 | 109 | [aykamko](https://github.com/aykamko) 110 | -------------------------------------------------------------------------------- /tag.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aykamko/tag/a1f5b04ac5664530afbfefad37bbb50286ec3fd5/tag.gif -------------------------------------------------------------------------------- /tag.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "regexp" 13 | "syscall" 14 | "text/template" 15 | 16 | "github.com/fatih/color" 17 | ) 18 | 19 | func check(e error) { 20 | if e != nil { 21 | log.Fatal(e) 22 | } 23 | } 24 | 25 | func extractCmdExitCode(err error) int { 26 | if err != nil { 27 | // Extract real exit code 28 | // Source: https://stackoverflow.com/a/10385867 29 | if exiterr, ok := err.(*exec.ExitError); ok { 30 | if status, ok := exiterr.Sys().(syscall.WaitStatus); ok { 31 | return status.ExitStatus() 32 | } 33 | } 34 | return 1 35 | } 36 | return 0 37 | } 38 | 39 | func optionIndex(args []string, option string) int { 40 | for i := len(args) - 1; i >= 0; i-- { 41 | if args[i] == option { 42 | return i 43 | } 44 | } 45 | return -1 46 | } 47 | 48 | func isatty(f *os.File) bool { 49 | stat, err := f.Stat() 50 | check(err) 51 | return stat.Mode()&os.ModeCharDevice != 0 52 | } 53 | 54 | func getEnvDefault(key, fallback string) string { 55 | if value, ok := os.LookupEnv(key); ok { 56 | return value 57 | } 58 | return fallback 59 | } 60 | 61 | var ( 62 | red = color.RedString 63 | blue = color.BlueString 64 | lineNumberRe = regexp.MustCompile(`^(\d+):(\d+):.*`) 65 | ansi = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) // Source: https://superuser.com/a/380778 66 | ) 67 | 68 | type AliasFile struct { 69 | filename string 70 | fmtStr string 71 | buf bytes.Buffer 72 | writer *bufio.Writer 73 | } 74 | 75 | func NewAliasFile() *AliasFile { 76 | aliasFilename := getEnvDefault("TAG_ALIAS_FILE", "/tmp/tag_aliases") 77 | aliasPrefix := getEnvDefault("TAG_ALIAS_PREFIX", "e") 78 | aliasCmdFmtString := getEnvDefault( 79 | "TAG_CMD_FMT_STRING", 80 | `vim -c "call cursor({{.LineNumber}}, {{.ColumnNumber}})" "{{.Filename}}"`) 81 | 82 | a := &AliasFile{ 83 | fmtStr: "alias " + aliasPrefix + "{{.MatchIndex}}='" + aliasCmdFmtString + "'\n", 84 | filename: aliasFilename, 85 | } 86 | a.writer = bufio.NewWriter(&a.buf) 87 | return a 88 | } 89 | 90 | func (a *AliasFile) WriteAlias(index int, filename, linenum string, colnum string) { 91 | t := template.Must(template.New("alias").Parse(a.fmtStr)) 92 | 93 | aliasVars := struct { 94 | MatchIndex int 95 | Filename string 96 | LineNumber string 97 | ColumnNumber string 98 | }{index, filename, linenum, colnum} 99 | 100 | err := t.Execute(a.writer, aliasVars) 101 | check(err) 102 | } 103 | 104 | func (a *AliasFile) WriteFile() { 105 | err := a.writer.Flush() 106 | check(err) 107 | 108 | err = ioutil.WriteFile(a.filename, a.buf.Bytes(), 0644) 109 | check(err) 110 | } 111 | 112 | func tagPrefix(aliasIndex int) string { 113 | return blue("[") + red("%d", aliasIndex) + blue("]") 114 | } 115 | 116 | func generateTags(cmd *exec.Cmd) int { 117 | cmd.Stderr = os.Stderr 118 | 119 | stdout, err := cmd.StdoutPipe() 120 | check(err) 121 | 122 | scanner := bufio.NewScanner(stdout) 123 | scanner.Split(bufio.ScanLines) 124 | 125 | var ( 126 | line []byte 127 | colorlessLine []byte 128 | curPath string 129 | groupIdxs []int 130 | ) 131 | 132 | aliasFile := NewAliasFile() 133 | defer aliasFile.WriteFile() 134 | 135 | aliasIndex := 1 136 | 137 | err = cmd.Start() 138 | check(err) 139 | 140 | for scanner.Scan() { 141 | line = scanner.Bytes() 142 | colorlessLine = ansi.ReplaceAll(line, nil) // strip ANSI 143 | if len(curPath) == 0 { 144 | // Path is always in the first line of a group (the heading). Extract and print it 145 | curPath = string(colorlessLine) 146 | curPath, err = filepath.Abs(curPath) 147 | check(err) 148 | fmt.Println(string(line)) 149 | } else if groupIdxs = lineNumberRe.FindSubmatchIndex(colorlessLine); len(groupIdxs) > 0 { 150 | // Extract and tag matches 151 | aliasFile.WriteAlias( 152 | aliasIndex, 153 | curPath, 154 | string(colorlessLine[groupIdxs[2]:groupIdxs[3]]), 155 | string(colorlessLine[groupIdxs[4]:groupIdxs[5]])) 156 | fmt.Printf("%s %s\n", tagPrefix(aliasIndex), string(line)) 157 | aliasIndex++ 158 | } else { 159 | // Empty line. End of grouping, reset curPath context 160 | fmt.Println(string(line)) 161 | curPath = "" 162 | } 163 | } 164 | 165 | err = cmd.Wait() 166 | return extractCmdExitCode(err) 167 | } 168 | 169 | func passThrough(cmd *exec.Cmd) int { 170 | cmd.Stdin = os.Stdin 171 | cmd.Stdout = os.Stdout 172 | cmd.Stderr = os.Stderr 173 | 174 | err := cmd.Run() 175 | return extractCmdExitCode(err) 176 | } 177 | 178 | func validateSearchProg(prog string) error { 179 | switch prog { 180 | case "ag", "rg": 181 | return nil 182 | default: 183 | return fmt.Errorf( 184 | "invalid environment variable TAG_SEARCH_PROG='%s'. only 'ag' and 'rg' are supported.", 185 | prog) 186 | } 187 | } 188 | 189 | func constructTagArgs(searchProg string, userArgs []string) []string { 190 | if isatty(os.Stdout) { 191 | switch searchProg { 192 | case "ag": 193 | return []string{"--group", "--color", "--column"} 194 | case "rg": 195 | // ripgrep can't handle more than one --color option, so if the user provides one 196 | // we have to explicilty keep tag from passing its own --color option 197 | if optionIndex(userArgs, "--color") >= 0 { 198 | return []string{"--heading", "--column"} 199 | } 200 | return []string{"--heading", "--color", "always", "--column"} 201 | } 202 | } 203 | return []string{} 204 | } 205 | 206 | func handleColorSetting(prog string, args []string) { 207 | switch prog { 208 | case "ag": 209 | color.NoColor = (optionIndex(args, "--nocolor") >= 0) 210 | case "rg": 211 | colorFlagIdx := optionIndex(args, "--color") 212 | color.NoColor = (colorFlagIdx >= 0 && args[colorFlagIdx+1] == "never") 213 | } 214 | } 215 | 216 | func main() { 217 | searchProg := getEnvDefault("TAG_SEARCH_PROG", "ag") 218 | check(validateSearchProg(searchProg)) 219 | 220 | userArgs := os.Args[1:] 221 | 222 | disableTag := false 223 | var tagArgs []string 224 | 225 | switch i := optionIndex(userArgs, "--notag"); { 226 | case i > 0: 227 | userArgs = append(userArgs[:i], userArgs[i+1:]...) 228 | fallthrough 229 | case len(userArgs) == 0: // no arguments; fall back to help message 230 | disableTag = true 231 | default: 232 | tagArgs = constructTagArgs(searchProg, userArgs) 233 | } 234 | finalArgs := append(tagArgs, userArgs...) 235 | 236 | cmd := exec.Command(searchProg, finalArgs...) 237 | 238 | if disableTag || !isatty(os.Stdin) || !isatty(os.Stdout) { 239 | // Data being piped from stdin 240 | os.Exit(passThrough(cmd)) 241 | } 242 | 243 | handleColorSetting(searchProg, finalArgs) 244 | os.Exit(generateTags(cmd)) 245 | } 246 | --------------------------------------------------------------------------------