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
17static 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 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 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 let generated = !is_changed && is_generated_file(file_path, content);
307 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 }
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 #[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}