Skip to content

Commit e7015a9

Browse files
authored
prompt: allow control of auto-complete word delimiters (#8)
1 parent 2cb1018 commit e7015a9

File tree

8 files changed

+233
-78
lines changed

8 files changed

+233
-78
lines changed

prompt/buffer.go

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ func (b *buffer) DeleteWordBackward() {
154154
foundWord := false
155155
line := b.getCurrentLine()
156156
for idx := b.cursor.Column - 1; idx >= 0; idx-- {
157-
isPartOfWord := b.isPartOfWord(line[idx])
157+
isPartOfWord := isPartOfWord(line[idx])
158158
if !isPartOfWord && foundWord {
159159
b.lines[b.cursor.Line] = line[:idx] + line[b.cursor.Column:]
160160
b.cursor.Column = idx
@@ -184,7 +184,7 @@ func (b *buffer) DeleteWordForward() {
184184
// delete till beginning of previous word
185185
foundWord, foundNonWord := false, false
186186
for idx := b.cursor.Column; idx < len(line); idx++ {
187-
isPartOfWord := b.isPartOfWord(line[idx])
187+
isPartOfWord := isPartOfWord(line[idx])
188188
if !isPartOfWord {
189189
foundNonWord = true
190190
}
@@ -511,7 +511,7 @@ func (b *buffer) MoveWordLeft(locked ...bool) {
511511
line := b.lines[lineIdx]
512512
for colIdx := b.cursor.Column - 1; colIdx >= 0; colIdx-- {
513513
b.cursor.Column = colIdx
514-
isPartOfWord := b.isPartOfWord(line[colIdx])
514+
isPartOfWord := isPartOfWord(line[colIdx])
515515
if foundWord && (!isPartOfWord || colIdx == 0) {
516516
if !isPartOfWord {
517517
b.cursor.Column++
@@ -559,7 +559,7 @@ func (b *buffer) MoveWordRight(locked ...bool) {
559559
line := b.lines[lineIdx]
560560
for colIdx := b.cursor.Column; colIdx < len(line); colIdx++ {
561561
b.cursor.Column = colIdx
562-
isPartOfWord := b.isPartOfWord(line[b.cursor.Column])
562+
isPartOfWord := isPartOfWord(line[b.cursor.Column])
563563
if isPartOfWord && foundBreak {
564564
return
565565
}
@@ -616,19 +616,19 @@ func (b *buffer) getCurrentLine() string {
616616
}
617617

618618
func (b *buffer) getCurrentWord(line string) (string, int, int) {
619-
if len(line) == 0 || b.cursor.Column >= len(line) || !b.isPartOfWord(line[b.cursor.Column]) {
619+
if len(line) == 0 || b.cursor.Column >= len(line) || !isPartOfWord(line[b.cursor.Column]) {
620620
return "", -1, -1
621621
}
622622

623623
idxWordStart, idxWordEnd := -1, -1
624624
for idx := b.cursor.Column; idx >= 0; idx-- {
625-
if !b.isPartOfWord(line[idx]) {
625+
if !isPartOfWord(line[idx]) {
626626
break
627627
}
628628
idxWordStart = idx
629629
}
630630
for idx := b.cursor.Column; idx < len(line); idx++ {
631-
if !b.isPartOfWord(line[idx]) {
631+
if !isPartOfWord(line[idx]) {
632632
break
633633
}
634634
idxWordEnd = idx
@@ -641,12 +641,17 @@ func (b *buffer) getLine(n int) string {
641641
return b.lines[n]
642642
}
643643

644-
func (b *buffer) getWordAtCursor() (string, int) {
644+
func (b *buffer) getWordAtCursor(wordDelimiters map[byte]bool) (string, int) {
645645
line := b.getCurrentLine()
646646
if b.cursor.Column == len(line) || (b.cursor.Column < len(line) && line[b.cursor.Column] == ' ') {
647647
idxWordStart := -1
648648
for idx := b.cursor.Column - 1; idx >= 0; idx-- {
649-
if !b.isPartOfWord(line[idx]) {
649+
r := line[idx]
650+
if wordDelimiters != nil {
651+
if wordDelimiters[r] {
652+
break
653+
}
654+
} else if !isPartOfWord(r) {
650655
break
651656
}
652657
idxWordStart = idx
@@ -658,27 +663,6 @@ func (b *buffer) getWordAtCursor() (string, int) {
658663
return "", -1
659664
}
660665

661-
var (
662-
reNonWordRunes = map[byte]bool{
663-
' ': true,
664-
'(': true,
665-
')': true,
666-
',': true,
667-
'.': true,
668-
';': true,
669-
'[': true,
670-
'\n': true,
671-
'\t': true,
672-
']': true,
673-
'{': true,
674-
'}': true,
675-
}
676-
)
677-
678-
func (b *buffer) isPartOfWord(r byte) bool {
679-
return !reNonWordRunes[r]
680-
}
681-
682666
type linesChangedMap map[int]bool
683667

684668
func (lc linesChangedMap) Clear() {
@@ -719,3 +703,24 @@ func (lc linesChangedMap) String() string {
719703
sort.Ints(lines)
720704
return fmt.Sprintf("%v", lines)
721705
}
706+
707+
var (
708+
nonWordRunes = map[byte]bool{
709+
' ': true,
710+
'(': true,
711+
')': true,
712+
',': true,
713+
'.': true,
714+
';': true,
715+
'[': true,
716+
'\n': true,
717+
'\t': true,
718+
']': true,
719+
'{': true,
720+
'}': true,
721+
}
722+
)
723+
724+
func isPartOfWord(r byte) bool {
725+
return !nonWordRunes[r]
726+
}

prompt/buffer_test.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -794,24 +794,32 @@ func TestBuffer_getWordAtCursor(t *testing.T) {
794794
b.InsertString("foo bar baz foo")
795795

796796
b.cursor.Column = 11
797-
word, idx := b.getWordAtCursor()
797+
word, idx := b.getWordAtCursor(nil)
798798
assert.Equal(t, "baz", word)
799799
assert.Equal(t, 8, idx)
800800

801801
b.cursor.Column = 7
802-
word, idx = b.getWordAtCursor()
802+
word, idx = b.getWordAtCursor(nil)
803803
assert.Equal(t, "bar", word)
804804
assert.Equal(t, 4, idx)
805805

806806
b.cursor.Column = 3
807-
word, idx = b.getWordAtCursor()
807+
word, idx = b.getWordAtCursor(nil)
808808
assert.Equal(t, "foo", word)
809809
assert.Equal(t, 0, idx)
810810

811811
b.cursor.Column = 2
812-
word, idx = b.getWordAtCursor()
812+
word, idx = b.getWordAtCursor(nil)
813813
assert.Equal(t, "", word)
814814
assert.Equal(t, -1, idx)
815+
816+
b.Set("foo.")
817+
word, idx = b.getWordAtCursor(nil)
818+
assert.Equal(t, "", word)
819+
assert.Equal(t, -1, idx)
820+
word, idx = b.getWordAtCursor(StyleAutoCompleteDefault.WordDelimiters)
821+
assert.Equal(t, "foo.", word)
822+
assert.Equal(t, 0, idx)
815823
}
816824

817825
func TestLinesChangedMap(t *testing.T) {

prompt/prompt_autocomplete.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func (p *prompt) autoComplete(lines []string, cursorPos CursorLocation, startIdx
1919

2020
// get the line styling
2121
linePrefix, prefixWidth, _, numLen, _, _ := p.calculateLineStyling(lines)
22-
word, _ := p.buffer.getWordAtCursor()
22+
word, _ := p.buffer.getWordAtCursor(p.style.AutoComplete.WordDelimiters)
2323
wordLen := len(word)
2424

2525
// get the suggestions printed to super-impose on the displayed lines
@@ -72,14 +72,15 @@ func (p *prompt) updateSuggestionsInternal(lastLine string, lastWord string, las
7272
p.buffer.mutex.Lock()
7373
line := p.buffer.getCurrentLine()
7474
location := uint(p.buffer.cursor.Column)
75-
word, idx := p.buffer.getWordAtCursor()
75+
word, idx := p.buffer.getWordAtCursor(p.style.AutoComplete.WordDelimiters)
7676
p.buffer.mutex.Unlock()
77+
minChars := p.style.AutoComplete.MinChars
7778

7879
// if there is no word currently, clear drop-down
7980
forced := false
8081
if p.forcedAutoComplete() {
8182
forced = true
82-
} else if word == "" || idx < 0 {
83+
} else if word == "" || idx < 0 || (minChars > 0 && len(word) < minChars) {
8384
p.setSuggestions(make([]Suggestion, 0))
8485
p.clearDebugData("ac.")
8586
return line, word, idx

prompt/prompt_handle.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ var autoCompleteActionHandlerMap = map[Action]actionHandler{
5656
return nil
5757
},
5858
AutoCompleteSelect: func(p *prompt, output *termenv.Output, key tea.KeyMsg) error {
59-
word, _ := p.buffer.getWordAtCursor()
59+
word, _ := p.buffer.getWordAtCursor(p.style.AutoComplete.WordDelimiters)
6060
suggestions, suggestionsIdx := p.getSuggestionsAndIdx()
6161
if suggestionsIdx < len(suggestions) {
6262
suggestion := suggestions[suggestionsIdx].Value

prompt/prompt_model_test.go

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ func compareModelLines(t *testing.T, expected, actual []string, msg ...any) {
3333
}
3434

3535
func generateTestPrompt(t *testing.T, ctx context.Context) *prompt {
36+
out := strings.Builder{}
37+
3638
p := &prompt{}
3739
err := p.SetKeyMap(KeyMapDefault)
3840
if err != nil {
@@ -42,7 +44,7 @@ func generateTestPrompt(t *testing.T, ctx context.Context) *prompt {
4244
p.SetHistoryExecPrefix("!")
4345
p.SetHistoryListPrefix("!!")
4446
p.SetInput(os.Stdin)
45-
p.SetOutput(os.Stdout)
47+
p.SetOutput(&out)
4648
p.SetPrefixer(PrefixText("[" + t.Name() + "] "))
4749
p.SetRefreshInterval(DefaultRefreshInterval)
4850
p.SetStyle(StyleDefault)
@@ -88,7 +90,42 @@ func TestPrompt_updateModel(t *testing.T) {
8890
p.buffer.InsertString(`select` + ` * from dual`)
8991
p.updateModel(true)
9092
expectedLines := []string{
91-
"[TestPrompt_updateModel/simple_one-liner_with_line-numbers] \x1b[38;5;240;48;5;236m 1 \x1b[0m \x1b[38;5;81mselect\x1b[0m\x1b[38;5;231m \x1b[0m\x1b[38;5;197m*\x1b[0m\x1b[38;5;231m \x1b[0m\x1b[38;5;81mfrom\x1b[0m\x1b[38;5;231m \x1b[0m\x1b[38;5;231mdual\x1b[0m\x1b[38;5;232;48;5;6m \x1b[0m",
93+
"[TestPrompt_updateModel/simple_one-liner_with_line-numbers] \x1b[38;5;239;48;5;235m 1 \x1b[0m \x1b[38;5;81mselect\x1b[0m\x1b[38;5;231m \x1b[0m\x1b[38;5;197m*\x1b[0m\x1b[38;5;231m \x1b[0m\x1b[38;5;81mfrom\x1b[0m\x1b[38;5;231m \x1b[0m\x1b[38;5;231mdual\x1b[0m\x1b[38;5;232;48;5;6m \x1b[0m",
94+
}
95+
compareModelLines(t, expectedLines, p.linesToRender)
96+
})
97+
98+
t.Run("multi-liner with line-numbers and scroll-bar", func(t *testing.T) {
99+
p := generateTestPrompt(t, ctx)
100+
p.SetAutoCompleter(AutoCompleteSQLKeywords())
101+
p.SetSyntaxHighlighter(syntaxHighlighter)
102+
p.Style().LineNumbers = StyleLineNumbersEnabled
103+
p.Style().LineNumbers.ZeroPrefixed = true
104+
p.Style().Dimensions.HeightMin = 5
105+
p.Style().Dimensions.HeightMax = 5
106+
p.init(ctx)
107+
108+
testInput := "foo\nbar\nbaz\n"
109+
p.buffer.InsertString(testInput)
110+
p.updateModel(true)
111+
expectedLines := []string{
112+
"[TestPrompt_updateModel/multi-liner_with_line-numbers_and_scroll-bar] \x1b[38;5;239;48;5;235m 1 \x1b[0m \x1b[38;5;231mfoo\x1b[0m\x1b[38;5;231m",
113+
"[TestPrompt_updateModel/multi-liner_with_line-numbers_and_scroll-bar] \x1b[38;5;239;48;5;235m 2 \x1b[0m \x1b[0m\x1b[38;5;231mbar\x1b[0m\x1b[38;5;231m",
114+
"[TestPrompt_updateModel/multi-liner_with_line-numbers_and_scroll-bar] \x1b[38;5;239;48;5;235m 3 \x1b[0m \x1b[0m\x1b[38;5;231mbaz\x1b[0m\x1b[38;5;231m",
115+
"[TestPrompt_updateModel/multi-liner_with_line-numbers_and_scroll-bar] \x1b[38;5;239;48;5;235m 4 \x1b[0m \x1b[0m\x1b[38;5;232;48;5;6m \x1b[0m",
116+
"[TestPrompt_updateModel/multi-liner_with_line-numbers_and_scroll-bar] \x1b[38;5;239;48;5;235m \x1b[0m",
117+
}
118+
compareModelLines(t, expectedLines, p.linesToRender)
119+
120+
p.buffer.InsertString(testInput)
121+
p.buffer.InsertString(testInput)
122+
p.updateModel(true)
123+
expectedLines = []string{
124+
"[TestPrompt_updateModel/multi-liner_with_line-numbers_and_scroll-bar] \x1b[38;5;239;48;5;235m 06 \x1b[0m \x1b[0m\x1b[38;5;231mbaz\x1b[0m\x1b[38;5;231m \x1b[38;5;237;48;5;233m░\x1b[0m",
125+
"[TestPrompt_updateModel/multi-liner_with_line-numbers_and_scroll-bar] \x1b[38;5;239;48;5;235m 07 \x1b[0m \x1b[0m\x1b[38;5;231mfoo\x1b[0m\x1b[38;5;231m \x1b[38;5;237;48;5;233m░\x1b[0m",
126+
"[TestPrompt_updateModel/multi-liner_with_line-numbers_and_scroll-bar] \x1b[38;5;239;48;5;235m 08 \x1b[0m \x1b[0m\x1b[38;5;231mbar\x1b[0m\x1b[38;5;231m \x1b[38;5;237;48;5;233m░\x1b[0m",
127+
"[TestPrompt_updateModel/multi-liner_with_line-numbers_and_scroll-bar] \x1b[38;5;239;48;5;235m 09 \x1b[0m \x1b[0m\x1b[38;5;231mbaz\x1b[0m\x1b[38;5;231m \x1b[38;5;237;48;5;233m░\x1b[0m",
128+
"[TestPrompt_updateModel/multi-liner_with_line-numbers_and_scroll-bar] \x1b[38;5;239;48;5;235m 10 \x1b[0m \x1b[0m\x1b[38;5;232;48;5;6m \x1b[0m \x1b[38;5;237;48;5;233m█\x1b[0m",
92129
}
93130
compareModelLines(t, expectedLines, p.linesToRender)
94131
})

0 commit comments

Comments
 (0)