From e76115676b2fb658eb66750979fe68d71766f818 Mon Sep 17 00:00:00 2001 From: Noorhteen Raja NJ Date: Thu, 27 Jan 2022 21:22:36 +0530 Subject: [PATCH] improve completers (#4648) * fix: pip -r appends spaces at the end modularize completing output from subproc-out * docs: * fix: flake8 * fix: failing pip comp tests * refactor: naming xonsh conflicts with actual package the IDE completions don't work. we add this naming convention instead. * feat: option to filter after completion returned this will help reduce some boilerplate, and we can enrich the filtering behaviour * feat: add gh completions * fix: filtering out completions * refactor: simplify invoking completer interface * test: add fixture for xsh with os-env * test: add tests for gh-completions * fix: flake error * fix: mypy errors and update gh completer tests * fix: handle cross-platform line endings * feat: include man,bash completer only if available * todo: improve man page completions * fix: failing man page tests * fix: py 3.7 compatibility * fix: qa error * fix: stop dir completions * feat: improve man page completions now shows descriptions, recognizes more number of options correctly * fix: update man page completions * feat: support filtering based on display as well * Update xonsh/completer.py Co-authored-by: Gil Forsyth * style: * test: xfail ptk-shell tests on windows Co-authored-by: Gil Forsyth --- news/completions-improve.rst | 23 ++++ tests/completers/test_gh.py | 21 ++++ tests/completers/test_pip_completer.py | 17 ++- tests/conftest.py | 38 +++++- tests/man1/man.1.gz | Bin 0 -> 11619 bytes tests/test_man.py | 122 ++++++++++++++---- tests/test_ptk_shell.py | 5 + tests/tools.py | 9 ++ xompletions/{xonsh.py => _xonsh.py} | 7 +- xompletions/gh.py | 25 ++++ xompletions/rmdir.py | 16 +-- xonsh/built_ins.py | 2 +- xonsh/completer.py | 108 +++++++++++----- xonsh/completers/_aliases.py | 25 ++-- xonsh/completers/commands.py | 23 ++-- xonsh/completers/init.py | 37 +++--- xonsh/completers/man.py | 167 +++++++++++++++++++------ xonsh/completers/tools.py | 117 ++++++++++++----- xontrib/fish_completer.py | 43 +------ 19 files changed, 590 insertions(+), 215 deletions(-) create mode 100644 news/completions-improve.rst create mode 100644 tests/completers/test_gh.py create mode 100644 tests/man1/man.1.gz rename xompletions/{xonsh.py => _xonsh.py} (50%) create mode 100644 xompletions/gh.py diff --git a/news/completions-improve.rst b/news/completions-improve.rst new file mode 100644 index 000000000..ee88fbab4 --- /dev/null +++ b/news/completions-improve.rst @@ -0,0 +1,23 @@ +**Added:** + +* completions from man page will now show the description for the options if available. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* ``pip`` completer now handles path completions correctly + +**Security:** + +* diff --git a/tests/completers/test_gh.py b/tests/completers/test_gh.py new file mode 100644 index 000000000..769aacd34 --- /dev/null +++ b/tests/completers/test_gh.py @@ -0,0 +1,21 @@ +import pytest + +from tests.tools import skip_if_not_has + +pytestmark = skip_if_not_has("gh") + + +@pytest.mark.parametrize( + "line, exp", + [ + ["gh rep", {"repo"}], + ["gh repo ", {"archive", "clone", "create", "delete", "edit", "fork"}], + ], +) +def test_completions(line, exp, check_completer, xsh_with_env): + # use the actual PATH from os. Otherwise subproc will fail on windows. `unintialized python...` + comps = check_completer(line, prefix=None) + + if callable(exp): + exp = exp() + assert comps.intersection(exp) diff --git a/tests/completers/test_pip_completer.py b/tests/completers/test_pip_completer.py index ed1f69578..c46ce019d 100644 --- a/tests/completers/test_pip_completer.py +++ b/tests/completers/test_pip_completer.py @@ -1,3 +1,6 @@ +import json +import subprocess + import pytest from xonsh.completers.commands import complete_xompletions @@ -37,17 +40,23 @@ def test_pip_list_re1(line): assert complete_xompletions.matcher.search_completer(line) is None +def pip_installed(): + out = subprocess.check_output(["pip", "list", "--format=json"]).decode() + pkgs = json.loads(out) + return {p["name"] for p in pkgs} + + @pytest.mark.parametrize( "line, prefix, exp", [ ["pip", "c", {"cache", "check", "config"}], - ["pip show", "", {"setuptools", "wheel", "pip"}], + ["pip show", "", pip_installed], ], ) -def test_completions(line, prefix, exp, check_completer, xession, os_env, monkeypatch): +def test_completions(line, prefix, exp, check_completer, xsh_with_env): # use the actual PATH from os. Otherwise subproc will fail on windows. `unintialized python...` - monkeypatch.setattr(xession, "env", os_env) - comps = check_completer(line, prefix=prefix) + if callable(exp): + exp = exp() assert comps.intersection(exp) diff --git a/tests/conftest.py b/tests/conftest.py index a567ea730..e9a908d5a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -224,9 +224,16 @@ def xession(mock_xonsh_session) -> XonshSession: @pytest.fixture def xsh_with_aliases(mock_xonsh_session) -> XonshSession: + """Xonsh mock-session with default set of aliases""" return mock_xonsh_session("aliases") +@pytest.fixture +def xsh_with_env(mock_xonsh_session) -> XonshSession: + """Xonsh mock-session with os.environ""" + return mock_xonsh_session("env") + + @pytest.fixture(scope="session") def completion_context_parse(): return CompletionContextParser().parse @@ -242,8 +249,35 @@ def check_completer(completer_obj): """Helper function to run completer and parse the results as set of strings""" completer = completer_obj - def _factory(line: str, prefix="", send_original=False): - completions, _ = completer.complete_line(line, prefix=prefix) + def _factory( + line: str, prefix: "None|str" = "", send_original=False, complete_fn=None + ): + """ + + Parameters + ---------- + line + prefix + send_original + if True, return the original result from the completer (e.g. RichCompletion instances ...) + complete_fn + if given, use that to get the completions + + Returns + ------- + completions as set of string if not send + """ + if prefix is not None: + line += " " + prefix + if complete_fn is None: + completions, _ = completer.complete_line(line) + else: + ctx = completer_obj.parse(line) + out = complete_fn(ctx) + if isinstance(out, tuple): + completions = out[0] + else: + completions = out values = {getattr(i, "value", i).strip() for i in completions} if send_original: diff --git a/tests/man1/man.1.gz b/tests/man1/man.1.gz new file mode 100644 index 0000000000000000000000000000000000000000..c474dea55feae4ad8ac8fe8321393e0c470dada9 GIT binary patch literal 11619 zcmV-pEu7LHiwFP!000021MPk3cN<5N=lA+6^5L~xWIq5DCEK#qEnAdCNnA^$hJ-A8 z3_rgDkRZz}R6&&m9zE{;+b4bzkw*cb?B4P2r=4k;KplBR#(m-5xM$jINdNuY-^}R3 zntW1USyL93HSVHbl`}K1o2fPJq%>`9CiH|^Hg?(6Q|nya(0ly)+q^Q%{M_Pkv&bu4 zg8`0p2e^Qy`1!kQ05^Pwo|wb{_@;L~t-%Zm^0-`^iR zxWE53U8mKV4|(hAY8a;Zv0l-%w`Nwjwkam7)|yo{vyEwKQ>|?l&eU`M^6L1#d1Wix zr)ldQP)Y_q2HI2O; z=F{Qoa#zM#(n?3K&D(=xvu}D2?myTc+$~6I z@5W|>4<6Jj=`@l2zmDO z?_erqa>qocG}ASW`5VnM9(?%poSPdWtbB2J_Wbnd-RS7#*bhsDH01c470<0 zdJ#s;oAcGeR;@WNt}Ib`os3b{%_1+$TOO>+iANFZ0Nbn2n|#q{x+>aRL!55Ds-{Be zBd~#^bjmCz201PAmKX_h3QG%6E(AuSD{w}ZT@mXfmSq4hVj{~jzqPYrcEBg>aj2ga zb1dYlnAusMhAm5axS_j#{Y21ZU0<5Iq75+r%3CZhB&}#& zJc$!F9l=no78Bb9p`gbmJE5gdWJT@m((*Hyn#Z^$oUYoupc5x%e3sEfX6Nbd-?P$` zH^XfI>FB?*!y7wY$qE!_9B{>5*fP+b%SQjo9^mOSVZB6`r87GopGoPEE9j=L+li)z5iK2smt)XqjGJxfP?k;2*s@o*(Va?A3U0 zJU`{PzQ9{AAqD544T3)okRU;a!;{iE4bO=(`BmFa_m+hG*E4z_uYZZxUlAekt{>ss z+rmu=9C>By6)mBtrsZk|6k6odrY3k#FB#-(^APHUiJlQ0X@Z?cyM25+Z|eD6zGIfJ z64>UmMNvU6=ZxU~2;$Be1);e#b=~soU*q-f802)3D88b_5Q93V_gzbG=gkbu@uQRU!Y6BKRGmzUv^!Vtduzr1ha{Th>)%()} zFhISQutdFb!e9J5hd&&R#^!8vFnaGVogR+fpB|6R_Xls@`)3>B1jm_vpFCL$a!t>AGzlw`Fc>C^+pY!1T=(S8r zu$BED9*)hyo3j&7(~-?22tg{r66~WE0)G~j6%SO#AQCF1x~?3N6DfGPIbRWJ0o7}I ztw|4N3sDntO={AFTYJ+If&nwY7>vCkj!Bb4Wh5R0A@DDH_^4=e(kj!(NntLbZLt`j z6_aMsKrjR$z^@W|(NyC>KAF;cgk5+wuUOE42}g;cB>t26nJ!wDm(0$w01SblPJhz@ zC%O!;C(LYFEQ(fm%Wr^ARkr~^ZGCP56~7%nEe+z877Vs_holDW(&o(f;&zTm&uYk0 zpm}9a8>5`!Bxw^H&!)TF9G$7|5GO!)py>Si#tKI9I&3~uEcYr?qFfjnZxy�sLL&EzK#?-5YAemq}pLlo_e5 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 literal 0 HcmV?d00001 diff --git a/tests/test_man.py b/tests/test_man.py index eb3767205..dcd597ec4 100644 --- a/tests/test_man.py +++ b/tests/test_man.py @@ -1,27 +1,107 @@ import os +import subprocess + import pytest # noqa F401 + +from tools import skip_if_on_windows, skip_if_not_on_darwin from xonsh.completers.man import complete_from_man -from tools import skip_if_on_windows - -from xonsh.parsers.completion_context import ( - CompletionContext, - CommandContext, - CommandArg, -) - @skip_if_on_windows -def test_man_completion(monkeypatch, tmpdir, xession): - tempdir = tmpdir.mkdir("test_man") - monkeypatch.setitem( - os.environ, "MANPATH", os.path.dirname(os.path.abspath(__file__)) - ) - xession.env.update({"XONSH_DATA_DIR": str(tempdir)}) - completions = complete_from_man( - CompletionContext( - CommandContext(args=(CommandArg("yes"),), arg_index=1, prefix="--") - ) - ) - assert "--version" in completions - assert "--help" in completions +@pytest.mark.parametrize( + "cmd,exp", + [ + [ + "yes", + {"--version", "--help"}, + ], + [ + "man", + { + "--all", + "--apropos", + "--ascii", + "--catman", + "--config-file", + "--debug", + "--default", + "--ditroff", + "--encoding", + "--extension", + "--global-apropos", + "--gxditview", + "--help", + "--html", + "--ignore-case", + "--local-file", + "--locale", + "--location", + "--location-cat", + "--manpath", + "--match-case", + "--names-only", + "--nh", + "--nj", + "--no-subpages", + "--pager", + "--preprocessor", + "--prompt", + "--recode", + "--regex", + "--sections", + "--systems", + "--troff", + "--troff-device", + "--update", + "--usage", + "--version", + "--warnings", + "--whatis", + "--wildcard", + }, + ], + ], +) +def test_man_completion(xession, check_completer, cmd, exp): + xession.env["MANPATH"] = os.path.dirname(os.path.abspath(__file__)) + completions = check_completer(cmd, complete_fn=complete_from_man, prefix="-") + assert completions == exp + + +@skip_if_not_on_darwin +@pytest.mark.parametrize( + "cmd,exp", + [ + [ + "ar", + { + "-L", + "-S", + "-T", + "-a", + "-b", + "-c", + "-d", + "-i", + "-m", + "-o", + "-p", + "-q", + "-r", + "-s", + "-t", + "-u", + "-x", + }, + ], + ], +) +def test_bsd_man_page_completions(xession, check_completer, cmd, exp): + proc = subprocess.run([cmd, "--version"], stderr=subprocess.PIPE) + if (cmd == "ar" and proc.returncode != 1) or ( + cmd == "man" and proc.stderr.strip() not in {b"man, version 1.6g"} + ): + pytest.skip("A different man page version is installed") + # BSD & Linux have different man page version + completions = check_completer(cmd, complete_fn=complete_from_man, prefix="-") + assert completions == exp diff --git a/tests/test_ptk_shell.py b/tests/test_ptk_shell.py index faac16266..4541c1ff1 100644 --- a/tests/test_ptk_shell.py +++ b/tests/test_ptk_shell.py @@ -9,6 +9,7 @@ import pyte from xonsh.ptk_shell.shell import tokenize_ansi from xonsh.shell import Shell +from tests.tools import ON_WINDOWS @pytest.mark.parametrize( @@ -106,6 +107,10 @@ def test_tokenize_ansi(prompt_tokens, ansi_string_parts): ["2 * 3", "6"], ], ) +@pytest.mark.xfail( + ON_WINDOWS, + reason="Recent versions use Proactor event loop. This may need some handling", +) def test_ptk_prompt(line, exp, ptk_shell, capsys): inp, out, shell = ptk_shell inp.send_text(f"{line}\nexit\n") # note: terminate with '\n' diff --git a/tests/tools.py b/tests/tools.py index 4f1560e98..c1dcf5a03 100644 --- a/tests/tools.py +++ b/tests/tools.py @@ -1,6 +1,7 @@ """Tests the xonsh lexer.""" import copy import os +import shutil import sys import ast import platform @@ -39,11 +40,19 @@ skip_if_on_unix = pytest.mark.skipif(not ON_WINDOWS, reason="Windows stuff") skip_if_on_darwin = pytest.mark.skipif(ON_DARWIN, reason="not Mac friendly") +skip_if_not_on_darwin = pytest.mark.skipif(not ON_DARWIN, reason="Mac only") + skip_if_on_travis = pytest.mark.skipif(ON_TRAVIS, reason="not Travis CI friendly") skip_if_pre_3_8 = pytest.mark.skipif(VER_FULL < (3, 8), reason="Python >= 3.8 feature") +def skip_if_not_has(exe: str): + has_exe = shutil.which(exe) + + return pytest.mark.skipif(not has_exe, reason=f"{exe} is not available.") + + def sp(cmd): return subprocess.check_output(cmd, universal_newlines=True) diff --git a/xompletions/xonsh.py b/xompletions/_xonsh.py similarity index 50% rename from xompletions/xonsh.py rename to xompletions/_xonsh.py index 3977643d5..0964552ae 100644 --- a/xompletions/xonsh.py +++ b/xompletions/_xonsh.py @@ -1,6 +1,5 @@ from xonsh.cli_utils import ArgparseCompleter -from xonsh.completers.tools import get_filter_function from xonsh.parsers.completion_context import CommandContext @@ -10,8 +9,4 @@ def xonsh_complete(command: CommandContext): from xonsh.main import parser completer = ArgparseCompleter(parser, command=command) - fltr = get_filter_function() - for comp in completer.complete(): - if fltr(comp, command.prefix): - yield comp - # todo: part of return value will have unfiltered=False/True. based on that we can use fuzzy to rank the results + return completer.complete(), False diff --git a/xompletions/gh.py b/xompletions/gh.py new file mode 100644 index 000000000..7160a8d89 --- /dev/null +++ b/xompletions/gh.py @@ -0,0 +1,25 @@ +"""Completers for gh CLI""" + +from xonsh.completers.tools import sub_proc_get_output, completion_from_cmd_output +from xonsh.parsers.completion_context import CommandContext + + +def _complete(cmd, *args): + out, _ = sub_proc_get_output(cmd, "__complete", *args) + if out: + # directives + # shellCompDirectiveError 1 + # shellCompDirectiveNoSpace 2 + # shellCompDirectiveNoFileComp 4 + # shellCompDirectiveFilterFileExt 8 + # shellCompDirectiveFilterDirs 16 + # todo: implement directive-numbers above + *lines, dir_num = out.decode().splitlines() + for ln in lines: + yield completion_from_cmd_output(ln) + + +def xonsh_complete(ctx: CommandContext): + cmd, *args = [arg.value for arg in ctx.args] + [ctx.prefix] + + return _complete(cmd, *args) diff --git a/xompletions/rmdir.py b/xompletions/rmdir.py index 0740685a2..e560d76dc 100644 --- a/xompletions/rmdir.py +++ b/xompletions/rmdir.py @@ -1,14 +1,14 @@ -from xonsh.completers.man import complete_from_man from xonsh.completers.path import complete_dir -from xonsh.parsers.completion_context import CompletionContext, CommandContext +from xonsh.parsers.completion_context import CommandContext -def xonsh_complete(command: CommandContext): +def xonsh_complete(ctx: CommandContext): """ Completion for "rmdir", includes only valid directory names. """ - opts = complete_from_man(CompletionContext(command)) - comps, lp = complete_dir(command) - if len(comps) == 0 and len(opts) == 0: - raise StopIteration - return comps | opts, lp + # if starts with the given prefix then it will get completions from man page + if not ctx.prefix.startswith("-") and ctx.arg_index > 0: + comps, lprefix = complete_dir(ctx) + if not comps: + raise StopIteration # no further file completions + return comps, lprefix diff --git a/xonsh/built_ins.py b/xonsh/built_ins.py index d552c68b2..5f03552ba 100644 --- a/xonsh/built_ins.py +++ b/xonsh/built_ins.py @@ -610,7 +610,7 @@ class XonshSession: self.modules_cache = {} self.all_jobs = {} - self.completers = default_completers() + self.completers = default_completers(self.commands_cache) self.builtins = get_default_builtins(execer) self._initial_builtin_names = frozenset(vars(self.builtins)) diff --git a/xonsh/completer.py b/xonsh/completer.py index a350452d5..ff369de2c 100644 --- a/xonsh/completer.py +++ b/xonsh/completer.py @@ -9,6 +9,7 @@ from xonsh.completers.tools import ( RichCompletion, apply_lprefix, is_exclusive_completer, + get_filter_function, ) from xonsh.built_ins import XSH from xonsh.parsers.completion_context import CompletionContext, CompletionContextParser @@ -21,18 +22,48 @@ class Completer: def __init__(self): self.context_parser = CompletionContextParser() - def complete_line(self, line: str, prefix: str = None): - """Handy wrapper to build completion-context when cursor is at the end""" - line = line.strip() - if prefix: - begidx = len(line) + 1 - endidx = begidx + len(prefix) - line = " ".join([line, prefix]) - else: - line += " " - begidx = endidx = len(line) + def parse( + self, text: str, cursor_index: "None|int" = None, ctx=None + ) -> "CompletionContext": + """Parse the given text + + Parameters + ---------- + text + multi-line text + cursor_index + position of the cursor. If not given, then it is considered to be at the end. + ctx + Execution context + """ + cursor_index = len(text) if cursor_index is None else cursor_index + return self.context_parser.parse(text, cursor_index, ctx) + + def complete_line(self, text: str): + """Handy wrapper to build command-completion-context when cursor is at the end. + + Notes + ----- + suffix is not supported; text after last space is parsed as prefix. + """ + ctx = self.parse(text) + cmd_ctx = ctx.command + if not cmd_ctx: + raise RuntimeError("Only Command context is empty") + prefix = cmd_ctx.prefix + + line = text + begidx = text.rfind(prefix) + endidx = begidx + len(prefix) + return self.complete( - prefix, line, begidx, endidx, cursor_index=len(line), multiline_text=line + prefix, + line, + begidx, + endidx, + cursor_index=len(line), + multiline_text=line, + completion_context=ctx, ) def complete( @@ -44,6 +75,7 @@ class Completer: ctx=None, multiline_text=None, cursor_index=None, + completion_context=None, ): """Complete the string, given a possible execution context. @@ -74,16 +106,16 @@ class Completer: Length of the prefix to be replaced in the completion. """ - if multiline_text is not None and cursor_index is not None: - completion_context: tp.Optional[ - CompletionContext - ] = self.context_parser.parse( + if ( + (multiline_text is not None) + and (cursor_index is not None) + and (completion_context is None) + ): + completion_context: tp.Optional[CompletionContext] = self.parse( multiline_text, cursor_index, ctx, ) - else: - completion_context = None ctx = ctx or {} return self.complete_from_context( @@ -140,6 +172,8 @@ class Completer: def generate_completions( completion_context, old_completer_args, trace: bool ) -> tp.Iterator[tp.Tuple[Completion, int]]: + filter_func = get_filter_function() + for name, func in XSH.completers.items(): try: if is_contextual_completer(func): @@ -167,24 +201,38 @@ class Completer: and completion_context is not None and completion_context.command is not None ) + + # -- set comp-defaults -- + + # the default is that the completer function filters out as necessary + # we can change that once fuzzy/substring matches are added + is_filtered = True + custom_lprefix = False + prefix = "" + if completing_contextual_command: + prefix = completion_context.command.prefix + elif old_completer_args is not None: + prefix = old_completer_args[0] + lprefix = len(prefix) + if isinstance(out, cabc.Sequence): - res, lprefix = out - custom_lprefix = True + # update comp-defaults from + res, lprefix_filtered = out + if isinstance(lprefix_filtered, bool): + is_filtered = lprefix_filtered + else: + lprefix = lprefix_filtered + custom_lprefix = True else: res = out - custom_lprefix = False - if completing_contextual_command: - lprefix = len(completion_context.command.prefix) - elif old_completer_args is not None: - lprefix = len(old_completer_args[0]) - else: - lprefix = 0 if res is None: continue items = [] for comp in res: + if (not is_filtered) and (not filter_func(comp, prefix)): + continue comp = Completer._format_completion( comp, completion_context, @@ -215,7 +263,10 @@ class Completer: print("\nTRACE COMPLETIONS: Getting completions with context:") sys.displayhook(completion_context) lprefix = 0 - completions = set() + + # using dict to keep order py3.6+ + completions = {} + query_limit = XSH.env.get("COMPLETION_QUERY_LIMIT") for comp in self.generate_completions( @@ -224,7 +275,7 @@ class Completer: trace, ): completion, lprefix = comp - completions.add(completion) + completions[completion] = None if query_limit and len(completions) >= query_limit: if trace: print( @@ -233,6 +284,7 @@ class Completer: break def sortkey(s): + # todo: should sort with prefix > substring > fuzzy return s.lstrip(''''"''').lower() # the last completer's lprefix is returned. other lprefix values are inside the RichCompletions. diff --git a/xonsh/completers/_aliases.py b/xonsh/completers/_aliases.py index caedc3a5e..437720f2e 100644 --- a/xonsh/completers/_aliases.py +++ b/xonsh/completers/_aliases.py @@ -5,10 +5,7 @@ from xonsh.completers.completer import ( remove_completer, add_one_completer, ) -from xonsh.completers.tools import ( - contextual_command_completer, - get_filter_function, -) +from xonsh.completers.tools import contextual_command_completer from xonsh.parsers.completion_context import CommandContext # for backward compatibility @@ -105,8 +102,7 @@ class CompleterAlias(xcli.ArgParserAlias): def complete( self, - line: xcli.Annotated["list[str]", xcli.Arg(nargs="...")], - prefix: "str | None" = None, + line: str, ): """Output the completions to stdout @@ -119,16 +115,18 @@ class CompleterAlias(xcli.ArgParserAlias): Examples -------- - To get completions such as `git checkout` + To get completions such as ``pip install`` - $ completer complete --prefix=check git + $ completer complete 'pip in' + + To get ``pip`` sub-commands, pass the command with a space at the end + + $ completer complete 'pip ' """ from xonsh.completer import Completer completer = Completer() - completions, prefix_length = completer.complete_line( - " ".join(line), prefix=prefix - ) + completions, prefix_length = completer.complete_line(line) self.out(f"Prefix Length: {prefix_length}") for comp in completions: @@ -172,7 +170,4 @@ def complete_aliases(command: CommandContext): return possible = completer(command=command, alias=alias) - fltr = get_filter_function() - for comp in possible: - if fltr(comp, command.prefix): - yield comp + return possible, False diff --git a/xonsh/completers/commands.py b/xonsh/completers/commands.py index e5a3f8c71..445557c00 100644 --- a/xonsh/completers/commands.py +++ b/xonsh/completers/commands.py @@ -1,3 +1,4 @@ +import contextlib import functools import importlib import importlib.util as im_util @@ -121,9 +122,7 @@ class ModuleMatcher: extra search paths to use if finding module on namespace package fails """ # list of pre-defined patterns. More can be added using the public method ``.wrap`` - self._patterns: tp.Dict[str, str] = { - r"\bx?pip(?:\d|\.)*(exe)?$": "pip", - } + self._patterns: tp.Dict[str, str] = {} self._compiled: tp.Dict[str, tp.Pattern] = {} self.contextual = True self.base = base @@ -182,13 +181,16 @@ class ModuleMatcher: return module @functools.lru_cache(maxsize=10) - def get_module(self, name): - try: - return importlib.import_module(f"{self.base}.{name}") - except ModuleNotFoundError: - file = self._find_file_path(name) - if file: - return self.import_module(file, self.base, name) + def get_module(self, module: str): + for name in [ + module, + f"_{module}", # naming convention to not clash with actual python package + ]: + with contextlib.suppress(ModuleNotFoundError): + return importlib.import_module(f"{self.base}.{name}") + file = self._find_file_path(module) + if file: + return self.import_module(file, self.base, module) def search_completer(self, cmd: str, cleaned=False): if not cleaned: @@ -221,6 +223,7 @@ class CommandCompleter: "xompletions", extra_paths=XSH.env.get("XONSH_COMPLETER_DIRS", []), ) + self._matcher.wrap(r"\bx?pip(?:\d|\.)*(exe)?$", "pip") return self._matcher @staticmethod diff --git a/xonsh/completers/init.py b/xonsh/completers/init.py index 7c7d69988..00fd650b1 100644 --- a/xonsh/completers/init.py +++ b/xonsh/completers/init.py @@ -19,23 +19,32 @@ from xonsh.completers._aliases import complete_aliases from xonsh.completers.environment import complete_environment_vars -def default_completers(): +def default_completers(cmd_cache): """Creates a copy of the default completers.""" - return collections.OrderedDict( + defaults = [ + # non-exclusive completers: + ("end_proc_tokens", complete_end_proc_tokens), + ("end_proc_keywords", complete_end_proc_keywords), + ("environment_vars", complete_environment_vars), + # exclusive completers: + ("base", complete_base), + ("skip", complete_skipper), + ("alias", complete_aliases), + ("xompleter", complete_xompletions), + ("import", complete_import), + ] + + for cmd, func in [ + ("bash", complete_from_bash), + ("man", complete_from_man), + ]: + if cmd in cmd_cache: + defaults.append((cmd, func)) + + defaults.extend( [ - # non-exclusive completers: - ("end_proc_tokens", complete_end_proc_tokens), - ("end_proc_keywords", complete_end_proc_keywords), - ("environment_vars", complete_environment_vars), - # exclusive completers: - ("base", complete_base), - ("skip", complete_skipper), - ("alias", complete_aliases), - ("xompleter", complete_xompletions), - ("import", complete_import), - ("bash", complete_from_bash), - ("man", complete_from_man), ("python", complete_python), ("path", complete_path), ] ) + return collections.OrderedDict(defaults) diff --git a/xonsh/completers/man.py b/xonsh/completers/man.py index cb440d100..db0838a61 100644 --- a/xonsh/completers/man.py +++ b/xonsh/completers/man.py @@ -1,27 +1,128 @@ -import os +import functools +import json import re -import pickle +import shutil import subprocess -import typing as tp +import textwrap +from pathlib import Path -from xonsh.parsers.completion_context import CommandContext from xonsh.built_ins import XSH -import xonsh.lazyasd as xl - -from xonsh.completers.tools import get_filter_function, contextual_command_completer - -OPTIONS: tp.Optional[tp.Dict[str, tp.Any]] = None -OPTIONS_PATH: tp.Optional[str] = None +from xonsh.completers.tools import ( + contextual_command_completer, + RichCompletion, +) +from xonsh.parsers.completion_context import CommandContext -@xl.lazyobject -def SCRAPE_RE(): - return re.compile(r"^(?:\s*(?:-\w|--[a-z0-9-]+)[\s,])+", re.M) +@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 -@xl.lazyobject -def INNER_OPTIONS_RE(): - return re.compile(r"-\w|--[a-z0-9-]+") +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