syntext 2.0.0

Hybrid code search index for agent workflows
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
//! Tiered verifier: confirms index candidates against actual file bytes.
//!
//! Two tiers:
//! - **Literal**: `memchr::memmem` for case-sensitive literal patterns. Fast path.
//! - **Regex**: compiled `regex::Regex` for everything else (regex patterns and
//!   case-insensitive literals). Correct for all inputs.
//!
//! Both tiers operate line-by-line: a file is split at `\n` boundaries, and each
//! line is checked independently. This matches ripgrep's default behavior.

use std::path::Path;

use memchr::{memchr, memchr_iter, memmem, memrchr};
use regex::bytes::Regex;

use crate::index::is_binary;
use crate::SearchMatch;

/// Verify a literal pattern against raw file bytes using `memchr::memmem`.
///
/// Case-sensitive. Returns one `SearchMatch` per matching line.
/// Binary content (null bytes) causes the file to be skipped entirely.
///
/// When `skip_line_content` is true, `line_content` is left empty (no per-line
/// byte copy) for callers that only need which files/lines matched (`-l`/`-L`).
pub fn verify_literal(
    pattern: &str,
    path: &Path,
    content: &[u8],
    skip_line_content: bool,
) -> Vec<SearchMatch> {
    if is_binary(content) {
        return Vec::new(); // skip binary files
    }
    let finder = memmem::Finder::new(pattern.as_bytes());
    let mut matches = Vec::new();

    let mut last_line_start = usize::MAX;
    let mut current_line_num = 1;
    let mut last_newline_counted_up_to = 0;
    let mut current_line_end = 0;

    for match_start in finder.find_iter(content) {
        if match_start < current_line_end {
            continue;
        }

        // Locate line boundaries around hits
        // 1. Line start is the byte after the last '\n' before match_start.
        //    Bound the backward scan to `last_newline_counted_up_to`, which is
        //    always 0 or a byte-after-newline (a valid line start) and, because
        //    matches arrive in increasing offset order, is <= this match's line
        //    start. Scanning only `[watermark..match_start]` removes the
        //    O(matches * file_size) full-prefix rescan.
        let from = last_newline_counted_up_to;
        let line_start = match memrchr(b'\n', &content[from..match_start]) {
            Some(pos) => from + pos + 1,
            None => from,
        };

        // If this match is on the same line as the previous match, we skip it
        // because we only return the first match per line.
        if line_start == last_line_start {
            continue;
        }

        // 2. Line end is the first '\n' at or after match_start (or end of file)
        let next_newline = memchr(b'\n', &content[match_start..]);
        let line_end = match next_newline {
            Some(pos) => match_start + pos,
            None => content.len(),
        };

        // Trim trailing '\r' if present
        let line_content_end = if line_end > line_start && content[line_end - 1] == b'\r' {
            line_end - 1
        } else {
            line_end
        };

        // 3. Count newlines between last_newline_counted_up_to and line_start
        if line_start > last_newline_counted_up_to {
            let newline_count =
                memchr_iter(b'\n', &content[last_newline_counted_up_to..line_start]).count();
            current_line_num += newline_count as u32;
            last_newline_counted_up_to = line_start;
        }

        matches.push(SearchMatch {
            path: path.to_path_buf(),
            line_number: current_line_num,
            line_content: if skip_line_content {
                Vec::new()
            } else {
                content[line_start..line_content_end].to_vec()
            },
            byte_offset: match_start as u64,
            submatch_start: match_start - line_start,
            // Clamp to line_content_end: a pattern ending in '\r' can match at
            // end-of-line where the '\r' was trimmed from line_content, so the
            // raw `match_start + pattern.len()` would run one byte past
            // line_content and panic a `line_content[..submatch_end]` slice.
            submatch_end: (match_start + pattern.len()).min(line_content_end) - line_start,
        });

        last_line_start = line_start;
        current_line_end = line_end;
    }

    matches
}

