1mod config_parser;
2mod generic;
3mod markdown;
4mod tree_sitter_strategy;
5
6use std::sync::Arc;
7
8use once_cell::sync::Lazy;
9
10use crate::config::parsers::PARSERS;
11use crate::config::tokenization::TOKENIZATION;
12use crate::types::Fragment;
13
14pub trait FragmentationStrategy: Send + Sync {
15 fn can_handle(&self, path: &str, content: &str) -> bool;
16 fn fragment(&self, path: Arc<str>, content: &str) -> Vec<Fragment>;
17}
18
19static STRATEGIES: Lazy<Vec<Box<dyn FragmentationStrategy>>> = Lazy::new(|| {
20 vec![
21 Box::new(tree_sitter_strategy::TreeSitterStrategy::new()),
22 Box::new(markdown::MarkdownStrategy),
23 Box::new(config_parser::ConfigStrategy),
24 Box::new(generic::GenericStrategy),
25 ]
26});
27
28pub fn fragment_file(path: Arc<str>, content: &str) -> Vec<Fragment> {
29 for strategy in STRATEGIES.iter() {
30 if strategy.can_handle(&path, content) {
31 let fragments = strategy.fragment(Arc::clone(&path), content);
32 if !fragments.is_empty() {
33 return fragments;
34 }
35 }
36 }
37
38 Vec::new()
39}
40
41fn create_snippet(lines: &[&str], start_line: u32, end_line: u32) -> Option<String> {
42 if start_line == 0 || end_line == 0 || start_line > end_line {
43 return None;
44 }
45 let start_idx = (start_line - 1) as usize;
46 let end_idx = end_line as usize;
47 if start_idx >= lines.len() || end_idx > lines.len() {
48 return None;
49 }
50 let mut snippet = lines[start_idx..end_idx].join("\n");
51 if snippet.trim().is_empty() {
52 return None;
53 }
54 if !snippet.ends_with('\n') {
55 snippet.push('\n');
56 }
57 Some(snippet)
58}
59
60fn build_covered_set(covered: &[(u32, u32)]) -> rustc_hash::FxHashSet<u32> {
61 let mut result = rustc_hash::FxHashSet::default();
62 for &(start, end) in covered {
63 for ln in start..=end {
64 result.insert(ln);
65 }
66 }
67 result
68}
69
70fn trim_blank_lines(lines: &[&str], mut start: u32, mut end: u32) -> (u32, u32) {
71 while start <= end
72 && lines
73 .get((start - 1) as usize)
74 .map_or(true, |l| l.trim().is_empty())
75 {
76 start += 1;
77 }
78 while end >= start
79 && lines
80 .get((end - 1) as usize)
81 .map_or(true, |l| l.trim().is_empty())
82 {
83 end -= 1;
84 }
85 (start, end)
86}
87
88fn split_long_gap(lines: &[&str], start: u32, end: u32) -> Vec<(u32, u32)> {
102 let threshold = PARSERS.sub_fragment_threshold_lines;
103 let target = PARSERS.sub_fragment_target_lines.max(1);
104 if end < start || end - start + 1 <= threshold {
105 return vec![(start, end)];
106 }
107
108 let is_blank = |ln: u32| {
109 lines
110 .get((ln - 1) as usize)
111 .is_some_and(|l| l.trim().is_empty())
112 };
113
114 let mut out = Vec::new();
115 let mut chunk_start = start;
116 while chunk_start <= end {
117 let ideal_end = (chunk_start + target - 1).min(end);
118 let slack = (target / 2).max(1);
122 let mut chunk_end = ideal_end;
123 if ideal_end < end {
124 let lo = ideal_end.saturating_sub(slack).max(chunk_start);
125 let hi = (ideal_end + slack).min(end);
126 if let Some(blank) = (lo..=hi).rev().find(|&ln| is_blank(ln)) {
127 chunk_end = blank;
128 }
129 }
130 if end - chunk_end < PARSERS.min_fragment_lines {
133 chunk_end = end;
134 }
135 out.push((chunk_start, chunk_end));
136 chunk_start = chunk_end + 1;
137 }
138 out
139}
140
141fn create_code_gap_fragments(
142 path: Arc<str>,
143 lines: &[&str],
144 covered: &[(u32, u32)],
145) -> Vec<Fragment> {
146 if lines.is_empty() {
147 return Vec::new();
148 }
149
150 let covered_set = build_covered_set(covered);
151 let total = lines.len() as u32;
152
153 let uncovered: Vec<u32> = (1..=total).filter(|ln| !covered_set.contains(ln)).collect();
154 if uncovered.is_empty() {
155 return Vec::new();
156 }
157
158 let mut gaps: Vec<(u32, u32)> = Vec::new();
159 let mut gap_start = uncovered[0];
160 let mut gap_end = uncovered[0];
161 for &ln in &uncovered[1..] {
162 if ln == gap_end + 1 {
163 gap_end = ln;
164 } else {
165 gaps.push((gap_start, gap_end));
166 gap_start = ln;
167 gap_end = ln;
168 }
169 }
170 gaps.push((gap_start, gap_end));
171
172 let mut fragments = Vec::new();
173 for (start, end) in gaps
174 .into_iter()
175 .flat_map(|(s, e)| split_long_gap(lines, s, e))
176 {
177 let (start, end) = trim_blank_lines(lines, start, end);
178 if start > end || end - start + 1 < PARSERS.min_fragment_lines {
179 continue;
180 }
181 if let Some(snippet) = create_snippet(lines, start, end) {
182 let identifiers = crate::types::extract_identifiers(
183 &snippet,
184 TOKENIZATION.fragment_min_identifier_length,
185 );
186 fragments.push(Fragment {
187 id: crate::types::FragmentId::new(Arc::clone(&path), start, end),
188 kind: crate::types::FragmentKind::Chunk,
189 content: Arc::from(snippet),
190 identifiers,
191 token_count: 0,
192 symbol_name: None,
193 });
194 }
195 }
196
197 fragments
198}
199
200#[cfg(test)]
201mod gap_tests {
202 use super::*;
203
204 fn numbered(n: usize) -> Vec<String> {
205 (1..=n).map(|i| format!("line {i}")).collect()
206 }
207
208 fn refs(v: &[String]) -> Vec<&str> {
209 v.iter().map(String::as_str).collect()
210 }
211
212 #[test]
215 fn a_short_gap_is_left_whole() {
216 let owned = numbered(PARSERS.sub_fragment_threshold_lines as usize);
217 let lines = refs(&owned);
218 assert_eq!(
219 split_long_gap(&lines, 1, lines.len() as u32),
220 vec![(1, lines.len() as u32)]
221 );
222 }
223
224 #[test]
228 fn a_long_gap_is_split_into_bounded_chunks_covering_every_line() {
229 let owned = numbered(300);
230 let lines = refs(&owned);
231 let chunks = split_long_gap(&lines, 1, 300);
232
233 assert!(chunks.len() > 1, "a 300-line gap was left as one fragment");
234 for &(start, end) in &chunks {
235 assert!(start <= end, "inverted chunk {start}-{end}");
236 assert!(
237 end - start + 1 <= PARSERS.sub_fragment_threshold_lines * 2,
238 "chunk {start}-{end} is far past the target size"
239 );
240 }
241 assert_eq!(chunks.first().unwrap().0, 1);
244 assert_eq!(chunks.last().unwrap().1, 300);
245 for pair in chunks.windows(2) {
246 assert_eq!(pair[1].0, pair[0].1 + 1, "gap or overlap at {pair:?}");
247 }
248 }
249
250 #[test]
253 fn a_cut_prefers_a_blank_line_near_the_target() {
254 let target = PARSERS.sub_fragment_target_lines as usize;
255 let mut owned = numbered(200);
256 let blank_at = target - 2;
258 owned[blank_at - 1] = String::new();
259 let lines = refs(&owned);
260
261 let chunks = split_long_gap(&lines, 1, 200);
262 assert_eq!(
263 chunks[0].1, blank_at as u32,
264 "the first cut ignored a blank line within reach: {chunks:?}"
265 );
266 }
267
268 #[test]
271 fn a_tiny_remainder_is_folded_into_the_previous_chunk() {
272 let target = PARSERS.sub_fragment_target_lines;
273 let total = target * 2 + PARSERS.min_fragment_lines.saturating_sub(1);
274 let owned = numbered(total as usize);
275 let lines = refs(&owned);
276
277 let chunks = split_long_gap(&lines, 1, total);
278 assert!(
279 chunks
280 .iter()
281 .all(|&(s, e)| e - s + 1 >= PARSERS.min_fragment_lines),
282 "a chunk below the minimum size survived: {chunks:?}"
283 );
284 }
285}