# -*- coding: utf-8 -*- #
# Copyright 2017 Google LLC. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""The local coshell module.

A coshell is an interactive non-login /bin/bash running as a coprocess. It has
the same stdin, stdout and stderr as the caller and reads command lines from a
pipe. Only one command runs at a time. ctrl-c interrupts and kills the currently
running command but does not kill the coshell. The coshell process exits when
the shell 'exit' command is executed. State is maintained by the coshell across
commands, including the current working directory and local and environment
variables. ~/.bashrc, if it exists, is sourced into the coshell at startup.
This gives the caller the opportunity to set up aliases and default
'set -o ...' shell modes.

Usage:
  cosh = coshell.Coshell()
  while True:
    command = <the next command line to run>
    try:
      command_exit_status = cosh.Run(command)
    except coshell.CoshellExitError:
      break
  coshell_exit_status = cosh.Close()

This module contains three Coshell implementations:
  * _UnixCoshell using /bin/bash
  * _MinGWCoshell using MinGW bash or git bash
  * _WindowsCoshell using cmd.exe, does not support state across commands
On the first instantiation Coshell.__init__() determines what implementation to
use. All subsequent instantiations will use the same implementation.
"""


import abc
import locale
import os
import re
import signal
import subprocess
from googlecloudsdk.core.util import encoding
import six

COSHELL_ENV = 'COSHELL'
COSHELL_VERSION = '1.1'


_GET_COMPLETIONS_INIT = r"""
# Defines functions to support completion requests to the coshell.
#
# The only coshell specific shell globals are functions prefixed by __coshell_.
# All other globals are part of the bash completion api.

