1# -*- coding: utf-8 -*-
2"""Tools to help interface with foreign shells, such as Bash."""
3import os
4import re
5import json
6import shlex
7import sys
8import tempfile
9import builtins
10import subprocess
11import warnings
12import functools
13import collections.abc as cabc
14
15from xonsh.lazyasd import lazyobject
16from xonsh.tools import to_bool, ensure_string
17from xonsh.platform import ON_WINDOWS, ON_CYGWIN, ON_MSYS
18
19
20COMMAND = """{seterrprevcmd}
21{prevcmd}
22echo __XONSH_ENV_BEG__
23{envcmd}
24echo __XONSH_ENV_END__
25echo __XONSH_ALIAS_BEG__
26{aliascmd}
27echo __XONSH_ALIAS_END__
28echo __XONSH_FUNCS_BEG__
29{funcscmd}
30echo __XONSH_FUNCS_END__
31{postcmd}
32{seterrpostcmd}"""
33
34DEFAULT_BASH_FUNCSCMD = r"""# get function names from declare
35declstr=$(declare -F)
36read -r -a decls <<< $declstr
37funcnames=""
38for((n=0;n<${#decls[@]};n++)); do
39  if (( $(($n % 3 )) == 2 )); then
40    # get every 3rd entry
41    funcnames="$funcnames ${decls[$n]}"
42  fi
43done
44
45# get functions locations: funcname lineno filename
46shopt -s extdebug
47namelocfilestr=$(declare -F $funcnames)
48shopt -u extdebug
49
50# print just names and files as JSON object
51read -r -a namelocfile <<< $namelocfilestr
52sep=" "
53namefile="{"
54while IFS='' read -r line || [[ -n "$line" ]]; do
55  name=${line%%"$sep"*}
56  locfile=${line#*"$sep"}
57  loc=${locfile%%"$sep"*}
58  file=${locfile#*"$sep"}
59  namefile="${namefile}\"${name}\":\"${file//\\/\\\\}\","
60done <<< "$namelocfilestr"
61if [[ "{" == "${namefile}" ]]; then
62  namefile="${namefile}}"
63else
64  namefile="${namefile%?}}"
65fi
66echo $namefile"""
67
68DEFAULT_ZSH_FUNCSCMD = """# get function names
69autoload -U is-at-least  # We'll need to version check zsh
70namefile="{"
71for name in ${(ok)functions}; do
72  # force zsh to load the func in order to get the filename,
73  # but use +X so that it isn't executed.
74  autoload +X $name || continue
75  loc=$(whence -v $name)
76  loc=${(z)loc}
77  if is-at-least 5.2; then
78    file=${loc[-1]}
79  else
80    file=${loc[7,-1]}
81  fi
82  namefile="${namefile}\\"${name}\\":\\"${(Q)file:A}\\","
83done
84if [[ "{" == "${namefile}" ]]; then
85  namefile="${namefile}}"
86else
87  namefile="${namefile%?}}"
88fi
89echo ${namefile}"""
90
91
92# mapping of shell name aliases to keys in other lookup dictionaries.
93@lazyobject
94def CANON_SHELL_NAMES():
95    return {
96        "bash": "bash",
97        "/bin/bash": "bash",
98        "zsh": "zsh",
99        "/bin/zsh": "zsh",
100        "/usr/bin/zsh": "zsh",
101        "cmd": "cmd",
102        "cmd.exe": "cmd",
103    }
104
105
106@lazyobject
107def DEFAULT_ENVCMDS():
108    return {"bash": "env", "zsh": "env", "cmd": "set"}
109
110
111@lazyobject
112def DEFAULT_ALIASCMDS():
113    return {"bash": "alias", "zsh": "alias -L", "cmd": ""}
114
115
116@lazyobject
117def DEFAULT_FUNCSCMDS():
118    return {"bash": DEFAULT_BASH_FUNCSCMD, "zsh": DEFAULT_ZSH_FUNCSCMD, "cmd": ""}
119
120
121@lazyobject
122def DEFAULT_SOURCERS():
123    return {"bash": "source", "zsh": "source", "cmd": "call"}
124
125
126@lazyobject
127def DEFAULT_TMPFILE_EXT():
128    return {"bash": ".sh", "zsh": ".zsh", "cmd": ".bat"}
129
130
131@lazyobject
132def DEFAULT_RUNCMD():
133    return {"bash": "-c", "zsh": "-c", "cmd": "/C"}
134
135
136@lazyobject
137def DEFAULT_SETERRPREVCMD():
138    return {"bash": "set -e", "zsh": "set -e", "cmd": "@echo off"}
139
140
141@lazyobject
142def DEFAULT_SETERRPOSTCMD():
143    return {"bash": "", "zsh": "", "cmd": "if errorlevel 1 exit 1"}
144
145
146@functools.lru_cache()
147def foreign_shell_data(
148    shell,
149    interactive=True,
150    login=False,
151    envcmd=None,
152    aliascmd=None,
153    extra_args=(),
154    currenv=None,
155    safe=True,
156    prevcmd="",
157    postcmd="",
158    funcscmd=None,
159    sourcer=None,
160    use_tmpfile=False,
161    tmpfile_ext=None,
162    runcmd=None,
163    seterrprevcmd=None,
164    seterrpostcmd=None,
165    show=False,
166    dryrun=False,
167):
168    """Extracts data from a foreign (non-xonsh) shells. Currently this gets
169    the environment, aliases, and functions but may be extended in the future.
170
171    Parameters
172    ----------
173    shell : str
174        The name of the shell, such as 'bash' or '/bin/sh'.
175    interactive : bool, optional
176        Whether the shell should be run in interactive mode.
177    login : bool, optional
178        Whether the shell should be a login shell.
179    envcmd : str or None, optional
180        The command to generate environment output with.
181    aliascmd : str or None, optional
182        The command to generate alias output with.
183    extra_args : tuple of str, optional
184        Additional command line options to pass into the shell.
185    currenv : tuple of items or None, optional
186        Manual override for the current environment.
187    safe : bool, optional
188        Flag for whether or not to safely handle exceptions and other errors.
189    prevcmd : str, optional
190        A command to run in the shell before anything else, useful for
191        sourcing and other commands that may require environment recovery.
192    postcmd : str, optional
193        A command to run after everything else, useful for cleaning up any
194        damage that the prevcmd may have caused.
195    funcscmd : str or None, optional
196        This is a command or script that can be used to determine the names
197        and locations of any functions that are native to the foreign shell.
198        This command should print *only* a JSON object that maps
199        function names to the filenames where the functions are defined.
200        If this is None, then a default script will attempted to be looked
201        up based on the shell name. Callable wrappers for these functions
202        will be returned in the aliases dictionary.
203    sourcer : str or None, optional
204        How to source a foreign shell file for purposes of calling functions
205        in that shell. If this is None, a default value will attempt to be
206        looked up based on the shell name.
207    use_tmpfile : bool, optional
208        This specifies if the commands are written to a tmp file or just
209        parsed directly to the shell
210    tmpfile_ext : str or None, optional
211        If tmpfile is True this sets specifies the extension used.
212    runcmd : str or None, optional
213        Command line switches to use when running the script, such as
214        -c for Bash and /C for cmd.exe.
215    seterrprevcmd : str or None, optional
216        Command that enables exit-on-error for the shell that is run at the
217        start of the script. For example, this is "set -e" in Bash. To disable
218        exit-on-error behavior, simply pass in an empty string.
219    seterrpostcmd : str or None, optional
220        Command that enables exit-on-error for the shell that is run at the end
221        of the script. For example, this is "if errorlevel 1 exit 1" in
222        cmd.exe. To disable exit-on-error behavior, simply pass in an
223        empty string.
224    show : bool, optional
225        Whether or not to display the script that will be run.
226    dryrun : bool, optional
227        Whether or not to actually run and process the command.
228
229
230    Returns
231    -------
232    env : dict
233        Dictionary of shell's environment. (None if the subproc command fails)
234    aliases : dict
235        Dictionary of shell's aliases, this includes foreign function
236        wrappers.(None if the subproc command fails)
237    """
238    cmd = [shell]
239    cmd.extend(extra_args)  # needs to come here for GNU long options
240    if interactive:
241        cmd.append("-i")
242    if login:
243        cmd.append("-l")
244    shkey = CANON_SHELL_NAMES[shell]
245    envcmd = DEFAULT_ENVCMDS.get(shkey, "env") if envcmd is None else envcmd
246    aliascmd = DEFAULT_ALIASCMDS.get(shkey, "alias") if aliascmd is None else aliascmd
247    funcscmd = DEFAULT_FUNCSCMDS.get(shkey, "echo {}") if funcscmd is None else funcscmd
248    tmpfile_ext = (
249        DEFAULT_TMPFILE_EXT.get(shkey, "sh") if tmpfile_ext is None else tmpfile_ext
250    )
251    runcmd = DEFAULT_RUNCMD.get(shkey, "-c") if runcmd is None else runcmd
252    seterrprevcmd = (
253        DEFAULT_SETERRPREVCMD.get(shkey, "") if seterrprevcmd is None else seterrprevcmd
254    )
255    seterrpostcmd = (
256        DEFAULT_SETERRPOSTCMD.get(shkey, "") if seterrpostcmd is None else seterrpostcmd
257    )
258    command = COMMAND.format(
259        envcmd=envcmd,
260        aliascmd=aliascmd,
261        prevcmd=prevcmd,
262        postcmd=postcmd,
263        funcscmd=funcscmd,
264        seterrprevcmd=seterrprevcmd,
265        seterrpostcmd=seterrpostcmd,
266    ).strip()
267    if show:
268        print(command)
269    if dryrun:
270        return None, None
271    cmd.append(runcmd)
272    if not use_tmpfile:
273        cmd.append(command)
274    else:
275        tmpfile = tempfile.NamedTemporaryFile(suffix=tmpfile_ext, delete=False)
276        tmpfile.write(command.encode("utf8"))
277        tmpfile.close()
278        cmd.append(tmpfile.name)
279    if currenv is None and hasattr(builtins, "__xonsh_env__"):
280        currenv = builtins.__xonsh_env__.detype()
281    elif currenv is not None:
282        currenv = dict(currenv)
283    try:
284        s = subprocess.check_output(
285            cmd,
286            stderr=subprocess.PIPE,
287            env=currenv,
288            # start new session to avoid hangs
289            # (doesn't work on Cygwin though)
290            start_new_session=((not ON_CYGWIN) and (not ON_MSYS)),
291            universal_newlines=True,
292        )
293    except (subprocess.CalledProcessError, FileNotFoundError):
294        if not safe:
295            raise
296        return None, None
297    finally:
298        if use_tmpfile:
299            os.remove(tmpfile.name)
300    env = parse_env(s)
301    aliases = parse_aliases(s)
302    funcs = parse_funcs(s, shell=shell, sourcer=sourcer, extra_args=extra_args)
303    aliases.update(funcs)
304    return env, aliases
305
306
307@lazyobject
308def ENV_RE():
309    return re.compile("__XONSH_ENV_BEG__\n(.*)" "__XONSH_ENV_END__", flags=re.DOTALL)
310
311
312@lazyobject
313def ENV_SPLIT_RE():
314    return re.compile("^([^=]+)=([^=]*|[^\n]*)$", flags=re.DOTALL | re.MULTILINE)
315
316
317def parse_env(s):
318    """Parses the environment portion of string into a dict."""
319    m = ENV_RE.search(s)
320    if m is None:
321        return {}
322    g1 = m.group(1)
323    g1 = g1[:-1] if g1.endswith("\n") else g1
324    env = dict(ENV_SPLIT_RE.findall(g1))
325    return env
326
327
328@lazyobject
329def ALIAS_RE():
330    return re.compile(
331        "__XONSH_ALIAS_BEG__\n(.*)" "__XONSH_ALIAS_END__", flags=re.DOTALL
332    )
333
334
335def parse_aliases(s):
336    """Parses the aliases portion of string into a dict."""
337    m = ALIAS_RE.search(s)
338    if m is None:
339        return {}
340    g1 = m.group(1)
341    items = [
342        line.split("=", 1)
343        for line in g1.splitlines()
344        if line.startswith("alias ") and "=" in line
345    ]
346    aliases = {}
347    for key, value in items:
348        try:
349            key = key[6:]  # lstrip 'alias '
350            # undo bash's weird quoting of single quotes (sh_single_quote)
351            value = value.replace("'\\''", "'")
352            # strip one single quote at the start and end of value
353            if value[0] == "'" and value[-1] == "'":
354                value = value[1:-1]
355            value = shlex.split(value)
356        except ValueError as exc:
357            warnings.warn(
358                'could not parse alias "{0}": {1!r}'.format(key, exc), RuntimeWarning
359            )
360            continue
361        aliases[key] = value
362    return aliases
363
364
365@lazyobject
366def FUNCS_RE():
367    return re.compile(
368        "__XONSH_FUNCS_BEG__\n(.+)\n" "__XONSH_FUNCS_END__", flags=re.DOTALL
369    )
370
371
372def parse_funcs(s, shell, sourcer=None, extra_args=()):
373    """Parses the funcs portion of a string into a dict of callable foreign
374    function wrappers.
375    """
376    m = FUNCS_RE.search(s)
377    if m is None:
378        return {}
379    g1 = m.group(1)
380    if ON_WINDOWS:
381        g1 = g1.replace(os.sep, os.altsep)
382    try:
383        namefiles = json.loads(g1.strip())
384    except json.decoder.JSONDecodeError as exc:
385        msg = (
386            "{0!r}\n\ncould not parse {1} functions:\n"
387            "  s  = {2!r}\n"
388            "  g1 = {3!r}\n\n"
389            "Note: you may be seeing this error if you use zsh with "
390            "prezto. Prezto overwrites GNU coreutils functions (like echo) "
391            "with its own zsh functions. Please try disabling prezto."
392        )
393        warnings.warn(msg.format(exc, shell, s, g1), RuntimeWarning)
394        return {}
395    sourcer = DEFAULT_SOURCERS.get(shell, "source") if sourcer is None else sourcer
396    funcs = {}
397    for funcname, filename in namefiles.items():
398        if funcname.startswith("_") or not filename:
399            continue  # skip private functions and invalid files
400        if not os.path.isabs(filename):
401            filename = os.path.abspath(filename)
402        wrapper = ForeignShellFunctionAlias(
403            name=funcname,
404            shell=shell,
405            sourcer=sourcer,
406            filename=filename,
407            extra_args=extra_args,
408        )
409        funcs[funcname] = wrapper
410    return funcs
411
412
413class ForeignShellFunctionAlias(object):
414    """This class is responsible for calling foreign shell functions as if
415    they were aliases. This does not currently support taking stdin.
416    """
417
418    INPUT = '{sourcer} "{filename}"\n' "{funcname} {args}\n"
419
420    def __init__(self, name, shell, filename, sourcer=None, extra_args=()):
421        """
422        Parameters
423        ----------
424        name : str
425            function name
426        shell : str
427            Name or path to shell
428        filename : str
429            Where the function is defined, path to source.
430        sourcer : str or None, optional
431            Command to source foreign files with.
432        extra_args : tuple of str, optional
433            Additional command line options to pass into the shell.
434        """
435        sourcer = DEFAULT_SOURCERS.get(shell, "source") if sourcer is None else sourcer
436        self.name = name
437        self.shell = shell
438        self.filename = filename
439        self.sourcer = sourcer
440        self.extra_args = extra_args
441
442    def __eq__(self, other):
443        if (
444            not hasattr(other, "name")
445            or not hasattr(other, "shell")
446            or not hasattr(other, "filename")
447            or not hasattr(other, "sourcer")
448            or not hasattr(other, "exta_args")
449        ):
450            return NotImplemented
451        return (
452            (self.name == other.name)
453            and (self.shell == other.shell)
454            and (self.filename == other.filename)
455            and (self.sourcer == other.sourcer)
456            and (self.extra_args == other.extra_args)
457        )
458
459    def __call__(self, args, stdin=None):
460        args, streaming = self._is_streaming(args)
461        input = self.INPUT.format(
462            sourcer=self.sourcer,
463            filename=self.filename,
464            funcname=self.name,
465            args=" ".join(args),
466        )
467        cmd = [self.shell] + list(self.extra_args) + ["-c", input]
468        env = builtins.__xonsh_env__
469        denv = env.detype()
470        if streaming:
471            subprocess.check_call(cmd, env=denv)
472            out = None
473        else:
474            out = subprocess.check_output(cmd, env=denv, stderr=subprocess.STDOUT)
475            out = out.decode(
476                encoding=env.get("XONSH_ENCODING"),
477                errors=env.get("XONSH_ENCODING_ERRORS"),
478            )
479            out = out.replace("\r\n", "\n")
480        return out
481
482    def _is_streaming(self, args):
483        """Test and modify args if --xonsh-stream is present."""
484        if "--xonsh-stream" not in args:
485            return args, False
486        args = list(args)
487        args.remove("--xonsh-stream")
488        return args, True
489
490
491@lazyobject
492def VALID_SHELL_PARAMS():
493    return frozenset(
494        [
495            "shell",
496            "interactive",
497            "login",
498            "envcmd",
499            "aliascmd",
500            "extra_args",
501            "currenv",
502            "safe",
503            "prevcmd",
504            "postcmd",
505            "funcscmd",
506            "sourcer",
507        ]
508    )
509
510
511def ensure_shell(shell):
512    """Ensures that a mapping follows the shell specification."""
513    if not isinstance(shell, cabc.MutableMapping):
514        shell = dict(shell)
515    shell_keys = set(shell.keys())
516    if not (shell_keys <= VALID_SHELL_PARAMS):
517        msg = "unknown shell keys: {0}"
518        raise KeyError(msg.format(shell_keys - VALID_SHELL_PARAMS))
519    shell["shell"] = ensure_string(shell["shell"]).lower()
520    if "interactive" in shell_keys:
521        shell["interactive"] = to_bool(shell["interactive"])
522    if "login" in shell_keys:
523        shell["login"] = to_bool(shell["login"])
524    if "envcmd" in shell_keys:
525        shell["envcmd"] = (
526            None if shell["envcmd"] is None else ensure_string(shell["envcmd"])
527        )
528    if "aliascmd" in shell_keys:
529        shell["aliascmd"] = (
530            None if shell["aliascmd"] is None else ensure_string(shell["aliascmd"])
531        )
532    if "extra_args" in shell_keys and not isinstance(shell["extra_args"], tuple):
533        shell["extra_args"] = tuple(map(ensure_string, shell["extra_args"]))
534    if "currenv" in shell_keys and not isinstance(shell["currenv"], tuple):
535        ce = shell["currenv"]
536        if isinstance(ce, cabc.Mapping):
537            ce = tuple([(ensure_string(k), v) for k, v in ce.items()])
538        elif isinstance(ce, cabc.Sequence):
539            ce = tuple([(ensure_string(k), v) for k, v in ce])
540        else:
541            raise RuntimeError("unrecognized type for currenv")
542        shell["currenv"] = ce
543    if "safe" in shell_keys:
544        shell["safe"] = to_bool(shell["safe"])
545    if "prevcmd" in shell_keys:
546        shell["prevcmd"] = ensure_string(shell["prevcmd"])
547    if "postcmd" in shell_keys:
548        shell["postcmd"] = ensure_string(shell["postcmd"])
549    if "funcscmd" in shell_keys:
550        shell["funcscmd"] = (
551            None if shell["funcscmd"] is None else ensure_string(shell["funcscmd"])
552        )
553    if "sourcer" in shell_keys:
554        shell["sourcer"] = (
555            None if shell["sourcer"] is None else ensure_string(shell["sourcer"])
556        )
557    if "seterrprevcmd" in shell_keys:
558        shell["seterrprevcmd"] = (
559            None
560            if shell["seterrprevcmd"] is None
561            else ensure_string(shell["seterrprevcmd"])
562        )
563    if "seterrpostcmd" in shell_keys:
564        shell["seterrpostcmd"] = (
565            None
566            if shell["seterrpostcmd"] is None
567            else ensure_string(shell["seterrpostcmd"])
568        )
569    return shell
570
571
572def load_foreign_envs(shells):
573    """Loads environments from foreign shells.
574
575    Parameters
576    ----------
577    shells : sequence of dicts
578        An iterable of dicts that can be passed into foreign_shell_data() as
579        keyword arguments.
580
581    Returns
582    -------
583    env : dict
584        A dictionary of the merged environments.
585    """
586    env = {}
587    for shell in shells:
588        shell = ensure_shell(shell)
589        shenv, _ = foreign_shell_data(**shell)
590        if shenv:
591            env.update(shenv)
592    return env
593
594
595def load_foreign_aliases(shells):
596    """Loads aliases from foreign shells.
597
598    Parameters
599    ----------
600    shells : sequence of dicts
601        An iterable of dicts that can be passed into foreign_shell_data() as
602        keyword arguments.
603
604    Returns
605    -------
606    aliases : dict
607        A dictionary of the merged aliases.
608    """
609    aliases = {}
610    xonsh_aliases = builtins.aliases
611    for shell in shells:
612        shell = ensure_shell(shell)
613        _, shaliases = foreign_shell_data(**shell)
614        if not builtins.__xonsh_env__.get("FOREIGN_ALIASES_OVERRIDE"):
615            shaliases = {} if shaliases is None else shaliases
616            for alias in set(shaliases) & set(xonsh_aliases):
617                del shaliases[alias]
618                if builtins.__xonsh_env__.get("XONSH_DEBUG") > 1:
619                    print(
620                        "aliases: ignoring alias {!r} of shell {!r} "
621                        "which tries to override xonsh alias."
622                        "".format(alias, shell["shell"]),
623                        file=sys.stderr,
624                    )
625        aliases.update(shaliases)
626    return aliases
627