diff --git a/bench_test.go b/bench_test.go new file mode 100644 index 0000000..9838bc7 --- /dev/null +++ b/bench_test.go @@ -0,0 +1,241 @@ +package jid + +import ( + "bytes" + "fmt" + "strings" + "testing" +) + +// genLargeJSON returns a deterministic JSON document with n array elements +// (~n * 200 bytes). Shape mirrors real jid usage: a top-level object with a +// large "users" array of nested objects, scalar fields, and a key containing +// a dot to exercise the escaped-key path. +func genLargeJSON(n int) []byte { + var sb strings.Builder + sb.Grow(n * 200) + sb.WriteString(`{"a.b":"dotkey","meta":{"count":`) + fmt.Fprintf(&sb, "%d", n) + sb.WriteString(`,"source":"bench"},"users":[`) + for i := 0; i < n; i++ { + if i > 0 { + sb.WriteByte(',') + } + fmt.Fprintf(&sb, + `{"id":%d,"name":"user%d","age":%d,"active":%t,"tags":["alpha","beta"],"address":{"city":"city%d","zip":"%07d","country":"JP"}}`, + i, i, 20+i%50, i%2 == 0, i, i) + } + sb.WriteString(`]}`) + return []byte(sb.String()) +} + +func benchJsonManager(b *testing.B, n int) *JsonManager { + b.Helper() + jm, err := NewJsonManager(bytes.NewReader(genLargeJSON(n))) + if err != nil { + b.Fatal(err) + } + return jm +} + +// benchPrettyRows returns the pretty-printed root document as display rows. +func benchPrettyRows(b *testing.B, n int) []string { + b.Helper() + jm := benchJsonManager(b, n) + s, _, _, err := jm.GetPretty(NewQueryWithString("."), false) + if err != nil { + b.Fatal(err) + } + return strings.Split(s, "\n") +} + +func BenchmarkGetPretty(b *testing.B) { + for _, n := range []int{100, 5000} { + for _, qs := range []string{".", ".users[0].address"} { + b.Run(fmt.Sprintf("n=%d/q=%s", n, qs), func(b *testing.B) { + jm := benchJsonManager(b, n) + q := NewQueryWithString(qs) + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, _, _, err := jm.GetPretty(q, false); err != nil { + b.Fatal(err) + } + } + }) + } + } +} + +func BenchmarkGetFilteredDataLegacy(b *testing.B) { + for _, n := range []int{100, 5000} { + b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) { + jm := benchJsonManager(b, n) + q := NewQueryWithString(".users[0].na") + b.ResetTimer() + for i := 0; i < b.N; i++ { + jm.GetFilteredData(q, false) + } + }) + } +} + +func BenchmarkGetFilteredDataJMESPath(b *testing.B) { + for _, n := range []int{100, 5000} { + for _, qs := range []string{".users[*].name", ". | keys(@)"} { + b.Run(fmt.Sprintf("n=%d/q=%s", n, qs), func(b *testing.B) { + jm := benchJsonManager(b, n) + q := NewQueryWithString(qs) + b.ResetTimer() + for i := 0; i < b.N; i++ { + jm.GetFilteredData(q, false) + } + }) + } + } +} + +func BenchmarkEvalJMESPath(b *testing.B) { + for _, n := range []int{100, 5000} { + b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) { + jm := benchJsonManager(b, n) + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := jm.evalJMESPath("users[*].name"); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func BenchmarkValidate(b *testing.B) { + r := []rune(".users[3].address.city") + b.ResetTimer() + for i := 0; i < b.N; i++ { + if !validate(r) { + b.Fatal("expected valid query") + } + } +} + +func BenchmarkGetKeywords(b *testing.B) { + q := NewQueryWithString(".users[3].address.ci") + b.ResetTimer() + for i := 0; i < b.N; i++ { + if kw := q.GetKeywords(); len(kw) == 0 { + b.Fatal("expected keywords") + } + } +} + +// benchWideObject returns a JsonManager whose root object has many keys, +// to exercise suggestion candidate generation. +func benchWideObjectManager(b *testing.B) *JsonManager { + b.Helper() + var sb strings.Builder + sb.WriteByte('{') + for i := 0; i < 50; i++ { + if i > 0 { + sb.WriteByte(',') + } + fmt.Fprintf(&sb, `"key%02d":%d`, i, i) + } + sb.WriteByte('}') + jm, err := NewJsonManager(strings.NewReader(sb.String())) + if err != nil { + b.Fatal(err) + } + return jm +} + +func BenchmarkSuggestionGet(b *testing.B) { + jm := benchWideObjectManager(b) + s := NewSuggestion() + b.ResetTimer() + for i := 0; i < b.N; i++ { + s.Get(jm.origin, "key1") + } +} + +func BenchmarkGetCandidateKeys(b *testing.B) { + jm := benchWideObjectManager(b) + s := NewSuggestion() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if c := s.GetCandidateKeys(jm.origin, "key"); len(c) == 0 { + b.Fatal("expected candidates") + } + } +} + +func BenchmarkGetCurrentKeys(b *testing.B) { + jm := benchWideObjectManager(b) + b.ResetTimer() + for i := 0; i < b.N; i++ { + if k := getCurrentKeys(jm.origin); len(k) == 0 { + b.Fatal("expected keys") + } + } +} + +func BenchmarkRowsToCellsColor(b *testing.B) { + rows := benchPrettyRows(b, 5000) + term := NewTerminal(FilterPrompt, DefaultY, false) + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := term.rowsToCells(rows, ""); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkRowsToCellsMono(b *testing.B) { + rows := benchPrettyRows(b, 5000) + term := NewTerminal(FilterPrompt, DefaultY, true) + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := term.rowsToCells(rows, ""); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkHighlightCandidateKey(b *testing.B) { + cells := makeCells(` "name": "user42",`) + b.ResetTimer() + for i := 0; i < b.N; i++ { + highlightCandidateKey(cells, "name", 6) + } +} + +func BenchmarkFindKeyLineInContents(b *testing.B) { + rows := benchPrettyRows(b, 5000) + b.ResetTimer() + for i := 0; i < b.N; i++ { + if line, _ := findKeyLineInContents(rows, "name"); line < 0 { + b.Fatal("expected key to be found") + } + } +} + +// BenchmarkEngineGetContents measures the per-frame cost of the engine's +// content pipeline (filter + pretty-print + split) without termbox. +func BenchmarkEngineGetContents(b *testing.B) { + for _, n := range []int{100, 5000} { + b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) { + jm := benchJsonManager(b, n) + e := &Engine{ + manager: jm, + query: NewQueryWithString(".users[0]"), + complete: []string{"", ""}, + candidates: []string{}, + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + if c := e.getContents(); len(c) == 0 { + b.Fatal("expected contents") + } + } + }) + } +} diff --git a/engine.go b/engine.go index 181bf99..df36648 100644 --- a/engine.go +++ b/engine.go @@ -54,6 +54,9 @@ type Engine struct { cfg Config // candidate key highlighting / auto-scroll candidateScrollNeeded bool + // raw pretty string the current contents were split from ("" in keymode); + // passed to the terminal so it can skip re-joining the rows + contentsRaw string // quit requested via quit keybinding quitRequested bool } @@ -188,6 +191,7 @@ func (e *Engine) Run() EngineResultInterface { ta := &TerminalDrawAttributes{ Query: e.query.StringGet(), Contents: contents, + ContentsRaw: e.contentsRaw, CandidateIndex: e.candidateidx, ContentsOffsetY: e.contentOffset, Complete: e.complete[0], @@ -296,22 +300,23 @@ func (e *Engine) Run() EngineResultInterface { } case termbox.EventError: panic(ev.Err) - break default: } } } func (e *Engine) getContents() []string { - var c string - var contents []string - c, e.complete, e.candidates, _ = e.manager.GetPretty(e.query, e.queryConfirm) if e.keymode { - contents = e.candidates - } else { - contents = strings.Split(c, "\n") + // Key mode only displays the candidates; skip pretty-printing the + // filtered JSON (its output was discarded here anyway). + _, e.complete, e.candidates, _ = e.manager.GetFilteredData(e.query, e.queryConfirm) + e.contentsRaw = "" + return e.candidates } - return contents + var c string + c, e.complete, e.candidates, _ = e.manager.GetPretty(e.query, e.queryConfirm) + e.contentsRaw = c + return strings.Split(c, "\n") } func (e *Engine) setCandidateData() { @@ -743,10 +748,6 @@ func (e *Engine) moveCursorForward() { } } -func (e *Engine) moveCursorWordBackwark() { -} -func (e *Engine) moveCursorWordForward() { -} func (e *Engine) moveCursorToTop() { e.clearPlaceholder() e.queryCursorIdx = 0 diff --git a/json_manager.go b/json_manager.go index 1ac67d7..17ba879 100644 --- a/json_manager.go +++ b/json_manager.go @@ -15,11 +15,60 @@ import ( var fastjson = jsoniter.ConfigCompatibleWithStandardLibrary +// Hot-path regexes, compiled once at package init. These run on every +// keystroke via isJMESPathQuery / getFilteredDataLegacy / getItem. +var ( + reJMESFuncCall = regexp.MustCompile(`[a-z_]+\(`) + reOpenBracketSuffix = regexp.MustCompile(`\[[0-9]*$`) + reArrayIndex = regexp.MustCompile(`\[([0-9]+)\]`) +) + +// fdKey identifies one GetFilteredData computation. origin/originData are +// write-once (set in NewJsonManager), so results are pure functions of this key. +type fdKey struct { + qs string + confirm bool +} + +// fdResult caches one GetFilteredData result. The cached json is only ever +// read (Array/Map/GetIndex/Interface) and the slices are only reassigned, +// never mutated in place, by all callers — so returning the same values +// repeatedly is safe. +type fdResult struct { + json *simplejson.Json + suggestion []string + candidates []string + err error +} + +type evalEntry struct { + json *simplejson.Json + err error +} + +// evalCacheMax bounds the per-keystroke JMESPath evaluation cache; when +// exceeded the whole map is dropped and rebuilt. +const evalCacheMax = 64 + type JsonManager struct { current *simplejson.Json origin *simplejson.Json originData interface{} suggestion *Suggestion + // Single-entry memo for GetFilteredData/GetPretty. The TUI calls them + // once per frame with an unchanged query while scrolling or cycling + // candidates. Single-goroutine access only (termbox event loop). + lastFDKey fdKey + lastFD *fdResult // nil = no cached entry + lastPretty string // MarshalIndent output for lastFDKey + lastPrettyErr error + lastPrettyOK bool + // evalCache memoizes evalJMESPath results: one keystroke can evaluate + // the same expression several times (base expr, rewrites, suggestions). + // Scoped to a single GetFilteredData computation — cleared on every + // cache miss there — so result trees don't outlive the frame that + // produced them. + evalCache map[string]evalEntry } func NewJsonManager(reader io.Reader) (*JsonManager, error) { @@ -62,10 +111,24 @@ func (jm *JsonManager) Get(q QueryInterface, confirm bool) (string, []string, [] } func (jm *JsonManager) GetPretty(q QueryInterface, confirm bool) (string, []string, []string, error) { + key := fdKey{q.StringGet(), confirm} j, suggestion, candidates, _ := jm.GetFilteredData(q, confirm) + if jm.lastPrettyOK && jm.lastFDKey == key { + if jm.lastPrettyErr != nil { + return "", []string{"", ""}, []string{"", ""}, jm.lastPrettyErr + } + return jm.lastPretty, suggestion, candidates, nil + } s, err := fastjson.MarshalIndent(j.Interface(), "", " ") if err != nil { - return "", []string{"", ""}, []string{"", ""}, errors.Wrap(err, "failure json encode") + wrapped := errors.Wrap(err, "failure json encode") + if jm.lastFD != nil && jm.lastFDKey == key { + jm.lastPretty, jm.lastPrettyErr, jm.lastPrettyOK = "", wrapped, true + } + return "", []string{"", ""}, []string{"", ""}, wrapped + } + if jm.lastFD != nil && jm.lastFDKey == key { + jm.lastPretty, jm.lastPrettyErr, jm.lastPrettyOK = string(s), nil, true } return string(s), suggestion, candidates, nil } @@ -81,7 +144,7 @@ func isJMESPathQuery(qs string) bool { return true } // wildcard array projection [*] or .* - if regexp.MustCompile(`\[\*\]|\.\*`).MatchString(inner) { + if strings.Contains(inner, "[*]") || strings.Contains(inner, ".*") { return true } // filter expression [? @@ -89,7 +152,7 @@ func isJMESPathQuery(qs string) bool { return true } // function call: word( - if regexp.MustCompile(`[a-z_]+\(`).MatchString(inner) { + if reJMESFuncCall.MatchString(inner) { return true } // multi-select hash or bare @ reference @@ -124,6 +187,23 @@ func jmespathExprFromQuery(qs string) string { // evalJMESPath evaluates a JMESPath expression against the raw JSON data and // returns the result as a *simplejson.Json. func (jm *JsonManager) evalJMESPath(expr string) (*simplejson.Json, error) { + if e, ok := jm.evalCache[expr]; ok { + return e.json, e.err + } + j, err := jm.evalJMESPathUncached(expr) + // Errors are cached too: evaluation is deterministic on the immutable + // originData. Drop the whole map when it grows past the bound. + if len(jm.evalCache) >= evalCacheMax { + jm.evalCache = nil + } + if jm.evalCache == nil { + jm.evalCache = make(map[string]evalEntry) + } + jm.evalCache[expr] = evalEntry{j, err} + return j, err +} + +func (jm *JsonManager) evalJMESPathUncached(expr string) (*simplejson.Json, error) { result, err := jmespath.Search(expr, jm.originData) if err != nil { return nil, err @@ -209,11 +289,25 @@ func pipeSuffix(qs string) string { func (jm *JsonManager) GetFilteredData(q QueryInterface, confirm bool) (*simplejson.Json, []string, []string, error) { qs := q.StringGet() - if isJMESPathQuery(qs) { - return jm.getFilteredDataJMESPath(qs, confirm) + key := fdKey{qs, confirm} + if jm.lastFD != nil && jm.lastFDKey == key { + r := jm.lastFD + return r.json, r.suggestion, r.candidates, r.err } - return jm.getFilteredDataLegacy(q, confirm) + // The query changed: release eval results kept for the previous one. + // All evalJMESPath calls happen inside the computation below, so this + // keeps the cache scoped to deduplicating within one filtering pass. + jm.evalCache = nil + + var r fdResult + if isJMESPathQuery(qs) { + r.json, r.suggestion, r.candidates, r.err = jm.getFilteredDataJMESPath(qs, confirm) + } else { + r.json, r.suggestion, r.candidates, r.err = jm.getFilteredDataLegacy(q, confirm) + } + jm.lastFDKey, jm.lastFD, jm.lastPrettyOK = key, &r, false + return r.json, r.suggestion, r.candidates, r.err } // isFunctionTypingMode returns true when the user appears to be mid-typing a @@ -510,12 +604,10 @@ func (jm *JsonManager) getFilteredDataLegacy(q QueryInterface, confirm bool) (*s for _, keyword := range keywords[0:idx] { json, _ = getItem(json, keyword) } - reg := regexp.MustCompile(`\[[0-9]*$`) - suggest := jm.suggestion.Get(json, lastKeyword) candidateKeys := jm.suggestion.GetCandidateKeys(json, lastKeyword) // hash - if len(reg.FindString(lastKeyword)) < 1 { + if len(reOpenBracketSuffix.FindString(lastKeyword)) < 1 { candidateNum := len(candidateKeys) if j, exist := getItem(json, lastKeyword); exist && (confirm || candidateNum == 1) { json = j @@ -541,8 +633,7 @@ func getItem(json *simplejson.Json, s string) (*simplejson.Json, bool) { var result *simplejson.Json var exist bool - re := regexp.MustCompile(`\[([0-9]+)\]`) - matches := re.FindStringSubmatch(s) + matches := reArrayIndex.FindStringSubmatch(s) if s == "" { return json, false diff --git a/query.go b/query.go index 1ca498f..e290da7 100644 --- a/query.go +++ b/query.go @@ -7,6 +7,19 @@ import ( runewidth "github.com/mattn/go-runewidth" ) +// Hot-path regexes, compiled once at package init. validate() and +// GetKeywords() run on every keystroke. +var ( + reKeywordBracket = regexp.MustCompile(`\[[0-9]*\]?`) + reValNoLeadingDot = regexp.MustCompile(`^[^.]`) + reValPipeExpr = regexp.MustCompile(`\|\s*\S`) + reValWildcardOrFilter = regexp.MustCompile(`\[\*\]|\[\?`) + reValMultiDot = regexp.MustCompile(`\.{2,}`) + reValBadAfterIndex = regexp.MustCompile(`\[[0-9]*\][^\.\[| ]`) + reValDoubleBracket = regexp.MustCompile(`\[{2,}|\]{2,}`) + reValDotBracket = regexp.MustCompile(`[^|]\.\[`) +) + type QueryInterface interface { Get() []rune Set(query []rune) []rune @@ -164,8 +177,7 @@ func (q *Query) GetKeywords() [][]rune { keywords := [][]rune{} for i, keyword := range splitQuery { if keyword != "" || i == lastIdx { - re := regexp.MustCompile(`\[[0-9]*\]?`) - matchIndexes := re.FindAllStringIndex(keyword, -1) + matchIndexes := reKeywordBracket.FindAllStringIndex(keyword, -1) if len(matchIndexes) < 1 { keywords = append(keywords, []rune(keyword)) } else { @@ -205,7 +217,7 @@ func (q *Query) PopKeyword() ([]rune, []rune) { break } } - re := regexp.MustCompile(`(\.)?(\\")?` + regexp.QuoteMeta(nq) + "$") + re := mustCompileCached(`(\.)?(\\")?` + regexp.QuoteMeta(nq) + "$") qq = re.ReplaceAllString(qq, "") @@ -243,27 +255,27 @@ func validate(r []rune) bool { if s == "" { return true } - if regexp.MustCompile(`^[^.]`).MatchString(s) { + if reValNoLeadingDot.MatchString(s) { return false } // Allow JMESPath pipe expressions: `. | func()` - if regexp.MustCompile(`\|\s*\S`).MatchString(s) { + if reValPipeExpr.MatchString(s) { return true } // Allow JMESPath wildcards and filter expressions - if regexp.MustCompile(`\[\*\]|\[\?`).MatchString(s) { + if reValWildcardOrFilter.MatchString(s) { return true } - if regexp.MustCompile(`\.{2,}`).MatchString(s) { + if reValMultiDot.MatchString(s) { return false } - if regexp.MustCompile(`\[[0-9]*\][^\.\[| ]`).MatchString(s) { + if reValBadAfterIndex.MatchString(s) { return false } - if regexp.MustCompile(`\[{2,}|\]{2,}`).MatchString(s) { + if reValDoubleBracket.MatchString(s) { return false } - if regexp.MustCompile(`[^|]\.\[`).MatchString(s) { + if reValDotBracket.MatchString(s) { return false } return true diff --git a/regex_cache.go b/regex_cache.go new file mode 100644 index 0000000..b422d00 --- /dev/null +++ b/regex_cache.go @@ -0,0 +1,37 @@ +package jid + +import ( + "regexp" + "strconv" + "sync" +) + +// reCache memoizes successful regexp compiles for patterns that are built +// from user input on every keystroke (e.g. "(?i)^" + keyword). Growth is +// bounded by the distinct patterns typed in one interactive session. +var reCache sync.Map // string -> *regexp.Regexp + +// compileCached is a drop-in replacement for regexp.Compile in hot paths. +// Failed compiles are not cached, so error behavior is identical to +// regexp.Compile. +func compileCached(pattern string) (*regexp.Regexp, error) { + if v, ok := reCache.Load(pattern); ok { + return v.(*regexp.Regexp), nil + } + re, err := regexp.Compile(pattern) + if err != nil { + return nil, err + } + reCache.Store(pattern, re) + return re, nil +} + +// mustCompileCached is a drop-in replacement for regexp.MustCompile in hot +// paths: it panics on invalid patterns, matching MustCompile behavior. +func mustCompileCached(pattern string) *regexp.Regexp { + re, err := compileCached(pattern) + if err != nil { + panic(`regexp: Compile(` + strconv.Quote(pattern) + `): ` + err.Error()) + } + return re +} diff --git a/suggestion.go b/suggestion.go index 18659b5..4888690 100644 --- a/suggestion.go +++ b/suggestion.go @@ -8,6 +8,10 @@ import ( simplejson "github.com/bitly/go-simplejson" ) +// reIndexKeyword matches an array-index keyword like "[0]", "[", or "[]". +// Compiled once at package init; Suggestion.Get runs on every keystroke. +var reIndexKeyword = regexp.MustCompile(`\[([0-9]+)?\]?`) + type SuggestionInterface interface { Get(json *simplejson.Json, keyword string) []string GetCandidateKeys(json *simplejson.Json, keyword string) []string @@ -135,7 +139,7 @@ func (s *Suggestion) Get(json *simplejson.Json, keyword string) []string { if a, err := json.Array(); err == nil { if len(a) > 1 { - kw := regexp.MustCompile(`\[([0-9]+)?\]?`).FindString(keyword) + kw := reIndexKeyword.FindString(keyword) if kw == "" { return []string{"[", "["} } else if kw == "[" { @@ -179,7 +183,7 @@ func (s *Suggestion) Get(json *simplejson.Json, keyword string) []string { suggestion = suggestion[0 : max+1] } } - if reg, err := regexp.Compile("(?i)^" + keyword); err == nil { + if reg, err := compileCached("(?i)^" + keyword); err == nil { completion = reg.ReplaceAllString(suggestion, "") } return []string{completion, suggestion} @@ -196,7 +200,7 @@ func (s *Suggestion) GetCandidateKeys(json *simplejson.Json, keyword string) []s return getCurrentKeys(json) } - reg, err := regexp.Compile(`(?i)^(\\")?` + keyword + `(\\")?`) + reg, err := compileCached(`(?i)^(\\")?` + keyword + `(\\")?`) if err != nil { return []string{} } @@ -209,31 +213,24 @@ func (s *Suggestion) GetCandidateKeys(json *simplejson.Json, keyword string) []s } func getCurrentKeys(json *simplejson.Json) []string { - - kk := []string{} m, err := json.Map() if err != nil { - return kk + return []string{} } + kk := make([]string, 0, len(m)) for k := range m { kk = append(kk, k) } + // Sort raw keys first, then escape in place: candidate ordering is + // defined on the unescaped key names. sort.Strings(kk) - - keys := []string{} - for _, k := range kk { + for i, k := range kk { if strings.Contains(k, ".") { - var sb strings.Builder - sb.Grow(len(k) + 4) - sb.WriteString(`\"`) - sb.WriteString(k) - sb.WriteString(`\"`) - k = sb.String() + kk[i] = `\"` + k + `\"` } - keys = append(keys, k) } - return keys + return kk } // jmespathFunctionsByType maps the primary input type to the subset of functions @@ -290,7 +287,7 @@ func (s *Suggestion) GetFunctionCandidatesFiltered(prefix string, t SuggestionDa } return out } - reg, err := regexp.Compile(`(?i)^` + regexp.QuoteMeta(prefix)) + reg, err := compileCached(`(?i)^` + regexp.QuoteMeta(prefix)) if err != nil { return []string{} } @@ -343,7 +340,7 @@ func (s *Suggestion) GetFunctionSuggestionFiltered(prefix string, t SuggestionDa return []string{"", ""} } // completion is the remaining characters after what has been typed - reg, err := regexp.Compile(`(?i)^` + regexp.QuoteMeta(prefix)) + reg, err := compileCached(`(?i)^` + regexp.QuoteMeta(prefix)) if err != nil { return []string{"", ""} } diff --git a/terminal.go b/terminal.go index 17d4a75..c14c693 100644 --- a/terminal.go +++ b/terminal.go @@ -3,8 +3,8 @@ package jid import ( "fmt" "io" - "regexp" "strings" + "unicode/utf8" runewidth "github.com/mattn/go-runewidth" termbox "github.com/nsf/termbox-go" @@ -17,19 +17,29 @@ type Terminal struct { formatter *jsoncolor.Formatter monochrome bool outputArea *[][]termbox.Cell + // Formatted-cell cache: colorizing the whole document dominates frame + // cost, and scrolling / candidate cycling redraws identical content. + // Rows in lastCells are never mutated (highlightCandidateKey is + // copy-on-write), so only the outer slice needs copying per frame. + lastCellsKey string + lastCells [][]termbox.Cell + lastCellsOK bool } type TerminalDrawAttributes struct { - Query string - Contents []string - CandidateIndex int - ContentsOffsetY int - Complete string - Candidates []string - CursorOffset int - FuncHelp string - PlaceholderStart int // rune index in Query; -1 if no placeholder - PlaceholderLen int + Query string + Contents []string + // ContentsRaw, when non-empty, is the exact string Contents was split + // from — lets rowsToCells skip re-joining the rows. + ContentsRaw string + CandidateIndex int + ContentsOffsetY int + Complete string + Candidates []string + CursorOffset int + FuncHelp string + PlaceholderStart int // rune index in Query; -1 if no placeholder + PlaceholderLen int SelectedCandidate string // field name to highlight in JSON; "" if none SelectedCandidateIndent int // indentation level of the target key } @@ -76,7 +86,7 @@ func (t *Terminal) Draw(attr *TerminalDrawAttributes) error { y++ } - cellsArr, err := t.rowsToCells(rows) + cellsArr, err := t.rowsToCells(rows, attr.ContentsRaw) if err != nil { return err } @@ -219,21 +229,32 @@ func (t *Terminal) initColorizeFormatter() *jsoncolor.Formatter { return formatter } -func (t *Terminal) rowsToCells(rows []string) ([][]termbox.Cell, error) { - *t.outputArea = [][]termbox.Cell{[]termbox.Cell{}} +// rowsToCells converts display rows to termbox cells. raw, when non-empty, +// must equal strings.Join(rows, "\n") and avoids rebuilding that string. +func (t *Terminal) rowsToCells(rows []string, raw string) ([][]termbox.Cell, error) { + if raw == "" { + raw = strings.Join(rows, "\n") + } + if t.lastCellsOK && raw == t.lastCellsKey { + // The caller replaces rows with highlighted copies, so hand out a + // fresh outer slice; the row slices themselves are read-only. + return append([][]termbox.Cell(nil), t.lastCells...), nil + } + + *t.outputArea = [][]termbox.Cell{{}} var err error if t.formatter != nil { - err = t.formatter.Format(io.Discard, []byte(strings.Join(rows, "\n"))) + err = t.formatter.Format(io.Discard, []byte(raw)) } cells := *t.outputArea if err != nil || t.monochrome { - cells = [][]termbox.Cell{} + cells = make([][]termbox.Cell, 0, len(rows)) for _, row := range rows { - var cls []termbox.Cell + cls := make([]termbox.Cell, 0, len(row)) for _, char := range row { cls = append(cls, termbox.Cell{ Ch: char, @@ -245,6 +266,10 @@ func (t *Terminal) rowsToCells(rows []string) ([][]termbox.Cell, error) { } } + t.lastCellsKey = raw + t.lastCells = append([][]termbox.Cell(nil), cells...) + t.lastCellsOK = true + return cells, nil } @@ -262,26 +287,13 @@ func (t *Terminal) drawCells(x int, y int, cells []termbox.Cell) { } } -func (t *Terminal) drawFuncHelp(x int, y int, help string) { - fg := termbox.ColorYellow - bg := termbox.ColorDefault - i := 0 - for _, ch := range help { - termbox.SetCell(x+i, y, ch, fg, bg) - w := runewidth.RuneWidth(ch) - if w == 0 || w == 2 && runewidth.IsAmbiguousWidth(ch) { - w = 1 - } - i += w - } -} - // highlightCandidateKey highlights the JSON key matching `key` in a row of cells // by applying a yellow background, but only when the key's indentation equals // targetIndent. This prevents nested keys with the same name from being highlighted. // Returns the original slice if the key is not found or is at the wrong indent. func highlightCandidateKey(cells []termbox.Cell, key string, targetIndent int) []termbox.Cell { var sb strings.Builder + sb.Grow(len(cells)) for _, c := range cells { sb.WriteRune(c.Ch) } @@ -303,8 +315,8 @@ func highlightCandidateKey(cells []termbox.Cell, key string, targetIndent int) [ } result := make([]termbox.Cell, len(cells)) copy(result, cells) - runeStart := len([]rune(rowStr[:idx])) - patternRuneLen := len([]rune(pattern)) + runeStart := utf8.RuneCountInString(rowStr[:idx]) + patternRuneLen := utf8.RuneCountInString(pattern) for i := runeStart; i < runeStart+patternRuneLen && i < len(result); i++ { result[i].Fg = termbox.ColorBlack result[i].Bg = termbox.ColorYellow @@ -319,7 +331,9 @@ func (t *Terminal) drawCandidates(x int, y int, index int, candidates []string) w, _ := termbox.Size() ss := candidates[index] - re := regexp.MustCompile("[[:space:]]" + regexp.QuoteMeta(ss) + "[[:space:]]") + // Rows are built below with plain " " separators, so matching the selected + // candidate surrounded by spaces needs no regex. + needle := " " + ss + " " var rows []string var str string @@ -334,7 +348,10 @@ func (t *Terminal) drawCandidates(x int, y int, index int, candidates []string) rows = append(rows, str+" ") for i, row := range rows { - match := re.FindStringIndex(row) + var match []int + if idx := strings.Index(row, needle); idx >= 0 { + match = []int{idx, idx + len(needle)} + } var c termbox.Attribute ii := 0 for k, s := range row {