From df0ccb497d2176a0ec26cd69377a7750684c7581 Mon Sep 17 00:00:00 2001 From: miles-to-go Date: Mon, 29 Sep 2025 06:31:24 -0400 Subject: [PATCH 1/5] minimize allocs in line.go --- internal/core/line.go | 185 ++++++++++++++++--------------------- internal/core/line_test.go | 7 ++ 2 files changed, 87 insertions(+), 105 deletions(-) diff --git a/internal/core/line.go b/internal/core/line.go index 862f87eb..f1d7b1b1 100644 --- a/internal/core/line.go +++ b/internal/core/line.go @@ -2,10 +2,9 @@ package core import ( "fmt" - "regexp" + "slices" "strings" "unicode" - "unicode/utf8" "github.com/reeflective/readline/inputrc" "github.com/reeflective/readline/internal/color" @@ -32,33 +31,20 @@ func (l *Line) Set(chars ...rune) { // If the position is either negative or greater than the // length of the line, nothing is inserted. func (l *Line) Insert(pos int, chars ...rune) { - for { - // I don't really understand why `0` is creeping in at the - // end of the array but it only happens with unicode characters. - if len(chars) > 1 && chars[len(chars)-1] == 0 { - chars = chars[:len(chars)-1] - continue - } - - break + // I don't really understand why `0` is creeping in at the + // end of the array but it only happens with unicode characters. + end := len(chars) + for end > 0 && chars[end-1] == 0 { + end-- } + chars = chars[:end] // Invalid position cancels the insertion if pos < 0 || pos > l.Len() { return } - switch { - case l.Len() == 0: - *l = chars - case pos < l.Len(): - forward := string((*l)[pos:]) - cut := string(append((*l)[:pos], chars...)) - cut += forward - *l = []rune(cut) - case pos == l.Len(): - *l = append(*l, chars...) - } + *l = slices.Insert([]rune(*l), pos, chars...) } // InsertBetween inserts one or more runes into the line, between the specified @@ -71,17 +57,12 @@ func (l *Line) InsertBetween(bpos, epos int, chars ...rune) { return } - switch { - case epos == -1: + switch epos { + case -1: l.Insert(bpos, chars...) - case epos == l.Len(): - cut := string((*l)[:bpos]) + string(chars) - *l = []rune(cut) default: - forward := string((*l)[epos:]) - cut := string(append((*l)[:bpos], chars...)) - cut += forward - *l = []rune(cut) + *l = slices.Delete([]rune(*l), bpos, epos) + l.Insert(bpos, chars...) } } @@ -96,13 +77,9 @@ func (l *Line) Cut(bpos, epos int) { switch epos { case -1: - cut := string((*l)[:bpos]) - *l = []rune(cut) + *l = slices.Delete([]rune(*l), bpos, l.Len()) default: - forward := string((*l)[epos:]) - cut := string((*l)[:bpos]) - cut += forward - *l = []rune(cut) + *l = slices.Delete([]rune(*l), bpos, epos) } } @@ -113,24 +90,20 @@ func (l *Line) CutRune(pos int) { return } - switch { - case pos == 0: - *l = (*l)[1:] - case pos == l.Len(): - *l = (*l)[:pos-1] + switch pos { + case l.Len(): + *l = slices.Delete([]rune(*l), pos-1, pos) default: - forward := string((*l)[pos+1:]) - cut := string((*l)[:pos]) - cut += forward - *l = []rune(cut) + *l = slices.Delete([]rune(*l), pos, pos+1) } + } -// Len returns the length of the line, as given by ut8.RuneCount. -// This should NOT confused with the length of the line in terms of +// Len returns the length of the line. +// This should NOT be confused with the length of the line in terms of // how many terminal columns its printed representation will take. func (l *Line) Len() int { - return utf8.RuneCountInString(string(*l)) + return len(*l) } // SelectWord returns the begin and end index positions of a word @@ -145,37 +118,35 @@ func (l *Line) SelectWord(pos int) (bpos, epos int) { pos-- } - wordRgx := regexp.MustCompile("[0-9a-zA-Z_]") bpos, epos = pos, pos - if match := wordRgx.MatchString(string((*l)[pos])); !match { - wordRgx = regexp.MustCompile(`\s`) + isInWord := isAlphaNumUnderscore + if !isAlphaNumUnderscore((*l)[pos]) { + isInWord = unicode.IsSpace } // To first space found backward - for ; bpos >= 0; bpos-- { - if match := wordRgx.MatchString(string((*l)[bpos])); !match { - break - } + for bpos > 0 && isInWord((*l)[bpos-1]) { + bpos-- } // And to first space found forward - for ; epos < l.Len(); epos++ { - if match := wordRgx.MatchString(string((*l)[epos])); !match { - break - } - } - - bpos++ - - // Ending position must be greater than 0 - if epos > 0 { - epos-- + for epos < l.Len()-1 && isInWord((*l)[epos+1]) { + epos++ } return bpos, epos } +// isAlphaNumUnderscore returns true if r is in the character +// class `[0-9a-zA-Z_]`. +func isAlphaNumUnderscore(r rune) bool { + return (r >= '0' && r <= '9') || + (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + r == '_' +} + // SelectBlankWord returns the begin and end index positions // of a full bigword (blank word) around the specified position. func (l *Line) SelectBlankWord(pos int) (bpos, epos int) { @@ -188,35 +159,23 @@ func (l *Line) SelectBlankWord(pos int) (bpos, epos int) { pos-- } - blankWordRgx := regexp.MustCompile(`[^\s]`) - bpos, epos = pos, pos - if match := blankWordRgx.MatchString(string((*l)[pos])); !match { - blankWordRgx = regexp.MustCompile(`\s`) + isInWord := func(r rune) bool { + return !unicode.IsSpace(r) + } + if unicode.IsSpace((*l)[pos]) { + isInWord = unicode.IsSpace } // To first space found backward - for ; bpos >= 0; bpos-- { - escaped := bpos > 0 && (*l)[bpos-1] == '\\' - if match := blankWordRgx.MatchString(string((*l)[bpos])); !match && !escaped { - break - } + for bpos > 0 && isInWord((*l)[bpos-1]) { + bpos-- } // And to first space found forward - for ; epos < l.Len(); epos++ { - escaped := epos > 0 && (*l)[epos-1] == '\\' - if match := blankWordRgx.MatchString(string((*l)[epos])); !match && !escaped { - break - } - } - - bpos++ - - // Ending position must be greater than 0 - if epos > 0 { - epos-- + for epos < l.Len()-1 && isInWord((*l)[epos+1]) { + epos++ } return bpos, epos @@ -356,29 +315,39 @@ func DisplayLine(l *Line, indent int) { // Returns: // @x - The number of columns, starting from the terminal left, to the end of the last line. // @y - The number of actual lines on which the line spans, accounting for line wrap. -func CoordinatesLine(l *Line, indent int) (x, y int) { - line := string(*l) - lines := strings.Split(line, "\n") - usedY, usedX := 0, 0 +func CoordinatesLine(l *Line, indent int) (int, int) { + var usedY, usedX, lineStart, lineIdx int - for i, line := range lines { - x, y := strutil.LineSpan([]rune(line), i, indent) - usedY += y - usedX = x + for i, r := range *l { + if r == '\n' { + _, y := strutil.LineSpan((*l)[lineStart:i], lineIdx, indent) + usedY += y + + lineStart = i + 1 + lineIdx++ + } } + // Last line + x, y := strutil.LineSpan((*l)[lineStart:], lineIdx, indent) + usedY += y + usedX = x + return usedX, usedY } // Lines returns the number of real lines in the input buffer. // If there are no newlines, the result is 0, otherwise it's -// the number of lines - 1. +// the number of newlines - 1. func (l *Line) Lines() int { - line := string(*l) - nl := regexp.MustCompile(string(inputrc.Newline)) - lines := nl.FindAllStringIndex(line, -1) + var count int + for _, r := range *l { + if r == inputrc.Newline { + count++ + } + } - return len(lines) + return count } // Forward returns the offset to the beginning of the next @@ -681,11 +650,17 @@ func closeToken(idx, count, cpos, match int, pos map[int]int, line []rune, split // newlines gives the indexes of all newline characters in the line. func (l *Line) newlines() [][]int { - line := string(*l) - line += string(inputrc.Newline) - nl := regexp.MustCompile(string(inputrc.Newline)) + var indices [][]int + + for i, r := range *l { + if r == inputrc.Newline { + indices = append(indices, []int{i, i + 1}) + } + } + + indices = append(indices, []int{l.Len(), l.Len() + 1}) - return nl.FindAllStringIndex(line, -1) + return indices } // returns bpos, epos ordered and true if either is valid. diff --git a/internal/core/line_test.go b/internal/core/line_test.go index 606499d6..21cf72b6 100644 --- a/internal/core/line_test.go +++ b/internal/core/line_test.go @@ -1074,6 +1074,13 @@ func TestCoordinatesLine(t *testing.T) { wantX int wantY int }{ + { + name: "Empty line buffer", + l: new(Line), + args: args{indent: indent}, + wantY: 0, + wantX: indent, + }, { name: "Single line buffer", l: &line, From c15991da62018f541462dd704a3e1106bca24023 Mon Sep 17 00:00:00 2001 From: miles-to-go Date: Tue, 30 Sep 2025 11:56:30 -0400 Subject: [PATCH 2/5] preallocate capacity for slices --- internal/keymap/dispatch.go | 2 +- internal/strutil/key.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/keymap/dispatch.go b/internal/keymap/dispatch.go index 0befc293..f59f21a1 100644 --- a/internal/keymap/dispatch.go +++ b/internal/keymap/dispatch.go @@ -181,7 +181,7 @@ func (m *Engine) matchBind(keys []byte, binds map[string]inputrc.Bind) (inputrc. var prefixed []inputrc.Bind // Make a sorted list with all keys in the binds map. - var sequences []string + sequences := make([]string, 0, len(binds)) for sequence := range binds { sequences = append(sequences, sequence) } diff --git a/internal/strutil/key.go b/internal/strutil/key.go index b55f969e..ba12145c 100644 --- a/internal/strutil/key.go +++ b/internal/strutil/key.go @@ -9,7 +9,7 @@ func ConvertMeta(keys []rune) string { return string(keys) } - converted := make([]rune, 0) + converted := make([]rune, 0, len(keys)) for i := 0; i < len(keys); i++ { char := keys[i] From 624b136fda848b9867091b487aa3072c8920e2f5 Mon Sep 17 00:00:00 2001 From: miles-to-go Date: Tue, 30 Sep 2025 12:37:03 -0400 Subject: [PATCH 3/5] add DisplayLine test cases --- internal/core/line_test.go | 53 +++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/internal/core/line_test.go b/internal/core/line_test.go index 21cf72b6..0b7d8945 100644 --- a/internal/core/line_test.go +++ b/internal/core/line_test.go @@ -1,9 +1,13 @@ package core import ( + "bytes" + "io" + "os" "reflect" "testing" + "github.com/reeflective/readline/internal/color" "github.com/reeflective/readline/internal/term" ) @@ -1037,6 +1041,10 @@ func TestLine_TokenizeBlock(t *testing.T) { } func TestDisplayLine(t *testing.T) { + indent := 10 + line := Line("basic -f \"commands.go,line.go\" -cp=/usr --option [value1 value2]") + multiline := Line("basic -f \"commands.go \nanother testing\" --alternate \"another\nquote\" -v { expression here } -a [value1 value2]") + type args struct { indent int } @@ -1044,15 +1052,58 @@ func TestDisplayLine(t *testing.T) { name string l *Line args args + want string }{ - // TODO: Add test cases. + { + name: "Empty line buffer", + l: new(Line), + args: args{indent: indent}, + want: color.BgDefault, + }, + { + name: "Single line buffer", + l: &line, + args: args{indent: indent}, + want: string(line) + color.BgDefault, + }, + { + name: "Multiline buffer", + l: &multiline, + args: args{indent: indent}, + want: "basic -f \"commands.go " + color.BgDefault + term.ClearLineAfter + "\r\n" + + "\x1b[10C" + term.ClearLineBefore + "another testing\" --alternate \"another" + color.BgDefault + term.ClearLineAfter + "\r\n" + + "\x1b[10C" + term.ClearLineBefore + "quote\" -v { expression here } -a [value1 value2]" + color.BgDefault, + }, } + savedStdout := os.Stdout + for _, tt := range tests { + r, w, err := os.Pipe() + if err != nil { + os.Stdout = savedStdout + t.Fatalf("pipe: %s", err) + } + + os.Stdout = w + t.Run(tt.name, func(t *testing.T) { DisplayLine(tt.l, tt.args.indent) }) + + w.Close() + + var buf bytes.Buffer + io.Copy(&buf, r) + + if buf.String() != tt.want { + t.Errorf("DisplayLine: got\n%q\nwant\n%q", buf.String(), tt.want) + } + + r.Close() } + + os.Stdout = savedStdout } func TestCoordinatesLine(t *testing.T) { From 22ac9820353adb85f9393ba37e163f371162ccd2 Mon Sep 17 00:00:00 2001 From: miles-to-go Date: Tue, 30 Sep 2025 13:18:10 -0400 Subject: [PATCH 4/5] combine DisplayLine prints --- internal/core/line.go | 47 +++++++++++++++++++++----------------- internal/core/line_test.go | 10 ++++++++ 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/internal/core/line.go b/internal/core/line.go index f1d7b1b1..1c518654 100644 --- a/internal/core/line.go +++ b/internal/core/line.go @@ -278,33 +278,38 @@ func (l *Line) SurroundQuotes(single bool, pos int) (bpos, epos int) { // Params: // @indent - Used to align all lines (except the first) together on a single column. func DisplayLine(l *Line, indent int) { - lines := strings.Split(string(*l), "\n") + var builtLine strings.Builder + var lineLen int - if strings.HasSuffix(string(*l), "\n") { - lines = append(lines, "") - } - - for num, line := range lines { - // Don't let any visual selection go further than length. - line += color.BgDefault - - // Clear everything before each line, except the first. - if num > 0 { - term.MoveCursorForwards(indent) - line = term.ClearLineBefore + line - } - - // Clear everything after each line, except the last. - if num < len(lines)-1 { - if len(line)+indent < term.GetWidth() { - line += term.ClearLineAfter + for _, r := range *l { + if r == '\n' { + builtLine.WriteString(color.BgDefault) + if lineLen < term.GetWidth() { + builtLine.WriteString(term.ClearLineAfter) } + builtLine.WriteString(term.NewlineReturn) + builtLine.WriteString(fmt.Sprintf("\x1b[%dC", indent)) // Equivalent of term.MoveCursorForwards + builtLine.WriteString(term.ClearLineBefore) - line += term.NewlineReturn + lineLen = 0 + } else { + builtLine.WriteRune(r) + lineLen++ } - fmt.Print(line) } + + if l.Len() > 0 && (*l)[l.Len()-1] == '\n' { + builtLine.WriteString(color.BgDefault) + builtLine.WriteString(term.ClearLineAfter) + builtLine.WriteString(term.NewlineReturn) + builtLine.WriteString(fmt.Sprintf("\x1b[%dC", indent)) // Equivalent of term.MoveCursorForwards + builtLine.WriteString(term.ClearLineBefore) + } + + builtLine.WriteString(color.BgDefault) + + fmt.Print(builtLine.String()) } // CoordinatesLine returns the number of real terminal lines on which the input line spans, considering diff --git a/internal/core/line_test.go b/internal/core/line_test.go index 0b7d8945..df0ad103 100644 --- a/internal/core/line_test.go +++ b/internal/core/line_test.go @@ -1044,6 +1044,7 @@ func TestDisplayLine(t *testing.T) { indent := 10 line := Line("basic -f \"commands.go,line.go\" -cp=/usr --option [value1 value2]") multiline := Line("basic -f \"commands.go \nanother testing\" --alternate \"another\nquote\" -v { expression here } -a [value1 value2]") + longMultiline := Line("longer than 80 characters, which is the term width reported when running go test \nanother line ending on newline \n") type args struct { indent int @@ -1074,6 +1075,15 @@ func TestDisplayLine(t *testing.T) { "\x1b[10C" + term.ClearLineBefore + "another testing\" --alternate \"another" + color.BgDefault + term.ClearLineAfter + "\r\n" + "\x1b[10C" + term.ClearLineBefore + "quote\" -v { expression here } -a [value1 value2]" + color.BgDefault, }, + { + name: "Long multiline buffer", + l: &longMultiline, + args: args{indent: indent}, + want: "longer than 80 characters, which is the term width reported when running go test " + color.BgDefault + "\r\n" + + "\x1b[10C" + term.ClearLineBefore + "another line ending on newline " + color.BgDefault + term.ClearLineAfter + "\r\n" + + "\x1b[10C" + term.ClearLineBefore + color.BgDefault + term.ClearLineAfter + "\r\n" + + "\x1b[10C" + term.ClearLineBefore + color.BgDefault, + }, } savedStdout := os.Stdout From bee9f1e028891835bb589b82cced590bb3af1c6c Mon Sep 17 00:00:00 2001 From: miles-to-go Date: Tue, 30 Sep 2025 16:09:37 -0400 Subject: [PATCH 5/5] bracketed paste --- emacs.go | 25 +++++++++++++++++++++++-- readline.go | 5 +++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/emacs.go b/emacs.go index 5dfbe148..13dcdabd 100644 --- a/emacs.go +++ b/emacs.go @@ -3,6 +3,7 @@ package readline import ( "fmt" "io" + "slices" "sort" "strings" "unicode" @@ -12,6 +13,7 @@ import ( "github.com/reeflective/readline/inputrc" "github.com/reeflective/readline/internal/color" "github.com/reeflective/readline/internal/completion" + "github.com/reeflective/readline/internal/core" "github.com/reeflective/readline/internal/keymap" "github.com/reeflective/readline/internal/strutil" "github.com/reeflective/readline/internal/term" @@ -448,8 +450,27 @@ func (rl *Shell) selfInsert() { } func (rl *Shell) bracketedPasteBegin() { - // keys, _ := rl.Keys.PeekAllBytes() - // fmt.Println(string(keys)) + // Length of bracketed paste escape code; this is the minimum length + // we will see here. + sequence := make([]byte, 0, 6) + + for { + key, empty := core.PopKey(rl.Keys) + if empty { + core.WaitAvailableKeys(rl.Keys, rl.Config) + continue + } + + sequence = append(sequence, key) + + if len(sequence) >= 6 && slices.Equal(sequence[len(sequence)-6:], []byte{'\x1b', '[', '2', '0', '1', '~'}) { + break + } + } + + if len(sequence) > 6 { + rl.cursor.InsertAt([]rune(string(sequence[:len(sequence)-6]))...) + } } // Drag the character before point forward over the character diff --git a/readline.go b/readline.go index d510a11b..64876d2b 100644 --- a/readline.go +++ b/readline.go @@ -57,6 +57,11 @@ func (rl *Shell) Readline() (string, error) { } defer term.Restore(descriptor, state) + if rl.Config.GetBool("enable-bracketed-paste") { + fmt.Print("\x1b[?2004h") + defer fmt.Print("\x1b[?2004l") + } + // Prompts and cursor styles rl.Display.PrintPrimaryPrompt() defer rl.Display.RefreshTransient()