Skip to content

Commit da1000a

Browse files
authored
perf: optimize sampling for grep mode in jsonl files (#504)
1 parent fc214a2 commit da1000a

6 files changed

Lines changed: 345 additions & 46 deletions

File tree

src/ingest/fileset.rs

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
1+
use crate::grep::GrepConfig;
12
use crate::order::NodeKind;
23
use crate::utils::tree_arena::{JsonTreeArena, JsonTreeNode};
34

45
use super::IngestOutput;
56
use super::formats::{
6-
json::{
7-
build_json_tree_arena_from_slice, build_jsonl_tree_arena_from_slice,
8-
},
7+
json::build_json_tree_arena_from_slice,
98
text::{
109
build_text_tree_arena_from_bytes,
1110
build_text_tree_arena_from_bytes_with_mode,
1211
},
1312
yaml::build_yaml_tree_arena_from_bytes,
1413
};
14+
use super::{grep_adjusted_cfg, jsonl_grep_predicate};
1515
use crate::PriorityConfig;
1616

1717
/// Input descriptor for a single file in a multi-format fileset ingest.
@@ -41,7 +41,10 @@ pub enum FilesetInputKind {
4141
pub fn parse_fileset_multi(
4242
inputs: Vec<FilesetInput>,
4343
cfg: &PriorityConfig,
44+
grep: &GrepConfig,
4445
) -> IngestOutput {
46+
let non_jsonl_cfg = grep_adjusted_cfg(cfg, grep);
47+
4548
let mut entries: Vec<FilesetEntry> = Vec::with_capacity(inputs.len());
4649
let mut warnings: Vec<String> = Vec::new();
4750
for FilesetInput {
@@ -54,26 +57,35 @@ pub fn parse_fileset_multi(
5457
FilesetInputKind::Json => parse_or_empty(
5558
&name,
5659
&mut bytes,
57-
cfg,
60+
&non_jsonl_cfg,
5861
&mut warnings,
5962
"JSON",
60-
|bytes, cfg| build_json_tree_arena_from_slice(bytes, cfg),
61-
),
62-
FilesetInputKind::Jsonl => parse_or_empty(
63-
&name,
64-
&bytes,
65-
cfg,
66-
&mut warnings,
67-
"JSONL",
68-
|bytes, cfg| build_jsonl_tree_arena_from_slice(bytes, cfg),
63+
|bytes, c| build_json_tree_arena_from_slice(bytes, c),
6964
),
65+
FilesetInputKind::Jsonl => {
66+
let must_include = jsonl_grep_predicate(&bytes, grep);
67+
parse_or_empty(
68+
&name,
69+
&bytes,
70+
cfg,
71+
&mut warnings,
72+
"JSONL",
73+
|bytes, c| {
74+
crate::ingest::formats::json::parse_jsonl_one(
75+
bytes,
76+
c,
77+
&*must_include,
78+
)
79+
},
80+
)
81+
}
7082
FilesetInputKind::Yaml => parse_or_empty(
7183
&name,
7284
&bytes,
73-
cfg,
85+
&non_jsonl_cfg,
7486
&mut warnings,
7587
"YAML",
76-
|bytes, cfg| build_yaml_tree_arena_from_bytes(bytes, cfg),
88+
|bytes, c| build_yaml_tree_arena_from_bytes(bytes, c),
7789
),
7890
FilesetInputKind::Text { atomic_lines } => {
7991
(parse_text_bytes(&bytes, cfg, atomic_lines), false)

src/ingest/formats/json/mod.rs

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ pub(crate) fn build_json_tree_arena_from_many(
6767
}
6868

6969
/// Collect (byte_start, 1-based line number) for every non-empty line.
70-
fn jsonl_line_offsets(text: &str) -> Vec<(usize, usize)> {
70+
pub(crate) fn jsonl_line_offsets(text: &str) -> Vec<(usize, usize)> {
7171
let mut offsets = Vec::new();
7272
let mut pos = 0usize;
7373
for (line_idx, raw_line) in text.split('\n').enumerate() {
@@ -88,21 +88,26 @@ fn jsonl_line_offsets(text: &str) -> Vec<(usize, usize)> {
8888
///
8989
/// Lines are sampled using the same strategy as JSON arrays (controlled by
9090
/// `PriorityConfig::array_max_items` and `array_sampler`), so only a subset
91-
/// of lines is actually parsed for large inputs.
91+
/// of lines is actually parsed for large inputs. When a `must_include`
92+
/// predicate is provided, matching lines are always kept regardless of the
93+
/// sampling cap.
9294
pub fn parse_jsonl_one(
9395
bytes: &[u8],
9496
cfg: &PriorityConfig,
97+
must_include: impl Fn(usize) -> bool,
9598
) -> Result<TreeArena> {
96-
use crate::ingest::sampling::{ArraySamplerKind, choose_indices};
99+
use crate::ingest::sampling::{
100+
ArraySamplerKind, choose_indices, merge_required,
101+
};
97102

98103
let text = std::str::from_utf8(bytes)
99104
.map_err(|e| anyhow::anyhow!("JSONL input is not valid UTF-8: {e}"))?;
100105

101106
let line_offsets = jsonl_line_offsets(text);
102107
let total = line_offsets.len();
103108
let sampler_kind: ArraySamplerKind = cfg.array_sampler.into();
104-
let kept_indices =
105-
choose_indices(sampler_kind, total, cfg.array_max_items);
109+
let sampled = choose_indices(sampler_kind, total, cfg.array_max_items);
110+
let kept_indices = merge_required(sampled, total, &must_include);
106111

107112
let builder = JsonTreeBuilder::new(cfg.array_max_items, sampler_kind);
108113
let root_id = builder.push_default();
@@ -138,14 +143,6 @@ pub fn parse_jsonl_one(
138143
Ok(arena)
139144
}
140145

141-
/// Parse JSONL from a byte slice (for fileset use).
142-
pub(crate) fn build_jsonl_tree_arena_from_slice(
143-
bytes: &[u8],
144-
cfg: &PriorityConfig,
145-
) -> Result<TreeArena> {
146-
parse_jsonl_one(bytes, cfg)
147-
}
148-
149146
/// Convenience functions for the JSON ingest path.
150147
pub fn parse_json_one(
151148
bytes: Vec<u8>,

src/ingest/mod.rs

Lines changed: 148 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use crate::order::PriorityConfig;
44
use crate::utils::tree_arena::JsonTreeArena as TreeArena;
55

66
use crate::InputKind;
7+
use crate::grep::GrepConfig;
78

89
pub mod fileset;
910
pub mod format;
@@ -25,48 +26,104 @@ pub(crate) struct IngestOutput {
2526
pub warnings: Vec<String>,
2627
}
2728

29+
/// Return a copy of `cfg` with array sampling disabled when strong grep is
30+
/// active. Non-JSONL formats need this to avoid sampling away matches;
31+
/// JSONL handles it via `merge_required` in the sampler instead.
32+
pub(crate) fn grep_adjusted_cfg(
33+
cfg: &PriorityConfig,
34+
grep: &GrepConfig,
35+
) -> PriorityConfig {
36+
if grep.has_strong() {
37+
let mut c = *cfg;
38+
c.array_max_items = usize::MAX;
39+
c
40+
} else {
41+
*cfg
42+
}
43+
}
44+
45+
/// Build a predicate that returns true for JSONL line indices matching the
46+
/// strong grep pattern. When no grep is active, returns a no-op.
47+
///
48+
/// Uses a single regex scan over the entire text and maps match positions
49+
/// back to line indices, avoiding per-line regex overhead.
50+
pub(crate) fn jsonl_grep_predicate(
51+
bytes: &[u8],
52+
grep: &GrepConfig,
53+
) -> Box<dyn Fn(usize) -> bool> {
54+
let Some(re) = grep.patterns.strong() else {
55+
return Box::new(|_| false);
56+
};
57+
let Ok(text) = std::str::from_utf8(bytes) else {
58+
return Box::new(|_| false);
59+
};
60+
let offsets = formats::json::jsonl_line_offsets(text);
61+
if offsets.is_empty() {
62+
return Box::new(|_| false);
63+
}
64+
// Single regex pass: find all match positions and map to line indices.
65+
let mut matching = vec![false; offsets.len()];
66+
for m in re.find_iter(text) {
67+
let pos = m.start();
68+
// Binary search for the line containing this byte position.
69+
let idx = offsets.partition_point(|&(start, _)| start <= pos);
70+
if idx > 0 {
71+
matching[idx - 1] = true;
72+
}
73+
}
74+
Box::new(move |i: usize| matching.get(i).copied().unwrap_or(false))
75+
}
76+
2877
/// Dispatch the appropriate ingest path for any supported input kind.
2978
pub(crate) fn ingest_into_arena(
3079
input: InputKind,
3180
priority_cfg: &PriorityConfig,
81+
grep: &GrepConfig,
3282
) -> Result<IngestOutput> {
3383
match input {
3484
InputKind::Json(bytes) => {
35-
parse_json_one(bytes, priority_cfg).map(|arena| IngestOutput {
85+
let cfg = grep_adjusted_cfg(priority_cfg, grep);
86+
parse_json_one(bytes, &cfg).map(|arena| IngestOutput {
3687
arena,
3788
warnings: Vec::new(),
3889
})
3990
}
4091
InputKind::Jsonl(bytes) => {
41-
parse_jsonl_one(&bytes, priority_cfg).map(|arena| IngestOutput {
42-
arena,
43-
warnings: Vec::new(),
44-
})
92+
let must_include = jsonl_grep_predicate(&bytes, grep);
93+
parse_jsonl_one(&bytes, priority_cfg, &*must_include).map(
94+
|arena| IngestOutput {
95+
arena,
96+
warnings: Vec::new(),
97+
},
98+
)
4599
}
46100
InputKind::Yaml(bytes) => {
47-
parse_yaml_one(&bytes, priority_cfg).map(|arena| IngestOutput {
101+
let cfg = grep_adjusted_cfg(priority_cfg, grep);
102+
parse_yaml_one(&bytes, &cfg).map(|arena| IngestOutput {
48103
arena,
49104
warnings: Vec::new(),
50105
})
51106
}
52107
InputKind::Text { bytes, mode } => {
108+
let cfg = grep_adjusted_cfg(priority_cfg, grep);
53109
let atomic = matches!(mode, crate::TextMode::CodeLike);
54-
parse_text_one_with_mode(bytes, priority_cfg, atomic).map(
55-
|arena| IngestOutput {
110+
parse_text_one_with_mode(bytes, &cfg, atomic).map(|arena| {
111+
IngestOutput {
56112
arena,
57113
warnings: Vec::new(),
58-
},
59-
)
114+
}
115+
})
60116
}
61117
InputKind::Fileset(inputs) => {
62-
Ok(fileset::parse_fileset_multi(inputs, priority_cfg))
118+
Ok(fileset::parse_fileset_multi(inputs, priority_cfg, grep))
63119
}
64120
}
65121
}
66122

67123
#[cfg(test)]
68124
mod tests {
69125
use super::*;
126+
use crate::grep::{GrepConfig, GrepPatterns, GrepShow};
70127
use crate::order::NodeKind;
71128

72129
#[test]
@@ -108,6 +165,85 @@ mod tests {
108165
assert_eq!(arena.nodes[root].object_len.unwrap_or(0), 2);
109166
}
110167

168+
fn grep_with_strong(pattern: &str) -> GrepConfig {
169+
GrepConfig {
170+
patterns: GrepPatterns::StrongOnly(
171+
regex::Regex::new(pattern).unwrap(),
172+
),
173+
show: GrepShow::Matching,
174+
}
175+
}
176+
177+
#[test]
178+
fn jsonl_grep_predicate_marks_matching_lines() {
179+
let input = b"{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n";
180+
let grep = grep_with_strong("b");
181+
let pred = jsonl_grep_predicate(input, &grep);
182+
assert!(!pred(0), "line 0 should not match");
183+
assert!(pred(1), "line 1 should match 'b'");
184+
assert!(!pred(2), "line 2 should not match");
185+
}
186+
187+
#[test]
188+
fn jsonl_grep_predicate_multiple_matches() {
189+
let input = b"{\"x\":1}\n{\"x\":2}\n{\"y\":3}\n{\"x\":4}\n";
190+
let grep = grep_with_strong("x");
191+
let pred = jsonl_grep_predicate(input, &grep);
192+
assert!(pred(0));
193+
assert!(pred(1));
194+
assert!(!pred(2));
195+
assert!(pred(3));
196+
}
197+
198+
#[test]
199+
fn jsonl_grep_predicate_no_strong_pattern_returns_noop() {
200+
let input = b"{\"a\":1}\n{\"b\":2}\n";
201+
let grep = GrepConfig::default(); // no patterns
202+
let pred = jsonl_grep_predicate(input, &grep);
203+
assert!(!pred(0));
204+
assert!(!pred(1));
205+
}
206+
207+
#[test]
208+
fn jsonl_grep_predicate_skips_empty_lines() {
209+
// Empty lines are excluded from offsets, so indices are dense
210+
let input = b"{\"a\":1}\n\n{\"b\":2}\n";
211+
let grep = grep_with_strong("b");
212+
let pred = jsonl_grep_predicate(input, &grep);
213+
// Only 2 non-empty lines: index 0 = {"a":1}, index 1 = {"b":2}
214+
assert!(!pred(0));
215+
assert!(pred(1));
216+
}
217+
218+
#[test]
219+
fn jsonl_grep_predicate_match_on_first_line() {
220+
let input = b"{\"needle\":true}\n{\"other\":false}\n";
221+
let grep = grep_with_strong("needle");
222+
let pred = jsonl_grep_predicate(input, &grep);
223+
assert!(pred(0), "match on first line should work");
224+
assert!(!pred(1));
225+
}
226+
227+
#[test]
228+
fn jsonl_grep_predicate_match_on_last_line() {
229+
let input = b"{\"a\":1}\n{\"needle\":true}";
230+
let grep = grep_with_strong("needle");
231+
let pred = jsonl_grep_predicate(input, &grep);
232+
assert!(!pred(0));
233+
assert!(
234+
pred(1),
235+
"match on last line (no trailing newline) should work"
236+
);
237+
}
238+
239+
#[test]
240+
fn jsonl_grep_predicate_out_of_bounds_returns_false() {
241+
let input = b"{\"a\":1}\n{\"b\":2}\n";
242+
let grep = grep_with_strong("a");
243+
let pred = jsonl_grep_predicate(input, &grep);
244+
assert!(!pred(99), "out of bounds index should return false");
245+
}
246+
111247
#[test]
112248
fn fileset_ingest_surfaces_parse_warnings() {
113249
let inputs = vec![fileset::FilesetInput {
@@ -118,6 +254,7 @@ mod tests {
118254
let IngestOutput { arena, warnings } = ingest_into_arena(
119255
InputKind::Fileset(inputs),
120256
&PriorityConfig::new(usize::MAX, usize::MAX),
257+
&GrepConfig::default(),
121258
)
122259
.unwrap();
123260
assert!(arena.is_fileset, "fileset input should mark arena");

0 commit comments

Comments
 (0)