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            let remaining = lines.len() - max_lines as usize;
152            let truncated_lines = &lines[..max_lines as usize];
153            let truncated_content = format!(
154                "{}\n# ... [{} more lines]",
155                truncated_lines.join("\n"),
156                remaining
157            );
158            let new_end = frag.start_line() + max_lines - 1;
159            let identifiers = extract_identifiers(
160                &truncated_content,
161                TOKENIZATION.fragment_min_identifier_length,
162            );
163            Fragment {
164                id: FragmentId::new(frag.id.path.clone(), frag.start_line(), new_end),
165                kind: frag.kind,
166                content: Arc::from(truncated_content),
167                identifiers,
168                token_count: 0,
169                symbol_name: frag.symbol_name,
170            }
171        })
172        .collect()
173}
174
175fn dedup_fragments(raw_frags: Vec<Fragment>, seen: &mut FxHashSet<FragmentId>) -> Vec<Fragment> {
176    let mut result = Vec::new();
177    for f in raw_frags {
178        if !seen.contains(&f.id) {
179            seen.insert(f.id.clone());
180            result.push(f);
181        }
182    }
183    result
184}
185
186fn normalize_path(path: &Path, root_dir: &Path) -> PathBuf {
187    if path.is_absolute() {
188        path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
189    } else {
190        let joined = root_dir.join(path);
191        joined.canonicalize().unwrap_or_else(|_| joined)
192    }
193}
194
195fn read_file_content(
196    file_path: &Path,
197    root_dir: &Path,
198    preferred_revs: &[String],
199    mut batch_reader: Option<&mut CatFileBatch>,
200    is_changed: bool,
201) -> Option<String> {
202    let ext = file_path
203        .extension()
204        .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
205        .unwrap_or_default();
206    if KNOWN_BINARY_EXTENSIONS.contains(ext.as_str()) {
207        return None;
208    }
209
210    let abs_path = normalize_path(file_path, root_dir);
211    let resolved_root = root_dir
212        .canonicalize()
213        .unwrap_or_else(|_| root_dir.to_path_buf());
214    let rel = abs_path.strip_prefix(&resolved_root).ok()?;
215
216    let max_size = if is_changed {
217        LIMITS.max_changed_file_size
218    } else {
219        LIMITS.max_file_size
220    };
221    for rev in preferred_revs {
222        if let Some(reader) = batch_reader.as_deref_mut() {
223            match reader.get(rev, rel) {
224                Ok(content) if content.len() <= max_size && !looks_binary(&content) => {
225                    return Some(content);
226                }
227                _ => continue,
228            }
229        } else {
230            match git::show_file_at_revision(root_dir, rev, rel) {
231                Ok(content) if content.len() <= max_size && !looks_binary(&content) => {
232                    return Some(content);
233                }
234                _ => continue,
235            }
236        }
237    }
238
239    if abs_path.exists() && abs_path.is_file() {
240        if let Ok(meta) = std::fs::metadata(&abs_path) {
241            if meta.len() as usize > max_size {
242                return None;
243            }
244        }
245        if let Ok(content) = std::fs::read_to_string(&abs_path) {
246            if !looks_binary(&content) {
247                return Some(content);
248            }
249        }
250    }
251
252    None
253}
254
255pub fn process_files_for_fragments(
256    files: &[PathBuf],
257    root_dir: &Path,
258    preferred_revs: &[String],
259    seen_frag_ids: &mut FxHashSet<FragmentId>,
260    mut batch_reader: Option<&mut CatFileBatch>,
261    is_changed: bool,
262) -> Vec<Fragment> {
263    let max_frags = LIMITS.max_fragments;
264    let max_generated = LIMITS.max_generated_fragments;
265
266    // Process files in chunks: sequential read (CatFileBatch is &mut, !Send) then
267    // parallel parse within each chunk. Peak raw-content memory = chunk_size × max_file_size
268    // instead of N_files × avg_file_size — eliminates OOM on large repos like astropy.
269    let chunk_size = rayon::current_num_threads().max(1);
270    let mut parsed: Vec<Vec<Fragment>> = Vec::with_capacity(files.len());
271    for chunk in files.chunks(chunk_size) {
272        let chunk_contents: Vec<(PathBuf, String)> = chunk
273            .iter()
274            .filter_map(|file_path| {
275                let content = read_file_content(
276                    file_path,
277                    root_dir,
278                    preferred_revs,
279                    batch_reader.as_deref_mut(),
280                    is_changed,
281                )?;
282                Some((file_path.clone(), content))
283            })
284            .collect();
285        parsed.extend(
286            chunk_contents
287                .par_iter()
288                .map(|(file_path, content)| {
289                    let path_arc: Arc<str> = Arc::from(file_path.to_string_lossy().as_ref());
290                    let mut raw_frags = fragment_file(path_arc, content);
291                    // Changed files are the subject of the diff: never apply the
292                    // aggressive generated-file reduction (cap=5 + 30-line content
293                    // truncation), which can drop the small fragment covering the
294                    // edited hunk before core identification runs.
295                    let generated = !is_changed && is_generated_file(file_path, content);
296                    // Changed files also get 10x cap headroom: the biggest-N
297                    // truncation below keeps the LONGEST fragments, and the
298                    // edited hunk is typically a small leaf that would be
299                    // dropped first (hunks are only known later, in core
300                    // identification). max_changed_file_size still bounds cost.
301                    let cap = if generated {
302                        max_generated
303                    } else if is_changed {
304                        max_frags.saturating_mul(10)
305                    } else {
306                        max_frags
307                    };
308                    if raw_frags.len() > cap {
309                        raw_frags.sort_by(|a, b| b.line_count().cmp(&a.line_count()));
310                        raw_frags.truncate(cap);
311                    }
312                    if generated {
313                        raw_frags = truncate_generated_fragments(raw_frags);
314                    }
315                    raw_frags
316                })
317                .collect::<Vec<_>>(),
318        );
319        // chunk_contents dropped here — raw file text freed before next chunk
320    }
321
322    let mut fragments: Vec<Fragment> = Vec::new();
323    for file_frags in parsed {
324        for frag in dedup_fragments(file_frags, seen_frag_ids) {
325            seen_frag_ids.insert(frag.id.clone());
326            fragments.push(frag);
327        }
328    }
329
330    fragments
331}
332
333pub fn create_whole_file_fragment(
334    path: &Path,
335    root_dir: &Path,
336    preferred_revs: &[String],
337    batch_reader: Option<&mut CatFileBatch>,
338) -> Option<Fragment> {
339    let content = read_file_content(path, root_dir, preferred_revs, batch_reader, true)?;
340    let trimmed = content.trim();
341    if trimmed.is_empty() {
342        return None;
343    }
344
345    let content = if is_generated_file(path, &content) {
346        let lines: Vec<&str> = content.lines().collect();
347        let max_lines = LIMITS.max_generated_lines;
348        if lines.len() > max_lines {
349            let remaining = lines.len() - max_lines;
350            format!(
351                "{}\n# ... [{} more lines]",
352                lines[..max_lines].join("\n"),
353                remaining
354            )
355        } else {
356            content
357        }
358    } else {
359        content
360    };
361
362    let lines: Vec<&str> = content.lines().collect();
363    let line_count = lines.len() as u32;
364    let path_arc: Arc<str> = Arc::from(path.to_string_lossy().as_ref());
365    let token_count = count_tokens(&content) + LIMITS.overhead_per_fragment;
366    let identifiers = extract_identifiers(&content, TOKENIZATION.fragment_min_identifier_length);
367
368    Some(Fragment {
369        id: FragmentId::new(path_arc, 1, line_count),
370        kind: FragmentKind::Chunk,
371        content: Arc::from(content),
372        identifiers,
373        token_count,
374        symbol_name: None,
375    })
376}