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.lru_cache(maxsize=None)
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()
manpage = subprocess.Popen(
["man", cmd], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env
)
# This is a trick to get rid of reverse line feeds
return subprocess.check_output(["col", "-b"], stdin=manpage.stdout, env=env)
@functools.lru_cache(maxsize=None)
def _man_option_string_regex():
return re.compile(
r"(?:(,\s?)|^|(\sor\s))(?P<option>-[\w]|--[\w-]+)(?=\[?(\s|,|=\w+|$))"
)
[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.replace("\n\t", "\n ")).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()
regex.findall(text)
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",
"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())
@functools.lru_cache(maxsize=10)
def _parse_man_page_options(cmd: str) -> "dict[str, tuple[str, ...]]":
path = get_man_completions_path() / f"{cmd}.json"
if path.exists():
return json.loads(path.read_text())
options = dict(generate_options_of(cmd))
path.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
def completions():
for desc, opts in _parse_man_page_options(cmd).items():
yield RichCompletion(
value=opts[-1], display=", ".join(opts), description=desc
)
return completions(), False