Skip to content

Commit c3de55f

Browse files
ind-igoclaude
andcommitted
Add pagination (--limit, --offset, --all) across all query commands
Commands now have default result limits to prevent unbounded output: definition (3), symbols (100), references (50). When truncated, a compact hint on stderr guides toward narrowing or paging forward. JSON output uses a paginated envelope {total, offset, limit, results} when results are truncated or offset > 0, bare array otherwise. Definition results are sorted by symbol priority (types first) and paginated before reading bodies from disk to avoid wasted I/O. Closes #15 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9e2a29c commit c3de55f

7 files changed

Lines changed: 430 additions & 40 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "cx-cli"
3-
version = "0.6.1"
3+
version = "0.6.2"
44
edition = "2024"
55
description = "Semantic code navigation for AI agents"
66
license = "MIT"

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,18 @@ Use `--file src/index.rs` to scope the search to a single file. Includes both de
185185

186186
References are computed on-the-fly via AST walking (not indexed), so results are always fresh.
187187

188+
### Pagination
189+
190+
Commands have default result limits to keep output bounded: definition shows 3, symbols 100, references 50. When results are truncated, cx prints a hint:
191+
192+
```
193+
cx: 3/32 definitions for "OnTypeModel" | --from PATH to narrow | --offset 3 for more | --all
194+
```
195+
196+
Use `--offset N` to page forward, `--all` to bypass the limit, or `--limit N` to override the default. Narrowing with `--from` / `--file` / `--kind` is usually better than paging.
197+
198+
With `--json`, paginated output uses `{total, offset, limit, results: [...]}`. Non-paginated output remains a bare array.
199+
188200
## How it works
189201

190202
On first invocation, cx builds an index by parsing all source files with tree-sitter. The index stores symbols, signatures, and byte ranges for every file. Subsequent invocations incrementally update only changed files.

src/main.rs

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ struct Cli {
2525
#[arg(long, global = true)]
2626
json: bool,
2727

28+
/// Max number of results to return (overrides per-command default)
29+
#[arg(long, global = true)]
30+
limit: Option<usize>,
31+
32+
/// Skip the first N results
33+
#[arg(long, global = true, default_value = "0")]
34+
offset: usize,
35+
36+
/// Return all results (bypass default limit)
37+
#[arg(long, global = true, conflicts_with = "limit")]
38+
all: bool,
2839
}
2940

