1
1
mirror of https://github.com/wader/fq.git synced 2024-10-27 04:09:37 +03:00
fq/pkg/cli/cli.go
Mattias Wadman bf7fa07c41 fq: Use go 1.20 and cleanup
Also rename *ex packages to *x
2024-04-01 19:14:10 +02:00

288 lines
6.1 KiB
Go

package cli
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"io/fs"
"log"
"os"
"os/signal"
"path/filepath"
"runtime"
"github.com/wader/fq/internal/mathx"
"github.com/wader/fq/pkg/interp"
"golang.org/x/term"
"github.com/ergochat/readline"
)
func maybeLogFile() {
// used during dev to redirect log to file, useful when debugging repl etc
if lf := os.Getenv("LOGFILE"); lf != "" {
if f, err := os.Create(lf); err == nil {
log.SetOutput(f)
}
}
}
// function implementing readline.AutoComplete interface
type autoCompleterFn func(line []rune, pos int) (newLine [][]rune, length int)
func (a autoCompleterFn) Do(line []rune, pos int) (newLine [][]rune, length int) {
return a(line, pos)
}
type stdOS struct {
rl *readline.Instance
historyFile string
closeChan chan struct{}
interruptChan chan struct{}
completerFn interp.CompleteFn
}
func newStandardOS() *stdOS {
closeChan := make(chan struct{})
interruptChan := make(chan struct{}, 1)
// this more or less converts a os signal chan to just a struct{} chan that
// ignores signals if forwarding it would block, also this makes sure interp
// does not know about os.
go func() {
interruptSignalChan := make(chan os.Signal, 1)
signal.Notify(interruptSignalChan, os.Interrupt)
defer func() {
signal.Stop(interruptSignalChan)
close(interruptSignalChan)
close(interruptChan)
}()
for {
select {
case <-interruptSignalChan:
// ignore if interruptChan is full
select {
case interruptChan <- struct{}{}:
default:
}
case <-closeChan:
return
}
}
}()
return &stdOS{
closeChan: closeChan,
interruptChan: interruptChan,
}
}
func (stdOS) Platform() interp.Platform {
return interp.Platform{
OS: runtime.GOOS,
Arch: runtime.GOARCH,
}
}
type fdTerminal uintptr
func (fd fdTerminal) Size() (int, int) {
w, h, _ := term.GetSize(int(fd))
// TODO: old version return 0 on no terminal
w, h = mathx.Max(0, w), mathx.Max(0, h)
return w, h
}
func (fd fdTerminal) IsTerminal() bool {
return term.IsTerminal(int(fd))
}
type stdinInput struct {
fdTerminal
fs.File
}
func (o *stdOS) Stdin() interp.Input {
return stdinInput{
fdTerminal: fdTerminal(os.Stdin.Fd()),
File: interp.FileReader{
R: os.Stdin,
FileInfo: interp.FixedFileInfo{
FName: "stdin",
FMode: fs.ModeIrregular,
},
},
}
}
type stdoutOutput struct {
fdTerminal
os *stdOS
}
func (o stdoutOutput) Write(p []byte) (n int, err error) {
// Let write go thru readline if it has been used. This to have ansi color emulation
// on windows thru readlins:s stdout rewriter
// TODO: check if tty instead? else only color when repl
if o.os.rl != nil {
return o.os.rl.Write(p)
}
return os.Stdout.Write(p)
}
func (o *stdOS) Stdout() interp.Output {
return stdoutOutput{fdTerminal: fdTerminal(os.Stdout.Fd()), os: o}
}
type stderrOutput struct {
fdTerminal
}
func (o stderrOutput) Write(p []byte) (n int, err error) { return os.Stderr.Write(p) }
func (o *stdOS) Stderr() interp.Output { return stderrOutput{fdTerminal: fdTerminal(os.Stderr.Fd())} }
func (o *stdOS) InterruptChan() chan struct{} { return o.interruptChan }
func (*stdOS) Args() []string { return os.Args }
func (*stdOS) Environ() []string { return os.Environ() }
func (*stdOS) ConfigDir() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", err
}
fqDir := filepath.Join(configDir, "fq")
if runtime.GOOS != "darwin" {
return fqDir, nil
}
// this is to support fallback to ~/.config on macOS/darwin
if _, err := os.Stat(fqDir); err == nil {
return fqDir, nil
}
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(homeDir, ".config", "fq"), nil
}
type stdOSFS struct{}
func (stdOSFS) Open(name string) (fs.File, error) { return os.Open(name) }
func (*stdOS) FS() fs.FS { return stdOSFS{} }
func (o *stdOS) Readline(opts interp.ReadlineOpts) (string, error) {
if o.rl == nil {
var err error
var historyFile string
cacheDir, err := os.UserCacheDir()
if err != nil {
return "", err
}
historyFile = filepath.Join(cacheDir, "fq/history")
_ = os.MkdirAll(filepath.Dir(historyFile), 0700)
cfg := &readline.Config{
HistoryFile: historyFile,
HistorySearchFold: true,
Undo: true,
}
cfg.AutoComplete = autoCompleterFn(func(line []rune, pos int) (newLine [][]rune, length int) {
if o.completerFn == nil {
return nil, 0
}
names, shared := o.completerFn(string(line), pos)
var runeNames [][]rune
for _, name := range names {
runeNames = append(runeNames, []rune(name[shared:]))
}
return runeNames, shared
})
o.rl, err = readline.NewEx(cfg)
if err != nil {
return "", err
}
o.historyFile = historyFile
}
// inject completer to autocompleter
o.completerFn = opts.CompleteFn
o.rl.SetPrompt(opts.Prompt)
line, err := o.rl.Readline()
if errors.Is(err, readline.ErrInterrupt) {
return "", interp.ErrInterrupt
} else if errors.Is(err, io.EOF) {
return "", interp.ErrEOF
} else if err != nil {
return "", err
}
return line, nil
}
func (o *stdOS) History() ([]string, error) {
// TODO: refactor history handling to use internal fs?
r, err := os.Open(o.historyFile)
if err != nil {
return nil, err
}
defer r.Close()
var hs []string
lineScanner := bufio.NewScanner(r)
for lineScanner.Scan() {
hs = append(hs, lineScanner.Text())
}
if err := lineScanner.Err(); err != nil {
return nil, err
}
return hs, nil
}
func (o *stdOS) Close() error {
// only close if is terminal otherwise ansi reset will write
// to stdout and mess up raw output
if o.rl != nil {
o.rl.Close()
}
close(o.closeChan)
return nil
}
func Main(r *interp.Registry, version string) {
os.Exit(func() int {
defer maybeProfile()()
maybeLogFile()
sos := newStandardOS()
defer sos.Close()
i, err := interp.New(sos, r)
defer i.Stop()
if err != nil {
fmt.Fprintln(sos.Stderr(), err)
return 1
}
if err := i.Main(context.Background(), sos.Stdout(), version); err != nil {
if ex, ok := err.(interp.Exiter); ok {
return ex.ExitCode()
}
return 1
}
return 0
}())
}