diff --git a/kittens/hints/main.py b/kittens/hints/main.py index f75369881..1140e97ab 100644 --- a/kittens/hints/main.py +++ b/kittens/hints/main.py @@ -1,437 +1,19 @@ #!/usr/bin/env python3 # License: GPL v3 Copyright: 2018, Kovid Goyal -import os -import re -import string import sys from functools import lru_cache -from gettext import gettext as _ -from itertools import repeat -from typing import Any, Callable, Dict, Generator, Iterable, Iterator, List, Optional, Pattern, Sequence, Set, Tuple, Type, cast +from typing import Any, Dict, List, Optional, Sequence, Tuple -from kitty.cli import parse_args -from kitty.cli_stub import HintsCLIOptions from kitty.clipboard import set_clipboard_string, set_primary_selection from kitty.constants import website_url -from kitty.fast_data_types import get_options, wcswidth -from kitty.key_encoding import KeyEvent -from kitty.typing import BossType, KittyCommonOpts -from kitty.utils import ScreenSize, kitty_ansi_sanitizer_pat, resolve_custom_file, screen_size_function +from kitty.fast_data_types import get_options +from kitty.typing import BossType +from kitty.utils import resolve_custom_file -from ..tui.handler import Handler, result_handler -from ..tui.loop import Loop -from ..tui.operations import faint, styled -from ..tui.utils import report_error, report_unhandled_error +from ..tui.handler import result_handler - -@lru_cache() -def kitty_common_opts() -> KittyCommonOpts: - import json - v = os.environ.get('KITTY_COMMON_OPTS') - if v: - return cast(KittyCommonOpts, json.loads(v)) - from kitty.config import common_opts_as_dict - return common_opts_as_dict() - - -DEFAULT_HINT_ALPHABET = string.digits + string.ascii_lowercase DEFAULT_REGEX = r'(?m)^\s*(.+)\s*$' -FILE_EXTENSION = r'\.(?:[a-zA-Z0-9]{2,7}|[ahcmo])(?!\.)' -PATH_REGEX = fr'(?:\S*?/[\r\S]+)|(?:\S[\r\S]*{FILE_EXTENSION})\b' -DEFAULT_LINENUM_REGEX = fr'(?P{PATH_REGEX}):(?P\d+)' - - -class Mark: - - __slots__ = ('index', 'start', 'end', 'text', 'is_hyperlink', 'group_id', 'groupdict') - - def __init__( - self, - index: int, start: int, end: int, - text: str, - groupdict: Any, - is_hyperlink: bool = False, - group_id: Optional[str] = None - ): - self.index, self.start, self.end = index, start, end - self.text = text - self.groupdict = groupdict - self.is_hyperlink = is_hyperlink - self.group_id = group_id - - def __repr__(self) -> str: - return (f'Mark(index={self.index!r}, start={self.start!r}, end={self.end!r},' - f' text={self.text!r}, groupdict={self.groupdict!r}, is_hyperlink={self.is_hyperlink!r}, group_id={self.group_id!r})') - - -@lru_cache(maxsize=2048) -def encode_hint(num: int, alphabet: str) -> str: - res = '' - d = len(alphabet) - while not res or num > 0: - num, i = divmod(num, d) - res = alphabet[i] + res - return res - - -def decode_hint(x: str, alphabet: str = DEFAULT_HINT_ALPHABET) -> int: - base = len(alphabet) - index_map = {c: i for i, c in enumerate(alphabet)} - i = 0 - for char in x: - i = i * base + index_map[char] - return i - - -def highlight_mark(m: Mark, text: str, current_input: str, alphabet: str, colors: Dict[str, str]) -> str: - hint = encode_hint(m.index, alphabet) - if current_input and not hint.startswith(current_input): - return faint(text) - hint = hint[len(current_input):] or ' ' - text = text[len(hint):] - return styled( - hint, - fg=colors['foreground'], - bg=colors['background'], - bold=True - ) + styled( - text, fg=colors['text'], fg_intense=True, bold=True - ) - - -def debug(*a: Any, **kw: Any) -> None: - from ..tui.loop import debug as d - d(*a, **kw) - - -def render(text: str, current_input: str, all_marks: Sequence[Mark], ignore_mark_indices: Set[int], alphabet: str, colors: Dict[str, str]) -> str: - for mark in reversed(all_marks): - if mark.index in ignore_mark_indices: - continue - mtext = highlight_mark(mark, text[mark.start:mark.end], current_input, alphabet, colors) - text = text[:mark.start] + mtext + text[mark.end:] - - text = text.replace('\0', '') - return re.sub('[\r\n]', '\r\n', text).rstrip() - - -class Hints(Handler): - - use_alternate_screen = False # disabled to avoid screen being blanked at exit causing flicker - overlay_ready_report_needed = True - - def __init__(self, text: str, all_marks: Sequence[Mark], index_map: Dict[int, Mark], args: HintsCLIOptions): - self.text, self.index_map = text, index_map - self.alphabet = args.alphabet or DEFAULT_HINT_ALPHABET - self.colors = {'foreground': args.hints_foreground_color, - 'background': args.hints_background_color, - 'text': args.hints_text_color} - self.all_marks = all_marks - self.ignore_mark_indices: Set[int] = set() - self.args = args - self.window_title = args.window_title or (_('Choose URL') if args.type == 'url' else _('Choose text')) - self.multiple = args.multiple - self.match_suffix = self.get_match_suffix(args) - self.chosen: List[Mark] = [] - self.reset() - - @property - def text_matches(self) -> List[str]: - return [m.text + self.match_suffix for m in self.chosen] - - @property - def groupdicts(self) -> List[Any]: - return [m.groupdict for m in self.chosen] - - def get_match_suffix(self, args: HintsCLIOptions) -> str: - if args.add_trailing_space == 'always': - return ' ' - if args.add_trailing_space == 'never': - return '' - return ' ' if args.multiple else '' - - def reset(self) -> None: - self.current_input = '' - self.current_text: Optional[str] = None - - def init_terminal_state(self) -> None: - self.cmd.set_cursor_visible(False) - self.cmd.set_window_title(self.window_title) - self.cmd.set_line_wrapping(False) - - def initialize(self) -> None: - self.init_terminal_state() - self.draw_screen() - - def on_text(self, text: str, in_bracketed_paste: bool = False) -> None: - changed = False - for c in text: - if c in self.alphabet: - self.current_input += c - changed = True - if changed: - matches = [ - m for idx, m in self.index_map.items() - if encode_hint(idx, self.alphabet).startswith(self.current_input) - ] - if len(matches) == 1: - self.chosen.append(matches[0]) - if self.multiple: - self.ignore_mark_indices.add(matches[0].index) - self.reset() - else: - self.quit_loop(0) - return - self.current_text = None - self.draw_screen() - - def on_key(self, key_event: KeyEvent) -> None: - if key_event.matches('backspace'): - self.current_input = self.current_input[:-1] - self.current_text = None - self.draw_screen() - elif (key_event.matches('enter') or key_event.matches('space')) and self.current_input: - try: - idx = decode_hint(self.current_input, self.alphabet) - self.chosen.append(self.index_map[idx]) - self.ignore_mark_indices.add(idx) - except Exception: - self.current_input = '' - self.current_text = None - self.draw_screen() - else: - if self.multiple: - self.reset() - self.draw_screen() - else: - self.quit_loop(0) - elif key_event.matches('esc'): - self.quit_loop(0 if self.multiple else 1) - - def on_interrupt(self) -> None: - self.quit_loop(1) - - def on_eot(self) -> None: - self.quit_loop(1) - - def on_resize(self, new_size: ScreenSize) -> None: - self.draw_screen() - - def draw_screen(self) -> None: - if self.current_text is None: - self.current_text = render(self.text, self.current_input, self.all_marks, self.ignore_mark_indices, self.alphabet, self.colors) - self.cmd.clear_screen() - self.write(self.current_text) - - -def regex_finditer(pat: 'Pattern[str]', minimum_match_length: int, text: str) -> Iterator[Tuple[int, int, 're.Match[str]']]: - has_named_groups = bool(pat.groupindex) - for m in pat.finditer(text): - s, e = m.span(0 if has_named_groups else pat.groups) - while e > s + 1 and text[e-1] == '\0': - e -= 1 - if e - s >= minimum_match_length: - yield s, e, m - - -closing_bracket_map = {'(': ')', '[': ']', '{': '}', '<': '>', '*': '*', '"': '"', "'": "'", "“": "”", "‘": "’"} -opening_brackets = ''.join(closing_bracket_map) -PostprocessorFunc = Callable[[str, int, int], Tuple[int, int]] -postprocessor_map: Dict[str, PostprocessorFunc] = {} - - -def postprocessor(func: PostprocessorFunc) -> PostprocessorFunc: - postprocessor_map[func.__name__] = func - return func - - -class InvalidMatch(Exception): - """Raised when a match turns out to be invalid.""" - pass - - -@postprocessor -def url(text: str, s: int, e: int) -> Tuple[int, int]: - if s > 4 and text[s - 5:s] == 'link:': # asciidoc URLs - url = text[s:e] - idx = url.rfind('[') - if idx > -1: - e -= len(url) - idx - while text[e - 1] in '.,?!' and e > 1: # remove trailing punctuation - e -= 1 - # truncate url at closing bracket/quote - if s > 0 and e <= len(text) and text[s-1] in opening_brackets: - q = closing_bracket_map[text[s-1]] - idx = text.find(q, s) - if idx > s: - e = idx - # Restructured Text URLs - if e > 3 and text[e-2:e] == '`_': - e -= 2 - - return s, e - - -@postprocessor -def brackets(text: str, s: int, e: int) -> Tuple[int, int]: - # Remove matching brackets - if s < e <= len(text): - before = text[s] - if before in '({[<': - q = closing_bracket_map[before] - if text[e-1] == q: - s += 1 - e -= 1 - elif text[e:e+1] == q: - s += 1 - return s, e - - -@postprocessor -def quotes(text: str, s: int, e: int) -> Tuple[int, int]: - # Remove matching quotes - if s < e <= len(text): - before = text[s] - if before in '\'"“‘': - q = closing_bracket_map[before] - if text[e-1] == q: - s += 1 - e -= 1 - elif text[e:e+1] == q: - s += 1 - return s, e - - -@postprocessor -def ip(text: str, s: int, e: int) -> Tuple[int, int]: - from ipaddress import ip_address - - # Check validity of IPs (or raise InvalidMatch) - ip = text[s:e] - - try: - ip_address(ip) - except Exception: - raise InvalidMatch("Invalid IP") - - return s, e - - -def mark(pattern: str, post_processors: Iterable[PostprocessorFunc], text: str, args: HintsCLIOptions) -> Iterator[Mark]: - pat = re.compile(pattern) - sanitize_pat = re.compile('[\r\n\0]') - for idx, (s, e, match_object) in enumerate(regex_finditer(pat, args.minimum_match_length, text)): - try: - for func in post_processors: - s, e = func(text, s, e) - except InvalidMatch: - continue - groupdict = match_object.groupdict() - for group_name in groupdict: - group_idx = pat.groupindex[group_name] - gs, ge = match_object.span(group_idx) - gs, ge = max(gs, s), min(ge, e) - groupdict[group_name] = sanitize_pat.sub('', text[gs:ge]) - mark_text = sanitize_pat.sub('', text[s:e]) - yield Mark(idx, s, e, mark_text, groupdict) - - -def run_loop(args: HintsCLIOptions, text: str, all_marks: Sequence[Mark], index_map: Dict[int, Mark], extra_cli_args: Sequence[str] = ()) -> Dict[str, Any]: - loop = Loop() - handler = Hints(text, all_marks, index_map, args) - loop.loop(handler) - if handler.chosen and loop.return_code == 0: - return { - 'match': handler.text_matches, 'programs': args.program, - 'multiple_joiner': args.multiple_joiner, 'customize_processing': args.customize_processing, - 'type': args.type, 'groupdicts': handler.groupdicts, 'extra_cli_args': extra_cli_args, - 'linenum_action': args.linenum_action, - 'cwd': os.getcwd(), - } - raise SystemExit(loop.return_code) - - -def escape(chars: str) -> str: - return chars.replace('\\', '\\\\').replace('-', r'\-').replace(']', r'\]') - - -def functions_for(args: HintsCLIOptions) -> Tuple[str, List[PostprocessorFunc]]: - post_processors = [] - if args.type == 'url': - if args.url_prefixes == 'default': - url_prefixes = kitty_common_opts()['url_prefixes'] - else: - url_prefixes = tuple(args.url_prefixes.split(',')) - from .url_regex import url_delimiters - pattern = '(?:{})://[^{}]{{3,}}'.format( - '|'.join(url_prefixes), url_delimiters - ) - post_processors.append(url) - elif args.type == 'path': - pattern = PATH_REGEX - post_processors.extend((brackets, quotes)) - elif args.type == 'line': - pattern = '(?m)^\\s*(.+)[\\s\0]*$' - elif args.type == 'hash': - pattern = '[0-9a-f][0-9a-f\r]{6,127}' - elif args.type == 'ip': - pattern = ( - # # IPv4 with no validation - r"((?:\d{1,3}\.){3}\d{1,3}" - r"|" - # # IPv6 with no validation - r"(?:[a-fA-F0-9]{0,4}:){2,7}[a-fA-F0-9]{1,4})" - ) - post_processors.append(ip) - elif args.type == 'word': - chars = args.word_characters - if chars is None: - chars = kitty_common_opts()['select_by_word_characters'] - pattern = fr'(?u)[{escape(chars)}\w]{{{args.minimum_match_length},}}' - post_processors.extend((brackets, quotes)) - else: - pattern = args.regex - return pattern, post_processors - - -def convert_text(text: str, cols: int) -> str: - lines: List[str] = [] - empty_line = '\0' * cols + '\n' - for full_line in text.split('\n'): - if full_line: - if not full_line.rstrip('\r'): # empty lines - lines.extend(repeat(empty_line, len(full_line))) - continue - appended = False - for line in full_line.split('\r'): - if line: - line_sz = wcswidth(line) - if line_sz < cols: - line += '\0' * (cols - line_sz) - lines.append(line) - lines.append('\r') - appended = True - if appended: - lines[-1] = '\n' - rstripped = re.sub('[\r\n]+$', '', ''.join(lines)) - return rstripped - - -def parse_input(text: str) -> str: - try: - cols = int(os.environ['OVERLAID_WINDOW_COLS']) - except KeyError: - cols = screen_size_function()().cols - return convert_text(text, cols) - - -def linenum_marks(text: str, args: HintsCLIOptions, Mark: Type[Mark], extra_cli_args: Sequence[str], *a: Any) -> Generator[Mark, None, None]: - regex = args.regex - if regex == DEFAULT_REGEX: - regex = DEFAULT_LINENUM_REGEX - yield from mark(regex, [brackets, quotes], text, args) - def load_custom_processor(customize_processing: str) -> Any: if customize_processing.startswith('::import::'): @@ -439,95 +21,12 @@ def load_custom_processor(customize_processing: str) -> Any: m = importlib.import_module(customize_processing[len('::import::'):]) return {k: getattr(m, k) for k in dir(m)} if customize_processing == '::linenum::': - return {'mark': linenum_marks, 'handle_result': linenum_handle_result} + return {'handle_result': linenum_handle_result} custom_path = resolve_custom_file(customize_processing) import runpy return runpy.run_path(custom_path, run_name='__main__') -def process_escape_codes(text: str) -> Tuple[str, Tuple[Mark, ...]]: - hyperlinks: List[Mark] = [] - removed_size = idx = 0 - active_hyperlink_url: Optional[str] = None - active_hyperlink_id: Optional[str] = None - active_hyperlink_start_offset = 0 - - def add_hyperlink(end: int) -> None: - nonlocal idx, active_hyperlink_url, active_hyperlink_id, active_hyperlink_start_offset - assert active_hyperlink_url is not None - hyperlinks.append(Mark( - idx, active_hyperlink_start_offset, end, - active_hyperlink_url, - groupdict={}, - is_hyperlink=True, group_id=active_hyperlink_id - )) - active_hyperlink_url = active_hyperlink_id = None - active_hyperlink_start_offset = 0 - idx += 1 - - def process_hyperlink(m: 're.Match[str]') -> str: - nonlocal removed_size, active_hyperlink_url, active_hyperlink_id, active_hyperlink_start_offset - raw = m.group() - if not raw.startswith('\x1b]8'): - removed_size += len(raw) - return '' - start = m.start() - removed_size - removed_size += len(raw) - if active_hyperlink_url is not None: - add_hyperlink(start) - raw = raw[4:-2] - parts = raw.split(';', 1) - if len(parts) == 2 and parts[1]: - active_hyperlink_url = parts[1] - active_hyperlink_start_offset = start - if parts[0]: - for entry in parts[0].split(':'): - if entry.startswith('id=') and len(entry) > 3: - active_hyperlink_id = entry[3:] - break - - return '' - - text = kitty_ansi_sanitizer_pat().sub(process_hyperlink, text) - if active_hyperlink_url is not None: - add_hyperlink(len(text)) - return text, tuple(hyperlinks) - - -def run(args: HintsCLIOptions, text: str, extra_cli_args: Sequence[str] = ()) -> Optional[Dict[str, Any]]: - try: - text = parse_input(text) - text, hyperlinks = process_escape_codes(text) - pattern, post_processors = functions_for(args) - if args.type == 'linenum': - args.customize_processing = '::linenum::' - if args.type == 'hyperlink': - all_marks = hyperlinks - elif args.customize_processing: - m = load_custom_processor(args.customize_processing) - if 'mark' in m: - all_marks = tuple(m['mark'](text, args, Mark, extra_cli_args)) - else: - all_marks = tuple(mark(pattern, post_processors, text, args)) - else: - all_marks = tuple(mark(pattern, post_processors, text, args)) - if not all_marks: - none_of = {'url': 'URLs', 'hyperlink': 'hyperlinks'}.get(args.type, 'matches') - report_error(_('No {} found.').format(none_of)) - return None - - largest_index = all_marks[-1].index - offset = max(0, args.hints_offset) - for m in all_marks: - if args.ascending: - m.index += offset - else: - m.index = largest_index - m.index + offset - index_map = {m.index: m for m in all_marks} - except Exception: - report_unhandled_error() - return run_loop(args, text, all_marks, index_map, extra_cli_args) - # CLI {{{ OPTIONS = r''' @@ -698,31 +197,8 @@ help_text = 'Select text from the screen using the keyboard. Defaults to searchi usage = '' -def parse_hints_args(args: List[str]) -> Tuple[HintsCLIOptions, List[str]]: - return parse_args(args, OPTIONS, usage, help_text, 'kitty +kitten hints', result_class=HintsCLIOptions) - - def main(args: List[str]) -> Optional[Dict[str, Any]]: - text = '' - if sys.stdin.isatty(): - if '--help' not in args and '-h' not in args: - report_unhandled_error('You must pass the text to be hinted on STDIN') - else: - text = sys.stdin.buffer.read().decode('utf-8') - sys.stdin = open(os.ctermid()) - try: - opts, items = parse_hints_args(args[1:]) - except SystemExit as e: - if e.code != 0: - report_unhandled_error(e.args[0]) - return None - if items and not (opts.customize_processing or opts.type == 'linenum'): - report_unhandled_error('Extra command line arguments present: {}'.format(' '.join(items))) - try: - return run(opts, text, items) - except Exception: - report_unhandled_error() - return None + raise SystemExit('Should be run as kitten hints') def linenum_process_result(data: Dict[str, Any]) -> Tuple[str, int]: @@ -775,13 +251,13 @@ def linenum_handle_result(args: List[str], data: Dict[str, Any], target_window_i }[action])(*cmd) -@result_handler(type_of_input='screen-ansi', has_ready_notification=Hints.overlay_ready_report_needed) +@result_handler(type_of_input='screen-ansi', has_ready_notification=True) def handle_result(args: List[str], data: Dict[str, Any], target_window_id: int, boss: BossType) -> None: cp = data['customize_processing'] if data['type'] == 'linenum': cp = '::linenum::' if cp: - m = load_custom_processor(data['customize_processing']) + m = load_custom_processor(cp) if 'handle_result' in m: m['handle_result'](args, data, target_window_id, boss, data['extra_cli_args']) return None diff --git a/tools/cmd/hints/main.go b/tools/cmd/hints/main.go index 53abb1e7c..810e9be19 100644 --- a/tools/cmd/hints/main.go +++ b/tools/cmd/hints/main.go @@ -318,7 +318,7 @@ func main(_ *cli.Command, o *Options, args []string) (rc int, err error) { result.Match[i] = m.Text + match_suffix result.Groupdicts[i] = m.Groupdict } - output(result) + fmt.Println(output(result)) return }