From 1f7990158a37cb49210c962e82a9c4b04c3aa02f Mon Sep 17 00:00:00 2001 From: simeji Date: Sat, 4 Jul 2026 12:32:20 +0900 Subject: [PATCH 01/10] chore: remove unreachable break after panic (fixes go vet) --- engine.go | 1 - 1 file changed, 1 deletion(-) diff --git a/engine.go b/engine.go index 181bf99..28af6e8 100644 --- a/engine.go +++ b/engine.go @@ -296,7 +296,6 @@ func (e *Engine) Run() EngineResultInterface { } case termbox.EventError: panic(ev.Err) - break default: } } From afad54151428946a2197835b53a4cbbd24fad035 Mon Sep 17 00:00:00 2001 From: simeji Date: Sat, 4 Jul 2026 12:32:20 +0900 Subject: [PATCH 02/10] bench: add micro-benchmarks for hot paths Add benchmarks covering the per-keystroke hot path: query validation, keyword parsing, JSON filtering (legacy and JMESPath), pretty-printing, suggestion candidates, terminal cell conversion, and key-line search. Fixtures are generated deterministically in code (no testdata files). --- bench_test.go | 241 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 bench_test.go diff --git a/bench_test.go b/bench_test.go new file mode 100644 index 0000000..05b2499 --- /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") + } + } + }) + } +} From eb075b69bfa1c56b958e3af2b052c9a6857b1dd0 Mon Sep 17 00:00:00 2001 From: simeji Date: Sat, 4 Jul 2026 12:34:45 +0900 Subject: [PATCH 03/10] perf: hoist static regexes to package-level vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate(), GetKeywords(), isJMESPathQuery(), getFilteredDataLegacy(), getItem(), and Suggestion.Get() compiled their regexes on every call — up to ~10 compilations per keystroke. Compile them once at package init, following the existing reWildcardFieldTyping/reWildcardIndexed pattern. The two literal-only alternations in isJMESPathQuery become plain strings.Contains checks (exactly equivalent). --- json_manager.go | 19 ++++++++++++------- query.go | 30 +++++++++++++++++++++--------- suggestion.go | 6 +++++- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/json_manager.go b/json_manager.go index 1ac67d7..c0aa85d 100644 --- a/json_manager.go +++ b/json_manager.go @@ -15,6 +15,14 @@ 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]+)\]`) +) + type JsonManager struct { current *simplejson.Json origin *simplejson.Json @@ -81,7 +89,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 +97,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 @@ -510,12 +518,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 +547,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..eae7066 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 { @@ -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/suggestion.go b/suggestion.go index 18659b5..6e73af4 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 == "[" { From d954aa6e563a89bb52a1e5f22ed3cdd2825174be Mon Sep 17 00:00:00 2001 From: simeji Date: Sat, 4 Jul 2026 12:36:05 +0900 Subject: [PATCH 04/10] perf: cache dynamic regex compiles; use strings.Index in drawCandidates Suggestion prefix patterns are built from user input, so they cannot be precompiled. Memoize successful compiles in a sync.Map keyed by pattern (bounded by distinct keystrokes per session); failed compiles keep the exact regexp.Compile error behavior. The keyword is intentionally NOT QuoteMeta'd where it wasn't before, preserving keyword-as-regex matching. drawCandidates matched the selected candidate with a per-frame regex of literal parts only ([[:space:]] can only match the plain spaces the rows are built with), so a strings.Index search is exactly equivalent. --- query.go | 2 +- regex_cache.go | 37 +++++++++++++++++++++++++++++++++++++ suggestion.go | 8 ++++---- terminal.go | 10 +++++++--- 4 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 regex_cache.go diff --git a/query.go b/query.go index eae7066..e290da7 100644 --- a/query.go +++ b/query.go @@ -217,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, "") 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 6e73af4..1faafad 100644 --- a/suggestion.go +++ b/suggestion.go @@ -183,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} @@ -200,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{} } @@ -294,7 +294,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{} } @@ -347,7 +347,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..a99df23 100644 --- a/terminal.go +++ b/terminal.go @@ -3,7 +3,6 @@ package jid import ( "fmt" "io" - "regexp" "strings" runewidth "github.com/mattn/go-runewidth" @@ -319,7 +318,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 +335,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 { From 6f89096d3bfa888f9d365ae8ad1e8058f1037e47 Mon Sep 17 00:00:00 2001 From: simeji Date: Sat, 4 Jul 2026 12:37:42 +0900 Subject: [PATCH 05/10] perf: memoize GetFilteredData/GetPretty and evalJMESPath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine calls GetPretty once per frame even when the query is unchanged (scrolling, candidate cycling, cursor moves), re-running the filter and re-marshaling the entire result each time. origin/originData are write-once, so results are pure functions of (query, confirm): a single-entry memo makes repeat frames free. evalJMESPath gets a small bounded cache as well — one keystroke can evaluate the same expression several times via evalBaseExpr and the suggestion paths, each round-tripping through json.Marshal + simplejson.NewJson. Cached results are safe to share: all callers only read the returned simplejson values and reassign (never mutate) the slices. --- json_manager.go | 86 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 82 insertions(+), 4 deletions(-) diff --git a/json_manager.go b/json_manager.go index c0aa85d..2e5eb6f 100644 --- a/json_manager.go +++ b/json_manager.go @@ -23,11 +23,49 @@ var ( 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). + evalCache map[string]evalEntry } func NewJsonManager(reader io.Reader) (*JsonManager, error) { @@ -70,10 +108,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 } @@ -132,6 +184,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 @@ -217,11 +286,20 @@ 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) + 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 From 8f2973be9c1eba267d1052a388d0c48e8d8ce999 Mon Sep 17 00:00:00 2001 From: simeji Date: Sat, 4 Jul 2026 12:37:42 +0900 Subject: [PATCH 06/10] perf: skip pretty marshal in keymode getContents pretty-printed the filtered JSON and immediately discarded the string when keymode is active. Call GetFilteredData directly in that branch; the marshal error was already ignored at this call site. --- engine.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/engine.go b/engine.go index 28af6e8..a1b4e86 100644 --- a/engine.go +++ b/engine.go @@ -302,15 +302,15 @@ func (e *Engine) Run() EngineResultInterface { } 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) + return e.candidates } - return contents + var c string + c, e.complete, e.candidates, _ = e.manager.GetPretty(e.query, e.queryConfirm) + return strings.Split(c, "\n") } func (e *Engine) setCandidateData() { From aa94ab1be0b134407cd7e5a51228fea45406f9c2 Mon Sep 17 00:00:00 2001 From: simeji Date: Sat, 4 Jul 2026 12:40:41 +0900 Subject: [PATCH 07/10] perf: reduce allocations in suggestion keys, rowsToCells, highlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getCurrentKeys built two slices and used a strings.Builder per dotted key; build one preallocated slice, sort raw keys, escape in place (ordering stays defined on the unescaped names). rowsToCells rejoined all rows into one string every frame to feed the jsoncolor formatter even though the engine already had the exact pretty string — pass it through as TerminalDrawAttributes.ContentsRaw. The monochrome fallback now preallocates its cell slices. highlightCandidateKey allocated rune slices just to count runes; use utf8.RuneCountInString and pre-size the row builder. --- bench_test.go | 4 ++-- engine.go | 6 ++++++ suggestion.go | 21 +++++++-------------- terminal.go | 47 +++++++++++++++++++++++++++++------------------ 4 files changed, 44 insertions(+), 34 deletions(-) diff --git a/bench_test.go b/bench_test.go index 05b2499..9838bc7 100644 --- a/bench_test.go +++ b/bench_test.go @@ -183,7 +183,7 @@ func BenchmarkRowsToCellsColor(b *testing.B) { term := NewTerminal(FilterPrompt, DefaultY, false) b.ResetTimer() for i := 0; i < b.N; i++ { - if _, err := term.rowsToCells(rows); err != nil { + if _, err := term.rowsToCells(rows, ""); err != nil { b.Fatal(err) } } @@ -194,7 +194,7 @@ func BenchmarkRowsToCellsMono(b *testing.B) { term := NewTerminal(FilterPrompt, DefaultY, true) b.ResetTimer() for i := 0; i < b.N; i++ { - if _, err := term.rowsToCells(rows); err != nil { + if _, err := term.rowsToCells(rows, ""); err != nil { b.Fatal(err) } } diff --git a/engine.go b/engine.go index a1b4e86..0406f7b 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], @@ -306,10 +310,12 @@ func (e *Engine) getContents() []string { // 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 } var c string c, e.complete, e.candidates, _ = e.manager.GetPretty(e.query, e.queryConfirm) + e.contentsRaw = c return strings.Split(c, "\n") } diff --git a/suggestion.go b/suggestion.go index 1faafad..4888690 100644 --- a/suggestion.go +++ b/suggestion.go @@ -213,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 diff --git a/terminal.go b/terminal.go index a99df23..5cddf28 100644 --- a/terminal.go +++ b/terminal.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "strings" + "unicode/utf8" runewidth "github.com/mattn/go-runewidth" termbox "github.com/nsf/termbox-go" @@ -19,16 +20,19 @@ type Terminal struct { } 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 } @@ -75,7 +79,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 } @@ -218,21 +222,27 @@ 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") + } + + *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, @@ -281,6 +291,7 @@ func (t *Terminal) drawFuncHelp(x int, y int, help string) { // 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) } @@ -302,8 +313,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 From 7f5783352aebc241da0b3e0b92e97eff5807c811 Mon Sep 17 00:00:00 2001 From: simeji Date: Sat, 4 Jul 2026 12:40:54 +0900 Subject: [PATCH 08/10] perf: cache formatted terminal cells across unchanged frames Scrolling and candidate cycling redraw identical content, but every frame re-ran the jsoncolor formatter over the whole document. Cache the converted cells keyed by the raw content string and reuse them when the content is unchanged. Only the outer slice is copied per frame: Draw replaces rows with highlighted copies (highlightCandidateKey is copy-on-write, pinned by TestHighlightCandidateKeyOriginalUnmodified), and the row slices themselves are never mutated. --- terminal.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/terminal.go b/terminal.go index 5cddf28..3faea41 100644 --- a/terminal.go +++ b/terminal.go @@ -17,6 +17,13 @@ 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 { @@ -228,6 +235,11 @@ func (t *Terminal) rowsToCells(rows []string, raw string) ([][]termbox.Cell, err 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{{}} @@ -254,6 +266,10 @@ func (t *Terminal) rowsToCells(rows []string, raw string) ([][]termbox.Cell, err } } + t.lastCellsKey = raw + t.lastCells = append([][]termbox.Cell(nil), cells...) + t.lastCellsOK = true + return cells, nil } From 1ef18ff85df5ba4452478e01679a9fc2963cdb3c Mon Sep 17 00:00:00 2001 From: simeji Date: Sat, 4 Jul 2026 22:05:16 +0900 Subject: [PATCH 09/10] perf: scope eval cache to a single GetFilteredData computation The eval cache could retain up to 64 full result trees across query changes, multiplying memory usage on large inputs. Clear it whenever the (query, confirm) key misses: all evalJMESPath calls happen inside that computation, so the cache still deduplicates repeated evaluations within one filtering pass while releasing results once the query moves on. Addresses Codex review feedback. --- json_manager.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/json_manager.go b/json_manager.go index 2e5eb6f..17ba879 100644 --- a/json_manager.go +++ b/json_manager.go @@ -65,6 +65,9 @@ type JsonManager struct { 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 } @@ -292,6 +295,11 @@ func (jm *JsonManager) GetFilteredData(q QueryInterface, confirm bool) (*simplej return r.json, r.suggestion, r.candidates, r.err } + // 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) From d47b952eb640524bf848eddba9600738da8c09b3 Mon Sep 17 00:00:00 2001 From: simeji Date: Sat, 4 Jul 2026 22:24:03 +0900 Subject: [PATCH 10/10] chore: remove dead code moveCursorWordBackwark/moveCursorWordForward were empty stubs with no callers; drawFuncHelp was superseded by the inline rendering in Draw. --- engine.go | 4 ---- terminal.go | 14 -------------- 2 files changed, 18 deletions(-) diff --git a/engine.go b/engine.go index 0406f7b..df36648 100644 --- a/engine.go +++ b/engine.go @@ -748,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/terminal.go b/terminal.go index 3faea41..c14c693 100644 --- a/terminal.go +++ b/terminal.go @@ -287,20 +287,6 @@ 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.