Source code for xonsh.completers.man

import functools
import json
import re
import shutil
import subprocess
import textwrap
from pathlib import Path

from xonsh.built_ins import XSH
from xonsh.completers.tools import RichCompletion, contextual_command_completer
from xonsh.parsers.completion_context import CommandContext


[docs] @functools.cache def get_man_completions_path() -> Path: env = XSH.env or {} datadir = Path(env["XONSH_DATA_DIR"]) / "generated_completions" / "man" if datadir.exists() and (not datadir.is_dir()): shutil.move(datadir, datadir.with_suffix(".bkp")) if not datadir.exists(): datadir.mkdir(exist_ok=True, parents=True) return datadir
def _get_man_page(cmd: str): """without control characters""" env = XSH.env.detype() # Use context manager to ensure man's Popen is waited on and its # stdout fd is closed. Without this, the man process becomes a # zombie (never reaped) and the pipe fd leaks. with subprocess.Popen( ["man", cmd], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env ) as manpage: # This is a trick to get rid of reverse line feeds result = subprocess.check_output(["col", "-b"], stdin=manpage.stdout, env=env) if manpage.stdout: manpage.stdout.close() manpage.wait() return result @functools.cache def _man_option_string_regex(): return re.compile( r"(?:(,\s?)|^|(\sor\s))(?P<option>-[\w]|--[\w-]+)(?=\[?(\s|,|=|$))" )
[docs] def generate_options_of(cmd: str): out = _get_man_page(cmd) if not out: return def get_headers(text: str): """split as header-body based on indent""" if not text: return header = "" body = [] for line in textwrap.dedent(text.expandtabs(8)).splitlines(): if not line.strip(): continue if line.startswith((" ", "\t")): body.append(line) else: if header or body: yield header, body # found new section header = line.strip() body = [] if header or body: yield header, body def split_options_string(text: str): text = text.strip() regex = _man_option_string_regex() options = [] for match in regex.finditer(text): option = match.groupdict().pop("option", None) if option: options.append(option) text = text[match.end() :] return options, text.strip() def get_option_section(): option_sect = dict(get_headers(out.decode())) small_names = {k.lower(): k for k in option_sect} for head in ( "options", "command options", "command line options", "description", ): # prefer sections in this order if head in small_names: title = small_names[head] return "\n".join(option_sect[title]) def get_options(text): """finally get the options""" # return old section if for opt, lines in get_headers(text): # todo: some have [+-] or such vague notations if opt.startswith("-"): # sometime a single line will have both desc and options option_strings, rest = split_options_string(opt) descs = [] if rest: descs.append(rest) if lines: descs.append(textwrap.dedent("\n".join(lines))) if option_strings: yield ". ".join(descs), tuple(option_strings) elif lines: # sometimes the options are nested inside subheaders yield from get_options("\n".join(lines)) yield from get_options(get_option_section())
def _man_page_path(cmd: str) -> "Path | None": """Return the file path of cmd's man page, or None.""" try: out = subprocess.check_output( ["man", "-w", cmd], stderr=subprocess.DEVNULL, text=True ) p = Path(out.strip()) return p if p.exists() else None except (subprocess.CalledProcessError, OSError): return None @functools.lru_cache(maxsize=10) def _parse_man_page_options(cmd: str) -> "dict[str, tuple[str, ...]]": cache = get_man_completions_path() / Path(cmd).with_suffix(".json").name if cache.exists(): # Invalidate if the man page is newer than the cached JSON. man = _man_page_path(cmd) if man is None or man.stat().st_mtime <= cache.stat().st_mtime: return json.loads(cache.read_text()) options = dict(generate_options_of(cmd)) cache.write_text(json.dumps(options)) return options
[docs] @contextual_command_completer def complete_from_man(context: CommandContext): """ Completes an option name, based on the contents of the associated man page. """ if context.arg_index == 0 or not context.prefix.startswith("-"): return cmd = context.args[0].value # Tools like cargo, docker use per-subcommand man pages # (e.g. cargo-build). Try the hyphenated form first. if context.arg_index >= 2: subcmd_man = f"{cmd}-{context.args[1].value}" if _man_page_path(subcmd_man) is not None: cmd = subcmd_man def completions(): for desc, opts in _parse_man_page_options(cmd).items(): yield RichCompletion( value=opts[-1], display=", ".join(opts), description=desc, provider=f"man:{cmd}", ) return completions(), False