Skip to main content

_diffctx/
fragmentation.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use once_cell::sync::Lazy;
5use rayon::prelude::*;
6use regex::Regex;
7use rustc_hash::FxHashSet;
8
9use crate::config::fragmentation::FRAGMENTATION;
10use crate::config::limits::LIMITS;
11use crate::config::tokenization::TOKENIZATION;
12use crate::git::{self, CatFileBatch};
13use crate::parsers::fragment_file;
14use crate::tokenizer::count_tokens;
15use crate::types::{Fragment, FragmentId, FragmentKind, extract_identifiers};
16
17// Content reaching here is already-decoded UTF-8 text, so the only reliable
18// binary signal is an embedded NUL (matching git's own heuristic). The old
19// range flagged ESC/BS/etc., wrongly dropping changed text fixtures that embed
20// ANSI escape codes (snapshot/terminal-recording files).
21static BINARY_CTRL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x00").unwrap());
22
23static GENERATED_FILENAME_PATTERNS: Lazy<FxHashSet<&'static str>> = Lazy::new(|| {
24    [
25        ".pb.go",
26        "_pb2.py",
27        "_pb2_grpc.py",
28        ".pb.h",
29        ".pb.cc",
30        ".pb.swift",
31        ".min.js",
32        ".min.css",
33        ".designer.cs",
34        ".api",
35    ]
36    .into_iter()
37    .collect()
38});
39
40const GENERATED_FILENAME_SUFFIXES: &[&str] = &["_generated.", "OuterClass.java"];
41
42static GENERATED_PATH_SEGMENTS: Lazy<FxHashSet<&'static str>> = Lazy::new(|| {
43    [
44        "generated",
45        "gen-java",
46        "gen-go",
47        "gen-py",
48        "gen-cpp",
49        "gen-swift",
50        "__generated__",
51        "autogen",
52        "codegen",
53    ]
54    .into_iter()
55    .collect()
56});
57
58const GENERATED_CONTENT_MARKERS: &[&str] = &[
59    "@generated",
60    "do not edit",
61    "code generated",
62    "auto-generated",
63    "this file is generated",
64    "generated by",
65    "automatically generated",
66    "auto generated",
67];
68
69static KNOWN_BINARY_EXTENSIONS: Lazy<FxHashSet<&'static str>> = Lazy::new(|| {
70    [
71        ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".svg", ".webp", ".mp3", ".mp4", ".wav",
72        ".ogg", ".flac", ".avi", ".mkv", ".mov", ".zip", ".gz", ".tar", ".bz2", ".xz", ".7z",
73        ".rar", ".jar", ".war", ".ear", ".class", ".pyc", ".pyo", ".o", ".a", ".so", ".dylib",
74        ".dll", ".exe", ".bin", ".dat", ".db", ".sqlite", ".pdf", ".doc", ".docx", ".xls", ".xlsx",
75        ".ppt", ".pptx", ".woff", ".woff2", ".ttf", ".otf", ".eot",
76    ]
77    .into_iter()
78    .collect()
79});
80
81fn looks_binary(content: &str) -> bool {
82    let mut check_len = content
83        .len()
84        .min(FRAGMENTATION.binary_detection_buffer_size);
85    while check_len > 0 && !content.is_char_boundary(check_len) {
86        check_len -= 1;
87    }
88    BINARY_CTRL_RE.is_match(&content[..check_len])
89}
90
91fn has_generated_filename(name: &str) -> bool {
92    GENERATED_FILENAME_PATTERNS
93        .iter()
94        .any(|p| name.ends_with(p))
95        || GENERATED_FILENAME_SUFFIXES
96            .iter()
97            .any(|s| name.ends_with(s))
98}
99
100fn has_generated_path_segment(path: &Path) -> bool {
101    path.components().any(|c| {
102        let s = c.as_os_str().to_string_lossy().to_lowercase();
103        GENERATED_PATH_SEGMENTS.contains(s.as_str())
104    })
105}
106
107fn has_generated_content_marker(content: &str) -> bool {
108    let header: String = content
109        .lines()
110        .take(FRAGMENTATION.generated_marker_header_lines)
111        .collect::<Vec<_>>()
112        .join("\n")
113        .to_lowercase();
114    for marker in GENERATED_CONTENT_MARKERS {
115        if !header.contains(marker) {
116            continue;
117        }
118        if *marker != "@generated" {
119            return true;
120        }
121        if header.contains("@generated") {
122            let after_idx = header.find("@generated").unwrap() + "@generated".len();
123            let next_char = header[after_idx..].chars().next();
124            if next_char.is_none() || !next_char.unwrap().is_ascii_lowercase() {
125                return true;
126            }
127        }
128    }
129    false
130}
131
132fn is_generated_file(path: &Path, content: &str) -> bool {
133    let name = path
134        .file_name()
135        .map(|n| n.to_string_lossy().to_string())
136        .unwrap_or_default();
137    has_generated_filename(&name)
138        || has_generated_path_segment(path)
139        || has_generated_content_marker(content)
140}
141
142fn truncate_generated_fragments(file_frags: Vec<Fragment>) -> Vec<Fragment> {
143    let max_lines = LIMITS.max_generated_lines as u32;
144    file_frags
145        .into_iter()
146        .map(|frag| {
147            if frag.line_count() <= max_lines {
148                return frag;
149            }
150            let lines: Vec<&str> = frag.content.lines().collect();
151            // `line_count()` comes from the id's span while the slice below
152            // indexes the actual text. The two agree for well-formed fragments,
153            // but nothing enforces it — a span wider than its content made
154            // `lines[..max_lines]` an out-of-bounds slice and the subtraction an
155            // underflow, i.e. a panic reachable from file content rather than a
156            // degraded fragment. Clamp instead: fewer lines than the cap means
157            // there is nothing to cut.
158            let keep = (max_lines as usize).min(lines.len());
159            let remaining = lines.len().saturating_sub(keep);
160            if remaining == 0 {
161                return frag;
162            }
163            let truncated_lines = &lines[..keep];
164            let truncated_content = format!(
165                "{}\n# ... [{} more lines]",
166                truncated_lines.join("\n"),
167                remaining
168            );
169            let new_end = frag.start_line() + max_lines - 1;
170            let identifiers = extract_identifiers(
171                &truncated_content,
172                TOKENIZATION.fragment_min_identifier_length,
173            );
174            Fragment {
175                id: FragmentId::new(frag.id.path.clone(), frag.start_line(), new_end),
176                kind: frag.kind,
177                content: Arc::from(truncated_content),
178                identifiers,
179                token_count: 0,
180                symbol_name: frag.symbol_name,
181            }
182        })
183        .collect()
184}
185
186fn dedup_fragments(raw_frags: Vec<Fragment>, seen: &mut FxHashSet<FragmentId>) -> Vec<Fragment> {
187    let mut result = Vec::new();
188    for f in raw_frags {
189        if !seen.contains(&f.id) {
190            seen.insert(f.id.clone());
191            result.push(f);
192        }
193    }
194    result
195}
196
197fn normalize_path(path: &Path, root_dir: &Path) -> PathBuf {
198    if path.is_absolute() {
199        path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
200    } else {
201        let joined = root_dir.join(path);
202        joined.canonicalize().unwrap_or_else(|_| joined)
203    }
204}
205
206fn read_file_content(
207    file_path: &Path,
208    root_dir: &Path,
209    preferred_revs: &[String],
210    mut batch_reader: Option<&mut CatFileBatch>,
211    is_changed: bool,
212) -> Option<String> {
213    let ext = file_path
214        .extension()
215        .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
216        .unwrap_or_default();
217    if KNOWN_BINARY_EXTENSIONS.contains(ext.as_str()) {
218        return None;
219    }
220
221    let abs_path = normalize_path(file_path, root_dir);
222    let resolved_root = root_dir
223        .canonicalize()
224        .unwrap_or_else(|_| root_dir.to_path_buf());
225    let rel = abs_path.strip_prefix(&resolved_root).ok()?;
226
227    let max_size = if is_changed {
228        LIMITS.max_changed_file_size
229    } else {
230        LIMITS.max_file_size
231    };
232    for rev in preferred_revs {
233        if let Some(reader) = batch_reader.as_deref_mut() {
234            match reader.get(rev, rel) {
235                Ok(content) if content.len() <= max_size && !looks_binary(&content) => {
236                    return Some(content);
237                }
238                _ => continue,
239            }
240        } else {
241            match git::show_file_at_revision(root_dir, rev, rel) {
242                Ok(content) if content.len() <= max_size && !looks_binary(&content) => {
243                    return Some(content);
244                }
245                _ => continue,
246            }
247        }
248    }
249
250    if abs_path.exists() && abs_path.is_file() {
251        if let Ok(meta) = std::fs::metadata(&abs_path) {
252            if meta.len() as usize > max_size {
253                return None;
254            }
255        }
256        if let Ok(content) = std::fs::read_to_string(&abs_path) {
257            if !looks_binary(&content) {
258                return Some(content);
259            }
260        }
261    }
262
263    None
264}
265
266pub fn process_files_for_fragments(
267    files: &[PathBuf],
268    root_dir: &Path,
269    preferred_revs: &[String],
270    seen_frag_ids: &mut FxHashSet<FragmentId>,
271    mut batch_reader: Option<&mut CatFileBatch>,
272    is_changed: bool,
273) -> Vec<Fragment> {
274    let max_frags = LIMITS.max_fragments;
275    let max_generated = LIMITS.max_generated_fragments;
276
277    // Process files in chunks: sequential read (CatFileBatch is &mut, !Send) then
278    // parallel parse within each chunk. Peak raw-content memory = chunk_size × max_file_size
279    // instead of N_files × avg_file_size — eliminates OOM on large repos like astropy.
280    let chunk_size = rayon::current_num_threads().max(1);
281    let mut parsed: Vec<Vec<Fragment>> = Vec::with_capacity(files.len());
282    for chunk in files.chunks(chunk_size) {
283        let chunk_contents: Vec<(PathBuf, String)> = chunk
284            .iter()
285            .filter_map(|file_path| {
286                let content = read_file_content(
287                    file_path,
288                    root_dir,
289                    preferred_revs,
290                    batch_reader.as_deref_mut(),
291                    is_changed,
292                )?;
293                Some((file_path.clone(), content))
294            })
295            .collect();
296        parsed.extend(
297            chunk_contents
298                .par_iter()
299                .map(|(file_path, content)| {
300                    let path_arc: Arc<str> = Arc::from(file_path.to_string_lossy().as_ref());
301                    let mut raw_frags = fragment_file(path_arc, content);
302                    // Changed files are the subject of the diff: never apply the
303                    // aggressive generated-file reduction (cap=5 + 30-line content
304                    // truncation), which can drop the small fragment covering the
305                    // edited hunk before core identification runs.
306                    let generated = !is_changed && is_generated_file(file_path, content);
307                    // Changed files also get 10x cap headroom: the biggest-N
308                    // truncation below keeps the LONGEST fragments, and the
309                    // edited hunk is typically a small leaf that would be
310                    // dropped first (hunks are only known later, in core
311                    // identification). max_changed_file_size still bounds cost.
312                    let cap = if generated {
313                        max_generated
314                    } else if is_changed {
315                        max_frags.saturating_mul(10)
316                    } else {
317                        max_frags
318                    };
319                    if raw_frags.len() > cap {
320                        raw_frags.sort_by(|a, b| b.line_count().cmp(&a.line_count()));
321                        raw_frags.truncate(cap);
322                    }
323                    if generated {
324                        raw_frags = truncate_generated_fragments(raw_frags);
325                    }
326                    raw_frags
327                })
328                .collect::<Vec<_>>(),
329        );
330        // chunk_contents dropped here — raw file text freed before next chunk
331    }
332
333    let mut fragments: Vec<Fragment> = Vec::new();
334    for file_frags in parsed {
335        for frag in dedup_fragments(file_frags, seen_frag_ids) {
336            seen_frag_ids.insert(frag.id.clone());
337            fragments.push(frag);
338        }
339    }
340
341    fragments
342}
343
344pub fn create_whole_file_fragment(
345    path: &Path,
346    root_dir: &Path,
347    preferred_revs: &[String],
348    batch_reader: Option<&mut CatFileBatch>,
349) -> Option<Fragment> {
350    let content = read_file_content(path, root_dir, preferred_revs, batch_reader, true)?;
351    let trimmed = content.trim();
352    if trimmed.is_empty() {
353        return None;
354    }
355
356    let content = if is_generated_file(path, &content) {
357        let lines: Vec<&str> = content.lines().collect();
358        let max_lines = LIMITS.max_generated_lines;
359        if lines.len() > max_lines {
360            let remaining = lines.len() - max_lines;
361            format!(
362                "{}\n# ... [{} more lines]",
363                lines[..max_lines].join("\n"),
364                remaining
365            )
366        } else {
367            content
368        }
369    } else {
370        content
371    };
372
373    let lines: Vec<&str> = content.lines().collect();
374    let line_count = lines.len() as u32;
375    let path_arc: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
376    let token_count = count_tokens(&content) + LIMITS.overhead_per_fragment;
377    let identifiers = extract_identifiers(&content, TOKENIZATION.fragment_min_identifier_length);
378
379    Some(Fragment {
380        id: FragmentId::new(path_arc, 1, line_count),
381        kind: FragmentKind::Chunk,
382        content: Arc::from(content),
383        identifiers,
384        token_count,
385        symbol_name: None,
386    })
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::types::FragmentKind;
393
394    fn frag(path: &str, start: u32, end: u32, content: &str) -> Fragment {
395        Fragment {
396            id: FragmentId::new(Arc::from(path), start, end),
397            kind: FragmentKind::Chunk,
398            content: Arc::from(content.to_string()),
399            identifiers: FxHashSet::default(),
400            token_count: 0,
401            symbol_name: None,
402        }
403    }
404
405    /// The cap is checked against the id's line span but applied by slicing the
406    /// content, and nothing keeps the two in step. A fragment whose span is
407    /// wider than its text made that an out-of-bounds slice — a panic taking
408    /// the whole process down, reachable from repository content.
409    #[test]
410    fn truncation_survives_a_span_wider_than_its_content() {
411        let over_cap = LIMITS.max_generated_lines as u32 + 50;
412        let short = frag("gen.rs", 1, over_cap, "one\ntwo\nthree\n");
413
414        let out = truncate_generated_fragments(vec![short.clone()]);
415
416        assert_eq!(out.len(), 1);
417        assert_eq!(
418            out[0].content.as_ref(),
419            short.content.as_ref(),
420            "content shorter than the cap has nothing to truncate"
421        );
422    }
423
424    #[test]
425    fn truncation_cuts_a_fragment_that_really_is_too_long() {
426        let max_lines = LIMITS.max_generated_lines;
427        let body: String = (1..=max_lines + 20)
428            .map(|n| format!("line {n}\n"))
429            .collect();
430        let long = frag("gen.rs", 1, (max_lines + 20) as u32, &body);
431
432        let out = truncate_generated_fragments(vec![long]);
433
434        assert_eq!(out.len(), 1);
435        assert!(out[0].content.contains("more lines]"));
436        assert_eq!(out[0].end_line(), max_lines as u32);
437        assert!(!out[0].content.contains(&format!("line {}", max_lines + 1)));
438    }
439}