Repository: xonsh/xonsh

Issue:
Regression in current folder tab completion (./executable)
In latest master when I type `./some_exe`, I have this turned into `some_executable`. Expected behavior: `./some_executable`

In version 0.1.6 this works well.

Regression in current folder tab completion (./executable)
In latest master when I type `./some_exe`, I have this turned into `some_executable`. Expected behavior: `./some_executable`

In version 0.1.6 this works well.



Discussion:
This is pretty annoying - It will get fixed soon.

Closed prematurely.

I am now using `completer.py` from the 0.1.6 edition in the current git version, and it works like I expect it to.  That means that this bug has been introduced between these two versions:

``` diff
--- completer-016.py    2015-05-29 22:48:23.000000000 +0200
+++ completer-git.py    2015-07-30 13:36:16.002230659 +0200
@@ -3,6 +3,7 @@
 import os
 import re
 import builtins
+import pickle
 import subprocess
 import sys

@@ -43,13 +44,14 @@
         self._alias_checksum = None
         self._path_mtime = -1
         self._cmds_cache = frozenset()
+        self._man_completer = ManCompleter()
         try:
             # FIXME this could be threaded for faster startup times
             self._load_bash_complete_funcs()
             # or we could make this lazy
             self._load_bash_complete_files()
             self.have_bash = True
-        except subprocess.CalledProcessError:
+        except (subprocess.CalledProcessError, FileNotFoundError):
             self.have_bash = False

     def complete(self, prefix, line, begidx, endidx, ctx=None):
@@ -94,13 +96,15 @@
         elif prefix.startswith('${') or prefix.startswith('@('):
             # python mode explicitly
             rtn = set()
+        elif prefix.startswith('-'):
+            return sorted(self._man_completer.option_complete(prefix, cmd))
         elif cmd not in ctx:
             if cmd == 'import' and begidx == len('import '):
                 # completing module to import
                 return sorted(self.module_complete(prefix))
             if cmd in self._all_commands():
                 # subproc mode; do path completions
-                return sorted(self.path_complete(prefix))
+                return sorted(self.path_complete(prefix, cdpath=True))
             else:
                 # if we're here, could be anything
                 rtn = set()
@@ -130,17 +134,25 @@
         if prefix == '..':
             paths.add('../')

+    def _add_cdpaths(self, paths, prefix):
+        """Completes current prefix using CDPATH"""
+        env = builtins.__xonsh_env__
+        for cdp in env.get("CDPATH", []):
+            for s in iglobpath(os.path.join(cdp, prefix) + '*'):
+                if os.path.isdir(s):
+                    paths.add(os.path.basename(s))
+
     def cmd_complete(self, cmd):
         """Completes a command name based on what is on the $PATH"""
         space = ' '
         return {s + space for s in self._all_commands() if s.startswith(cmd)}

     def module_complete(self, prefix):
-        """Completes a name of a module to import"""
+        """Completes a name of a module to import."""
         modules = set(sys.modules.keys())
         return {s for s in modules if s.startswith(prefix)}

-    def path_complete(self, prefix):
+    def path_complete(self, prefix, cdpath=False):
         """Completes based on a path name."""
         space = ' '  # intern some strings for faster appending
         slash = '/'
@@ -159,7 +171,9 @@
             paths = {s.replace(home, tilde) for s in paths}
         self._add_env(paths, prefix)
         self._add_dots(paths, prefix)
-        return paths
+        if cdpath:
+            self._add_cdpaths(paths, prefix)
+        return {os.path.normpath(s) for s in paths}

     def bash_complete(self, prefix, line, begidx, endidx):
         """Attempts BASH completion."""
@@ -224,10 +238,10 @@
         if len(inp) == 0:
             self.bash_complete_files = {}
             return
-        inp.append('shopt -s extdebug')
-        declare_f = 'declare -F '
-        inp += [declare_f + f for f in self.bash_complete_funcs.values()]
-        inp.append('shopt -u extdebug\n')
+        if self.bash_complete_funcs:
+            inp.append('shopt -s extdebug')
+            inp.append('declare -F ' + ' '.join([f for f in set(self.bash_complete_funcs.values())]))
+            inp.append('shopt -u extdebug\n')
         out = subprocess.check_output(['bash'], input='\n'.join(inp),
                                       universal_newlines=True)
         func_files = {}
@@ -268,7 +282,7 @@
             rpl = opt + '(' if callable(a) else opt
             # note that prefix[:prelen-len(attr)] != prefix[:-len(attr)]
             # when len(attr) == 0.
-            comp = prefix[:prelen-len(attr)] + rpl
+            comp = prefix[:prelen - len(attr)] + rpl
             attrs.add(comp)
         return attrs

@@ -298,3 +312,45 @@
         allcmds |= set(builtins.aliases.keys())
         self._cmds_cache = frozenset(allcmds)
         return self._cmds_cache
+
+
+OPTIONS_PATH = os.path.expanduser('~') + "/.xonsh_man_completions"
+SCRAPE_RE = re.compile(r'^(?:\s*(?:-\w|--[a-z0-9-]+)[\s,])+', re.M)
+INNER_OPTIONS_RE = re.compile(r'-\w|--[a-z0-9-]+')
+
+
+class ManCompleter(object):
+    """Helper class that loads completions derived from man pages."""
+
+    def __init__(self):
+        self._load_cached_options()
+
+    def __del__(self):
+        self._save_cached_options()
+
+    def option_complete(self, prefix, cmd):
+        """Completes an option name, basing on content of man page."""
+        if cmd not in self._options.keys():
+            try:
+                manpage = subprocess.Popen(["man", cmd], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
+                # This is a trick to get rid of reverse line feeds
+                text = subprocess.check_output(["col", "-b"], stdin=manpage.stdout).decode('utf-8')
+                scraped_text = ' '.join(SCRAPE_RE.findall(text))
+                matches = INNER_OPTIONS_RE.findall(scraped_text)
+                self._options[cmd] = matches
+            except:
+                return set()
+        return {s for s in self._options[cmd] if s.startswith(prefix)}
+
+    def _load_cached_options(self):
+        """Load options from file at startup."""
+        try:
+            with open(OPTIONS_PATH, 'rb') as f:
+                self._options = pickle.load(f)
+        except:
+            self._options = {}
+
+    def _save_cached_options(self):
+        """Save completions to file."""
+        with open(OPTIONS_PATH, 'wb') as f:
+            pickle.dump(self._options, f)
```

