#!/usr/bin/env python # License: GPLv3 Copyright: 2021, Kovid Goyal import os import re import subprocess from collections.abc import Callable from contextlib import suppress from typing import Iterable from .constants import shell_integration_dir from .fast_data_types import get_options from .options.types import Options, defaults from .types import run_once from .utils import log_error, which def setup_fish_env(env: dict[str, str], argv: list[str]) -> None: val = env.get('XDG_DATA_DIRS') env['KITTY_FISH_XDG_DATA_DIR'] = shell_integration_dir if not val: env['XDG_DATA_DIRS'] = shell_integration_dir else: dirs = list(filter(None, val.split(os.pathsep))) dirs.insert(0, shell_integration_dir) env['XDG_DATA_DIRS'] = os.pathsep.join(dirs) def is_new_zsh_install(env: dict[str, str], zdotdir: str | None) -> bool: # if ZDOTDIR is empty, zsh will read user rc files from / # if there aren't any, it'll run zsh-newuser-install # the latter will bail if there are rc files in $HOME if not zdotdir: zdotdir = env.get('HOME', os.path.expanduser('~')) assert isinstance(zdotdir, str) if zdotdir == '~': return True for q in ('.zshrc', '.zshenv', '.zprofile', '.zlogin'): if os.path.exists(os.path.join(zdotdir, q)): return False return True def get_zsh_zdotdir_from_global_zshenv(env: dict[str, str], argv: list[str]) -> str | None: exe = which(argv[0], only_system=True) or 'zsh' with suppress(Exception): return subprocess.check_output([exe, '--norcs', '--interactive', '-c', 'echo -n $ZDOTDIR'], env=env).decode('utf-8') return None def setup_zsh_env(env: dict[str, str], argv: list[str]) -> None: zdotdir = env.get('ZDOTDIR') if is_new_zsh_install(env, zdotdir): if zdotdir is None: # Try to get ZDOTDIR from /etc/zshenv, when all startup files are not present zdotdir = get_zsh_zdotdir_from_global_zshenv(env, argv) if zdotdir is None or is_new_zsh_install(env, zdotdir): return else: # dont prevent zsh-newuser-install from running # zsh-newuser-install never runs as root but we assume that it does return if zdotdir is not None: env['KITTY_ORIG_ZDOTDIR'] = zdotdir else: # KITTY_ORIG_ZDOTDIR can be set at this point if, for example, the global # zshenv overrides ZDOTDIR; we try to limit the damage in this case env.pop('KITTY_ORIG_ZDOTDIR', None) env['ZDOTDIR'] = os.path.join(shell_integration_dir, 'zsh') def setup_bash_env(env: dict[str, str], argv: list[str]) -> None: inject = {'1'} posix_env = rcfile = '' remove_args = set() expecting_multi_chars_opt = True expecting_option_arg = False interactive_opt = False expecting_file_arg = False file_arg_set = False for i in range(1, len(argv)): arg = argv[i] if expecting_file_arg: file_arg_set = True break if expecting_option_arg: expecting_option_arg = False continue if arg in ('-', '--'): if not expecting_file_arg: expecting_file_arg = True continue elif len(arg) > 1 and arg[1] != '-' and (arg[0] == '-' or arg.startswith('+O')): expecting_multi_chars_opt = False options = arg.lstrip('-+') # shopt option if 'O' in options: t = options.split('O', maxsplit=1) if not t[1]: expecting_option_arg = True options = t[0] # command string if 'c' in options: # non-interactive shell # also skip `bash -ic` interactive mode with command string return # read from stdin and follow with args if 's' in options: break # interactive option if 'i' in options: interactive_opt = True elif arg.startswith('--') and expecting_multi_chars_opt: if arg == '--posix': inject.add('posix') posix_env = env.get('ENV', '') remove_args.add(i) elif arg == '--norc': inject.add('no-rc') remove_args.add(i) elif arg == '--noprofile': inject.add('no-profile') remove_args.add(i) elif arg in ('--rcfile', '--init-file') and i + 1 < len(argv): expecting_option_arg = True rcfile = argv[i+1] remove_args |= {i, i+1} else: file_arg_set = True break if file_arg_set and not interactive_opt: # non-interactive shell return env['ENV'] = os.path.join(shell_integration_dir, 'bash', 'kitty.bash') env['KITTY_BASH_INJECT'] = ' '.join(inject) if posix_env: env['KITTY_BASH_POSIX_ENV'] = posix_env if rcfile: env['KITTY_BASH_RCFILE'] = rcfile for i in sorted(remove_args, reverse=True): del argv[i] if 'HISTFILE' not in env and 'posix' not in inject: # In POSIX mode the default history file is ~/.sh_history instead of ~/.bash_history env['HISTFILE'] = os.path.expanduser('~/.bash_history') env['KITTY_BASH_UNEXPORT_HISTFILE'] = '1' argv.insert(1, '--posix') def as_str_literal(x: str) -> str: parts = x.split("'") return '"\'"'.join(f"'{x}'" for x in parts) def as_fish_str_literal(x: str) -> str: x = x.replace('\\', '\\\\').replace("'", "\\'") return f"'{x}'" def posix_serialize_env(env: dict[str, str], prefix: str = 'builtin export', sep: str = '=') -> str: ans = [] for k, v in env.items(): ans.append(f'{prefix} {as_str_literal(k)}{sep}{as_str_literal(v)}') return '\n'.join(ans) def fish_serialize_env(env: dict[str, str]) -> str: ans = [] for k, v in env.items(): ans.append(f'set -gx {as_fish_str_literal(k)} {as_fish_str_literal(v)}') return '\n'.join(ans) ENV_MODIFIERS = { 'fish': setup_fish_env, 'zsh': setup_zsh_env, 'bash': setup_bash_env, } ENV_SERIALIZERS: dict[str, Callable[[dict[str, str]], str]] = { 'zsh': posix_serialize_env, 'bash': posix_serialize_env, 'fish': fish_serialize_env, } QUOTERES = { 'fish': as_fish_str_literal } def get_supported_shell_name(path: str) -> str | None: name = os.path.basename(path) if name.lower().endswith('.exe'): name = name.rpartition('.')[0] if name.startswith('-'): name = name[1:] return name if name in ENV_MODIFIERS else None def shell_integration_allows_rc_modification(opts: Options) -> bool: return not (opts.shell_integration & {'disabled', 'no-rc'}) def serialize_env(path: str, env: dict[str, str]) -> str: if not env: return '' name = get_supported_shell_name(path) if not name: raise ValueError(f'{path} is not a supported shell') return ENV_SERIALIZERS[name](env) @run_once def unsafe_pat() -> re.Pattern[str]: return re.compile(r'[^\w@%+=:,./-]', re.ASCII) def join(path: str, cmd: Iterable[str]) -> str: name = get_supported_shell_name(path) _find_unsafe = unsafe_pat().search if not name: raise ValueError(f'{path} is not a supported shell') q = QUOTERES.get(name, as_str_literal) def quote(x: str) -> str: return x if _find_unsafe(x) is None else q(x) return ' '.join(map(quote, cmd)) def get_effective_ksi_env_var(opts: Options | None = None) -> str: opts = opts or get_options() if 'disabled' in opts.shell_integration: return '' # Use the default when shell_integration is empty due to misconfiguration if 'invalid' in opts.shell_integration: return ' '.join(defaults.shell_integration) return ' '.join(opts.shell_integration) def modify_shell_environ(opts: Options, env: dict[str, str], argv: list[str]) -> None: shell = get_supported_shell_name(argv[0]) ksi = get_effective_ksi_env_var(opts) if shell is None or not ksi: return env['KITTY_SHELL_INTEGRATION'] = ksi if not shell_integration_allows_rc_modification(opts): return f = ENV_MODIFIERS.get(shell) if f is not None: try: f(env, argv) except Exception: import traceback traceback.print_exc() log_error(f'Failed to setup shell integration for: {shell}')