summaryrefslogtreecommitdiff
path: root/internal/shell/shell.go
blob: 195d1abb5f3a6f4cf59187085aa48734f2790674 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
// Package shell provides interactive readline-based shell integration for foostore.
// It wraps github.com/ergochat/readline to offer vi mode, tab completion,
// history deduplication (matching the Ruby reference implementation), and
// password reading without echo.
package shell

import (
	"bufio"
	"context"
	"fmt"
	"io"
	"os"
	"strings"

	"github.com/ergochat/readline"
	"golang.org/x/term"
)

// Shell manages an interactive readline loop with vi mode and tab completion.
type Shell struct {
	rl *readline.Instance
}

func shellPrompt() string {
	if os.Getenv("NO_COLOR") != "" {
		return "% "
	}
	// Bright cyan prompt marker for better visibility.
	return "\x1b[1;96m%\x1b[0m "
}

// viInputFilter provides a small vi-style modal key layer for readline.
// It is used as a reliability fallback because VimMode handling can vary
// by terminal; this keeps navigation deterministic.
type viInputFilter struct {
	normalMode bool
}

func newVIInputFilter() *viInputFilter {
	// Start in insert mode to keep command entry ergonomic.
	return &viInputFilter{normalMode: false}
}

// filter maps typed runes into readline control runes.
// Returns (mappedRune, true) to pass into readline, or (_, false) to swallow.
func (v *viInputFilter) filter(r rune) (rune, bool) {
	switch r {
	case readline.CharEnter, readline.CharCtrlJ:
		// Enter submits the line and returns to insert mode for next prompt.
		v.normalMode = false
		return r, true
	case readline.CharEsc, 29: // Esc or Ctrl-]
		v.normalMode = true
		return 0, false
	}

	if !v.normalMode {
		return r, true
	}

	switch r {
	case 'i':
		v.normalMode = false
		return 0, false
	case 'a':
		v.normalMode = false
		return readline.CharForward, true
	case 'h':
		return readline.CharBackward, true
	case 'j':
		return readline.CharNext, true
	case 'k':
		return readline.CharPrev, true
	case 'l':
		return readline.CharForward, true
	case 'w', 'W':
		return readline.MetaForward, true
	case 'b', 'B':
		return readline.MetaBackward, true
	case '0', '^':
		return readline.CharLineStart, true
	case '$':
		return readline.CharLineEnd, true
	case 'x':
		return readline.MetaDeleteKey, true
	default:
		// In normal mode, unknown keys should not insert text.
		return readline.CharBell, true
	}
}

// prefixCompleter implements readline.AutoCompleter by delegating to a
// caller-supplied function that returns completions for a given prefix.
// This mirrors the Ruby implementation's Readline.completion_proc.
type prefixCompleter struct {
	fn func(prefix string) []string
}

// Do satisfies the readline.AutoCompleter interface.
// It extracts the current word (characters after the last space) from the
// line buffer up to the cursor, calls the completion function, and returns
// the suffix portions that readline should append.
func (p *prefixCompleter) Do(line []rune, pos int) (newLine [][]rune, length int) {
	// Work only with the portion of the line up to the cursor.
	lineStr := string(line[:pos])

	// Find the start of the current word being typed.
	wordStart := strings.LastIndex(lineStr, " ") + 1
	prefix := lineStr[wordStart:]

	// Ask the caller for all candidates matching the prefix.
	candidates := p.fn(prefix)

	// Return the suffix of each candidate (the part still to be typed),
	// plus a trailing space so readline inserts a space after completion.
	for _, c := range candidates {
		if strings.HasPrefix(c, prefix) {
			suffix := c[len(prefix):]
			newLine = append(newLine, []rune(suffix+" "))
		}
	}

	// length is the number of runes in the prefix that have already been typed.
	length = len([]rune(prefix))
	return
}