/// Verify a compiled regex against raw file bytes.
///
/// Returns one `SearchMatch` per matching line.
/// Binary content (null bytes) causes the file to be skipped entirely.
///
/// When `skip_line_content` is true, `line_content` is left empty (see
/// [`verify_literal`]).
pub fn verify_regex(
    re: &Regex,
    path: &Path,
    content: &[u8],
    skip_line_content: bool,
) -> Vec<SearchMatch> {
    if is_binary(content) {
        return Vec::new(); // skip binary files
    }
    let mut matches = Vec::new();

    let mut last_line_start = usize::MAX;
    let mut current_line_num = 1;
    let mut last_newline_counted_up_to = 0;
    let mut current_line_end = 0;

    for m in re.find_iter(content) {
        let match_start = m.start();
        let match_end = m.end();

        if match_start < current_line_end {
            continue;
        }

        // 1. Line start is the byte after the last '\n' before match_start.
        //    Bounded by the watermark (a valid line start <= this line start,
        //    matches being in offset order); see verify_literal for the full
        //    rationale on why this avoids the quadratic full-prefix rescan.
        let from = last_newline_counted_up_to;
        let line_start = match memrchr(b'\n', &content[from..match_start]) {
            Some(pos) => from + pos + 1,
            None => from,
        };

        // 2. Line end is the first '\n' at or after match_start (or end of file)
        let next_newline = memchr(b'\n', &content[match_start..]);
        let line_end = match next_newline {
            Some(pos) => match_start + pos,
            None => content.len(),
        };

        // Trim trailing '\r' if present
        let line_content_end = if line_end > line_start && content[line_end - 1] == b'\r' {
            line_end - 1
        } else {
            line_end
        };

        // If the match spans across a newline, it is invalid (matches must be line-by-line).
        if match_end > line_end {
            continue;
        }

        // If this match is on the same line as the previous match, we skip it.
        if line_start == last_line_start {
            continue;
        }

        // 3. Count newlines between last_newline_counted_up_to and line_start
        if line_start > last_newline_counted_up_to {
            let newline_count =
                memchr_iter(b'\n', &content[last_newline_counted_up_to..line_start]).count();
            current_line_num += newline_count as u32;
            last_newline_counted_up_to = line_start;
        }

        matches.push(SearchMatch {
            path: path.to_path_buf(),
            line_number: current_line_num,
            line_content: if skip_line_content {
                Vec::new()
            } else {
                content[line_start..line_content_end].to_vec()
            },
            byte_offset: match_start as u64,
            submatch_start: match_start - line_start,
            submatch_end: match_end.min(line_content_end) - line_start,
        });

        last_line_start = line_start;
        current_line_end = line_end;
    }

    matches
}

