main.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. // License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
  2. package hyperlinked_grep
  3. import (
  4. "bytes"
  5. "errors"
  6. "fmt"
  7. "net/url"
  8. "os"
  9. "os/exec"
  10. "path/filepath"
  11. "regexp"
  12. "strings"
  13. "sync"
  14. "unicode"
  15. "kitty/tools/cli"
  16. "kitty/tools/utils"
  17. "golang.org/x/sys/unix"
  18. )
  19. var _ = fmt.Print
  20. var RgExe = sync.OnceValue(func() string {
  21. return utils.FindExe("rg")
  22. })
  23. func get_options_for_rg() (expecting_args map[string]bool, alias_map map[string]string, err error) {
  24. var raw []byte
  25. raw, err = exec.Command(RgExe(), "--help").Output()
  26. if err != nil {
  27. err = fmt.Errorf("Failed to execute rg: %w", err)
  28. return
  29. }
  30. scanner := utils.NewLineScanner(utils.UnsafeBytesToString(raw))
  31. options_started := false
  32. expecting_args = make(map[string]bool, 64)
  33. alias_map = make(map[string]string, 52)
  34. for scanner.Scan() {
  35. line := scanner.Text()
  36. if options_started {
  37. s := strings.TrimLeft(line, " ")
  38. indent := len(line) - len(s)
  39. if indent < 8 && indent > 0 {
  40. expecting_arg := strings.Contains(s, "=")
  41. single_letter_aliases := make([]string, 0, 1)
  42. long_option_names := make([]string, 0, 1)
  43. for _, x := range strings.Split(s, ",") {
  44. x = strings.TrimSpace(x)
  45. if strings.HasPrefix(x, "--") {
  46. lon, _, _ := strings.Cut(x[2:], "=")
  47. long_option_names = append(long_option_names, lon)
  48. } else if strings.HasPrefix(x, "-") {
  49. son, _, _ := strings.Cut(x[1:], " ")
  50. single_letter_aliases = append(single_letter_aliases, son)
  51. }
  52. }
  53. if len(long_option_names) == 0 {
  54. err = fmt.Errorf("Failed to parse rg help output line: %s", line)
  55. return
  56. }
  57. for _, x := range single_letter_aliases {
  58. alias_map[x] = long_option_names[0]
  59. }
  60. for _, x := range long_option_names[1:] {
  61. alias_map[x] = long_option_names[0]
  62. }
  63. expecting_args[long_option_names[0]] = expecting_arg
  64. }
  65. } else {
  66. if strings.HasSuffix(line, "OPTIONS:") {
  67. options_started = true
  68. }
  69. }
  70. }
  71. if len(expecting_args) == 0 || len(alias_map) == 0 {
  72. err = fmt.Errorf("Failed to parse rg help output, could not find any options")
  73. return
  74. }
  75. return
  76. }
  77. type kitten_options struct {
  78. matching_lines, context_lines, file_headers bool
  79. with_filename, heading, line_number bool
  80. stats, count, count_matches bool
  81. files, files_with_matches, files_without_match bool
  82. vimgrep bool
  83. }
  84. func default_kitten_opts() *kitten_options {
  85. return &kitten_options{
  86. matching_lines: true, context_lines: true, file_headers: true,
  87. with_filename: true, heading: true, line_number: true,
  88. }
  89. }
  90. func parse_args(args ...string) (delegate_to_rg bool, sanitized_args []string, kitten_opts *kitten_options, err error) {
  91. options_that_expect_args, alias_map, err := get_options_for_rg()
  92. if err != nil {
  93. return
  94. }
  95. options_that_expect_args["kitten"] = true
  96. kitten_opts = default_kitten_opts()
  97. sanitized_args = make([]string, 0, len(args))
  98. expecting_option_arg := ""
  99. context_separator := "--"
  100. field_context_separator := "-"
  101. field_match_separator := "-"
  102. handle_option_arg := func(key, val string, with_equals bool) error {
  103. if key != "kitten" {
  104. if with_equals {
  105. sanitized_args = append(sanitized_args, "--"+key+"="+val)
  106. } else {
  107. sanitized_args = append(sanitized_args, "--"+key, val)
  108. }
  109. }
  110. switch key {
  111. case "path-separator":
  112. if val != string(os.PathSeparator) {
  113. delegate_to_rg = true
  114. }
  115. case "context-separator":
  116. context_separator = val
  117. case "field-context-separator":
  118. field_context_separator = val
  119. case "field-match-separator":
  120. field_match_separator = val
  121. case "kitten":
  122. k, v, found := strings.Cut(val, "=")
  123. if !found || k != "hyperlink" {
  124. return fmt.Errorf("Unknown --kitten option: %s", val)
  125. }
  126. for _, x := range strings.Split(v, ",") {
  127. switch x {
  128. case "none":
  129. kitten_opts.context_lines = false
  130. kitten_opts.file_headers = false
  131. kitten_opts.matching_lines = false
  132. case "all":
  133. kitten_opts.context_lines = true
  134. kitten_opts.file_headers = true
  135. kitten_opts.matching_lines = true
  136. case "matching_lines":
  137. kitten_opts.matching_lines = true
  138. case "file_headers":
  139. kitten_opts.file_headers = true
  140. case "context_lines":
  141. kitten_opts.context_lines = true
  142. default:
  143. return fmt.Errorf("hyperlink option invalid: %s", x)
  144. }
  145. }
  146. }
  147. return nil
  148. }
  149. handle_bool_option := func(key string) {
  150. switch key {
  151. case "no-context-separator":
  152. context_separator = ""
  153. case "no-filename":
  154. kitten_opts.with_filename = false
  155. case "with-filename":
  156. kitten_opts.with_filename = true
  157. case "heading":
  158. kitten_opts.heading = true
  159. case "no-heading":
  160. kitten_opts.heading = false
  161. case "line-number":
  162. kitten_opts.line_number = true
  163. case "no-line-number":
  164. kitten_opts.line_number = false
  165. case "pretty":
  166. kitten_opts.line_number = true
  167. kitten_opts.heading = true
  168. case "stats":
  169. kitten_opts.stats = true
  170. case "count":
  171. kitten_opts.count = true
  172. case "count-matches":
  173. kitten_opts.count_matches = true
  174. case "files":
  175. kitten_opts.files = true
  176. case "files-with-matches":
  177. kitten_opts.files_with_matches = true
  178. case "files-without-match":
  179. kitten_opts.files_without_match = true
  180. case "vimgrep":
  181. kitten_opts.vimgrep = true
  182. case "null", "null-data", "type-list", "version", "help":
  183. delegate_to_rg = true
  184. }
  185. }
  186. for i, x := range args {
  187. if expecting_option_arg != "" {
  188. if err = handle_option_arg(expecting_option_arg, x, false); err != nil {
  189. return
  190. }
  191. expecting_option_arg = ""
  192. } else {
  193. if x == "--" {
  194. sanitized_args = append(sanitized_args, args[i:]...)
  195. break
  196. }
  197. if strings.HasPrefix(x, "--") {
  198. a, b, found := strings.Cut(x, "=")
  199. a = a[2:]
  200. q := alias_map[a]
  201. if q != "" {
  202. a = q
  203. }
  204. if found {
  205. if _, is_known_option := options_that_expect_args[a]; is_known_option {
  206. if err = handle_option_arg(a, b, true); err != nil {
  207. return
  208. }
  209. } else {
  210. sanitized_args = append(sanitized_args, x)
  211. }
  212. } else {
  213. if options_that_expect_args[a] {
  214. expecting_option_arg = a
  215. } else {
  216. handle_bool_option(a)
  217. sanitized_args = append(sanitized_args, x)
  218. }
  219. }
  220. } else if strings.HasPrefix(x, "-") {
  221. ok := true
  222. chars := make([]string, len(x)-1)
  223. for i, ch := range x[1:] {
  224. chars[i] = string(ch)
  225. _, ok = alias_map[string(ch)]
  226. if !ok {
  227. sanitized_args = append(sanitized_args, x)
  228. break
  229. }
  230. }
  231. if ok {
  232. for _, ch := range chars {
  233. target := alias_map[ch]
  234. if options_that_expect_args[target] {
  235. expecting_option_arg = target
  236. } else {
  237. handle_bool_option(target)
  238. sanitized_args = append(sanitized_args, "-"+ch)
  239. }
  240. }
  241. }
  242. } else {
  243. sanitized_args = append(sanitized_args, x)
  244. }
  245. }
  246. }
  247. if !kitten_opts.with_filename || context_separator != "--" || field_context_separator != "-" || field_match_separator != "-" {
  248. delegate_to_rg = true
  249. }
  250. return
  251. }
  252. type stdout_filter struct {
  253. prefix []byte
  254. process_line func(string)
  255. }
  256. func (self *stdout_filter) Write(p []byte) (n int, err error) {
  257. n = len(p)
  258. for len(p) > 0 {
  259. idx := bytes.IndexByte(p, '\n')
  260. if idx < 0 {
  261. self.prefix = append(self.prefix, p...)
  262. break
  263. }
  264. line := p[:idx]
  265. if len(self.prefix) > 0 {
  266. self.prefix = append(self.prefix, line...)
  267. line = self.prefix
  268. }
  269. p = p[idx+1:]
  270. self.process_line(utils.UnsafeBytesToString(line))
  271. self.prefix = self.prefix[:0]
  272. }
  273. return
  274. }
  275. func main(_ *cli.Command, _ *Options, args []string) (rc int, err error) {
  276. delegate_to_rg, sanitized_args, kitten_opts, err := parse_args(args...)
  277. if err != nil {
  278. return 1, err
  279. }
  280. if delegate_to_rg {
  281. sanitized_args = append([]string{"rg"}, sanitized_args...)
  282. err = unix.Exec(RgExe(), sanitized_args, os.Environ())
  283. if err != nil {
  284. err = fmt.Errorf("Failed to execute rg: %w", err)
  285. rc = 1
  286. }
  287. return
  288. }
  289. cmdline := append([]string{"--pretty", "--with-filename"}, sanitized_args...)
  290. cmd := exec.Command(RgExe(), cmdline...)
  291. cmd.Stdin = os.Stdin
  292. cmd.Stderr = os.Stderr
  293. buf := stdout_filter{prefix: make([]byte, 0, 8*1024)}
  294. cmd.Stdout = &buf
  295. sgr_pat := regexp.MustCompile("\x1b\\[.*?m")
  296. osc_pat := regexp.MustCompile("\x1b\\].*?\x1b\\\\")
  297. num_pat := regexp.MustCompile(`^(\d+)([:-])`)
  298. path_with_count_pat := regexp.MustCompile(`^(.*?)(:\d+)`)
  299. path_with_linenum_pat := regexp.MustCompile(`^(.*?):(\d+):`)
  300. stats_pat := regexp.MustCompile(`^\d+ matches$`)
  301. vimgrep_pat := regexp.MustCompile(`^(.*?):(\d+):(\d+):`)
  302. in_stats := false
  303. in_result := ""
  304. hostname := utils.Hostname()
  305. get_quoted_url := func(file_path string) string {
  306. q, err := filepath.Abs(file_path)
  307. if err == nil {
  308. file_path = q
  309. }
  310. file_path = filepath.ToSlash(file_path)
  311. file_path = strings.Join(utils.Map(url.PathEscape, strings.Split(file_path, "/")), "/")
  312. return "file://" + hostname + file_path
  313. }
  314. write := func(items ...string) {
  315. for _, x := range items {
  316. os.Stdout.WriteString(x)
  317. }
  318. }
  319. write_hyperlink := func(url, line, frag string) {
  320. write("\033]8;;", url)
  321. if frag != "" {
  322. write("#", frag)
  323. }
  324. write("\033\\", line, "\n\033]8;;\033\\")
  325. }
  326. buf.process_line = func(line string) {
  327. line = osc_pat.ReplaceAllLiteralString(line, "") // remove existing hyperlinks
  328. clean_line := strings.TrimRightFunc(line, unicode.IsSpace)
  329. clean_line = sgr_pat.ReplaceAllLiteralString(clean_line, "") // remove SGR formatting
  330. if clean_line == "" {
  331. in_result = ""
  332. write("\n")
  333. } else if in_stats {
  334. write(line, "\n")
  335. } else if in_result != "" {
  336. if kitten_opts.line_number {
  337. m := num_pat.FindStringSubmatch(clean_line)
  338. if len(m) > 0 {
  339. is_match_line := len(m) > 1 && m[2] == ":"
  340. if (is_match_line && kitten_opts.matching_lines) || (!is_match_line && kitten_opts.context_lines) {
  341. write_hyperlink(in_result, line, m[1])
  342. return
  343. }
  344. }
  345. }
  346. write(line, "\n")
  347. } else {
  348. if strings.TrimSpace(line) != "" {
  349. // The option priority should be consistent with ripgrep here.
  350. if kitten_opts.stats && !in_stats && stats_pat.MatchString(clean_line) {
  351. in_stats = true
  352. } else if kitten_opts.count || kitten_opts.count_matches {
  353. if m := path_with_count_pat.FindStringSubmatch(clean_line); len(m) > 0 && kitten_opts.file_headers {
  354. write_hyperlink(get_quoted_url(m[1]), line, "")
  355. return
  356. }
  357. } else if kitten_opts.files || kitten_opts.files_with_matches || kitten_opts.files_without_match {
  358. if kitten_opts.file_headers {
  359. write_hyperlink(get_quoted_url(clean_line), line, "")
  360. return
  361. }
  362. } else if kitten_opts.vimgrep || !kitten_opts.heading {
  363. var m []string
  364. // When the vimgrep option is present, it will take precedence.
  365. if kitten_opts.vimgrep {
  366. m = vimgrep_pat.FindStringSubmatch(clean_line)
  367. } else {
  368. m = path_with_linenum_pat.FindStringSubmatch(clean_line)
  369. }
  370. if len(m) > 0 && (kitten_opts.file_headers || kitten_opts.matching_lines) {
  371. write_hyperlink(get_quoted_url(m[1]), line, m[2])
  372. return
  373. }
  374. } else {
  375. in_result = get_quoted_url(clean_line)
  376. if kitten_opts.file_headers {
  377. write_hyperlink(in_result, line, "")
  378. return
  379. }
  380. }
  381. }
  382. write(line, "\n")
  383. }
  384. }
  385. err = cmd.Run()
  386. var ee *exec.ExitError
  387. if err != nil {
  388. if errors.As(err, &ee) {
  389. return ee.ExitCode(), nil
  390. }
  391. return 1, fmt.Errorf("Failed to execute rg: %w", err)
  392. }
  393. return
  394. }
  395. func specialize_command(hg *cli.Command) {
  396. hg.Usage = "arguments for the rg command"
  397. hg.ShortDescription = "Add hyperlinks to the output of ripgrep"
  398. 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`."
  399. hg.IgnoreAllArgs = true
  400. hg.OnlyArgsAllowed = true
  401. hg.ArgCompleter = cli.CompletionForWrapper("rg")
  402. }
  403. type Options struct {
  404. }
  405. func create_cmd(root *cli.Command, run_func func(*cli.Command, *Options, []string) (int, error)) {
  406. ans := root.AddSubCommand(&cli.Command{
  407. Name: "hyperlinked_grep",
  408. Run: func(cmd *cli.Command, args []string) (int, error) {
  409. opts := Options{}
  410. err := cmd.GetOptionValues(&opts)
  411. if err != nil {
  412. return 1, err
  413. }
  414. return run_func(cmd, &opts, args)
  415. },
  416. Hidden: true,
  417. })
  418. specialize_command(ans)
  419. clone := root.AddClone(ans.Group, ans)
  420. clone.Hidden = false
  421. clone.Name = "hyperlinked-grep"
  422. }
  423. func EntryPoint(parent *cli.Command) {
  424. create_cmd(parent, main)
  425. }