github.com/markusbkk/elvish@v0.0.0-20231204143114-91dc52438621/pkg/shell/interact.go (about)

     1  package shell
     2  
     3  import (
     4  	"bufio"
     5  	"fmt"
     6  	"io"
     7  	"os"
     8  	"path/filepath"
     9  	"strings"
    10  	"syscall"
    11  	"time"
    12  
    13  	"github.com/markusbkk/elvish/pkg/cli"
    14  	"github.com/markusbkk/elvish/pkg/cli/term"
    15  	"github.com/markusbkk/elvish/pkg/daemon/daemondefs"
    16  	"github.com/markusbkk/elvish/pkg/diag"
    17  	"github.com/markusbkk/elvish/pkg/edit"
    18  	"github.com/markusbkk/elvish/pkg/eval"
    19  	"github.com/markusbkk/elvish/pkg/mods/daemon"
    20  	"github.com/markusbkk/elvish/pkg/mods/store"
    21  	"github.com/markusbkk/elvish/pkg/parse"
    22  	"github.com/markusbkk/elvish/pkg/strutil"
    23  	"github.com/markusbkk/elvish/pkg/sys"
    24  	"github.com/markusbkk/elvish/pkg/ui"
    25  )
    26  
    27  // InteractiveRescueShell determines whether a panic results in a rescue shell
    28  // being launched. It should be set to false by interactive mode unit tests.
    29  var interactiveRescueShell bool = true
    30  
    31  // Configuration for the interactive mode.
    32  type interactCfg struct {
    33  	RC string
    34  
    35  	ActivateDaemon daemondefs.ActivateFunc
    36  	SpawnConfig    *daemondefs.SpawnConfig
    37  }
    38  
    39  // Interface satisfied by the line editor. Used for swapping out the editor with
    40  // minEditor when necessary.
    41  type editor interface {
    42  	ReadCode() (string, error)
    43  	RunAfterCommandHooks(src parse.Source, duration float64, err error)
    44  }
    45  
    46  // Runs an interactive shell session.
    47  func interact(ev *eval.Evaler, fds [3]*os.File, cfg *interactCfg) {
    48  	if interactiveRescueShell {
    49  		defer handlePanic()
    50  	}
    51  
    52  	var daemonClient daemondefs.Client
    53  	if cfg.ActivateDaemon != nil && cfg.SpawnConfig != nil {
    54  		// TODO(xiaq): Connect to daemon and install daemon module
    55  		// asynchronously.
    56  		cl, err := cfg.ActivateDaemon(fds[2], cfg.SpawnConfig)
    57  		if err != nil {
    58  			fmt.Fprintln(fds[2], "Cannot connect to daemon:", err)
    59  			fmt.Fprintln(fds[2], "Daemon-related functions will likely not work.")
    60  		}
    61  		defer func() {
    62  			err := cl.Close()
    63  			if err != nil {
    64  				fmt.Fprintln(fds[2],
    65  					"warning: failed to close connection to daemon:", err)
    66  			}
    67  		}()
    68  		daemonClient = cl
    69  		// Even if error is not nil, we install daemon-related functionalities
    70  		// anyway. Daemon may eventually come online and become functional.
    71  		ev.AddBeforeExit(func() { cl.Close() })
    72  		ev.AddModule("store", store.Ns(cl))
    73  		ev.AddModule("daemon", daemon.Ns(cl))
    74  	}
    75  
    76  	// Build Editor.
    77  	var ed editor
    78  	if sys.IsATTY(fds[0]) {
    79  		newed := edit.NewEditor(cli.NewTTY(fds[0], fds[2]), ev, daemonClient)
    80  		ev.ExtendBuiltin(eval.BuildNs().AddNs("edit", newed))
    81  		ev.BgJobNotify = func(s string) { newed.Notify(ui.T(s)) }
    82  		ed = newed
    83  	} else {
    84  		ed = newMinEditor(fds[0], fds[2])
    85  	}
    86  
    87  	// Source rc.elv.
    88  	if cfg.RC != "" {
    89  		err := sourceRC(fds, ev, ed, cfg.RC)
    90  		if err != nil {
    91  			diag.ShowError(fds[2], err)
    92  		}
    93  	}
    94  
    95  	term.Sanitize(fds[0], fds[2])
    96  
    97  	cooldown := time.Second
    98  	cmdNum := 0
    99  
   100  	for {
   101  		cmdNum++
   102  
   103  		line, err := ed.ReadCode()
   104  		if err == io.EOF {
   105  			break
   106  		} else if err != nil {
   107  			fmt.Fprintln(fds[2], "Editor error:", err)
   108  			if _, isMinEditor := ed.(*minEditor); !isMinEditor {
   109  				fmt.Fprintln(fds[2], "Falling back to basic line editor")
   110  				ed = newMinEditor(fds[0], fds[2])
   111  			} else {
   112  				fmt.Fprintln(fds[2], "Don't know what to do, pid is", os.Getpid())
   113  				fmt.Fprintln(fds[2], "Restarting editor in", cooldown)
   114  				time.Sleep(cooldown)
   115  				if cooldown < time.Minute {
   116  					cooldown *= 2
   117  				}
   118  			}
   119  			continue
   120  		}
   121  
   122  		// No error; reset cooldown.
   123  		cooldown = time.Second
   124  
   125  		// Execute the command line only if it is not entirely whitespace. This keeps side-effects,
   126  		// such as executing `$edit:after-command` hooks, from occurring when we didn't actually
   127  		// evaluate any code entered by the user.
   128  		if strings.TrimSpace(line) == "" {
   129  			continue
   130  		}
   131  		src := parse.Source{Name: fmt.Sprintf("[tty %v]", cmdNum), Code: line}
   132  		duration, err := evalInTTY(ev, fds, src)
   133  		ed.RunAfterCommandHooks(src, duration, err)
   134  		term.Sanitize(fds[0], fds[2])
   135  		if err != nil {
   136  			diag.ShowError(fds[2], err)
   137  		}
   138  	}
   139  }
   140  
   141  // Interactive mode panic handler.
   142  func handlePanic() {
   143  	r := recover()
   144  	if r != nil {
   145  		println()
   146  		print(sys.DumpStack())
   147  		println()
   148  		fmt.Println(r)
   149  		println("\nExecing recovery shell /bin/sh")
   150  		syscall.Exec("/bin/sh", []string{"/bin/sh"}, os.Environ())
   151  	}
   152  }
   153  
   154  func sourceRC(fds [3]*os.File, ev *eval.Evaler, ed editor, rcPath string) error {
   155  	absPath, err := filepath.Abs(rcPath)
   156  	if err != nil {
   157  		return fmt.Errorf("cannot get full path of rc.elv: %v", err)
   158  	}
   159  	code, err := readFileUTF8(absPath)
   160  	if err != nil {
   161  		if os.IsNotExist(err) {
   162  			return nil
   163  		}
   164  		return err
   165  	}
   166  	src := parse.Source{Name: absPath, Code: code, IsFile: true}
   167  	duration, err := evalInTTY(ev, fds, src)
   168  	ed.RunAfterCommandHooks(src, duration, err)
   169  	return err
   170  }
   171  
   172  type minEditor struct {
   173  	in  *bufio.Reader
   174  	out io.Writer
   175  }
   176  
   177  func newMinEditor(in, out *os.File) *minEditor {
   178  	return &minEditor{bufio.NewReader(in), out}
   179  }
   180  
   181  func (ed *minEditor) RunAfterCommandHooks(src parse.Source, duration float64, err error) {
   182  	// no-op; minEditor doesn't support this hook.
   183  }
   184  
   185  func (ed *minEditor) ReadCode() (string, error) {
   186  	wd, err := os.Getwd()
   187  	if err != nil {
   188  		wd = "?"
   189  	}
   190  	fmt.Fprintf(ed.out, "%s> ", wd)
   191  	line, err := ed.in.ReadString('\n')
   192  	return strutil.ChopLineEnding(line), err
   193  }