__coshell_get_completions__() {
  # Prints the completions for the (partial) command line "$@" terminated by
  # a blank line sentinel. The first arg is either 'prefix' for command
  # executable completeions or 'default' for default completions.

  local command completion_function last_word next_to_last_word
  local COMP_CWORD COMP_LINE COMP_POINT COMP_WORDS COMPREPLY=()

  (( $# )) || {
    printf '\n'
    return
  }

  command=$1
  COMP_WORDS=( "$@" )

  # Get the command specific completion function.
  set -- $(complete -p "$command" 2>/dev/null)
  if (( ! $# )); then
    # Load the completion function for the command.
    _completion_loader "$command"
    set -- $(complete -p "$command" 2>/dev/null)
  fi
  # Check if it was loaded.
  if (( $# )); then
    # There is an explicit completer.
    shift $(( $# - 2 ))
    completion_function=$1
  else
    # Use the coshell default completer.
    __coshell_get_file_completions__ "${COMP_WORDS[${#COMP_WORDS[*]}-1]}"
    return
  fi

  # Set up the completion call stack -- really, this is the api?
  COMP_LINE=${COMP_WORDS[@]}
  COMP_POINT=${#COMP_LINE}

  # Index and value of the last word.
  COMP_CWORD=$(( ${#COMP_WORDS[@]} - 1 ))
  last_word=${COMP_WORDS[$COMP_CWORD]}

  # Value of the next to last word.
  if (( COMP_CWORD >= 2 )); then
    next_to_last_word=${COMP_WORDS[$((${COMP_CWORD}-1))]}
  else
    next_to_last_word=''
  fi

  # Execute the completion function. Some completers, like _python_argcomplete,
  # require $1, $2 and $3.
  if $completion_function "${command}" "${last_word}" "${next_to_last_word}" 2>/dev/null; then
    # Print the completions to stdout.
    printf '%s\n' "${COMPREPLY[@]}" ''
  else
    # Fall back to the coshell default completer on error.
    __coshell_get_file_completions__ "${COMP_WORDS[${#COMP_WORDS[@]}-1]}"
  fi
}

__coshell_get_executable_completions__() {
  # Prints the executable completions for $1 one per line, terminated by a
  # blank line sentinel.
  compgen -A command -- "$1"
  printf '\n'
}

__coshell_get_file_completions__() {
  # Prints the file completions for $1, with trailing / for dirs, one per line,
  # terminated by a blank line sentinel. We could almost use_filedir_xspec, but
  #   * it's not installed/sourced by default on some systems (like macos)
  #   * it's part of a ~2K line rc file with no clear way of slicing it out
  #   * ~ and $... are expanded in the completions
  if __coshell_var_brace_expand "$1"; then
    # ...$AB
    compgen -A variable -P "${1%\$*}\${" -S "}" -- "${1##*\$\{}"
  elif __coshell_var_plain_expand "$1"; then
    # ...${AB
    compgen -A variable -P "${1%\$*}\$" -- "${1##*\$}"
  else
    local word_raw word_exp word words=() x IFS=$'\n'
    word_raw=$1
    eval word_exp=\"$word_raw\"
    if [[ $word_exp == "$word_raw" ]]; then
      # No $... expansions, just add trailing / for dirs.
      words=( $(compgen -A file -- "$word_exp") )
      for word in ${words[@]}; do
        if [[ $word != */ ]]; then
          if [[ $word == \~* ]]; then
            eval x="$word"
          else
            x=$word
          fi
          [[ -d $x ]] && word+=/
        fi
        printf '%s\n' "$word"
      done
    else
      # $... expansions: expand for -d tests, return unexpanded completions with
      # trailing / for dirs. compgen -A file handles ~ but does not expand it,
      # too bad it doesn't do the same for $... expansions.
      local prefix_exp suffix_raw
      __coshell_suffix_raw "$word_raw"  # Sets suffix_raw.
      prefix_raw=${word_raw%"$suffix_raw"}
      prefix_exp=${word_exp%"$suffix_raw"}
      words=( $(compgen -A file "$word_exp") )
      for word in ${words[@]}; do
        [[ $word != */ && -d $word ]] && word+=/
        printf '%s\n' "${prefix_raw}${word#"$prefix_exp"}"
      done
    fi
  fi
  printf '\n'
}

__coshell_get_directory_completions__() {
  # Prints the directory completions for $1, with trailing /, one per line,
  # terminated by a blank line sentinel.
  if __coshell_var_brace_expand "$1"; then
    # ...$AB
    compgen -A variable -P "${1%\$*}\${" -S "}" -- "${1##*\$\{}"
  elif __coshell_var_plain_expand "$1"; then
    # ...${AB
    compgen -A variable -P "${1%\$*}\$" -- "${1##*\$}"
  else
    local word_raw word_exp word words=() x IFS=$'\n'
    word_raw=$1
    eval word_exp=\"$word_raw\"
    if [[ $word_exp == "$word_raw" ]]; then
      # No $... expansions, just add trailing / for dirs.
      words=( $(compgen -A directory -S/ -- "$word_exp") )
      printf '%s\n' "${words[@]}"
    else
      # $... expansions: return unexpanded completions with trailing /.
      local prefix_exp suffix_raw
      __coshell_suffix_raw "$word_raw"  # Sets suffix_raw.
      prefix_raw=${word_raw%"$suffix_raw"}
      prefix_exp=${word_exp%"$suffix_raw"}
      words=( $(compgen -A file -S/ -- "$word_exp") )
      for word in ${words[@]}; do
        printf '%s\n' "${prefix_raw}${word#"$prefix_exp"}"
      done
    fi
  fi
  printf '\n'
}

__coshell_default_completer__() {
  # The default interactive completer. Handles ~ and embedded $... expansion.
  local IFS=$'\n' completer=__coshell_get_file_completions__
  for o in "$@"; do
    case $o in
    -c) completer=__coshell_get_executable_completions__ ;;
    -d) completer=__coshell_get_directory_completions__ ;;
    esac
  done
  COMPREPLY=( $($completer "$cur") )
}

__coshell_init_completions__() {
  # Loads bash-completion if necessary.

  declare -F _completion_loader &>/dev/null || {
    source /usr/share/bash-completion/bash_completion 2>/dev/null || {
      _completion_loader() {
        return 1
      }
    }
  }

  # Defines bash version dependent functions.

  local x y

  x='${HOME}/tmp'
  y=${x##*\$?(\{)+([a-zA-Z0-90-9_])?(\})}
  if [[ $x != $y ]]; then
    # Modern bash.
    eval '
      __coshell_suffix_raw() {
        coshell_suffix_raw=${1##*\$?(\{)+([a-zA-Z0-90-9_])?(\})}
      }
    '
  else
    __coshell_suffix_raw() {
      suffix_raw=$(sed 's/.*\${*[a-zA-Z0-9_]*}*//' <<<"$1")
    }
  fi

  if eval '[[ x == *\$\{*([a-zA-Z0-90-9_]) ]]' 2>/dev/null; then
    # Modern bash.
    eval '
      __coshell_var_brace_expand() {
        [[ $1 == *\$\{*([a-zA-Z0-90-9_]) ]]
      }
      __coshell_var_plain_expand() {
        [[ $1 == *\$+([a-zA-Z0-90-9_]) ]]
      }
    '
  else
    __coshell_var_brace_expand() {
      __coshell_partial_expand=$(sed 's/.*\$\({*\)[a-zA-Z0-9_]*$/\1/' <<<"$1")
      [[ $1 && $__coshell_partial_expand == "{" ]]
    }
    __coshell_var_plain_expand() {
      __coshell_partial_expand=$(sed 's/.*\$\({*\)[a-zA-Z0-9_]*$/\1/' <<<"$1")
      [[ $1 && $__coshell_partial_expand == "" ]]
    }
  fi

  _filedir() {
    # Overrides the bash_completion function that completes internal $cur.
    __coshell_default_completer__ "$@"
  }

  _minimal() {
    # Overrides the bash_completion function that completes external COMP_WORDS.
    cur=${COMP_WORDS[$COMP_CWORD]}
    __coshell_default_completer__ "$@"
  }

  compopt() {
    # $completion_function is called by __coshell_get_file_completions__
    # outside a completion context. Any of those functions calling compopt will
    # get an annoying error and completely break completions. This override
    # ignores the errors -- the other coshell completer overrides should wash
    # them out.
    command compopt "$@" 2>/dev/null
    return 0
  }

}

__coshell_init_completions__
"""


class CoshellExitError(Exception):
  """The coshell exited."""

  def __init__(self, message, status=None):
    super(CoshellExitError, self).__init__(message)
    self.status = status


class _CoshellBase(six.with_metaclass(abc.ABCMeta, object)):
  """The local coshell base class.

  Attributes:
    _edit_mode: The coshell edit mode, one of {'emacs', 'vi'}.
    _ignore_eof: True if the coshell should ignore EOF on stdin and not exit.
    _set_modes_callback: Called when SetModesCallback() is called or when
      mutable shell modes may have changed.
    _state_is_preserved: True if shell process state is preserved across Run().
  """

  def __init__(self, state_is_preserved=True):
    self._set_modes_callback = None
    # Immutable coshell object properties.
    self._encoding = locale.getpreferredencoding()
    self._state_is_preserved = state_is_preserved
    # Mutable shell modes controlled by `set -o ...` and `set +o ...`.
    self._edit_mode = 'emacs'
    self._ignore_eof = False

  @property
  def edit_mode(self):
    return self._edit_mode

  @property
  def ignore_eof(self):
    return self._ignore_eof

  @property
  def state_is_preserved(self):
    return self._state_is_preserved

  @staticmethod
  def _ShellStatus(status):
    """Returns the shell $? status given a python Popen returncode."""
    if status is None:
      status = 0
    elif status < 0:
      status = 256 - status
    return status

  def _Decode(self, data):
    """Decodes external data if needed and returns internal string."""
    try:
      return data.decode(self._encoding)
    except (AttributeError, UnicodeError):
      return data

  def _Encode(self, string):
    """Encodes internal string if needed and returns external data."""
    try:
      return string.encode(self._encoding)
    except UnicodeError:
      return string

  def Close(self):
    """Closes the coshell connection and release any resources."""
    pass

  def SetModesCallback(self, callback):
    """Sets the callback function to be called when any mutable mode changed.

    If callback is not None then it is called immediately to initialize the
    caller.

    Args:
      callback: func() called when any mutable mode changed, None to disable.
    """
    self._set_modes_callback = callback
    if callback:
      callback()

  @abc.abstractmethod
  def Run(self, command, check_modes=True):
    """Runs command in the coshell and waits for it to complete.

    Args:
      command: The command line string to run. Must be a sytactically complete
        shell statement. Nothing is executed if there is a syntax error.
      check_modes: If True runs self._GetModes() after command has executed if
        command contains `set -o ...` or `set +o ...`.
    """
    pass

  @abc.abstractmethod
  def Interrupt(self, sig):
    """Sends the interrupt signal to the coshell."""
    pass

  def GetCompletions(self, args, prefix=False):
    """Returns the list of completion choices for args.

    Args:
      args: The list of command line argument strings to complete.
      prefix: Complete the last arg as a command prefix.
    """
    del args
    return None

  def Communicate(self, args, quote=True):
    """Runs args and returns the list of output lines, up to first empty one.

    Args:
      args: The list of command line arguments.
      quote: Shell quote args if True.

    Returns:
      The list of output lines from command args up to the first empty line.
    """
    del args
    return []


class _UnixCoshellBase(six.with_metaclass(abc.ABCMeta, _CoshellBase)):
  """The unix local coshell base class.

  Attributes:
    _shell: The coshell subprocess object.
  """

  SHELL_STATUS_EXIT = 'x'
  SHELL_STATUS_FD = 9
  SHELL_STDIN_FD = 8

  def __init__(self):
    super(_UnixCoshellBase, self).__init__()
    self.status = None
    self._status_fd = None
    self._shell = None

  @staticmethod
  def _Quote(command):
    """Quotes command in single quotes so it can be eval'd in coshell."""
    return "'{}'".format(command.replace("'", r"'\''"))

  def _Exited(self):
    """Raises the coshell exit exception."""
    try:
      self._WriteLine(':')
    except (IOError, OSError, ValueError):
      # Yeah, ValueError for IO on a closed file.
      pass
    status = self._ShellStatus(self._shell.returncode)
    raise CoshellExitError(
        'The coshell exited [status={}].'.format(status),
        status=status)

  def _ReadLine(self):
    """Reads and returns a decoded stripped line from the coshell."""
    return self._Decode(self._shell.stdout.readline()).strip()

  def _ReadStatusChar(self):
    """Reads and returns one encoded character from the coshell status fd."""
    return os.read(self._status_fd, 1)

  def _WriteLine(self, line):
    """Writes an encoded line to the coshell."""
    self._shell.communicate(self._Encode(line + '\n'))

  def _SendCommand(self, command):
    """Sends command to the coshell for execution."""
    try:
      self._shell.stdin.write(self._Encode(command + '\n'))
      self._shell.stdin.flush()
    except (IOError, OSError, ValueError):
      # Yeah, ValueError for IO on a closed file.
      self._Exited()

  def _GetStatus(self):
    """Gets the status of the last command sent to the coshell."""
    line = []
    shell_status_exit = self.SHELL_STATUS_EXIT.encode('ascii')
    while True:
      c = self._ReadStatusChar()
      if c in (None, b'\n', shell_status_exit):
        break
      line.append(c)
    status_string = self._Decode(b''.join(line))
    if not status_string.isdigit() or c == shell_status_exit:
      self._Exited()
    return int(status_string)

  def _GetModes(self):
    """Syncs the user settable modes of interest to the Coshell.

    Calls self._set_modes_callback if it was specified and any mode changed.
    """

    changed = False

    # Get the caller emacs/vi mode.
    if self.Run('set -o | grep -q "^vi.*on"', check_modes=False) == 0:
      if self._edit_mode != 'vi':
        changed = True
        self._edit_mode = 'vi'
    else:
      if self._edit_mode != 'emacs':
        changed = True
        self._edit_mode = 'emacs'

    # Get the caller ignoreeof setting.
    ignore_eof = self._ignore_eof
    self._ignore_eof = self.Run(
        'set -o | grep -q "^ignoreeof.*on"', check_modes=False) == 0
    if self._ignore_eof != ignore_eof:
      changed = True

    if changed and self._set_modes_callback:
      self._set_modes_callback()

  def GetPwd(self):
    """Gets the coshell pwd, sets local pwd, returns the pwd, None on error."""
    pwd = self.Communicate([r'printf "$PWD\n\n"'], quote=False)
    if len(pwd) == 1:
      try:
        os.chdir(pwd[0])
        return pwd[0]
      except OSError:
        pass
    return None

  def _GetUserConfigDefaults(self):
    """Consults the user shell config for defaults."""

    self._SendCommand(
        # For rc file tests.
        'COSHELL_VERSION={coshell_version};'
        # Set $? to $1.
        '_status() {{ return $1; }};'
        # .bashrc configures aliases and set -o modes. Must be done explicitly
        # because the input pipe makes bash think it's not interactive.
        '[[ -f $HOME/.bashrc ]] && source $HOME/.bashrc;'
        # The exit command hits this trap, reaped by _GetStatus() in Run().
        "trap 'echo $?{exit} >&{fdstatus}' 0;"
        # This catches interrupts so commands die while the coshell stays alive.
        'trap ":" 2;{get_completions_init}'
        .format(coshell_version=COSHELL_VERSION,
                exit=self.SHELL_STATUS_EXIT,
                fdstatus=self.SHELL_STATUS_FD,
                get_completions_init=_GET_COMPLETIONS_INIT))

    # Enable job control if supported.
    self._SendCommand('set -o monitor 2>/dev/null')

    # Enable alias expansion if supported.
    self._SendCommand('shopt -s expand_aliases 2>/dev/null')

    # Sync the user settable modes to the coshell.
    self._GetModes()

    # Set $? to 0.
    self._SendCommand('true')

  @abc.abstractmethod
  def _Run(self, command, check_modes=True):
    """Runs command in the coshell and waits for it to complete."""
    pass

  def Run(self, command, check_modes=True):
    """Runs command in the coshell and waits for it to complete."""
    status = 130  # assume the worst: 128 (had signal) + 2 (it was SIGINT)
    sigint = signal.signal(signal.SIGINT, signal.SIG_IGN)
    try:
      status = self._Run(command, check_modes=check_modes)
    except KeyboardInterrupt:
      pass
    finally:
      signal.signal(signal.SIGINT, sigint)
    return status

  def GetCompletions(self, args, prefix=False):
    """Returns the list of completion choices for args.

    Args:
      args: The list of command line argument strings to complete.
      prefix: Complete the last arg as a command prefix.

    Returns:
      The list of completions for args.
    """
    if prefix:
      completions = self.Communicate(['__coshell_get_executable_completions__',
                                      args[-1]])
    else:
      completions = self.Communicate(['__coshell_get_completions__'] + args)
    # Some shell completers return unsorted with dups -- that stops here.
    return sorted(set(completions))

  def Interrupt(self):
    """Sends the interrupt signal to the coshell."""
    self._shell.send_signal(signal.SIGINT)


class _UnixCoshell(_UnixCoshellBase):
  """The unix local coshell implementation.

  This implementation preserves coshell process state across Run().

  Attributes:
    _status_fd: The read side of the pipe where the coshell write 1 char status
      lines. The status line is used to mark the exit of the currently running
      command.
  """

  SHELL_PATH = '/bin/bash'

  def __init__(self, stdout=1, stderr=2):
    super(_UnixCoshell, self).__init__()

    # The dup/close/dup dance preserves caller fds that collide with SHELL_*_FD.

    try:
      caller_shell_status_fd = os.dup(self.SHELL_STATUS_FD)
    except OSError:
      caller_shell_status_fd = -1
    os.dup2(1, self.SHELL_STATUS_FD)

    try:
      caller_shell_stdin_fd = os.dup(self.SHELL_STDIN_FD)
    except OSError:
      caller_shell_stdin_fd = -1
    os.dup2(0, self.SHELL_STDIN_FD)

    self._status_fd, w = os.pipe()
    os.dup2(w, self.SHELL_STATUS_FD)
    os.close(w)

    # Check for an alternate coshell command.

    coshell_command_line = encoding.GetEncodedValue(os.environ, COSHELL_ENV)
    if coshell_command_line:
      shell_command = coshell_command_line.split(' ')
    else:
      shell_command = [self.SHELL_PATH]

    # Python 3 adds a restore_signals kwarg to subprocess.Popen that defaults to
    # True, and has the effect of restoring the subprocess's SIGPIPE handler to
    # the default action. Python 2, on the other hand, keeps the modified
    # SIGPIPE handler for the subprocess. The coshell relies on the latter
    # behavior.
    additional_kwargs = {} if six.PY2 else {'restore_signals': False}
    self._shell = subprocess.Popen(
        shell_command,
        env=os.environ,  # NOTE: Needed to pass mocked environ to children.
        stdin=subprocess.PIPE,
        stdout=stdout,
        stderr=stderr,
        close_fds=False,
        **additional_kwargs)

    if caller_shell_status_fd >= 0:
      os.dup2(caller_shell_status_fd, self.SHELL_STATUS_FD)
      os.close(caller_shell_status_fd)
    else:
      os.close(self.SHELL_STATUS_FD)

    if caller_shell_stdin_fd >= 0:
      os.dup2(caller_shell_stdin_fd, self.SHELL_STDIN_FD)
      os.close(caller_shell_stdin_fd)
    else:
      os.close(self.SHELL_STDIN_FD)

    self._GetUserConfigDefaults()

  def Close(self):
    """Closes the coshell connection and release any resources."""
    if self._status_fd >= 0:
      os.close(self._status_fd)
      self._status_fd = -1
    try:
      self._WriteLine('exit')  # This closes internal fds.
    except (IOError, ValueError):
      # Yeah, ValueError for IO on a closed file.
      pass
    return self._ShellStatus(self._shell.returncode)

  def _Run(self, command, check_modes=True):
    """Runs command in the coshell and waits for it to complete."""
    self._SendCommand(
        'command eval {command} <&{fdin} && echo 0 >&{fdstatus} || '
        '{{ status=$?; echo $status 1>&{fdstatus}; _status $status; }}'.format(
            command=self._Quote(command),
            fdstatus=self.SHELL_STATUS_FD,
            fdin=self.SHELL_STDIN_FD))
    status = self._GetStatus()

    # Re-check shell shared state and modes.
    if check_modes:
      if re.search(r'\bset\s+[-+]o\s+\w', command):
        self._GetModes()
      if re.search(r'\bcd\b', command):
        self.GetPwd()

    return status

  def Communicate(self, args, quote=True):
    """Runs args and returns the list of output lines, up to first empty one.

    Args:
      args: The list of command line arguments.
      quote: Shell quote args if True.

    Returns:
      The list of output lines from command args up to the first empty line.
    """
    if quote:
      command = ' '.join([self._Quote(arg) for arg in args])
    else:
      command = ' '.join(args)
    self._SendCommand('{command} >&{fdstatus}\n'.format(
        command=command, fdstatus=self.SHELL_STATUS_FD))
    lines = []
    line = []
    while True:
      try:
        c = self._ReadStatusChar()
      except (IOError, OSError, ValueError):
        # Yeah, ValueError for IO on a closed file.
        self._Exited()
      if c in (None, b'\n'):
        if not line:
          break
        lines.append(self._Decode(b''.join(line).rstrip()))
        line = []
      else:
        line.append(c)
    return lines


class _MinGWCoshell(_UnixCoshellBase):
  """The MinGW local coshell implementation.

  This implementation preserves coshell process state across Run().

  NOTE: The Windows subprocess module passes fds 0,1,2 to the child process and
  no others. It is possble to pass handles that can be converted to/from fds,
  but the child process needs to know what handles to convert back to fds. Until
  we figure out how to reconstitute handles as fds >= 3 we are stuck with
  restricting fds 0,1,2 to be /dev/tty, via shell redirection, for Run(). For
  internal communication fds 0,1 are pipes. Luckily this works for the shell
  interactive prompt. Unfortunately this fails for the test environment.
  """

  SHELL_PATH = None  # Determined by the Coshell dynamic class below.
  STDIN_PATH = '/dev/tty'
  STDOUT_PATH = '/dev/tty'

  def __init__(self):
    super(_MinGWCoshell, self).__init__()
    self._shell = self._Popen()
    self._GetUserConfigDefaults()

  def _Popen(self):
    """Mockable popen+startupinfo so we can test on Unix."""
    startupinfo = subprocess.STARTUPINFO()
    startupinfo.dWflags = subprocess.CREATE_NEW_PROCESS_GROUP
    return subprocess.Popen([self.SHELL_PATH],
                            stdin=subprocess.PIPE,
                            stdout=subprocess.PIPE,
                            startupinfo=startupinfo)

  def Close(self):
    """Closes the coshell connection and release any resources."""
    try:
      self._WriteLine('exit')  # This closes internal fds.
    except (IOError, ValueError):
      # Yeah, ValueError for IO on a closed file.
      pass
    return self._ShellStatus(self._shell.returncode)

  def _GetStatus(self):
    """Gets the status of the last command sent to the coshell."""
    status_string = self._ReadLine()
    if status_string.endswith(self.SHELL_STATUS_EXIT):
      c = self.SHELL_STATUS_EXIT
      status_string = status_string[:-1]
    else:
      c = ''
    if not status_string.isdigit() or c == self.SHELL_STATUS_EXIT:
      self._Exited()
    return int(status_string)

  def _Run(self, command, check_modes=True):
    """Runs command in the coshell and waits for it to complete."""
    self._SendCommand(
        "command eval {command} <'{stdin}' >>'{stdout}' && echo 0 || "
        "{{ status=$?; echo 1; (exit $status); }}".format(
            command=self._Quote(command),
            stdin=self.STDIN_PATH,
            stdout=self.STDOUT_PATH,
        ))
    status = self._GetStatus()

    # Re-check shell shared state and modes.
    if check_modes:
      if re.search(r'\bset\s+[-+]o\s+\w', command):
        self._GetModes()
      if re.search(r'\bcd\b', command):
        self.GetPwd()

    return status

  def Communicate(self, args, quote=True):
    """Runs args and returns the list of output lines, up to first empty one.

    Args:
      args: The list of command line arguments.
      quote: Shell quote args if True.

    Returns:
      The list of output lines from command args up to the first empty line.
    """
    if quote:
      command = ' '.join([self._Quote(arg) for arg in args])
    else:
      command = ' '.join(args)
    self._SendCommand(command + '\n')
    lines = []
    while True:
      try:
        line = self._ReadLine()
      except (IOError, OSError, ValueError):
        # Yeah, ValueError for IO on a closed file.
        self._Exited()
      if not line:
        break
      lines.append(line)
    return lines

  def Interrupt(self):
    """Sends the interrupt signal to the coshell."""
    self._shell.send_signal(signal.CTRL_C_EVENT)


class _WindowsCoshell(_CoshellBase):
  """The windows local coshell implementation.

  This implementation does not preserve shell coprocess state across Run().
  """

  def __init__(self):
    super(_WindowsCoshell, self).__init__(state_is_preserved=False)

  def Run(self, command, check_modes=False):
    """Runs command in the coshell and waits for it to complete."""
    del check_modes
    return subprocess.call(command, shell=True)

  def Interrupt(self):
    """Sends the interrupt signal to the coshell."""
    pass


def _RunningOnWindows():
  """Lightweight mockable Windows check."""
  try:
    return bool(WindowsError)
  except NameError:
    return False


class Coshell(object):
  """The local coshell implementation shim.

  This shim class delays os specific checks until the first instantiation. The
  checks are memoized in the shim class for subsequent instantiations.
  """

  _IMPLEMENTATION = None

  def __new__(cls, *args, **kwargs):
    if not cls._IMPLEMENTATION:
      if _RunningOnWindows():
        cls._IMPLEMENTATION = _WindowsCoshell
        # We do an explicit search rather than PATH lookup because:
        # (1) It's not clear that a git or MinGW installation automatically
        #     sets up PATH to point to sh.exe.
        # (2) Picking up any old sh.exe on PATH on a Windows system is dicey.
        for shell in [r'C:\MinGW\bin\sh.exe',
                      r'C:\Program Files\Git\bin\sh.exe']:
          if os.path.isfile(shell):
            cls._IMPLEMENTATION = _MinGWCoshell
            cls._IMPLEMENTATION.SHELL_PATH = shell
            break
      else:
        cls._IMPLEMENTATION = _UnixCoshell
    obj = cls._IMPLEMENTATION.__new__(cls._IMPLEMENTATION, *args, **kwargs)
    obj.__init__()  # The docs say this is unnecessary.
    return obj
