Files

369 lines
11 KiB
Go

// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package shell_integration
import (
"archive/tar"
"bytes"
"fmt"
"maps"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"kitty"
"kitty/tools/tty"
"kitty/tools/utils"
)
var _ = fmt.Print
type integration_setup_func = func(shell_integration_dir string, argv []string, env map[string]string) ([]string, map[string]string, error)
func TerminfoData() string {
d := Data()
entry := d["terminfo/x/xterm-kitty"]
return utils.UnsafeBytesToString(entry.Data)
}
func extract_files(match, dest_dir string) (err error) {
d := Data()
for _, fname := range d.FilesMatching(match) {
entry := d[fname]
dest := filepath.Join(dest_dir, fname)
ddir := filepath.Dir(dest)
if err = os.MkdirAll(ddir, 0o755); err != nil {
return
}
switch entry.Metadata.Typeflag {
case tar.TypeDir:
if err = os.MkdirAll(dest, 0o755); err != nil {
return
}
case tar.TypeSymlink:
if err = os.Symlink(entry.Metadata.Linkname, dest); err != nil {
return
}
case tar.TypeReg:
if existing, rerr := os.ReadFile(dest); rerr == nil && bytes.Equal(existing, entry.Data) {
continue
}
if err = utils.AtomicWriteFile(dest, bytes.NewReader(entry.Data), 0o644); err != nil {
return
}
}
}
return
}
func extract_shell_integration_for(shell_name string, dest_dir string) (err error) {
return extract_files("shell-integration/"+shell_name+"/", dest_dir)
}
func extract_terminfo(dest_dir string) (err error) {
var s os.FileInfo
if s, err = os.Stat(filepath.Join(dest_dir, "terminfo", "x", kitty.DefaultTermName)); err == nil && s.Mode().IsRegular() {
if s, err = os.Stat(filepath.Join(dest_dir, "terminfo", "78", kitty.DefaultTermName)); err == nil && s.Mode().IsRegular() {
return
}
}
if err = extract_files("terminfo/", dest_dir); err == nil {
dest := filepath.Join(dest_dir, "terminfo", "78")
err = os.Symlink("x", dest)
}
return
}
func PathToTerminfoDb(term string) (ans string) {
// see man terminfo for the algorithm ncurses uses for this
seen := utils.NewSet[string]()
check_dir := func(path string) string {
if seen.Has(path) {
return ``
}
seen.Add(path)
q := filepath.Join(path, term[:1], term)
if s, err := os.Stat(q); err == nil && s.Mode().IsRegular() {
return q
}
if entries, err := os.ReadDir(filepath.Join(path)); err == nil {
for _, x := range entries {
q := filepath.Join(path, x.Name(), term)
if s, err := os.Stat(q); err == nil && s.Mode().IsRegular() {
return q
}
}
}
return ``
}
if td := os.Getenv("TERMINFO"); td != "" {
if ans = check_dir(td); ans != "" {
return ans
}
}
if ans = check_dir(utils.Expanduser("~/.terminfo")); ans != "" {
return ans
}
if td := os.Getenv("TERMINFO_DIRS"); td != "" {
for _, q := range strings.Split(td, string(os.PathListSeparator)) {
if q == "" {
q = "/usr/share/terminfo"
}
if ans = check_dir(q); ans != "" {
return ans
}
}
}
for _, q := range []string{"/usr/share/terminfo", "/usr/lib/terminfo", "/usr/share/lib/terminfo"} {
if ans = check_dir(q); ans != "" {
return ans
}
}
return
}
func EnsureTerminfoFiles() (terminfo_dir string, err error) {
if kid := os.Getenv("KITTY_INSTALLATION_DIR"); kid != "" {
if s, e := os.Stat(kid); e == nil && s.IsDir() {
q := filepath.Join(kid, "terminfo")
if s, e := os.Stat(q); e == nil && s.IsDir() {
return q, nil
}
}
}
base := filepath.Join(utils.CacheDir(), "extracted-kti")
if err = os.MkdirAll(base, 0o755); err != nil {
return "", err
}
if err = extract_terminfo(base); err != nil {
return "", fmt.Errorf("Failed to extract terminfo files with error: %w", err)
}
return filepath.Join(base, "terminfo"), nil
}
func EnsureShellIntegrationFilesFor(shell_name string) (shell_integration_dir_for_shell string, err error) {
if kid := os.Getenv("KITTY_INSTALLATION_DIR"); kid != "" {
if s, e := os.Stat(kid); e == nil && s.IsDir() {
q := filepath.Join(kid, "shell-integration", shell_name)
if s, e := os.Stat(q); e == nil && s.IsDir() {
return q, nil
}
}
}
base := filepath.Join(utils.CacheDir(), "extracted-ksi")
if err = os.MkdirAll(base, 0o755); err != nil {
return "", err
}
if err = extract_shell_integration_for(shell_name, base); err != nil {
return "", err
}
return filepath.Join(base, "shell-integration", shell_name), nil
}
func is_new_zsh_install(env map[string]string, zdotdir string) 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 zdotdir == "" {
if zdotdir = env[`HOME`]; zdotdir == "" {
if q, err := os.UserHomeDir(); err == nil {
zdotdir = q
} else {
return true
}
}
}
for _, q := range []string{`.zshrc`, `.zshenv`, `.zprofile`, `.zlogin`} {
if _, e := os.Stat(filepath.Join(zdotdir, q)); e == nil {
return false
}
}
return true
}
func get_zsh_zdotdir_from_global_zshenv(argv []string, env map[string]string) string {
c := exec.Command(utils.FindExe(argv[0]), `--norcs`, `--interactive`, `-c`, `echo -n $ZDOTDIR`)
for k, v := range env {
c.Env = append(c.Env, k+"="+v)
}
if raw, err := c.Output(); err == nil {
return utils.UnsafeBytesToString(raw)
}
return ""
}
func zsh_setup_func(shell_integration_dir string, argv []string, env map[string]string) (final_argv []string, final_env map[string]string, err error) {
zdotdir := env[`ZDOTDIR`]
final_argv, final_env = argv, env
if is_new_zsh_install(env, zdotdir) {
if zdotdir == "" {
// Try to get ZDOTDIR from /etc/zshenv, when all startup files are not present
zdotdir = get_zsh_zdotdir_from_global_zshenv(argv, env)
if zdotdir == "" || is_new_zsh_install(env, zdotdir) {
return final_argv, final_env, nil
}
} else {
// dont prevent zsh-newuser-install from running
// zsh-newuser-install never runs as root but we assume that it does
return final_argv, final_env, nil
}
}
if zdotdir != "" {
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
delete(final_env, `KITTY_ORIG_ZDOTDIR`)
}
final_env[`ZDOTDIR`] = shell_integration_dir
return
}
func fish_setup_func(shell_integration_dir string, argv []string, env map[string]string) (final_argv []string, final_env map[string]string, err error) {
shell_integration_dir = filepath.Dir(shell_integration_dir)
val := env[`XDG_DATA_DIRS`]
env[`KITTY_FISH_XDG_DATA_DIR`] = shell_integration_dir
if val == "" {
env[`XDG_DATA_DIRS`] = shell_integration_dir
} else {
dirs := utils.Filter(strings.Split(val, string(filepath.ListSeparator)), func(x string) bool { return x != "" })
dirs = append([]string{shell_integration_dir}, dirs...)
env[`XDG_DATA_DIRS`] = strings.Join(dirs, string(filepath.ListSeparator))
}
return argv, env, nil
}
var debugprintln = tty.DebugPrintln
var _ = debugprintln
func bash_setup_func(shell_integration_dir string, argv []string, env map[string]string) ([]string, map[string]string, error) {
inject := utils.NewSetWithItems(`1`)
var posix_env, rcfile string
remove_args := utils.NewSet[int](8)
expecting_multi_chars_opt := true
var expecting_option_arg, interactive_opt, expecting_file_arg, file_arg_set bool
for i := 1; i < len(argv); i++ {
arg := argv[i]
if expecting_file_arg {
file_arg_set = true
break
}
if expecting_option_arg {
expecting_option_arg = false
continue
}
if arg == `-` || arg == `--` {
if !expecting_file_arg {
expecting_file_arg = true
}
continue
} else if len(arg) > 1 && arg[1] != '-' && (arg[0] == '-' || strings.HasPrefix(arg, `+O`)) {
expecting_multi_chars_opt = false
options := strings.TrimLeft(arg, `-+`)
// shopt option
if a, b, found := strings.Cut(options, `O`); found {
if b == "" {
expecting_option_arg = true
}
options = a
}
// command string
if strings.ContainsRune(options, 'c') {
// non-interactive shell
// also skip `bash -ic` interactive mode with command string
return argv, env, nil
}
// read from stdin and follow with args
if strings.ContainsRune(options, 's') {
break
}
// interactive option
if strings.ContainsRune(options, 'i') {
interactive_opt = true
}
} else if strings.HasPrefix(arg, `--`) && expecting_multi_chars_opt {
if arg == `--posix` {
inject.Add(`posix`)
posix_env = env[`ENV`]
remove_args.Add(i)
} else if arg == `--norc` {
inject.Add(`no-rc`)
remove_args.Add(i)
} else if arg == `--noprofile` {
inject.Add(`no-profile`)
remove_args.Add(i)
} else if (arg == `--rcfile` || arg == `--init-file`) && i+1 < len(argv) {
expecting_option_arg = true
rcfile = argv[i+1]
remove_args.AddItems(i, i+1)
}
} else {
file_arg_set = true
break
}
}
if file_arg_set && !interactive_opt {
// non-interactive shell
return argv, env, nil
}
env[`ENV`] = filepath.Join(shell_integration_dir, `kitty.bash`)
env[`KITTY_BASH_INJECT`] = strings.Join(inject.AsSlice(), " ")
if posix_env != "" {
env[`KITTY_BASH_POSIX_ENV`] = posix_env
}
if rcfile != "" {
env[`KITTY_BASH_RCFILE`] = rcfile
}
sorted := remove_args.AsSlice()
slices.Sort(sorted)
for _, i := range utils.Reverse(sorted) {
argv = slices.Delete(argv, i, i+1)
}
if env[`HISTFILE`] == "" && !inject.Has(`posix`) {
// In POSIX mode the default history file is ~/.sh_history instead of ~/.bash_history
env[`HISTFILE`] = utils.Expanduser(`~/.bash_history`)
env[`KITTY_BASH_UNEXPORT_HISTFILE`] = `1`
}
argv = slices.Insert(argv, 1, `--posix`)
if bashrc := os.Getenv(`KITTY_RUNNING_BASH_INTEGRATION_TEST`); bashrc != `` && os.Getenv("KITTY_RUNNING_SHELL_INTEGRATION_TEST") == "1" {
// prevent bash from sourcing /etc/profile which is not under our control
env[`KITTY_BASH_INJECT`] += ` posix`
env[`KITTY_BASH_POSIX_ENV`] = bashrc
}
return argv, env, nil
}
func setup_func_for_shell(shell_name string) integration_setup_func {
switch shell_name {
case "zsh":
return zsh_setup_func
case "fish":
return fish_setup_func
case "bash":
return bash_setup_func
}
return nil
}
func IsSupportedShell(shell_name string) bool { return setup_func_for_shell(shell_name) != nil }
func Setup(shell_name string, ksi_var string, argv []string, env map[string]string) ([]string, map[string]string, error) {
ksi_dir, err := EnsureShellIntegrationFilesFor(shell_name)
if err != nil {
return nil, nil, err
}
argv, env, err = setup_func_for_shell(shell_name)(ksi_dir, slices.Clone(argv), maps.Clone(env))
if err == nil {
env[`KITTY_SHELL_INTEGRATION`] = ksi_var
}
return argv, env, err
}