3041
#[derive(Subcommand)]
@@ -142,29 +153,42 @@ fn main() {
142153
let cli = Cli::parse();
143154
let root = resolve_root(cli.root);
144155

156+
let resolve_pagination = |default_limit: Option<usize>| -> query::Pagination {
157+
let limit = if cli.all {
158+
None
159+
} else {
160+
Some(cli.limit.unwrap_or_else(|| default_limit.unwrap_or(usize::MAX)))
161+
};
162+
// Treat usize::MAX as "no limit" → normalize to None
163+
let limit = limit.filter(|&n| n < usize::MAX);
164+
query::Pagination { limit, offset: cli.offset }
165+
};
166+
145167
let exit_code = match cli.command {
146168
Commands::Overview { path, full } => {
147169
let idx = index::Index::load_or_build(&root);
148170
let abs = if path.is_absolute() { path.clone() } else {
149171
root.join(&path)
150172
};
151173
if abs.is_dir() {
152-
query::dir_overview(&idx, &path, full, cli.json)
174+
query::dir_overview(&idx, &path, full, cli.json, &resolve_pagination(None))
153175
} else {
154-
query::symbols(&idx, Some(&path), None, None, cli.json)
176+
query::symbols(&idx, Some(&path), None, None, cli.json, &resolve_pagination(None))
155177
}
156178
}
157179
Commands::Symbols { file, name, kind } => {
158180
let idx = index::Index::load_or_build(&root);
159-
query::symbols(&idx, file.as_deref(), name.as_deref(), kind, cli.json)
181+
query::symbols(&idx, file.as_deref(), name.as_deref(), kind, cli.json, &resolve_pagination(Some(100)))
160182
}
161183
Commands::Definition { name, from, kind, max_lines } => {
162184
let idx = index::Index::load_or_build(&root);
163-
query::definition(&idx, &name, from.as_deref(), kind, max_lines, cli.json)
185+
// --from narrows to a single file, so skip default limit
186+
let default = if from.is_some() { None } else { Some(3) };
187+
query::definition(&idx, &name, from.as_deref(), kind, max_lines, cli.json, &resolve_pagination(default))
164188
}
165189
Commands::References { name, file, unique } => {
166190
let idx = index::Index::load_or_build(&root);
167-
query::references(&idx, &name, file.as_deref(), unique, cli.json)
191+
query::references(&idx, &name, file.as_deref(), unique, cli.json, &resolve_pagination(Some(50)))
168192
}
169193
Commands::Lang { action } => {
170194
match action {

src/query.rs

Lines changed: 159 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,79 @@ use crate::language::{self, detect_language};
99
use crate::output::{print_toon, print_json};
1010
use crate::util::glob::glob_match;
1111

12+
// --- Pagination ---
13+
14+
/// Pagination parameters resolved from CLI flags.
15+
pub struct Pagination {
16+
/// Max results to return (None = unlimited).
17+
pub limit: Option<usize>,
18+
/// Number of results to skip.
19+
pub offset: usize,
20+
}
21+
22+
/// Result of applying pagination to a result set.
23+
struct Paginated<T> {
24+
/// The visible slice after offset + limit.
25+
items: Vec<T>,
26+
/// Total number of results before pagination.
27+
total: usize,
28+
/// The offset that was applied.
29+
offset: usize,
30+
/// The limit that was applied (None = unlimited).
31+
limit: Option<usize>,
32+
}
33+
34+
impl<T> Paginated<T> {
35+
/// True when results were cut off (more items exist after this page).
36+
fn was_truncated(&self) -> bool {
37+
self.offset + self.items.len() < self.total
38+
}
39+
40+
/// True when JSON output should use the paginated envelope
41+
/// (either truncated or mid-pagination via offset).
42+
fn needs_envelope(&self) -> bool {
43+
self.was_truncated() || self.offset > 0
44+
}
45+
}
46+
47+
fn paginate<T>(items: Vec<T>, pg: &Pagination) -> Paginated<T> {
48+
let total = items.len();
49+
let visible = items.into_iter()
50+
.skip(pg.offset)
51+
.take(pg.limit.unwrap_or(usize::MAX))
52+
.collect();
53+
Paginated { items: visible, total, offset: pg.offset, limit: pg.limit }
54+
}
55+
56+
/// Wraps results with pagination metadata for JSON output.
57+
#[derive(Serialize)]
58+
struct PaginatedJson<'a, T: Serialize> {
59+
total: usize,
60+
offset: usize,
61+
#[serde(skip_serializing_if = "Option::is_none")]
62+
limit: Option<usize>,
63+
results: &'a [T],
64+
}
65+
66+
/// Emit a compact pagination hint on stderr.
67+
fn emit_pagination_hint(total: usize, offset: usize, shown: usize, subject: &str, narrow_hint: &str) {
68+
let next_offset = offset + shown;
69+
eprintln!(
70+
"cx: {}/{} {} | {} to narrow | --offset {} for more | --all",
71+
shown, total, subject, narrow_hint, next_offset
72+
);
73+
}
74+
75+
fn print_paginated_json<T: Serialize>(pg: &Paginated<T>) {
76+
let wrapper = PaginatedJson {
77+
total: pg.total,
78+
offset: pg.offset,
79+
limit: pg.limit,
80+
results: &pg.items,
81+
};
82+
print_json(&wrapper);
83+
}
84+
1285
// --- Serializable output types ---
1386

1487
#[derive(Serialize)]
@@ -46,6 +119,7 @@ pub fn symbols(
46119
name_glob: Option<&str>,
47120
kind_filter: Option<SymbolKind>,
48121
json: bool,
122+
pg: &Pagination,
49123
) -> i32 {
50124
let mut rows: Vec<SymbolRow<'_>> = Vec::new();
51125

@@ -106,7 +180,22 @@ pub fn symbols(
106180
signature: r.symbol.signature.clone(),
107181
})
108182
.collect();
109-
if json { print_json(&out) } else { print_toon(&out) }
183+
184+
let paged = paginate(out, pg);
185+
186+
if json {
187+
if paged.needs_envelope() {
188+
print_paginated_json(&paged);
189+
} else {
190+
print_json(&paged.items);
191+
}
192+
} else {
193+
print_toon(&paged.items);
194+
}
195+
196+
if paged.was_truncated() {
197+
emit_pagination_hint(paged.total, paged.offset, paged.items.len(), "symbols", "--file PATH | --kind KIND");
198+
}
110199

111200
0
112201
}
@@ -119,6 +208,7 @@ pub fn definition(
119208
kind_filter: Option<SymbolKind>,
120209
max_lines: usize,
121210
json: bool,
211+
pg: &Pagination,
122212
) -> i32 {
123213
let from_rel = from.map(|f| make_relative(f, &index.root));
124214

@@ -151,7 +241,16 @@ pub fn definition(
151241
return 0;
152242
}
153243

154-
let results: Vec<DefinitionResult> = matches
244+
// Sort by symbol priority (types first) then by file path
245+
matches.sort_by(|a, b| {
246+
symbol_priority(a.1.kind).cmp(&symbol_priority(b.1.kind))
247+
.then(a.0.cmp(b.0))
248+
});
249+
250+
// Paginate matches BEFORE reading bodies to avoid pointless disk I/O
251+
let paged_matches = paginate(matches, pg);
252+
253+
let results: Vec<DefinitionResult> = paged_matches.items
155254
.iter()
156255
.map(|(path, sym)| {
157256
let (body, start_line) = read_body(&index.root, path, sym.byte_range)
@@ -179,7 +278,17 @@ pub fn definition(
179278
.collect();
180279

181280
if json {
182-
print_json(&results);
281+
if paged_matches.needs_envelope() {
282+
let wrapper = PaginatedJson {
283+
total: paged_matches.total,
284+
offset: paged_matches.offset,
285+
limit: paged_matches.limit,
286+
results: &results,
287+
};
288+
print_json(&wrapper);
289+
} else {
290+
print_json(&results);
291+
}
183292
} else {
184293
for (i, r) in results.iter().enumerate() {
185294
if i > 0 {
@@ -193,6 +302,11 @@ pub fn definition(
193302
}
194303
}
195304

305+
if paged_matches.was_truncated() {
306+
let subject = format!("definitions for \"{}\"", name);
307+
emit_pagination_hint(paged_matches.total, paged_matches.offset, results.len(), &subject, "--from PATH");
308+
}
309+
196310
0
197311
}
198312

@@ -230,6 +344,7 @@ pub fn references(
230344
file: Option<&Path>,
231345
unique: bool,
232346
json: bool,
347+
pg: &Pagination,
233348
) -> i32 {
234349
let rel_path = file.map(|f| make_relative(f, &index.root));
235350

@@ -307,6 +422,8 @@ pub fn references(
307422
rows.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
308423
rows.dedup_by(|a, b| a.file == b.file && a.line == b.line);
309424

425+
let narrow_hint = "--file PATH";
426+
310427
if unique {
311428
// Deduplicate to one row per (file, caller) pair
312429
let mut seen = std::collections::HashSet::new();
@@ -325,9 +442,27 @@ pub fn references(
325442
eprintln!("cx: no callers found");
326443
return 0;
327444
}
328-
if json { print_json(&unique_rows) } else { print_toon(&unique_rows) }
445+
let paged = paginate(unique_rows, pg);
446+
if json {
447+
if paged.needs_envelope() { print_paginated_json(&paged); } else { print_json(&paged.items); }
448+
} else {
449+
print_toon(&paged.items);
450+
}
451+
if paged.was_truncated() {
452+
let subject = format!("references for \"{}\"", name);
453+
emit_pagination_hint(paged.total, paged.offset, paged.items.len(), &subject, narrow_hint);
454+
}
329455
} else {
330-
if json { print_json(&rows) } else { print_toon(&rows) }
456+
let paged = paginate(rows, pg);
457+
if json {
458+
if paged.needs_envelope() { print_paginated_json(&paged); } else { print_json(&paged.items); }
459+
} else {
460+
print_toon(&paged.items);
461+
}
462+
if paged.was_truncated() {
463+
let subject = format!("references for \"{}\"", name);
464+
emit_pagination_hint(paged.total, paged.offset, paged.items.len(), &subject, narrow_hint);
465+
}
331466
}
332467

333468
0
@@ -417,6 +552,7 @@ pub fn dir_overview(
417552
dir: &Path,
418553
full: bool,
419554
json: bool,
555+
pg: &Pagination,
420556
) -> i32 {
421557
let rel_dir = make_relative(dir, &index.root);
422558
// Normalize "." to empty path so starts_with matches all entries
@@ -507,7 +643,15 @@ pub fn dir_overview(
507643
});
508644
}
509645
}
510-
if json { print_json(&rows) } else { print_toon(&rows) }
646+
let paged = paginate(rows, pg);
647+
if json {
648+
if paged.needs_envelope() { print_paginated_json(&paged); } else { print_json(&paged.items); }
649+
} else {
650+
print_toon(&paged.items);
651+
}
652+
if paged.was_truncated() {
653+
emit_pagination_hint(paged.total, paged.offset, paged.items.len(), "entries", "cx overview <subdir>");
654+
}
511655
} else {
512656
let mut rows: Vec<DirOverviewRow> = Vec::new();
513657
for (dir_name, (file_count, sym_count)) in &subdirs {
@@ -538,7 +682,15 @@ pub fn dir_overview(
538682
symbols: format!("{}{}", names.join(", "), suffix),
539683
});
540684
}
541-
if json { print_json(&rows) } else { print_toon(&rows) }
685+
let paged = paginate(rows, pg);
686+
if json {
687+
if paged.needs_envelope() { print_paginated_json(&paged); } else { print_json(&paged.items); }
688+
} else {
689+
print_toon(&paged.items);
690+
}
691+
if paged.was_truncated() {
692+
emit_pagination_hint(paged.total, paged.offset, paged.items.len(), "entries", "cx overview <subdir>");
693+
}
542694
}
543695

544696
0

0 commit comments

Comments
 (0)