Skip to main content

_diffctx/parsers/
mod.rs

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
88/// Splits an over-long uncovered run into bounded chunks, preferring a blank
89/// line as the cut point.
90///
91/// A gap used to become one fragment however long it was, so a file the grammar
92/// extracts nothing from — a flat bash script, a `CMakeLists.txt`, any language
93/// without a grammar — collapsed into a single whole-file chunk. Nothing
94/// narrower could ever be selected, so a one-line diff rendered the entire file
95/// as changed (#105, #107) and its unchanged remainder was unavailable as
96/// context at any useful granularity.
97///
98/// Reuses the thresholds that already govern sub-fragmenting large definitions
99/// (`sub_fragment_threshold_lines` / `sub_fragment_target_lines`) rather than
100/// introducing a second size policy for the same question.
101fn 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        // Prefer a blank line near the target so a chunk boundary lands between
119        // logical blocks rather than mid-statement. Search a window of up to
120        // half the target on either side, then fall back to the hard cut.
121        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        // The remainder is too small to stand alone: fold it into this chunk
131        // instead of emitting a stub.
132        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    /// A gap shorter than the threshold is one chunk: splitting it would only
213    /// fragment a block that already reads as a unit.
214    #[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    /// The defect behind #105/#107: an uncovered run became one fragment however
225    /// long, so a file the grammar extracts nothing from had no sub-file
226    /// granularity at all and a one-line diff rendered all of it.
227    #[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        // Contiguous and complete: no line may be dropped or duplicated, or the
242        // file's content would silently go missing from the universe.
243        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    /// Blank lines are the cheapest available proxy for a block boundary, so a
251    /// cut should land on one rather than mid-statement when one is in reach.
252    #[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        // Put a blank line a couple of lines before the first ideal cut.
257        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    /// Splitting must not leave a stub behind: a remainder below the minimum
269    /// fragment size is folded into the preceding chunk.
270    #[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}