Skip to content

Commit e29aa00

Browse files
authored
Merge pull request #1742 from entireio/evis/ent-1130-code-search-improve-readability-of-terminal
Improve readability of code search results in the terminal
2 parents aacb4d6 + 37160e4 commit e29aa00

2 files changed

Lines changed: 263 additions & 18 deletions

File tree

cmd/entire/cli/search_cmd.go

Lines changed: 130 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"sort"
1010
"strings"
1111
"time"
12+
"unicode/utf8"
1213

1314
tea "charm.land/bubbletea/v2"
1415
"github.com/entireio/cli/cmd/entire/cli/api"
@@ -125,6 +126,7 @@ branch:<name>, repo:<owner/name>, and repo:* to search all accessible repos.`,
125126
query: codeQuery,
126127
repoFilters: codeRepos,
127128
limit: limitFlag,
129+
limitExplicit: cmd.Flags().Changed("limit"),
128130
caseSensitive: caseSensitive,
129131
jsonOutput: jsonOutput,
130132
insecureHTTP: insecureHTTPAuth,
@@ -375,6 +377,7 @@ type codeSearchOpts struct {
375377
repoFilters []string
376378
resolvedRepoIDs []string // ULIDs resolved from repoFilters via repo index
377379
limit int
380+
limitExplicit bool // user passed --limit; don't override for text display
378381
caseSensitive bool
379382
jsonOutput bool
380383
insecureHTTP bool
@@ -476,6 +479,14 @@ func runCodeSearch(ctx context.Context, cmd *cobra.Command, opts codeSearchOpts)
476479
}
477480

478481
w := cmd.OutOrStdout()
482+
textOutput := !opts.jsonOutput && interactive.IsTerminalWriter(w)
483+
484+
// Text output shows up to maxCodeSearchFiles files with a few matches
485+
// each, so fetch a deeper result set than the default --limit (which is
486+
// tuned for flat JSON output) unless the user asked for a specific limit.
487+
if textOutput && !opts.limitExplicit {
488+
opts.limit = codeSearchTextFetchLimit
489+
}
479490

480491
// Always fan out via searchAllCells — it fetches the repo index,
481492
// resolves slugs to ULIDs, and handles single- vs multi-jurisdiction.
@@ -484,12 +495,11 @@ func runCodeSearch(ctx context.Context, cmd *cobra.Command, opts codeSearchOpts)
484495
return err
485496
}
486497

487-
isTerminal := interactive.IsTerminalWriter(w)
488-
if opts.jsonOutput || !isTerminal {
498+
if !textOutput {
489499
return writeCodeSearchJSON(w, resp)
490500
}
491501

492-
writeCodeSearchText(w, resp)
502+
writeCodeSearchText(w, resp, newStatusStyles(w), opts.caseSensitive)
493503
return nil
494504
}
495505

@@ -803,8 +813,20 @@ func writeCodeSearchJSON(w io.Writer, resp *codesearch.SearchResponse) error {
803813
// with an ellipsis so that JSONL/minified files don't blow up the terminal.
804814
const maxContextLineLen = 200
805815

806-
// writeCodeSearchText renders code search results in grep-style format.
807-
func writeCodeSearchText(w io.Writer, resp *codesearch.SearchResponse) {
816+
// Text output display caps: show breadth (files) over depth (in-file matches).
817+
// The fetch limit leaves headroom beyond files×matches so per-file overflow
818+
// ("+ N matches") counts have data to count.
819+
const (
820+
maxCodeSearchFiles = 10 // files shown in text output
821+
maxCodeSearchFileMatches = 3 // matches shown per file
822+
codeSearchTextFetchLimit = 100 // results fetched for text display
823+
)
824+
825+
// writeCodeSearchText renders code search results grouped by file (ripgrep
826+
// style): a colored "repo:path" header per file, indented line-numbered
827+
// matches beneath it, and a dimmed stats footer. Colors are applied only when
828+
// the writer supports them (styles.colorEnabled); piped output stays plain.
829+
func writeCodeSearchText(w io.Writer, resp *codesearch.SearchResponse, styles statusStyles, caseSensitive bool) {
808830
if len(resp.Results) == 0 {
809831
if len(resp.FailedJurisdictions) > 0 {
810832
fmt.Fprintf(w, "No code search results found (some regions failed: %s)\n",
@@ -814,26 +836,120 @@ func writeCodeSearchText(w io.Writer, resp *codesearch.SearchResponse) {
814836
}
815837
return
816838
}
839+
840+
// Group results by repo:path, preserving first-appearance order so the
841+
// best-scored file stays on top (results arrive globally score-sorted).
842+
type fileGroup struct {
843+
key string
844+
results []codesearch.Result
845+
}
846+
var groups []fileGroup
847+
idx := make(map[string]int, len(resp.Results))
817848
for _, r := range resp.Results {
818-
line := r.ContextLine
819-
runes := []rune(line)
820-
if len(runes) > maxContextLineLen {
821-
line = string(runes[:maxContextLineLen]) + "…"
849+
key := r.Repo + ":" + r.Path
850+
i, ok := idx[key]
851+
if !ok {
852+
i = len(groups)
853+
idx[key] = i
854+
groups = append(groups, fileGroup{key: key})
822855
}
823-
fmt.Fprintf(w, "%s:%s:%d: %s\n", r.Repo, r.Path, r.Line, line)
856+
groups[i].results = append(groups[i].results, r)
824857
}
825-
shown := len(resp.Results)
858+
859+
shown := 0
860+
for gi, g := range groups {
861+
if gi == maxCodeSearchFiles {
862+
break
863+
}
864+
fmt.Fprintln(w)
865+
fmt.Fprintln(w, styles.render(styles.cyan, g.key))
866+
for mi, r := range g.results {
867+
if mi == maxCodeSearchFileMatches {
868+
break
869+
}
870+
// Truncate before highlighting but append the ellipsis after,
871+
// so the non-ASCII "…" doesn't disable case-insensitive
872+
// highlighting (isASCII) for the rest of the line.
873+
line := r.ContextLine
874+
ellipsis := ""
875+
if runes := []rune(line); len(runes) > maxContextLineLen {
876+
line = string(runes[:maxContextLineLen])
877+
ellipsis = "…"
878+
}
879+
lineNo := styles.render(styles.dim, fmt.Sprintf("%d:", r.Line))
880+
fmt.Fprintf(w, " %s %s%s\n", lineNo, highlightCodeMatches(line, resp.Query, styles, caseSensitive), ellipsis)
881+
shown++
882+
}
883+
// ponytail: overflow counts only what this page fetched (peregrine
884+
// has no per-file totals); a hot file shows "+ 97 matches" at most.
885+
if extra := len(g.results) - maxCodeSearchFileMatches; extra > 0 {
886+
label := "matches"
887+
if extra == 1 {
888+
label = "match"
889+
}
890+
fmt.Fprintln(w, styles.render(styles.dim, fmt.Sprintf(" + %d %s", extra, label)))
891+
}
892+
}
893+
894+
var summary string
826895
if resp.Stats.TotalMatches > shown {
827-
fmt.Fprintf(w, "\nShowing %d of %d matches across %d files in %d repos (%.0fms)\n",
896+
summary = fmt.Sprintf("Showing %d of %d matches across %d files in %d repos (%.0fms)",
828897
shown, resp.Stats.TotalMatches, resp.Stats.TotalFiles, resp.Stats.ReposSearched, resp.Stats.DurationMs)
829898
} else {
830-
fmt.Fprintf(w, "\n%d matches across %d files in %d repos (%.0fms)\n",
899+
summary = fmt.Sprintf("%d matches across %d files in %d repos (%.0fms)",
831900
resp.Stats.TotalMatches, resp.Stats.TotalFiles, resp.Stats.ReposSearched, resp.Stats.DurationMs)
832901
}
902+
fmt.Fprintf(w, "\n%s\n", styles.render(styles.dim, summary))
833903
if len(resp.FailedJurisdictions) > 0 {
834-
fmt.Fprintf(w, "Warning: results may be incomplete (failed jurisdictions: %s)\n",
904+
warning := fmt.Sprintf("Warning: results may be incomplete (failed jurisdictions: %s)",
835905
strings.Join(resp.FailedJurisdictions, ", "))
906+
fmt.Fprintln(w, styles.render(styles.yellow, warning))
907+
}
908+
}
909+
910+
// highlightCodeMatches bold-red highlights occurrences of query in line
911+
// (grep convention). Matching mirrors the search: case-insensitive unless
912+
// caseSensitive is set. Case folding is only applied when both strings are
913+
// pure ASCII, since Unicode case mappings can change byte widths and
914+
// misalign offsets against the original line; non-ASCII input falls back to
915+
// exact matching. Returns line unchanged when color is disabled or there's
916+
// nothing to highlight.
917+
func highlightCodeMatches(line, query string, styles statusStyles, caseSensitive bool) string {
918+
if !styles.colorEnabled || query == "" {
919+
return line
920+
}
921+
haystack, needle := line, query
922+
if !caseSensitive && isASCII(line) && isASCII(query) {
923+
haystack, needle = strings.ToLower(line), strings.ToLower(query)
924+
}
925+
matchStyle := styles.red.Bold(true)
926+
var b strings.Builder
927+
i := 0
928+
for {
929+
j := strings.Index(haystack[i:], needle)
930+
if j < 0 {
931+
break
932+
}
933+
j += i
934+
b.WriteString(line[i:j])
935+
b.WriteString(matchStyle.Render(line[j : j+len(needle)]))
936+
i = j + len(needle)
937+
}
938+
if i == 0 {
939+
return line // no matches; skip the builder copy
940+
}
941+
b.WriteString(line[i:])
942+
return b.String()
943+
}
944+
945+
// isASCII reports whether s contains only ASCII bytes.
946+
func isASCII(s string) bool {
947+
for i := range len(s) {
948+
if s[i] >= utf8.RuneSelf {
949+
return false
950+
}
836951
}
952+
return true
837953
}
838954

839955
// writeSearchJSON writes client-side paginated search results as JSON.

cmd/entire/cli/search_cmd_test.go

Lines changed: 133 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"errors"
7+
"fmt"
78
"strings"
89
"testing"
910

@@ -167,15 +168,119 @@ func TestWriteCodeSearchText(t *testing.T) {
167168
}
168169

169170
var buf bytes.Buffer
170-
writeCodeSearchText(&buf, resp)
171+
writeCodeSearchText(&buf, resp, newStatusStyles(&buf), false)
171172

172173
output := buf.String()
173-
if !strings.Contains(output, "entireio/cli:main.go:10: func main() {") {
174+
if !strings.Contains(output, "entireio/cli:main.go\n") {
175+
t.Errorf("output missing file header:\n%s", output)
176+
}
177+
if !strings.Contains(output, " 10: func main() {") {
174178
t.Errorf("output missing first result:\n%s", output)
175179
}
180+
if !strings.Contains(output, " 42: \tfmt.Println(\"hello\")") {
181+
t.Errorf("output missing second result:\n%s", output)
182+
}
176183
if !strings.Contains(output, "2 matches across 1 files") {
177184
t.Errorf("output missing summary line:\n%s", output)
178185
}
186+
if strings.Contains(output, "\x1b[") {
187+
t.Errorf("expected no ANSI codes for non-terminal writer:\n%s", output)
188+
}
189+
}
190+
191+
func TestWriteCodeSearchText_GroupsByFile(t *testing.T) {
192+
t.Parallel()
193+
194+
// Interleaved files (score-sorted input) should collapse into one header
195+
// per file, in first-appearance order.
196+
resp := &codesearch.SearchResponse{
197+
Stats: codesearch.Stats{TotalMatches: 3, TotalFiles: 2, ReposSearched: 1, DurationMs: 1},
198+
Results: []codesearch.Result{
199+
{Repo: "r", Path: "a.go", Line: 1, ContextLine: "one"},
200+
{Repo: "r", Path: "b.go", Line: 2, ContextLine: "two"},
201+
{Repo: "r", Path: "a.go", Line: 3, ContextLine: "three"},
202+
},
203+
}
204+
205+
var buf bytes.Buffer
206+
writeCodeSearchText(&buf, resp, newStatusStyles(&buf), false)
207+
208+
output := buf.String()
209+
if got := strings.Count(output, "r:a.go\n"); got != 1 {
210+
t.Errorf("expected exactly 1 header for a.go, got %d:\n%s", got, output)
211+
}
212+
if aIdx, bIdx := strings.Index(output, "r:a.go"), strings.Index(output, "r:b.go"); aIdx > bIdx {
213+
t.Errorf("expected a.go header before b.go:\n%s", output)
214+
}
215+
}
216+
217+
func TestWriteCodeSearchText_CapsFilesAndMatchesPerFile(t *testing.T) {
218+
t.Parallel()
219+
220+
var results []codesearch.Result
221+
// First file has 5 matches — 2 over the per-file cap.
222+
for line := 1; line <= maxCodeSearchFileMatches+2; line++ {
223+
results = append(results, codesearch.Result{Repo: "r", Path: "hot.go", Line: line, ContextLine: "x"})
224+
}
225+
// More files than the file cap.
226+
for f := range maxCodeSearchFiles + 3 {
227+
results = append(results, codesearch.Result{Repo: "r", Path: fmt.Sprintf("f%02d.go", f), Line: 1, ContextLine: "y"})
228+
}
229+
resp := &codesearch.SearchResponse{
230+
Stats: codesearch.Stats{TotalMatches: len(results), TotalFiles: maxCodeSearchFiles + 4, ReposSearched: 1},
231+
Results: results,
232+
}
233+
234+
var buf bytes.Buffer
235+
writeCodeSearchText(&buf, resp, newStatusStyles(&buf), false)
236+
output := buf.String()
237+
238+
if got := strings.Count(output, "r:"); got != maxCodeSearchFiles {
239+
t.Errorf("expected %d file headers, got %d:\n%s", maxCodeSearchFiles, got, output)
240+
}
241+
if !strings.Contains(output, "+ 2 matches") {
242+
t.Errorf("expected '+ 2 matches' overflow for hot.go:\n%s", output)
243+
}
244+
// hot.go shows only the per-file cap: lines 1..3, not 4/5.
245+
if strings.Contains(output, fmt.Sprintf(" %d: x", maxCodeSearchFileMatches+1)) {
246+
t.Errorf("expected at most %d matches for hot.go:\n%s", maxCodeSearchFileMatches, output)
247+
}
248+
}
249+
250+
func TestHighlightCodeMatches(t *testing.T) {
251+
t.Parallel()
252+
253+
styles := statusStyles{colorEnabled: true}
254+
255+
out := highlightCodeMatches("func HandleRequest(w)", "handlerequest", styles, false)
256+
if !strings.Contains(out, "\x1b[") {
257+
t.Errorf("expected ANSI codes in highlighted output, got %q", out)
258+
}
259+
if !strings.HasPrefix(out, "func ") || !strings.HasSuffix(out, "(w)") {
260+
t.Errorf("expected unmatched text preserved around highlight, got %q", out)
261+
}
262+
263+
if out := highlightCodeMatches("no match here", "zzz", styles, false); out != "no match here" {
264+
t.Errorf("expected unchanged line when no match, got %q", out)
265+
}
266+
267+
// Case-sensitive search must not highlight case variants.
268+
if out := highlightCodeMatches("func HandleRequest(w)", "handlerequest", styles, true); out != "func HandleRequest(w)" {
269+
t.Errorf("expected no highlight for case mismatch with caseSensitive, got %q", out)
270+
}
271+
272+
// Non-ASCII input falls back to exact matching (no case folding).
273+
if out := highlightCodeMatches("comment ÉTÉ ici", "été", styles, false); out != "comment ÉTÉ ici" {
274+
t.Errorf("expected no case-folded highlight for non-ASCII input, got %q", out)
275+
}
276+
if out := highlightCodeMatches("comment été ici", "été", styles, false); !strings.Contains(out, "\x1b[") {
277+
t.Errorf("expected exact non-ASCII match highlighted, got %q", out)
278+
}
279+
280+
plain := statusStyles{colorEnabled: false}
281+
if out := highlightCodeMatches("func main()", "main", plain, false); out != "func main()" {
282+
t.Errorf("expected unchanged line when color disabled, got %q", out)
283+
}
179284
}
180285

181286
func TestWriteCodeSearchJSON(t *testing.T) {
@@ -218,7 +323,7 @@ func TestWriteCodeSearchText_TruncatesLongLines(t *testing.T) {
218323
}
219324

220325
var buf bytes.Buffer
221-
writeCodeSearchText(&buf, resp)
326+
writeCodeSearchText(&buf, resp, newStatusStyles(&buf), false)
222327

223328
output := buf.String()
224329
if strings.Contains(output, longLine) {
@@ -234,6 +339,30 @@ func TestWriteCodeSearchText_TruncatesLongLines(t *testing.T) {
234339
}
235340
}
236341

342+
func TestWriteCodeSearchText_HighlightsTruncatedLines(t *testing.T) {
343+
t.Parallel()
344+
345+
// The appended "…" is non-ASCII; it must not disable case-insensitive
346+
// highlighting for an otherwise ASCII line.
347+
longLine := "FooBar " + strings.Repeat("x", 300)
348+
resp := &codesearch.SearchResponse{
349+
Query: "foobar",
350+
Stats: codesearch.Stats{TotalMatches: 1, TotalFiles: 1, ReposSearched: 1, DurationMs: 1},
351+
Results: []codesearch.Result{{Repo: "r", Path: "f.go", Line: 1, ContextLine: longLine}},
352+
}
353+
354+
var buf bytes.Buffer
355+
writeCodeSearchText(&buf, resp, statusStyles{colorEnabled: true}, false)
356+
357+
output := buf.String()
358+
if !strings.Contains(output, "…") {
359+
t.Errorf("expected truncated line to end with ellipsis:\n%s", output)
360+
}
361+
if !strings.Contains(output, "\x1b[") {
362+
t.Errorf("expected case-insensitive highlight on truncated line:\n%s", output)
363+
}
364+
}
365+
237366
func TestWriteCodeSearchText_Empty(t *testing.T) {
238367
t.Parallel()
239368

@@ -242,7 +371,7 @@ func TestWriteCodeSearchText_Empty(t *testing.T) {
242371
}
243372

244373
var buf bytes.Buffer
245-
writeCodeSearchText(&buf, resp)
374+
writeCodeSearchText(&buf, resp, newStatusStyles(&buf), false)
246375

247376
if !strings.Contains(buf.String(), "No code search results found") {
248377
t.Errorf("expected empty results message, got:\n%s", buf.String())

0 commit comments

Comments
 (0)