Skip to content
Merged

dev #94

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions internal/completion/syntax.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,21 @@ func AutopairInsertOrJump(key rune, line *core.Line, cur *core.Cursor) (skipInse
return
}

switch {
case closer && cur.Char() == key:
if closer && cur.Char() == key {
skipInsert = true

cur.Inc()
return
}

// If we are currently inside a quoted string, we don't want to insert pairs.
// This also effectively allows closing the quote we are currently in.
if key == '"' || key == '\'' {
if unclosed, _ := strutil.GetQuotedWordStart((*line)[:cur.Pos()]); unclosed {
return
}
}

switch {
case closer && key != '\'' && key != '"':
return
default:
Expand Down
87 changes: 87 additions & 0 deletions internal/completion/syntax_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package completion

import (
"testing"

"github.com/reeflective/readline/internal/core"
)

func TestAutopairInsertOrJump(t *testing.T) {
tests := []struct {
name string
line string
cursor int
key rune
wantLine string
wantSkip bool
wantCursor int // Relative to original if not specified? No, absolute.
}{
{
name: "Empty line, insert quote",
line: "",
cursor: 0,
key: '"',
wantLine: "\"", // Function inserts closer
wantSkip: false, // selfInsert will insert opener
wantCursor: 0, // Cursor stays same (caller handles insert)
},
{
name: "Inside quote, type closing quote",
line: "\"foo",
cursor: 4,
key: '"',
wantLine: "\"foo", // Should NOT insert pair
wantSkip: false, // selfInsert will insert '"' -> "foo"
wantCursor: 4,
},
{
name: "Balanced quotes, type new quote",
line: "\"foo\"",
cursor: 5,
key: '"',
wantLine: "\"foo\"\"", // Inserts closer
wantSkip: false,
wantCursor: 5,
},
{
name: "Escaped quote inside double, type quote",
line: "\"foo \\\"",
cursor: 7,
key: '"',
wantLine: "\"foo \\\"", // Should detect unclosed and NOT insert pair
wantSkip: false,
wantCursor: 7,
},
{
name: "Jump over closing quote",
line: "\"foo\"",
cursor: 4, // before last "
key: '"',
wantLine: "\"foo\"",
wantSkip: true,
wantCursor: 5, // Inc
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
line := core.Line([]rune(tt.line))
cur := core.NewCursor(&line)
cur.Set(tt.cursor)

skip := AutopairInsertOrJump(tt.key, &line, cur)

if skip != tt.wantSkip {
t.Errorf("AutopairInsertOrJump() skip = %v, want %v", skip, tt.wantSkip)
}

if string(line) != tt.wantLine {
t.Errorf("AutopairInsertOrJump() line = %q, want %q", string(line), tt.wantLine)
}

if cur.Pos() != tt.wantCursor {
t.Errorf("AutopairInsertOrJump() cursor = %v, want %v", cur.Pos(), tt.wantCursor)
}
})
}
}
10 changes: 10 additions & 0 deletions internal/core/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type Keys struct {
cursor chan []byte // Cursor coordinates has been read on stdin.
resize chan bool // Resize events on Windows are sent on stdin. USED IN WINDOWS

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

Expand Down Expand Up @@ -99,6 +101,14 @@ func WaitAvailableKeys(keys *Keys, cfg *inputrc.Config) {
}
}

// IsEOF returns true if the input stream has reached the end.
func (k *Keys) IsEOF() bool {
k.mutex.RLock()
defer k.mutex.RUnlock()

return k.eof
}

// PeekKey returns the first key in the stack, without removing it.
func PeekKey(keys *Keys) (key byte, empty bool) {
switch {
Expand Down
6 changes: 6 additions & 0 deletions internal/core/keys_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,17 @@ import (
"io"
"os"
"strconv"

"github.com/reeflective/readline/internal/term"
)

// GetCursorPos returns the current cursor position in the terminal.
// It is safe to call this function even if the shell is reading input.
func (k *Keys) GetCursorPos() (x, y int) {
if !term.IsTerminal(int(os.Stdin.Fd())) {
return -1, -1
}

disable := func() (int, int) {
os.Stderr.WriteString("\r\ngetCursorPos() not supported by terminal emulator, disabling....\r\n")
return -1, -1
Expand Down
62 changes: 36 additions & 26 deletions internal/strutil/surround.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,42 +138,52 @@ func IsBracket(char rune) bool {
// Ex: `this 'quote contains "surrounded" words`. the outermost quote is the single one.
func GetQuotedWordStart(line []rune) (unclosed bool, pos int) {
var (
single, double bool
spos, dpos = -1, -1
inSingle, inDouble bool
escape bool
spos, dpos = -1, -1
)

for pos, char := range line {
switch char {
case '\'':
single = !single
spos = pos
case '"':
double = !double
dpos = pos
default:
for i, r := range line {
if escape {
escape = false
continue
}
}

if single && double {
unclosed = true
// Backslash escapes the next character if:
// - we are not in quotes
// - we are in double quotes
if r == '\\' {
if !inSingle {
escape = true
continue
}
}

if spos < dpos {
pos = spos
} else {
pos = dpos
switch r {
case '"':
if !inSingle {
inDouble = !inDouble
if inDouble {
dpos = i
}
}
case '\'':
if !inDouble {
inSingle = !inSingle
if inSingle {
spos = i
}
}
}
}

return unclosed, pos
if inDouble {
return true, dpos
}

if single {
unclosed = true
pos = spos
} else if double {
unclosed = true
pos = dpos
if inSingle {
return true, spos
}

return unclosed, pos
return false, -1
}
94 changes: 94 additions & 0 deletions internal/strutil/surround_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package strutil

import (
"testing"
)

func TestGetQuotedWordStart(t *testing.T) {
tests := []struct {
name string
line string
wantUnclosed bool
wantPos int
}{
{
name: "Empty",
line: "",
wantUnclosed: false,
wantPos: -1,
},
{
name: "Single word",
line: "word",
wantUnclosed: false,
wantPos: -1,
},
{
name: "Unclosed double",
line: "\"word",
wantUnclosed: true,
wantPos: 0,
},
{
name: "Closed double",
line: "\"word\"",
wantUnclosed: false,
wantPos: -1, // Or whatever dpos is left at? dpos tracks OPENING.
// If closed, inDouble is false. Returns false, -1.
},
{
name: "Unclosed single",
line: "'word",
wantUnclosed: true,
wantPos: 0,
},
{
name: "Escaped quote in double",
line: "\"word \\\"",
wantUnclosed: true,
wantPos: 0,
},
{
name: "Escaped quote in single (literal)",
line: "'word \\'",
wantUnclosed: false,
wantPos: -1,
},
{
name: "Nested quotes (single in double)",
line: "\"'\"",
wantUnclosed: false,
wantPos: -1,
},
{
name: "Nested quotes (double in single)",
line: "'\"'",
wantUnclosed: false,
wantPos: -1,
},
{
name: "Balanced nested",
line: "\"'hello'\"",
wantUnclosed: false,
wantPos: -1,
},
{
name: "Multiple words unclosed",
line: "hello \"world",
wantUnclosed: true,
wantPos: 6,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
unclosed, pos := GetQuotedWordStart([]rune(tt.line))
if unclosed != tt.wantUnclosed {
t.Errorf("GetQuotedWordStart() unclosed = %v, want %v", unclosed, tt.wantUnclosed)
}
if unclosed && pos != tt.wantPos {
t.Errorf("GetQuotedWordStart() pos = %v, want %v", pos, tt.wantPos)
}
})
}
}
3 changes: 3 additions & 0 deletions internal/term/codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ const (
RestoreCursorPos = "\x1b8"
HideCursor = "\x1b[?25l"
ShowCursor = "\x1b[?25h"

BracketedPasteStart = "\x1b[?2004h"
BracketedPasteEnd = "\x1b[?2004l"
)

// Some core keys needed by some stuff.
Expand Down
10 changes: 10 additions & 0 deletions internal/term/term.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,13 @@ func printf(format string, a ...interface{}) {
s := fmt.Sprintf(format, a...)
fmt.Print(s)
}

// EnableBracketedPaste enables bracketed paste mode.
func EnableBracketedPaste() {
fmt.Print(BracketedPasteStart)
}

// DisableBracketedPaste disables bracketed paste mode.
func DisableBracketedPaste() {
fmt.Print(BracketedPasteEnd)
}
Loading
Loading