/// Match every line of the file (for empty pattern searches).
pub fn verify_empty(path: &Path, content: &[u8], skip_line_content: bool) -> Vec<SearchMatch> {
    if is_binary(content) {
        return Vec::new();
    }
    let mut matches = Vec::new();
    let mut line_start = 0;
    let mut line_num = 1;

    for pos in memchr_iter(b'\n', content) {
        let line_end = pos;
        let line_content_end = if line_end > line_start && content[line_end - 1] == b'\r' {
            line_end - 1
        } else {
            line_end
        };
        matches.push(SearchMatch {
            path: path.to_path_buf(),
            line_number: line_num,
            line_content: if skip_line_content {
                Vec::new()
            } else {
                content[line_start..line_content_end].to_vec()
            },
            byte_offset: line_start as u64,
            submatch_start: 0,
            submatch_end: 0,
        });
        line_start = pos + 1;
        line_num += 1;
    }

    if line_start <= content.len() {
        let line_end = content.len();
        let line_content_end = if line_end > line_start && content[line_end - 1] == b'\r' {
            line_end - 1
        } else {
            line_end
        };
        matches.push(SearchMatch {
            path: path.to_path_buf(),
            line_number: line_num,
            line_content: if skip_line_content {
                Vec::new()
            } else {
                content[line_start..line_content_end].to_vec()
            },
            byte_offset: line_start as u64,
            submatch_start: 0,
            submatch_end: 0,
        });
    }

    matches
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn literal_reports_match_start_offset() {
        let matches = verify_literal(
            "needle",
            Path::new("file.txt"),
            b"prefix needle suffix\n",
            false,
        );
        assert_eq!(matches.len(), 1);
        assert_eq!(matches[0].byte_offset, 7);
        assert_eq!(matches[0].submatch_start, 7);
        assert_eq!(matches[0].submatch_end, 13);
    }

    #[test]
    fn regex_reports_match_start_offset() {
        let re = Regex::new("needle").unwrap();
        let matches = verify_regex(&re, Path::new("file.txt"), b"prefix needle suffix\n", false);
        assert_eq!(matches.len(), 1);
        assert_eq!(matches[0].byte_offset, 7);
        assert_eq!(matches[0].submatch_start, 7);
        assert_eq!(matches[0].submatch_end, 13);
    }

    #[test]
    fn literal_pattern_ending_in_cr_clamps_submatch_end() {
        // Pattern ends in '\r' and matches right before the '\n'. The '\r' is
        // trimmed from line_content, so submatch_end must clamp to
        // line_content.len() instead of running one byte past (which would
        // panic a `line_content[..submatch_end]` slice for library consumers).
        let matches = verify_literal("abc\r", Path::new("f"), b"abc\r\n", false);
        assert_eq!(matches.len(), 1);
        assert_eq!(matches[0].line_content, b"abc");
        assert!(
            matches[0].submatch_end <= matches[0].line_content.len(),
            "submatch_end {} must not exceed line_content len {}",
            matches[0].submatch_end,
            matches[0].line_content.len()
        );
        // The clamped span is still sliceable without panicking.
        let _ = &matches[0].line_content[matches[0].submatch_start..matches[0].submatch_end];
    }

    #[test]
    fn crlf_offsets_include_line_break_bytes_before_match() {
        let matches = verify_literal(
            "needle",
            Path::new("file.txt"),
            b"one\r\ntwo needle\r\n",
            false,
        );
        assert_eq!(matches.len(), 1);
        assert_eq!(matches[0].line_number, 2);
        assert_eq!(matches[0].byte_offset, 9);
        assert_eq!(matches[0].line_content, b"two needle");
    }

    #[test]
    fn literal_many_matches_across_and_clustered_on_lines() {
        // Ensure correct line numbers and offsets when matches span many lines
        // and cluster late in the file, verifying the line-start scan behavior.
        // Build 500 leading no-match lines, then a run of match lines.
        let mut content = Vec::new();
        for _ in 0..500 {
            content.extend_from_slice(b"nomatch here\n");
        }
        // 3 match lines; only the first hit per line is reported.
        content.extend_from_slice(b"aa needle bb needle\n"); // line 501
        content.extend_from_slice(b"cc needle\n"); // line 502
        content.extend_from_slice(b"dd needle ee\n"); // line 503

        let matches = verify_literal("needle", Path::new("f"), &content, false);
        assert_eq!(matches.len(), 3, "one match reported per line");
        assert_eq!(matches[0].line_number, 501);
        assert_eq!(matches[0].line_content, b"aa needle bb needle");
        assert_eq!(matches[0].submatch_start, 3);
        assert_eq!(matches[1].line_number, 502);
        assert_eq!(matches[1].submatch_start, 3);
        assert_eq!(matches[2].line_number, 503);
        assert_eq!(matches[2].submatch_start, 3);
    }

    #[test]
    fn regex_line_numbers_correct_with_gaps() {
        let re = Regex::new("needle").unwrap();
        let content = b"a\nb\nc needle\nd\ne needle\n";
        let matches = verify_regex(&re, Path::new("f"), content, false);
        assert_eq!(matches.len(), 2);
        assert_eq!(matches[0].line_number, 3);
        assert_eq!(matches[1].line_number, 5);
    }

    #[test]
    fn skip_line_content_leaves_content_empty_but_keeps_offsets() {
        // -l/-L path: line_content is skipped, but line numbers and match
        // offsets stay correct.
        let lit = verify_literal("needle", Path::new("f"), b"a\nx needle y\n", true);
        assert_eq!(lit.len(), 1);
        assert!(lit[0].line_content.is_empty(), "content skipped");
        assert_eq!(lit[0].line_number, 2);
        assert_eq!(lit[0].submatch_start, 2);
        assert_eq!(lit[0].submatch_end, 8);

        let re = Regex::new("needle").unwrap();
        let rgx = verify_regex(&re, Path::new("f"), b"a\nx needle y\n", true);
        assert_eq!(rgx.len(), 1);
        assert!(rgx[0].line_content.is_empty());
        assert_eq!(rgx[0].line_number, 2);
        assert_eq!(rgx[0].submatch_start, 2);
    }

    #[test]
    fn regex_matches_invalid_utf8_line_bytes() {
        let re = Regex::new(r"(?-u)\xFF").unwrap();
        let matches = verify_regex(&re, Path::new("file.bin"), b"prefix\xFFsuffix\n", false);
        assert_eq!(matches.len(), 1);
        assert_eq!(matches[0].line_content, b"prefix\xFFsuffix");
        assert_eq!(matches[0].submatch_start, 6);
        assert_eq!(matches[0].submatch_end, 7);
    }

    #[test]
    fn regex_pattern_ending_in_cr_clamps_submatch_end() {
        let re = Regex::new("abc\r").unwrap();
        let matches = verify_regex(&re, Path::new("f"), b"abc\r\n", false);
        assert_eq!(matches.len(), 1);
        assert_eq!(matches[0].line_content, b"abc");
        assert!(
            matches[0].submatch_end <= matches[0].line_content.len(),
            "submatch_end {} must not exceed line_content len {}",
            matches[0].submatch_end,
            matches[0].line_content.len()
        );
        let _ = &matches[0].line_content[matches[0].submatch_start..matches[0].submatch_end];
    }

    #[test]
    fn empty_pattern_matches_all_lines() {
        let content = b"line one\nline two\r\nline three";
        let matches = verify_empty(Path::new("f"), content, false);
        assert_eq!(matches.len(), 3);
        assert_eq!(matches[0].line_number, 1);
        assert_eq!(matches[0].line_content, b"line one");
        assert_eq!(matches[1].line_number, 2);
        assert_eq!(matches[1].line_content, b"line two");
        assert_eq!(matches[2].line_number, 3);
        assert_eq!(matches[2].line_content, b"line three");
    }
}