Skip to content

Commit 8dbd25a

Browse files
authored
Merge pull request #94 from reeflective/dev
dev
2 parents c6a83e5 + 59360a1 commit 8dbd25a

File tree

9 files changed

+275
-36
lines changed

9 files changed

+275
-36
lines changed

internal/completion/syntax.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,21 @@ func AutopairInsertOrJump(key rune, line *core.Line, cur *core.Cursor) (skipInse
3535
return
3636
}
3737

38-
switch {
39-
case closer && cur.Char() == key:
38+
if closer && cur.Char() == key {
4039
skipInsert = true
41-
4240
cur.Inc()
41+
return
42+
}
43+
44+
// If we are currently inside a quoted string, we don't want to insert pairs.
45+
// This also effectively allows closing the quote we are currently in.
46+
if key == '"' || key == '\'' {
47+
if unclosed, _ := strutil.GetQuotedWordStart((*line)[:cur.Pos()]); unclosed {
48+
return
49+
}
50+
}
51+
52+
switch {
4353
case closer && key != '\'' && key != '"':
4454
return
4555
default:

internal/completion/syntax_test.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package completion
2+
3+
import (
4+
"testing"
5+
6+
"github.com/reeflective/readline/internal/core"
7+
)
8+
9+
func TestAutopairInsertOrJump(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
line string
13+
cursor int
14+
key rune
15+
wantLine string
16+
wantSkip bool
17+
wantCursor int // Relative to original if not specified? No, absolute.
18+
}{
19+
{
20+
name: "Empty line, insert quote",
21+
line: "",
22+
cursor: 0,
23+
key: '"',
24+
wantLine: "\"", // Function inserts closer
25+
wantSkip: false, // selfInsert will insert opener
26+
wantCursor: 0, // Cursor stays same (caller handles insert)
27+
},
28+
{
29+
name: "Inside quote, type closing quote",
30+
line: "\"foo",
31+
cursor: 4,
32+
key: '"',
33+
wantLine: "\"foo", // Should NOT insert pair
34+
wantSkip: false, // selfInsert will insert '"' -> "foo"
35+
wantCursor: 4,
36+
},
37+
{
38+
name: "Balanced quotes, type new quote",
39+
line: "\"foo\"",
40+
cursor: 5,
41+
key: '"',
42+
wantLine: "\"foo\"\"", // Inserts closer
43+
wantSkip: false,
44+
wantCursor: 5,
45+
},
46+
{
47+
name: "Escaped quote inside double, type quote",
48+
line: "\"foo \\\"",
49+
cursor: 7,
50+
key: '"',
51+
wantLine: "\"foo \\\"", // Should detect unclosed and NOT insert pair
52+
wantSkip: false,
53+
wantCursor: 7,
54+
},
55+
{
56+
name: "Jump over closing quote",
57+
line: "\"foo\"",
58+
cursor: 4, // before last "
59+
key: '"',
60+
wantLine: "\"foo\"",
61+
wantSkip: true,
62+
wantCursor: 5, // Inc
63+
},
64+
}
65+
66+
for _, tt := range tests {
67+
t.Run(tt.name, func(t *testing.T) {
68+
line := core.Line([]rune(tt.line))
69+
cur := core.NewCursor(&line)
70+
cur.Set(tt.cursor)
71+
72+
skip := AutopairInsertOrJump(tt.key, &line, cur)
73+
74+
if skip != tt.wantSkip {
75+
t.Errorf("AutopairInsertOrJump() skip = %v, want %v", skip, tt.wantSkip)
76+
}
77+
78+
if string(line) != tt.wantLine {
79+
t.Errorf("AutopairInsertOrJump() line = %q, want %q", string(line), tt.wantLine)
80+
}
81+
82+
if cur.Pos() != tt.wantCursor {
83+
t.Errorf("AutopairInsertOrJump() cursor = %v, want %v", cur.Pos(), tt.wantCursor)
84+
}
85+
})
86+
}
87+
}

internal/core/keys.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type Keys struct {
3636
cursor chan []byte // Cursor coordinates has been read on stdin.
3737
resize chan bool // Resize events on Windows are sent on stdin. USED IN WINDOWS
3838

39+
eof bool // EOF has been reached.
3940
cfg *inputrc.Config // Configuration file used for meta key settings
4041
mutex sync.RWMutex // Concurrency safety
4142
}
@@ -71,6 +72,7 @@ func WaitAvailableKeys(keys *Keys, cfg *inputrc.Config) {
7172
// send by ourselves, because we pause reading.
7273
keyBuf, err := keys.readInputFiltered()
7374
if err != nil && errors.Is(err, io.EOF) {
75+
keys.eof = true
7476
return
7577
}
7678

@@ -99,6 +101,14 @@ func WaitAvailableKeys(keys *Keys, cfg *inputrc.Config) {
99101
}
100102
}
101103

104+
// IsEOF returns true if the input stream has reached the end.
105+
func (k *Keys) IsEOF() bool {
106+
k.mutex.RLock()
107+
defer k.mutex.RUnlock()
108+
109+
return k.eof
110+
}
111+
102112
// PeekKey returns the first key in the stack, without removing it.
103113
func PeekKey(keys *Keys) (key byte, empty bool) {
104114
switch {

internal/core/keys_unix.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,17 @@ import (
88
"io"
99
"os"
1010
"strconv"
11+
12+
"github.com/reeflective/readline/internal/term"
1113
)
1214

1315
// GetCursorPos returns the current cursor position in the terminal.
1416
// It is safe to call this function even if the shell is reading input.
1517
func (k *Keys) GetCursorPos() (x, y int) {
18+
if !term.IsTerminal(int(os.Stdin.Fd())) {
19+
return -1, -1
20+
}
21+
1622
disable := func() (int, int) {
1723
os.Stderr.WriteString("\r\ngetCursorPos() not supported by terminal emulator, disabling....\r\n")
1824
return -1, -1

internal/strutil/surround.go

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -138,42 +138,52 @@ func IsBracket(char rune) bool {
138138
// Ex: `this 'quote contains "surrounded" words`. the outermost quote is the single one.
139139
func GetQuotedWordStart(line []rune) (unclosed bool, pos int) {
140140
var (
141-
single, double bool
142-
spos, dpos = -1, -1
141+
inSingle, inDouble bool
142+
escape bool
143+
spos, dpos = -1, -1
143144
)
144145

145-
for pos, char := range line {
146-
switch char {
147-
case '\'':
148-
single = !single
149-
spos = pos
150-
case '"':
151-
double = !double
152-
dpos = pos
153-
default:
146+
for i, r := range line {
147+
if escape {
148+
escape = false
154149
continue
155150
}
156-
}
157151

158-
if single && double {
159-
unclosed = true
152+
// Backslash escapes the next character if:
153+
// - we are not in quotes
154+
// - we are in double quotes
155+
if r == '\\' {
156+
if !inSingle {
157+
escape = true
158+
continue
159+
}
160+
}
160161

161-
if spos < dpos {
162-
pos = spos
163-
} else {
164-
pos = dpos
162+
switch r {
163+
case '"':
164+
if !inSingle {
165+
inDouble = !inDouble
166+
if inDouble {
167+
dpos = i
168+
}
169+
}
170+
case '\'':
171+
if !inDouble {
172+
inSingle = !inSingle
173+
if inSingle {
174+
spos = i
175+
}
176+
}
165177
}
178+
}
166179

167-
return unclosed, pos
180+
if inDouble {
181+
return true, dpos
168182
}
169183

170-
if single {
171-
unclosed = true
172-
pos = spos
173-
} else if double {
174-
unclosed = true
175-
pos = dpos
184+
if inSingle {
185+
return true, spos
176186
}
177187

178-
return unclosed, pos
188+
return false, -1
179189
}

internal/strutil/surround_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package strutil
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestGetQuotedWordStart(t *testing.T) {
8+
tests := []struct {
9+
name string
10+
line string
11+
wantUnclosed bool
12+
wantPos int
13+
}{
14+
{
15+
name: "Empty",
16+
line: "",
17+
wantUnclosed: false,
18+
wantPos: -1,
19+
},
20+
{
21+
name: "Single word",
22+
line: "word",
23+
wantUnclosed: false,
24+
wantPos: -1,
25+
},
26+
{
27+
name: "Unclosed double",
28+
line: "\"word",
29+
wantUnclosed: true,
30+
wantPos: 0,
31+
},
32+
{
33+
name: "Closed double",
34+
line: "\"word\"",
35+
wantUnclosed: false,
36+
wantPos: -1, // Or whatever dpos is left at? dpos tracks OPENING.
37+
// If closed, inDouble is false. Returns false, -1.
38+
},
39+
{
40+
name: "Unclosed single",
41+
line: "'word",
42+
wantUnclosed: true,
43+
wantPos: 0,
44+
},
45+
{
46+
name: "Escaped quote in double",
47+
line: "\"word \\\"",
48+
wantUnclosed: true,
49+
wantPos: 0,
50+
},
51+
{
52+
name: "Escaped quote in single (literal)",
53+
line: "'word \\'",
54+
wantUnclosed: false,
55+
wantPos: -1,
56+
},
57+
{
58+
name: "Nested quotes (single in double)",
59+
line: "\"'\"",
60+
wantUnclosed: false,
61+
wantPos: -1,
62+
},
63+
{
64+
name: "Nested quotes (double in single)",
65+
line: "'\"'",
66+
wantUnclosed: false,
67+
wantPos: -1,
68+
},
69+
{
70+
name: "Balanced nested",
71+
line: "\"'hello'\"",
72+
wantUnclosed: false,
73+
wantPos: -1,
74+
},
75+
{
76+
name: "Multiple words unclosed",
77+
line: "hello \"world",
78+
wantUnclosed: true,
79+
wantPos: 6,
80+
},
81+
}
82+
83+
for _, tt := range tests {
84+
t.Run(tt.name, func(t *testing.T) {
85+
unclosed, pos := GetQuotedWordStart([]rune(tt.line))
86+
if unclosed != tt.wantUnclosed {
87+
t.Errorf("GetQuotedWordStart() unclosed = %v, want %v", unclosed, tt.wantUnclosed)
88+
}
89+
if unclosed && pos != tt.wantPos {
90+
t.Errorf("GetQuotedWordStart() pos = %v, want %v", pos, tt.wantPos)
91+
}
92+
})
93+
}
94+
}

internal/term/codes.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ const (
1616
RestoreCursorPos = "\x1b8"
1717
HideCursor = "\x1b[?25l"
1818
ShowCursor = "\x1b[?25h"
19+
20+
BracketedPasteStart = "\x1b[?2004h"
21+
BracketedPasteEnd = "\x1b[?2004l"
1922
)
2023

2124
// Some core keys needed by some stuff.

internal/term/term.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,13 @@ func printf(format string, a ...interface{}) {
5353
s := fmt.Sprintf(format, a...)
5454
fmt.Print(s)
5555
}
56+
57+
// EnableBracketedPaste enables bracketed paste mode.
58+
func EnableBracketedPaste() {
59+
fmt.Print(BracketedPasteStart)
60+
}
61+
62+
// DisableBracketedPaste disables bracketed paste mode.
63+
func DisableBracketedPaste() {
64+
fmt.Print(BracketedPasteEnd)
65+
}

0 commit comments

Comments
 (0)