Here's the sinner (in line 177):

``` py
    return {os.path.normpath(s) for s in paths}
```

proof:

``` py
>>> import os
>>> os.path.normpath('./key.txt')    # this file exists in cwd
'key.txt'
>>> 
```

This is pretty annoying - It will get fixed soon.

Closed prematurely.

I am now using `completer.py` from the 0.1.6 edition in the current git version, and it works like I expect it to.  That means that this bug has been introduced between these two versions:

``` diff
--- completer-016.py    2015-05-29 22:48:23.000000000 +0200
+++ completer-git.py    2015-07-30 13:36:16.002230659 +0200
@@ -3,6 +3,7 @@
 import os
 import re
 import builtins
+import pickle
 import subprocess
 import sys

@@ -43,13 +44,14 @@
         self._alias_checksum = None
         self._path_mtime = -1
         self._cmds_cache = frozenset()
+        self._man_completer = ManCompleter()
         try:
             # FIXME this could be threaded for faster startup times
             self._load_bash_complete_funcs()
             # or we could make this lazy
             self._load_bash_complete_files()
             self.have_bash = True
-        except subprocess.CalledProcessError:
+        except (subprocess.CalledProcessError, FileNotFoundError):
             self.have_bash = False

     def complete(self, prefix, line, begidx, endidx, ctx=None):
@@ -94,13 +96,15 @@
         elif prefix.startswith('${') or prefix.startswith('@('):
             # python mode explicitly
             rtn = set()
+        elif prefix.startswith('-'):
+            return sorted(self._man_completer.option_complete(prefix, cmd))
         elif cmd not in ctx:
             if cmd == 'import' and begidx == len('import '):
                 # completing module to import
                 return sorted(self.module_complete(prefix))
             if cmd in self._all_commands():
                 # subproc mode; do path completions
-                return sorted(self.path_complete(prefix))
+                return sorted(self.path_complete(prefix, cdpath=True))
             else:
                 # if we're here, could be anything
                 rtn = set()
@@ -130,17 +134,25 @@
         if prefix == '..':
             paths.add('../')

+    def _add_cdpaths(self, paths, prefix):
+        """Completes current prefix using CDPATH"""
+        env = builtins.__xonsh_env__
+        for cdp in env.get("CDPATH", []):
+            for s in iglobpath(os.path.join(cdp, prefix) + '*'):
+                if os.path.isdir(s):
+                    paths.add(os.path.basename(s))
+
     def cmd_complete(self, cmd):
         """Completes a command name based on what is on the $PATH"""
         space = ' '
         return {s + space for s in self._all_commands() if s.startswith(cmd)}

     def module_complete(self, prefix):
-        """Completes a name of a module to import"""
+        """Completes a name of a module to import."""
         modules = set(sys.modules.keys())
         return {s for s in modules if s.startswith(prefix)}

-    def path_complete(self, prefix):
+    def path_complete(self, prefix, cdpath=False):
         """Completes based on a path name."""
         space = ' '  # intern some strings for faster appending
         slash = '/'
@@ -159,7 +171,9 @@
             paths = {s.replace(home, tilde) for s in paths}
         self._add_env(paths, prefix)
         self._add_dots(paths, prefix)
-        return paths
+        if cdpath:
+            self._add_cdpaths(paths, prefix)
+        return {os.path.normpath(s) for s in paths}

     def bash_complete(self, prefix, line, begidx, endidx):
         """Attempts BASH completion."""
@@ -224,10 +238,10 @@
         if len(inp) == 0:
             self.bash_complete_files = {}
             return
-        inp.append('shopt -s extdebug')
-        declare_f = 'declare -F '
-        inp += [declare_f + f for f in self.bash_complete_funcs.values()]
-        inp.append('shopt -u extdebug\n')
+        if self.bash_complete_funcs:
+            inp.append('shopt -s extdebug')
+            inp.append('declare -F ' + ' '.join([f for f in set(self.bash_complete_funcs.values())]))
+            inp.append('shopt -u extdebug\n')
         out = subprocess.check_output(['bash'], input='\n'.join(inp),
                                       universal_newlines=True)
         func_files = {}
@@ -268,7 +282,7 @@
             rpl = opt + '(' if callable(a) else opt
             # note that prefix[:prelen-len(attr)] != prefix[:-len(attr)]
             # when len(attr) == 0.
-            comp = prefix[:prelen-len(attr)] + rpl
+            comp = prefix[:prelen - len(attr)] + rpl
             attrs.add(comp)
         return attrs

@@ -298,3 +312,45 @@
         allcmds |= set(builtins.aliases.keys())
         self._cmds_cache = frozenset(allcmds)
         return self._cmds_cache
+
+
+OPTIONS_PATH = os.path.expanduser('~') + "/.xonsh_man_completions"
+SCRAPE_RE = re.compile(r'^(?:\s*(?:-\w|--[a-z0-9-]+)[\s,])+', re.M)
+INNER_OPTIONS_RE = re.compile(r'-\w|--[a-z0-9-]+')
+
+
+class ManCompleter(object):
+    """Helper class that loads completions derived from man pages."""
+
+    def __init__(self):
+        self._load_cached_options()
+
+    def __del__(self):
+        self._save_cached_options()
+
+    def option_complete(self, prefix, cmd):
+        """Completes an option name, basing on content of man page."""
+        if cmd not in self._options.keys():
+            try:
+                manpage = subprocess.Popen(["man", cmd], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
+                # This is a trick to get rid of reverse line feeds
+                text = subprocess.check_output(["col", "-b"], stdin=manpage.stdout).decode('utf-8')
+                scraped_text = ' '.join(SCRAPE_RE.findall(text))
+                matches = INNER_OPTIONS_RE.findall(scraped_text)
+                self._options[cmd] = matches
+            except:
+                return set()
+        return {s for s in self._options[cmd] if s.startswith(prefix)}
+
+    def _load_cached_options(self):
+        """Load options from file at startup."""
+        try:
+            with open(OPTIONS_PATH, 'rb') as f:
+                self._options = pickle.load(f)
+        except:
+            self._options = {}
+
+    def _save_cached_options(self):
+        """Save completions to file."""
+        with open(OPTIONS_PATH, 'wb') as f:
+            pickle.dump(self._options, f)
```

Here's the sinner (in line 177):

``` py
    return {os.path.normpath(s) for s in paths}
```

proof:

``` py
>>> import os
>>> os.path.normpath('./key.txt')    # this file exists in cwd
'key.txt'
>>> 
```


Resolving patch:
diff --git a/xonsh/completer.py b/xonsh/completer.py
--- a/xonsh/completer.py
+++ b/xonsh/completer.py
@@ -35,6 +35,14 @@
 """
 
 
+def _normpath(p):
+    # Prevent normpath() from removing initial ‘./’
+    here = os.curdir + os.sep
+    if p.startswith(here):
+        return os.path.join(os.curdir, os.path.normpath(p[len(here):]))
+    return os.path.normpath(p)
+
+
 class Completer(object):
     """This provides a list of optional completions for the xonsh shell."""
 
@@ -173,7 +181,7 @@ def path_complete(self, prefix, cdpath=False):
         self._add_dots(paths, prefix)
         if cdpath:
             self._add_cdpaths(paths, prefix)
-        return {os.path.normpath(s) for s in paths}
+        return {_normpath(s) for s in paths}
 
     def bash_complete(self, prefix, line, begidx, endidx):
         """Attempts BASH completion."""


Test patch: