// License: GPLv3 Copyright: 2023, Kovid Goyal, package hyperlinked_grep import ( "bufio" "bytes" "errors" "fmt" "net/url" "os" "os/exec" "path/filepath" "regexp" "strings" "unicode" "kitty/tools/cli" "kitty/tools/utils" "golang.org/x/sys/unix" ) var _ = fmt.Print var RgExe = (&utils.Once[string]{Run: func() string { ans := utils.Which("rg") if ans != "" { return ans } ans = utils.Which("rg", utils.DefaultExeSearchPaths()...) if ans == "" { ans = "rg" } return ans }}).Get func get_options_for_rg() (expecting_args map[string]bool, alias_map map[string]string, err error) { var raw []byte raw, err = exec.Command(RgExe(), "--help").Output() if err != nil { err = fmt.Errorf("Failed to execute rg: %w", err) return } scanner := bufio.NewScanner(strings.NewReader(utils.UnsafeBytesToString(raw))) options_started := false expecting_args = make(map[string]bool, 64) alias_map = make(map[string]string, 52) for scanner.Scan() { line := scanner.Text() if options_started { s := strings.TrimLeft(line, " ") indent := len(line) - len(s) if indent < 12 && indent > 0 { s, _, expecting_arg := strings.Cut(s, "<") single_letter_aliases := make([]string, 0, 1) long_option_names := make([]string, 0, 1) for _, x := range strings.Split(s, ",") { x = strings.TrimSpace(x) if strings.HasPrefix(x, "--") { long_option_names = append(long_option_names, x[2:]) } else if strings.HasPrefix(x, "-") { single_letter_aliases = append(single_letter_aliases, x[1:]) } } if len(long_option_names) == 0 { err = fmt.Errorf("Failed to parse rg help output line: %s", line) return } for _, x := range single_letter_aliases { alias_map[x] = long_option_names[0] } for _, x := range long_option_names[1:] { alias_map[x] = long_option_names[0] } expecting_args[long_option_names[0]] = expecting_arg } } else { if strings.HasPrefix(line, "OPTIONS:") { options_started = true } } } return } type kitten_options struct { matching_lines, context_lines, file_headers bool with_filename, heading, line_number bool stats, count, count_matches bool files, files_with_matches, files_without_match bool vimgrep bool } func default_kitten_opts() *kitten_options { return &kitten_options{ matching_lines: true, context_lines: true, file_headers: true, with_filename: true, heading: true, line_number: true, } } func parse_args(args ...string) (delegate_to_rg bool, sanitized_args []string, kitten_opts *kitten_options, err error) { options_that_expect_args, alias_map, err := get_options_for_rg() if err != nil { return } options_that_expect_args["kitten"] = true kitten_opts = default_kitten_opts() sanitized_args = make([]string, 0, len(args)) expecting_option_arg := "" context_separator := "--" field_context_separator := "-" field_match_separator := "-" handle_option_arg := func(key, val string, with_equals bool) error { if key != "kitten" { if with_equals { sanitized_args = append(sanitized_args, "--"+key+"="+val) } else { sanitized_args = append(sanitized_args, "--"+key, val) } } switch key { case "path-separator": if val != string(os.PathSeparator) { delegate_to_rg = true } case "context-separator": context_separator = val case "field-context-separator": field_context_separator = val case "field-match-separator": field_match_separator = val case "kitten": k, v, found := strings.Cut(val, "=") if !found || k != "hyperlink" { return fmt.Errorf("Unknown --kitten option: %s", val) } for _, x := range strings.Split(v, ",") { switch x { case "none": kitten_opts.context_lines = false kitten_opts.file_headers = false kitten_opts.matching_lines = false case "all": kitten_opts.context_lines = true kitten_opts.file_headers = true kitten_opts.matching_lines = true case "matching_lines": kitten_opts.matching_lines = true case "file_headers": kitten_opts.file_headers = true case "context_lines": kitten_opts.context_lines = true default: return fmt.Errorf("hyperlink option invalid: %s", x) } } } return nil } handle_bool_option := func(key string) { switch key { case "no-context-separator": context_separator = "" case "no-filename": kitten_opts.with_filename = false case "with-filename": kitten_opts.with_filename = true case "heading": kitten_opts.heading = true case "no-heading": kitten_opts.heading = false case "line-number": kitten_opts.line_number = true case "no-line-number": kitten_opts.line_number = false case "pretty": kitten_opts.line_number = true kitten_opts.heading = true case "stats": kitten_opts.stats = true case "count": kitten_opts.count = true case "count-matches": kitten_opts.count_matches = true case "files": kitten_opts.files = true case "files-with-matches": kitten_opts.files_with_matches = true case "files-without-match": kitten_opts.files_without_match = true case "vimgrep": kitten_opts.vimgrep = true case "null", "null-data", "type-list", "version", "help": delegate_to_rg = true } } for i, x := range args { if expecting_option_arg != "" { if err = handle_option_arg(expecting_option_arg, x, false); err != nil { return } expecting_option_arg = "" } else { if x == "--" { sanitized_args = append(sanitized_args, args[i:]...) break } if strings.HasPrefix(x, "--") { a, b, found := strings.Cut(x, "=") a = a[2:] q := alias_map[a] if q != "" { a = q } if found { if _, is_known_option := options_that_expect_args[a]; is_known_option { if err = handle_option_arg(a, b, true); err != nil { return } } else { sanitized_args = append(sanitized_args, x) } } else { if options_that_expect_args[a] { expecting_option_arg = a } else { handle_bool_option(a) sanitized_args = append(sanitized_args, x) } } } else if strings.HasPrefix(x, "-") { ok := true chars := make([]string, len(x)-1) for i, ch := range x[1:] { chars[i] = string(ch) _, ok = alias_map[string(ch)] if !ok { break } } if !ok { sanitized_args = append(sanitized_args, x) continue } for _, ch := range chars { target := alias_map[ch] if options_that_expect_args[target] { expecting_option_arg = target } else { handle_bool_option(target) sanitized_args = append(sanitized_args, "-"+ch) } } } else { sanitized_args = append(sanitized_args, x) } } } if !kitten_opts.with_filename || context_separator != "--" || field_context_separator != "-" || field_match_separator != "-" { delegate_to_rg = true } return } type stdout_filter struct { prefix []byte process_line func(string) } func (self *stdout_filter) Write(p []byte) (n int, err error) { n = len(p) for len(p) > 0 { idx := bytes.IndexByte(p, '\n') if idx < 0 { self.prefix = append(self.prefix, p...) break } line := p[:idx] if len(self.prefix) > 0 { self.prefix = append(self.prefix, line...) line = self.prefix } p = p[idx+1:] self.process_line(utils.UnsafeBytesToString(line)) self.prefix = self.prefix[:0] } return } func main(_ *cli.Command, _ *Options, args []string) (rc int, err error) { delegate_to_rg, sanitized_args, kitten_opts, err := parse_args(args...) if delegate_to_rg { sanitized_args = append([]string{"rg"}, sanitized_args...) err = unix.Exec(RgExe(), sanitized_args, os.Environ()) if err != nil { err = fmt.Errorf("Failed to execute rg: %w", err) rc = 1 } return } cmdline := append([]string{"--pretty", "--with-filename"}, sanitized_args...) cmd := exec.Command(RgExe(), cmdline...) cmd.Stdin = os.Stdin cmd.Stderr = os.Stderr buf := stdout_filter{prefix: make([]byte, 0, 8*1024)} cmd.Stdout = &buf sgr_pat := regexp.MustCompile("\x1b\\[.*?m") osc_pat := regexp.MustCompile("\x1b\\].*?\x1b\\\\") num_pat := regexp.MustCompile(`^(\d+)([:-])`) path_with_count_pat := regexp.MustCompile(`^(.*?)(:\d+)`) path_with_linenum_pat := regexp.MustCompile(`^(.*?):(\d+):`) stats_pat := regexp.MustCompile(`^\d+ matches$`) vimgrep_pat := regexp.MustCompile(`^(.*?):(\d+):(\d+):`) in_stats := false in_result := "" hostname := utils.Hostname() get_quoted_url := func(file_path string) string { q, err := filepath.Abs(file_path) if err == nil { file_path = q } file_path = filepath.ToSlash(file_path) file_path = strings.Join(utils.Map(url.PathEscape, strings.Split(file_path, "/")), "/") return "file://" + hostname + file_path } write := func(items ...string) { for _, x := range items { os.Stdout.WriteString(x) } } write_hyperlink := func(url, line, frag string) { write("\033]8;;", url) if frag != "" { write("#", frag) } write("\033\\", line, "\n\033]8;;\033\\") } buf.process_line = func(line string) { line = osc_pat.ReplaceAllLiteralString(line, "") // remove existing hyperlinks clean_line := strings.TrimRightFunc(line, unicode.IsSpace) clean_line = sgr_pat.ReplaceAllLiteralString(clean_line, "") // remove SGR formatting if clean_line == "" { in_result = "" write("\n") } else if in_stats { write(line, "\n") } else if in_result != "" { if kitten_opts.line_number { m := num_pat.FindStringSubmatch(clean_line) if len(m) > 0 { is_match_line := len(m) > 1 && m[2] == ":" if (is_match_line && kitten_opts.matching_lines) || (!is_match_line && kitten_opts.context_lines) { write_hyperlink(in_result, line, m[1]) return } } } write(line, "\n") } else { if strings.TrimSpace(line) != "" { // The option priority should be consistent with ripgrep here. if kitten_opts.stats && !in_stats && stats_pat.MatchString(clean_line) { in_stats = true } else if kitten_opts.count || kitten_opts.count_matches { if m := path_with_count_pat.FindStringSubmatch(clean_line); len(m) > 0 && kitten_opts.file_headers { write_hyperlink(get_quoted_url(m[1]), line, "") return } } else if kitten_opts.files || kitten_opts.files_with_matches || kitten_opts.files_without_match { if kitten_opts.file_headers { write_hyperlink(get_quoted_url(clean_line), line, "") return } } else if kitten_opts.vimgrep || !kitten_opts.heading { var m []string // When the vimgrep option is present, it will take precedence. if kitten_opts.vimgrep { m = vimgrep_pat.FindStringSubmatch(clean_line) } else { m = path_with_linenum_pat.FindStringSubmatch(clean_line) } if len(m) > 0 && (kitten_opts.file_headers || kitten_opts.matching_lines) { write_hyperlink(get_quoted_url(m[1]), line, m[2]) return } } else { in_result = get_quoted_url(clean_line) if kitten_opts.file_headers { write_hyperlink(in_result, line, "") return } } } write(line, "\n") } } err = cmd.Run() var ee *exec.ExitError if err != nil { if errors.As(err, &ee) { return ee.ExitCode(), nil } return 1, fmt.Errorf("Failed to execute rg: %w", err) } return } func specialize_command(hg *cli.Command) { hg.Usage = "arguments for the rg command" hg.ShortDescription = "Add hyperlinks to the output of ripgrep" hg.HelpText = "The hyperlinked_grep kitten is a thin wrapper around the rg command. It automatically adds hyperlinks to the output of rg allowing the user to click on search results to have them open directly in their editor. For details on its usage, see :doc:`/kittens/hyperlinked_grep`." hg.IgnoreAllArgs = true hg.OnlyArgsAllowed = true hg.ArgCompleter = cli.CompletionForWrapper("rg") } func EntryPoint(parent *cli.Command) { create_cmd(parent, main) }