// New creates a readline instance configured with:
//   - "% " prompt (matching the Ruby shell_loop prompt)
//   - vi mode (matching Readline.vi_editing_mode in the Ruby setup)
//   - 500-entry in-memory history limit
//   - tab completion via completionFn
//   - manual history saving so we can deduplicate entries ourselves
func New(completionFn func(prefix string) []string) (*Shell, error) {
	viFilter := newVIInputFilter()
	cfg := &readline.Config{
		Prompt:       shellPrompt(),
		VimMode:      false,
		HistoryLimit: 500,
		AutoComplete: &prefixCompleter{fn: completionFn},
		FuncFilterInputRune: func(r rune) (rune, bool) {
			return viFilter.filter(r)
		},
		// Disable automatic history saving so ReadLine can deduplicate
		// entries before committing them, matching the Ruby behaviour:
		//   Readline::HISTORY.pop if argv.empty? ||
		//     (Readline::HISTORY.length > 1 && HISTORY[-1] == HISTORY[-2])
		DisableAutoSaveHistory: true,
		HistoryFile:            "", // no persistent history file
	}

	rl, err := readline.NewFromConfig(cfg)
	if err != nil {
		return nil, err
	}

	return &Shell{rl: rl}, nil
}

// ReadLine reads one line from the terminal, applying history deduplication.
//
// Behaviour:
//   - Ctrl+D (EOF)       → returns ("", io.EOF) — caller should exit
//   - Ctrl+C (interrupt) → returns ("", io.EOF) — caller should exit (same as Ruby's SIGINT)
//   - non-empty line     → saved to history only if it differs from the
//     previous entry, then returned to the caller
//
// The ctx parameter is reserved for future cancellation support; the
// underlying readline call is blocking and does not yet respect context.
func (s *Shell) ReadLine(ctx context.Context) (string, error) {
	line, err := s.rl.Readline()
	if err != nil {
		if err == io.EOF {
			// Ctrl+D — signal a clean exit to the caller.
			return "", io.EOF
		}
		if err == readline.ErrInterrupt {
			// Ctrl+C — exit the shell, mirroring the Ruby behaviour where
			// SIGINT terminates the process.
			return "", io.EOF
		}
		return "", err
	}

	line = strings.TrimSpace(line)

	// Deduplicate history: save the line only when it is non-empty and
	// differs from the most recent history entry.  This matches:
	//   Readline::HISTORY.pop if argv.empty? ||
	//     (Readline::HISTORY.length > 1 && HISTORY[-1] == HISTORY[-2])
	if line != "" {
		if err := s.rl.SaveToHistory(line); err != nil {
			// History save failure is non-fatal; log-worthy but ignorable.
			_ = err
		}
	}

	return line, nil
}

// Close releases the underlying readline instance and restores terminal state.
func (s *Shell) Close() {
	_ = s.rl.Close()
}

// ReadPassword reads a password from the shell's terminal without echoing
// characters.  Use this after the shell has already been created (e.g. for
// PIN re-entry during a session).
func (s *Shell) ReadPassword(prompt string) (string, error) {
	bytes, err := s.rl.ReadPassword(prompt)
	if err != nil {
		return "", err
	}
	return string(bytes), nil
}

// ReadPassword prints prompt then reads a password from the terminal using
// readline in Vim mode with masked visual feedback ("*").
//
// For non-interactive input (stdin is not a terminal), it falls back to
// reading a single line from stdin.
func ReadPassword(prompt string) (string, error) {
	fd := int(os.Stdin.Fd())
	if !term.IsTerminal(fd) {
		fmt.Print(prompt)
		defer fmt.Println() // move to next line after input is complete

		r := bufio.NewReader(os.Stdin)
		line, err := r.ReadString('\n')
		if err != nil && err != io.EOF {
			return "", err
		}
		return strings.TrimRight(line, "\r\n"), nil
	}

	viFilter := newVIInputFilter()
	rl, err := readline.NewFromConfig(&readline.Config{
		FuncFilterInputRune: func(r rune) (rune, bool) {
			return viFilter.filter(r)
		},
		Prompt:                 prompt,
		VimMode:                false,
		EnableMask:             true,
		MaskRune:               '*',
		HistoryFile:            "",
		DisableAutoSaveHistory: true,
	})
	if err != nil {
		return "", err
	}
	defer rl.Close()

	line, err := rl.Readline()
	if err != nil {
		if err == readline.ErrInterrupt {
			return "", fmt.Errorf("interrupted")
		}
		return "", err
	}
	return strings.TrimRight(line, "\r\n"), nil
}