1#!/usr/local/bin/python3.8 2# vim:fileencoding=utf-8 3# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net> 4 5import os 6import re 7import string 8import sys 9from functools import lru_cache 10from gettext import gettext as _ 11from itertools import repeat 12from typing import ( 13 Any, Callable, Dict, Generator, Iterable, List, Optional, Pattern, 14 Sequence, Set, Tuple, Type, cast 15) 16 17from kitty.cli import parse_args 18from kitty.cli_stub import HintsCLIOptions 19from kitty.constants import website_url 20from kitty.fast_data_types import set_clipboard_string 21from kitty.key_encoding import KeyEvent 22from kitty.typing import BossType, KittyCommonOpts 23from kitty.utils import ScreenSize, screen_size_function, set_primary_selection 24 25from ..tui.handler import Handler, result_handler 26from ..tui.loop import Loop 27from ..tui.operations import faint, styled 28 29 30@lru_cache() 31def kitty_common_opts() -> KittyCommonOpts: 32 import json 33 v = os.environ.get('KITTY_COMMON_OPTS') 34 if v: 35 return cast(KittyCommonOpts, json.loads(v)) 36 from kitty.config import common_opts_as_dict 37 return common_opts_as_dict() 38 39 40DEFAULT_HINT_ALPHABET = string.digits + string.ascii_lowercase 41DEFAULT_REGEX = r'(?m)^\s*(.+)\s*$' 42 43 44class Mark: 45 46 __slots__ = ('index', 'start', 'end', 'text', 'is_hyperlink', 'group_id', 'groupdict') 47 48 def __init__( 49 self, 50 index: int, start: int, end: int, 51 text: str, 52 groupdict: Any, 53 is_hyperlink: bool = False, 54 group_id: Optional[str] = None 55 ): 56 self.index, self.start, self.end = index, start, end 57 self.text = text 58 self.groupdict = groupdict 59 self.is_hyperlink = is_hyperlink 60 self.group_id = group_id 61 62 63@lru_cache(maxsize=2048) 64def encode_hint(num: int, alphabet: str) -> str: 65 res = '' 66 d = len(alphabet) 67 while not res or num > 0: 68 num, i = divmod(num, d) 69 res = alphabet[i] + res 70 return res 71 72 73def decode_hint(x: str, alphabet: str = DEFAULT_HINT_ALPHABET) -> int: 74 base = len(alphabet) 75 index_map = {c: i for i, c in enumerate(alphabet)} 76 i = 0 77 for char in x: 78 i = i * base + index_map[char] 79 return i 80 81 82def highlight_mark(m: Mark, text: str, current_input: str, alphabet: str, colors: Dict[str, str]) -> str: 83 hint = encode_hint(m.index, alphabet) 84 if current_input and not hint.startswith(current_input): 85 return faint(text) 86 hint = hint[len(current_input):] or ' ' 87 text = text[len(hint):] 88 return styled( 89 hint, 90 fg=colors['foreground'], 91 bg=colors['background'], 92 bold=True 93 ) + styled( 94 text, fg=colors['text'], fg_intense=True, bold=True 95 ) 96 97 98def debug(*a: Any, **kw: Any) -> None: 99 from ..tui.loop import debug as d 100 d(*a, **kw) 101 102 103def render(text: str, current_input: str, all_marks: Sequence[Mark], ignore_mark_indices: Set[int], alphabet: str, colors: Dict[str, str]) -> str: 104 for mark in reversed(all_marks): 105 if mark.index in ignore_mark_indices: 106 continue 107 mtext = highlight_mark(mark, text[mark.start:mark.end], current_input, alphabet, colors) 108 text = text[:mark.start] + mtext + text[mark.end:] 109 110 text = text.replace('\0', '') 111 return re.sub('[\r\n]', '\r\n', text).rstrip() 112 113 114class Hints(Handler): 115 116 def __init__(self, text: str, all_marks: Sequence[Mark], index_map: Dict[int, Mark], args: HintsCLIOptions): 117 self.text, self.index_map = text, index_map 118 self.alphabet = args.alphabet or DEFAULT_HINT_ALPHABET 119 self.colors = {'foreground': args.hints_foreground_color, 120 'background': args.hints_background_color, 121 'text': args.hints_text_color} 122 self.all_marks = all_marks 123 self.ignore_mark_indices: Set[int] = set() 124 self.args = args 125 self.window_title = args.window_title or (_('Choose URL') if args.type == 'url' else _('Choose text')) 126 self.multiple = args.multiple 127 self.match_suffix = self.get_match_suffix(args) 128 self.chosen: List[Mark] = [] 129 self.reset() 130 131 @property 132 def text_matches(self) -> List[str]: 133 return [m.text + self.match_suffix for m in self.chosen] 134 135 @property 136 def groupdicts(self) -> List[Any]: 137 return [m.groupdict for m in self.chosen] 138 139 def get_match_suffix(self, args: HintsCLIOptions) -> str: 140 if args.add_trailing_space == 'always': 141 return ' ' 142 if args.add_trailing_space == 'never': 143 return '' 144 return ' ' if args.multiple else '' 145 146 def reset(self) -> None: 147 self.current_input = '' 148 self.current_text: Optional[str] = None 149 150 def init_terminal_state(self) -> None: 151 self.cmd.set_cursor_visible(False) 152 self.cmd.set_window_title(self.window_title) 153 self.cmd.set_line_wrapping(False) 154 155 def initialize(self) -> None: 156 self.init_terminal_state() 157 self.draw_screen() 158 159 def on_text(self, text: str, in_bracketed_paste: bool = False) -> None: 160 changed = False 161 for c in text: 162 if c in self.alphabet: 163 self.current_input += c 164 changed = True 165 if changed: 166 matches = [ 167 m for idx, m in self.index_map.items() 168 if encode_hint(idx, self.alphabet).startswith(self.current_input) 169 ] 170 if len(matches) == 1: 171 self.chosen.append(matches[0]) 172 if self.multiple: 173 self.ignore_mark_indices.add(matches[0].index) 174 self.reset() 175 else: 176 self.quit_loop(0) 177 return 178 self.current_text = None 179 self.draw_screen() 180 181 def on_key(self, key_event: KeyEvent) -> None: 182 if key_event.matches('backspace'): 183 self.current_input = self.current_input[:-1] 184 self.current_text = None 185 self.draw_screen() 186 elif key_event.matches('enter') and self.current_input: 187 try: 188 idx = decode_hint(self.current_input, self.alphabet) 189 self.chosen.append(self.index_map[idx]) 190 self.ignore_mark_indices.add(idx) 191 except Exception: 192 self.current_input = '' 193 self.current_text = None 194 self.draw_screen() 195 else: 196 if self.multiple: 197 self.reset() 198 self.draw_screen() 199 else: 200 self.quit_loop(0) 201 elif key_event.matches('esc'): 202 self.quit_loop(0 if self.multiple else 1) 203 204 def on_interrupt(self) -> None: 205 self.quit_loop(1) 206 207 def on_eot(self) -> None: 208 self.quit_loop(1) 209 210 def on_resize(self, new_size: ScreenSize) -> None: 211 self.draw_screen() 212 213 def draw_screen(self) -> None: 214 if self.current_text is None: 215 self.current_text = render(self.text, self.current_input, self.all_marks, self.ignore_mark_indices, self.alphabet, self.colors) 216 self.cmd.clear_screen() 217 self.write(self.current_text) 218 219 220def regex_finditer(pat: Pattern, minimum_match_length: int, text: str) -> Generator[Tuple[int, int, Dict], None, None]: 221 has_named_groups = bool(pat.groupindex) 222 for m in pat.finditer(text): 223 s, e = m.span(0 if has_named_groups else pat.groups) 224 while e > s + 1 and text[e-1] == '\0': 225 e -= 1 226 if e - s >= minimum_match_length: 227 yield s, e, m.groupdict() 228 229 230closing_bracket_map = {'(': ')', '[': ']', '{': '}', '<': '>', '*': '*', '"': '"', "'": "'"} 231opening_brackets = ''.join(closing_bracket_map) 232PostprocessorFunc = Callable[[str, int, int], Tuple[int, int]] 233postprocessor_map: Dict[str, PostprocessorFunc] = {} 234 235 236def postprocessor(func: PostprocessorFunc) -> PostprocessorFunc: 237 postprocessor_map[func.__name__] = func 238 return func 239 240 241class InvalidMatch(Exception): 242 """Raised when a match turns out to be invalid.""" 243 pass 244 245 246@postprocessor 247def url(text: str, s: int, e: int) -> Tuple[int, int]: 248 if s > 4 and text[s - 5:s] == 'link:': # asciidoc URLs 249 url = text[s:e] 250 idx = url.rfind('[') 251 if idx > -1: 252 e -= len(url) - idx 253 while text[e - 1] in '.,?!' and e > 1: # remove trailing punctuation 254 e -= 1 255 # truncate url at closing bracket/quote 256 if s > 0 and e <= len(text) and text[s-1] in opening_brackets: 257 q = closing_bracket_map[text[s-1]] 258 idx = text.find(q, s) 259 if idx > s: 260 e = idx 261 # Restructured Text URLs 262 if e > 3 and text[e-2:e] == '`_': 263 e -= 2 264 265 return s, e 266 267 268@postprocessor 269def brackets(text: str, s: int, e: int) -> Tuple[int, int]: 270 # Remove matching brackets 271 if s < e <= len(text): 272 before = text[s] 273 if before in '({[<' and text[e-1] == closing_bracket_map[before]: 274 s += 1 275 e -= 1 276 return s, e 277 278 279@postprocessor 280def quotes(text: str, s: int, e: int) -> Tuple[int, int]: 281 # Remove matching quotes 282 if s < e <= len(text): 283 before = text[s] 284 if before in '\'"' and text[e-1] == before: 285 s += 1 286 e -= 1 287 return s, e 288 289 290@postprocessor 291def ip(text: str, s: int, e: int) -> Tuple[int, int]: 292 from ipaddress import ip_address 293 294 # Check validity of IPs (or raise InvalidMatch) 295 ip = text[s:e] 296 297 try: 298 ip_address(ip) 299 except Exception: 300 raise InvalidMatch("Invalid IP") 301 302 return s, e 303 304 305def mark(pattern: str, post_processors: Iterable[PostprocessorFunc], text: str, args: HintsCLIOptions) -> Generator[Mark, None, None]: 306 pat = re.compile(pattern) 307 for idx, (s, e, groupdict) in enumerate(regex_finditer(pat, args.minimum_match_length, text)): 308 try: 309 for func in post_processors: 310 s, e = func(text, s, e) 311 except InvalidMatch: 312 continue 313 314 mark_text = re.sub('[\r\n\0]', '', text[s:e]) 315 yield Mark(idx, s, e, mark_text, groupdict) 316 317 318def run_loop(args: HintsCLIOptions, text: str, all_marks: Sequence[Mark], index_map: Dict[int, Mark], extra_cli_args: Sequence[str] = ()) -> Dict[str, Any]: 319 loop = Loop() 320 handler = Hints(text, all_marks, index_map, args) 321 loop.loop(handler) 322 if handler.chosen and loop.return_code == 0: 323 return { 324 'match': handler.text_matches, 'programs': args.program, 325 'multiple_joiner': args.multiple_joiner, 'customize_processing': args.customize_processing, 326 'type': args.type, 'groupdicts': handler.groupdicts, 'extra_cli_args': extra_cli_args, 327 'linenum_action': args.linenum_action, 328 'cwd': os.getcwd(), 329 } 330 raise SystemExit(loop.return_code) 331 332 333def escape(chars: str) -> str: 334 return chars.replace('\\', '\\\\').replace('-', r'\-').replace(']', r'\]') 335 336 337def functions_for(args: HintsCLIOptions) -> Tuple[str, List[PostprocessorFunc]]: 338 post_processors = [] 339 if args.type == 'url': 340 if args.url_prefixes == 'default': 341 url_prefixes = kitty_common_opts()['url_prefixes'] 342 else: 343 url_prefixes = tuple(args.url_prefixes.split(',')) 344 from .url_regex import url_delimiters 345 pattern = '(?:{})://[^{}]{{3,}}'.format( 346 '|'.join(url_prefixes), url_delimiters 347 ) 348 post_processors.append(url) 349 elif args.type == 'path': 350 pattern = r'(?:\S*?/[\r\S]+)|(?:\S[\r\S]*\.[a-zA-Z0-9\r]{2,7})' 351 post_processors.extend((brackets, quotes)) 352 elif args.type == 'line': 353 pattern = '(?m)^\\s*(.+)[\\s\0]*$' 354 elif args.type == 'hash': 355 pattern = '[0-9a-f][0-9a-f\r]{6,127}' 356 elif args.type == 'ip': 357 pattern = ( 358 # # IPv4 with no validation 359 r"((?:\d{1,3}\.){3}\d{1,3}" 360 r"|" 361 # # IPv6 with no validation 362 r"(?:[a-fA-F0-9]{0,4}:){2,7}[a-fA-F0-9]{1,4})" 363 ) 364 post_processors.append(ip) 365 elif args.type == 'word': 366 chars = args.word_characters 367 if chars is None: 368 chars = kitty_common_opts()['select_by_word_characters'] 369 pattern = r'(?u)[{}\w]{{{},}}'.format(escape(chars), args.minimum_match_length) 370 post_processors.extend((brackets, quotes)) 371 else: 372 pattern = args.regex 373 return pattern, post_processors 374 375 376def convert_text(text: str, cols: int) -> str: 377 lines: List[str] = [] 378 empty_line = '\0' * cols + '\n' 379 for full_line in text.split('\n'): 380 if full_line: 381 if not full_line.rstrip('\r'): # empty lines 382 lines.extend(repeat(empty_line, len(full_line))) 383 continue 384 appended = False 385 for line in full_line.split('\r'): 386 if line: 387 lines.append(line.ljust(cols, '\0')) 388 lines.append('\r') 389 appended = True 390 if appended: 391 lines[-1] = '\n' 392 rstripped = re.sub('[\r\n]+$', '', ''.join(lines)) 393 return rstripped 394 395 396def parse_input(text: str) -> str: 397 try: 398 cols = int(os.environ['OVERLAID_WINDOW_COLS']) 399 except KeyError: 400 cols = screen_size_function()().cols 401 return convert_text(text, cols) 402 403 404def linenum_marks(text: str, args: HintsCLIOptions, Mark: Type[Mark], extra_cli_args: Sequence[str], *a: Any) -> Generator[Mark, None, None]: 405 regex = args.regex 406 if regex == DEFAULT_REGEX: 407 regex = r'(?P<path>(?:\S*/\S+?)|(?:\S+[.][a-zA-Z0-9]{2,7})):(?P<line>\d+)' 408 yield from mark(regex, [brackets, quotes], text, args) 409 410 411def load_custom_processor(customize_processing: str) -> Any: 412 if customize_processing.startswith('::import::'): 413 import importlib 414 m = importlib.import_module(customize_processing[len('::import::'):]) 415 return {k: getattr(m, k) for k in dir(m)} 416 if customize_processing == '::linenum::': 417 return {'mark': linenum_marks, 'handle_result': linenum_handle_result} 418 from kitty.constants import resolve_custom_file 419 custom_path = resolve_custom_file(customize_processing) 420 import runpy 421 return runpy.run_path(custom_path, run_name='__main__') 422 423 424def remove_sgr(text: str) -> str: 425 return re.sub(r'\x1b\[.*?m', '', text) 426 427 428def process_hyperlinks(text: str) -> Tuple[str, Tuple[Mark, ...]]: 429 hyperlinks: List[Mark] = [] 430 removed_size = idx = 0 431 active_hyperlink_url: Optional[str] = None 432 active_hyperlink_id: Optional[str] = None 433 active_hyperlink_start_offset = 0 434 435 def add_hyperlink(end: int) -> None: 436 nonlocal idx, active_hyperlink_url, active_hyperlink_id, active_hyperlink_start_offset 437 assert active_hyperlink_url is not None 438 hyperlinks.append(Mark( 439 idx, active_hyperlink_start_offset, end, 440 active_hyperlink_url, 441 groupdict={}, 442 is_hyperlink=True, group_id=active_hyperlink_id 443 )) 444 active_hyperlink_url = active_hyperlink_id = None 445 active_hyperlink_start_offset = 0 446 idx += 1 447 448 def process_hyperlink(m: 're.Match') -> str: 449 nonlocal removed_size, active_hyperlink_url, active_hyperlink_id, active_hyperlink_start_offset 450 raw = m.group() 451 start = m.start() - removed_size 452 removed_size += len(raw) 453 if active_hyperlink_url is not None: 454 add_hyperlink(start) 455 raw = raw[4:-2] 456 parts = raw.split(';', 1) 457 if len(parts) == 2 and parts[1]: 458 active_hyperlink_url = parts[1] 459 active_hyperlink_start_offset = start 460 if parts[0]: 461 for entry in parts[0].split(':'): 462 if entry.startswith('id=') and len(entry) > 3: 463 active_hyperlink_id = entry[3:] 464 break 465 466 return '' 467 468 text = re.sub(r'\x1b\]8.+?\x1b\\', process_hyperlink, text) 469 if active_hyperlink_url is not None: 470 add_hyperlink(len(text)) 471 return text, tuple(hyperlinks) 472 473 474def run(args: HintsCLIOptions, text: str, extra_cli_args: Sequence[str] = ()) -> Optional[Dict[str, Any]]: 475 try: 476 text = parse_input(remove_sgr(text)) 477 text, hyperlinks = process_hyperlinks(text) 478 pattern, post_processors = functions_for(args) 479 if args.type == 'linenum': 480 args.customize_processing = '::linenum::' 481 if args.type == 'hyperlink': 482 all_marks = hyperlinks 483 elif args.customize_processing: 484 m = load_custom_processor(args.customize_processing) 485 if 'mark' in m: 486 all_marks = tuple(m['mark'](text, args, Mark, extra_cli_args)) 487 else: 488 all_marks = tuple(mark(pattern, post_processors, text, args)) 489 else: 490 all_marks = tuple(mark(pattern, post_processors, text, args)) 491 if not all_marks: 492 none_of = {'url': 'URLs', 'hyperlink': 'hyperlinks'}.get(args.type, 'matches') 493 input(_('No {} found, press Enter to quit.').format(none_of)) 494 return None 495 496 largest_index = all_marks[-1].index 497 offset = max(0, args.hints_offset) 498 for m in all_marks: 499 if args.ascending: 500 m.index += offset 501 else: 502 m.index = largest_index - m.index + offset 503 index_map = {m.index: m for m in all_marks} 504 except Exception: 505 import traceback 506 traceback.print_exc() 507 input('Press Enter to quit.') 508 raise SystemExit(1) 509 510 return run_loop(args, text, all_marks, index_map, extra_cli_args) 511 512 513# CLI {{{ 514OPTIONS = r''' 515--program 516type=list 517What program to use to open matched text. Defaults to the default open program 518for the operating system. Use a value of :file:`-` to paste the match into the 519terminal window instead. A value of :file:`@` will copy the match to the 520clipboard. A value of :file:`*` will copy the match to the primary selection 521(on systems that support primary selections). A value of :file:`default` will 522run the default open program. Can be specified multiple times to run multiple 523programs. 524 525 526--type 527default=url 528choices=url,regex,path,line,hash,word,linenum,hyperlink,ip 529The type of text to search for. A value of :code:`linenum` is special, it looks 530for error messages using the pattern specified with :option:`--regex`, which 531must have the named groups, :code:`path` and :code:`line`. If not specified, 532will look for :code:`path:line`. The :option:`--linenum-action` option 533controls where to display the selected error message, other options are ignored. 534 535 536--regex 537default={default_regex} 538The regular expression to use when :option:`kitty +kitten hints --type`=regex. 539The regular expression is in python syntax. If you specify a numbered group in 540the regular expression only the group will be matched. This allow you to match 541text ignoring a prefix/suffix, as needed. The default expression matches lines. 542To match text over multiple lines you should prefix the regular expression with 543:code:`(?ms)`, which turns on MULTILINE and DOTALL modes for the regex engine. 544If you specify named groups and a :option:`kitty +kitten hints --program` then 545the program will be passed arguments corresponding to each named group of 546the form key=value. 547 548 549--linenum-action 550default=self 551type=choice 552choices=self,window,tab,os_window,background 553Where to perform the action on matched errors. :code:`self` means the current 554window, :code:`window` a new kitty window, :code:`tab` a new tab, 555:code:`os_window` a new OS window and :code:`background` run in the background. 556The actual action is whatever arguments are provided to the kitten, for 557example: 558:code:`kitty + kitten hints --type=linenum --linenum-action=tab vim +{line} {path}` 559will open the matched path at the matched line number in vim in 560a new kitty tab. Note that only when using :code:`self` are the special values for 561:option:`kitty +kitten hints --program` to copy/paste the text respected. 562 563 564--url-prefixes 565default=default 566Comma separated list of recognized URL prefixes. Defaults, to 567the list of prefixes defined in kitty.conf. 568 569 570--word-characters 571Characters to consider as part of a word. In addition, all characters marked as 572alphanumeric in the unicode database will be considered as word characters. 573Defaults to the select_by_word_characters setting from kitty.conf. 574 575 576--minimum-match-length 577default=3 578type=int 579The minimum number of characters to consider a match. 580 581 582--multiple 583type=bool-set 584Select multiple matches and perform the action on all of them together at the end. 585In this mode, press :kbd:`Esc` to finish selecting. 586 587 588--multiple-joiner 589default=auto 590String to use to join multiple selections when copying to the clipboard or 591inserting into the terminal. The special strings: "space", "newline", "empty", 592"json" and "auto" are interpreted as a space character, a newline an empty 593joiner, a JSON serialized list and an automatic choice, based on the type of 594text being selected. In addition, integers are interpreted as zero-based 595indices into the list of selections. You can use 0 for the first selection and 596-1 for the last. 597 598 599--add-trailing-space 600default=auto 601choices=auto,always,never 602Add trailing space after matched text. Defaults to auto, which adds the space 603when used together with --multiple. 604 605 606--hints-offset 607default=1 608type=int 609The offset (from zero) at which to start hint numbering. Note that only numbers 610greater than or equal to zero are respected. 611 612 613--alphabet 614The list of characters to use for hints. The default is to use numbers and lowercase 615English alphabets. Specify your preference as a string of characters. Note that 616unless you specify the hints offset as zero the first match will be highlighted with 617the second character you specify. 618 619 620--ascending 621type=bool-set 622Have the hints increase from top to bottom instead of decreasing from top to bottom. 623 624 625--hints-foreground-color 626default=black 627type=str 628The foreground color for hints 629 630 631--hints-background-color 632default=green 633type=str 634The background color for hints 635 636 637--hints-text-color 638default=gray 639type=str 640The foreground color for text pointed to by the hints 641 642 643--customize-processing 644Name of a python file in the kitty config directory which will be imported to provide 645custom implementations for pattern finding and performing actions 646on selected matches. See {hints_url} 647for details. You can also specify absolute paths to load the script from elsewhere. 648 649 650--window-title 651The window title for the hints window, default title is selected based on 652the type of text being hinted. 653'''.format( 654 default_regex=DEFAULT_REGEX, 655 line='{{line}}', path='{{path}}', 656 hints_url=website_url('kittens/hints'), 657).format 658help_text = 'Select text from the screen using the keyboard. Defaults to searching for URLs.' 659usage = '' 660 661 662def parse_hints_args(args: List[str]) -> Tuple[HintsCLIOptions, List[str]]: 663 return parse_args(args, OPTIONS, usage, help_text, 'kitty +kitten hints', result_class=HintsCLIOptions) 664 665 666def main(args: List[str]) -> Optional[Dict[str, Any]]: 667 text = '' 668 if sys.stdin.isatty(): 669 if '--help' not in args and '-h' not in args: 670 print('You must pass the text to be hinted on STDIN', file=sys.stderr) 671 input(_('Press Enter to quit')) 672 return None 673 else: 674 text = sys.stdin.buffer.read().decode('utf-8') 675 sys.stdin = open(os.ctermid()) 676 try: 677 opts, items = parse_hints_args(args[1:]) 678 except SystemExit as e: 679 if e.code != 0: 680 print(e.args[0], file=sys.stderr) 681 input(_('Press Enter to quit')) 682 return None 683 if items and not (opts.customize_processing or opts.type == 'linenum'): 684 print('Extra command line arguments present: {}'.format(' '.join(items)), file=sys.stderr) 685 input(_('Press Enter to quit')) 686 try: 687 return run(opts, text, items) 688 except Exception: 689 import traceback 690 traceback.print_exc() 691 input(_('Press Enter to quit')) 692 693 694def linenum_handle_result(args: List[str], data: Dict[str, Any], target_window_id: int, boss: BossType, extra_cli_args: Sequence[str], *a: Any) -> None: 695 for m, g in zip(data['match'], data['groupdicts']): 696 if m: 697 path, line = g['path'], g['line'] 698 path = os.path.expanduser(path.split(':')[-1]) 699 line = int(line) 700 break 701 else: 702 return 703 704 cmd = [x.format(path=path, line=line) for x in extra_cli_args or ('vim', '+{line}', '{path}')] 705 w = boss.window_id_map.get(target_window_id) 706 action = data['linenum_action'] 707 708 if action == 'self': 709 if w is not None: 710 is_copy_action = cmd[0] in ('-', '@', '*') 711 if is_copy_action: 712 text = ' '.join(cmd[1:]) 713 if cmd[0] == '-': 714 w.paste_bytes(text) 715 elif cmd[0] == '@': 716 set_clipboard_string(text) 717 elif cmd[0] == '*': 718 set_primary_selection(text) 719 else: 720 import shlex 721 text = ' '.join(shlex.quote(arg) for arg in cmd) 722 w.paste_bytes(text + '\r') 723 elif action == 'background': 724 import subprocess 725 subprocess.Popen(cmd, cwd=data['cwd']) 726 else: 727 getattr(boss, { 728 'window': 'new_window_with_cwd', 'tab': 'new_tab_with_cwd', 'os_window': 'new_os_window_with_cwd' 729 }[action])(*cmd) 730 731 732@result_handler(type_of_input='screen-ansi') 733def handle_result(args: List[str], data: Dict[str, Any], target_window_id: int, boss: BossType) -> None: 734 if data['customize_processing']: 735 m = load_custom_processor(data['customize_processing']) 736 if 'handle_result' in m: 737 m['handle_result'](args, data, target_window_id, boss, data['extra_cli_args']) 738 return None 739 740 programs = data['programs'] or ('default',) 741 matches: List[str] = [] 742 groupdicts = [] 743 for m, g in zip(data['match'], data['groupdicts']): 744 if m: 745 matches.append(m) 746 groupdicts.append(g) 747 joiner = data['multiple_joiner'] 748 try: 749 is_int: Optional[int] = int(joiner) 750 except Exception: 751 is_int = None 752 text_type = data['type'] 753 754 @lru_cache() 755 def joined_text() -> str: 756 if is_int is not None: 757 try: 758 return matches[is_int] 759 except IndexError: 760 return matches[-1] 761 if joiner == 'json': 762 import json 763 return json.dumps(matches, ensure_ascii=False, indent='\t') 764 if joiner == 'auto': 765 q = '\n\r' if text_type in ('line', 'url') else ' ' 766 else: 767 q = {'newline': '\n\r', 'space': ' '}.get(joiner, '') 768 return q.join(matches) 769 770 for program in programs: 771 if program == '-': 772 w = boss.window_id_map.get(target_window_id) 773 if w is not None: 774 w.paste(joined_text()) 775 elif program == '@': 776 set_clipboard_string(joined_text()) 777 elif program == '*': 778 set_primary_selection(joined_text()) 779 else: 780 cwd = data['cwd'] 781 program = None if program == 'default' else program 782 if text_type == 'hyperlink': 783 w = boss.window_id_map.get(target_window_id) 784 for m in matches: 785 if w is not None: 786 w.open_url(m, hyperlink_id=1, cwd=cwd) 787 else: 788 for m, groupdict in zip(matches, groupdicts): 789 if groupdict: 790 m = [] 791 for k, v in groupdict.items(): 792 m.append('{}={}'.format(k, v or '')) 793 boss.open_url(m, program, cwd=cwd) 794 795 796if __name__ == '__main__': 797 # Run with kitty +kitten hints 798 ans = main(sys.argv) 799 if ans: 800 print(ans) 801elif __name__ == '__doc__': 802 cd = sys.cli_docs # type: ignore 803 cd['usage'] = usage 804 cd['options'] = OPTIONS 805 cd['help_text'] = help_text 806# }}} 807