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