#!/usr/bin/env python # License: GPLv3 Copyright: 2020, Kovid Goyal import argparse import os import re import signal import subprocess import sys from typing import Callable, List, cast from urllib.parse import quote_from_bytes from kitty.utils import get_hostname def write_hyperlink(write: Callable[[bytes], None], url: bytes, line: bytes, frag: bytes = b'') -> None: text = b'\033]8;;' + url if frag: text += b'#' + frag text += b'\033\\' + line + b'\033]8;;\033\\' write(text) def parse_options(argv: List[str]) -> argparse.Namespace: p = argparse.ArgumentParser(add_help=False) p.add_argument('--context-separator', default='--') p.add_argument('-c', '--count', action='store_true') p.add_argument('--count-matches', action='store_true') p.add_argument('--field-context-separator', default='-') p.add_argument('--field-match-separator', default='-') p.add_argument('--files', action='store_true') p.add_argument('-l', '--files-with-matches', action='store_true') p.add_argument('--files-without-match', action='store_true') p.add_argument('-h', '--help', action='store_true') p.add_argument('--json', action='store_true') p.add_argument('-I', '--no-filename', action='store_true') p.add_argument('--no-heading', action='store_true') p.add_argument('-N', '--no-line-number', action='store_true') p.add_argument('-0', '--null', action='store_true') p.add_argument('--null-data', action='store_true') p.add_argument('--path-separator', default=os.path.sep) p.add_argument('--stats', action='store_true') p.add_argument('--type-list', action='store_true') p.add_argument('-V', '--version', action='store_true') p.add_argument('--vimgrep', action='store_true') p.add_argument( '-p', '--pretty', default=sys.stdout.isatty(), action='store_true', ) p.add_argument('--kitten', action='append', default=[]) args, _ = p.parse_known_args(argv) return args def main() -> None: i = 1 args = parse_options(sys.argv[1:]) all_link_options = {'matching_lines', 'context_lines', 'file_headers'} link_options = set() delegate_to_rg = False for raw in args.kitten: p, _, s = raw.partition('=') if p != 'hyperlink': raise SystemExit(f'Unknown argument for --kitten: {raw}') for option in s.split(','): if option == 'all': link_options.update(all_link_options) delegate_to_rg = False elif option == 'none': delegate_to_rg = True link_options.clear() elif option not in all_link_options: a = ', '.join(sorted(all_link_options)) raise SystemExit(f"hyperlink option must be one of all, none, {a}, not '{option}'") else: link_options.add(option) delegate_to_rg = False while i < len(sys.argv): if sys.argv[i] == '--kitten': del sys.argv[i:i+2] elif sys.argv[i].startswith('--kitten='): del sys.argv[i] else: i += 1 if not link_options: # Default to linking everything if no options given link_options.update(all_link_options) link_file_headers = 'file_headers' in link_options link_context_lines = 'context_lines' in link_options link_matching_lines = 'matching_lines' in link_options if any(( args.context_separator != '--', args.field_context_separator != '-', args.field_match_separator != '-', args.help, args.json, args.no_filename, args.null, args.null_data, args.path_separator != os.path.sep, args.type_list, args.version, not args.pretty, )): delegate_to_rg = True if delegate_to_rg: os.execlp('rg', 'rg', *sys.argv[1:]) cmdline = ['rg', '--pretty', '--with-filename'] + sys.argv[1:] try: p = subprocess.Popen(cmdline, stdout=subprocess.PIPE) except FileNotFoundError: raise SystemExit('Could not find the rg executable in your PATH. Is ripgrep installed?') assert p.stdout is not None write: Callable[[bytes], None] = cast(Callable[[bytes], None], sys.stdout.buffer.write) sgr_pat = re.compile(br'\x1b\[.*?m') osc_pat = re.compile(b'\x1b\\].*?\x1b\\\\') num_pat = re.compile(br'^(\d+)([:-])') path_with_count_pat = re.compile(br'(.*?)(:\d+)') path_with_linenum_pat = re.compile(br'^(.*?):(\d+):') stats_pat = re.compile(br'^\d+ matches$') vimgrep_pat = re.compile(br'^(.*?):(\d+):(\d+):') in_stats = False in_result: bytes = b'' hostname = get_hostname().encode('utf-8') def get_quoted_url(file_path: bytes) -> bytes: return b'file://' + hostname + quote_from_bytes(os.path.abspath(file_path)).encode('utf-8') try: for line in p.stdout: line = osc_pat.sub(b'', line) # remove any existing hyperlinks clean_line = sgr_pat.sub(b'', line).rstrip() # remove SGR formatting if not clean_line: in_result = b'' write(b'\n') elif in_stats: write(line) elif in_result: if not args.no_line_number: m = num_pat.match(clean_line) if m is not None: is_match_line = m.group(2) == b':' if (is_match_line and link_matching_lines) or (not is_match_line and link_context_lines): write_hyperlink(write, in_result, line, frag=m.group(1)) continue write(line) else: if line.strip(): # The option priority should be consistent with ripgrep here. if args.stats and not in_stats and stats_pat.match(clean_line): in_stats = True elif args.count or args.count_matches: m = path_with_count_pat.match(clean_line) if m is not None and link_file_headers: write_hyperlink(write, get_quoted_url(m.group(1)), line) continue elif args.files or args.files_with_matches or args.files_without_match: if link_file_headers: write_hyperlink(write, get_quoted_url(clean_line), line) continue elif args.vimgrep or args.no_heading: # When the vimgrep option is present, it will take precedence. m = vimgrep_pat.match(clean_line) if args.vimgrep else path_with_linenum_pat.match(clean_line) if m is not None and (link_file_headers or link_matching_lines): write_hyperlink(write, get_quoted_url(m.group(1)), line, frag=m.group(2)) continue else: in_result = get_quoted_url(clean_line) if link_file_headers: write_hyperlink(write, in_result, line) continue write(line) except KeyboardInterrupt: p.send_signal(signal.SIGINT) except (EOFError, BrokenPipeError): pass finally: p.stdout.close() raise SystemExit(p.wait()) if __name__ == '__main__': main() elif __name__ == '__wrapper_of__': cd = sys.cli_docs # type: ignore cd['wrapper_of'] = 'rg'