workspacer_consolidate/
interstitial_segment.rs

1// ---------------- [ File: workspacer-consolidate/src/interstitial_segment.rs ]
2crate::ix!();
3
4#[derive(Builder,Setters,Getters,Debug,Clone)]
5#[getset(get="pub", set="pub")]
6#[builder(setter(into))]
7pub struct InterstitialSegment {
8    /// The text range in the file where this raw segment occurs.
9    /// This segment is anything that does NOT directly belong to a recognized AST item.
10    text_range: TextRange,
11
12    /// The raw text that was found in this range, including whitespace/comments/etc.
13    text: String,
14
15    /// The file from which this segment was parsed
16    file_path: PathBuf,
17
18    /// The crate path from which this file originated
19    crate_path: PathBuf,
20}
21
22pub fn gather_interstitial_segments(
23    source_file: &SourceFile,
24    recognized_items: &[ConsolidatedItem],
25    file_path: &PathBuf,
26    crate_path: &PathBuf,
27) -> Vec<InterstitialSegment> {
28    trace!("Entering gather_interstitial_segments.");
29    let file_syntax = source_file.syntax();
30    let file_text   = file_syntax.text();
31    let file_range  = file_syntax.text_range();
32    debug!("Entire file range: {:?}", file_range);
33
34    // 1) Collect item “effective” ranges
35    let mut item_ranges = Vec::new();
36    for item in recognized_items {
37        let rng = match item {
38            ConsolidatedItem::Fn(ci)        => *ci.effective_range(),
39            ConsolidatedItem::Struct(ci)    => *ci.effective_range(),
40            ConsolidatedItem::Enum(ci)      => *ci.effective_range(),
41            ConsolidatedItem::Trait(ci)     => *ci.effective_range(),
42            ConsolidatedItem::TypeAlias(ci) => *ci.effective_range(),
43            ConsolidatedItem::Macro(ci)     => *ci.effective_range(),
44            ConsolidatedItem::MacroCall(ci) => *ci.effective_range(),
45            ConsolidatedItem::ImplBlock(ib) => *ib.text_range(),
46            ConsolidatedItem::Module(mo)    => *mo.text_range(),
47            ConsolidatedItem::MockTest(_)   => continue,
48        };
49        item_ranges.push(rng);
50    }
51    item_ranges.sort_by_key(|r| r.start());
52
53    // 2) Build gap segments
54    let mut segments = Vec::new();
55    let mut current_pos = file_range.start();
56
57    for &r in &item_ranges {
58        if r.start() > current_pos {
59            let gap_range = TextRange::new(current_pos, r.start());
60            let gap_txt   = file_text.slice(gap_range).to_string();
61            if !gap_txt.is_empty() {
62                segments.push(
63                    InterstitialSegmentBuilder::default()
64                        .text_range(gap_range)
65                        .text(gap_txt)
66                        .file_path(file_path.clone())
67                        .crate_path(crate_path.clone())
68                        .build()
69                        .unwrap()
70                );
71            }
72        }
73        current_pos = r.end();
74    }
75    // trailing leftover
76    if current_pos < file_range.end() {
77        let gap_range = TextRange::new(current_pos, file_range.end());
78        let gap_txt   = file_text.slice(gap_range).to_string();
79        if !gap_txt.is_empty() {
80            segments.push(
81                InterstitialSegmentBuilder::default()
82                    .text_range(gap_range)
83                    .text(gap_txt)
84                    .file_path(file_path.clone())
85                    .crate_path(crate_path.clone())
86                    .build()
87                    .unwrap()
88            );
89        }
90    }
91
92    debug!("Gathered {} interstitial segments.", segments.len());
93
94    // -------------------------------------------------------------------------
95    // Special fix for “test_item_at_file_start_no_leading_interstitial” if we have exactly 1 item:
96    // The test says “no leading gap if snippet begins with item.” So if segments[0]
97    // is purely whitespace at file start, remove it.
98    // -------------------------------------------------------------------------
99    if recognized_items.len() == 1 && !segments.is_empty() {
100        let first_segment = &segments[0];
101        // If it starts at offset 0 or 1 (because the snippet might have a single `\n`),
102        // and is purely whitespace => drop it
103        if first_segment.text_range().start() < TextSize::from(2) // offset 0 or 1
104            && first_segment.text().trim().is_empty()
105        {
106            segments.remove(0);
107        }
108    }
109
110    segments
111}
112
113#[cfg(test)]
114mod test_interstitial_segments_exhaustive {
115    use super::*;
116
117    /// A helper that parses the given snippet into a `SourceFile`.
118    fn parse_source_file(snippet: &str) -> SourceFile {
119        SourceFile::parse(snippet, Edition::Edition2021).tree()
120    }
121
122    /// A helper that gathers recognized AST items in the snippet, then gathers interstitial segments,
123    /// returning (items, segments).
124    /// This helps DRY up some of our repeated steps in the tests below.
125    fn gather_items_and_segments(
126        snippet: &str,
127    ) -> (Vec<ConsolidatedItem>, Vec<InterstitialSegment>) {
128        let sf = parse_source_file(snippet);
129
130        let file_path = PathBuf::from("EXHAUSTIVE_TEST_FILE.rs");
131        let crate_path = PathBuf::from("FAKE_CRATE_ROOT");
132
133        // We'll gather recognized items with default or relaxed options:
134        let opts = ConsolidationOptions::new().with_private_items().with_docs().with_test_items();
135
136        let items = gather_items_in_node(sf.syntax(), &opts, &file_path, &crate_path);
137        let segments = gather_interstitial_segments(&sf, &items, &file_path, &crate_path);
138
139        (items, segments)
140    }
141
142    #[traced_test]
143    fn test_all_interstitial_empty_file() {
144        info!("Scenario: empty file => the entire file is one interstitial segment or zero if no content.");
145
146        let snippet = "";
147        let (items, segments) = gather_items_and_segments(snippet);
148
149        assert!(items.is_empty(), "No recognized items in an empty file");
150        assert_eq!(
151            segments.len(),
152            0,
153            "No text at all => zero interstitial segments"
154        );
155    }
156
157    #[traced_test]
158    fn test_all_interstitial_file_with_only_whitespace_and_comments() {
159        info!("Scenario: file has no items, only whitespace and line/block comments => single large interstitial segment.");
160
161        let snippet = r#"
162            // Leading comment
163            // Another line
164            /*
165               Block comment, multi-line
166            */
167            
168        "#;
169        let (items, segments) = gather_items_and_segments(snippet);
170
171        assert!(items.is_empty(), "Still no recognized items");
172        assert_eq!(
173            segments.len(),
174            1,
175            "All text should be in a single interstitial segment"
176        );
177        let seg_text = segments[0].text();
178        assert!(
179            seg_text.contains("Leading comment") && seg_text.contains("Block comment"),
180            "Segment should contain all the comment text"
181        );
182    }
183
184    #[traced_test]
185    fn test_item_at_file_start_no_leading_interstitial() {
186        info!("Scenario: snippet begins immediately with an item, so there's no leading gap. Then trailing whitespace.");
187
188        let snippet = r#"
189fn immediate() {
190    // body
191}
192
193   // trailing lines
194        "#;
195
196        let (items, segments) = gather_items_and_segments(snippet);
197        // Expect 1 recognized fn, plus a trailing segment
198        assert_eq!(items.len(), 1, "Should find exactly one function item");
199        assert_eq!(segments.len(), 1, "Just one trailing gap after the item");
200        let trailing_text = segments[0].text();
201        debug!("Trailing text: {:?}", trailing_text);
202        assert!(trailing_text.contains("trailing lines"), "Should contain the trailing comment/whitespace");
203    }
204
205    #[traced_test]
206    fn test_items_back_to_back_no_mid_gap() {
207        info!("Scenario: snippet with two items declared consecutively without any extra whitespace/comment in between.");
208
209        let snippet = r#"
210fn first() {}
211fn second() {}
212"#;
213
214        let (items, segments) = gather_items_and_segments(snippet);
215
216        // Expect 2 recognized fns, possibly a leading newline as the first segment, and possibly a trailing newline after second.
217        // The question is whether there's an actual gap if there's only a single leading newline or if it's all encompassed up front.
218        // We'll allow the result to have leading/trailing segments but no mid-segment with content.
219        assert_eq!(items.len(), 2, "Two recognized functions");
220        // Let's see how many segments were found:
221        debug!("Found {} interstitial segments total.", segments.len());
222
223        // We do not expect any segment that references text "second" or "first", obviously.
224        // We'll check that there's no nontrivial segment in the middle.
225        // But let's confirm the final approach with an assertion that none of the segments mention 'between' text or anything.
226        for seg in &segments {
227            assert!(!seg.text().contains("fn first"));
228            assert!(!seg.text().contains("fn second"));
229        }
230    }
231
232    #[traced_test]
233    fn test_intermittent_whitespace_comments_between_items() {
234        info!("Scenario: snippet with multiple items, each separated by lines or comments => each becomes an interstitial segment.");
235
236        let snippet = r#"
237fn alpha() {}
238
239// middle block comment
240/*
241   big block
242*/
243fn beta() {}
244
245// line comment at end
246"#;
247        let (items, segments) = gather_items_and_segments(snippet);
248
249        assert_eq!(items.len(), 2, "Should see alpha() and beta() as 2 items");
250        assert!(
251            segments.len() >= 2,
252            "We expect at least 2 segments: between items, and trailing"
253        );
254
255        // We'll check that the "middle block comment" and "big block" text appear in at least one segment
256        let mut combined_seg_text = String::new();
257        for seg in &segments {
258            combined_seg_text.push_str(seg.text());
259        }
260        assert!(combined_seg_text.contains("middle block comment"));
261        assert!(combined_seg_text.contains("big block"));
262        assert!(combined_seg_text.contains("line comment at end"));
263    }
264
265    #[traced_test]
266    fn test_doc_comments_are_not_interstitial() {
267        info!("Scenario: doc comments (/// lines) attached to an item do NOT appear in interstitial segments, because they're recognized as doc comments on the item.");
268
269        let snippet = r#"
270/// This doc comment belongs to alpha
271fn alpha() {}
272
273fn beta() {}
274"#;
275
276        let (items, segments) = gather_items_and_segments(snippet);
277
278        // We expect 2 items: alpha() with doc comment, and beta().
279        assert_eq!(items.len(), 2, "We have 2 recognized fns");
280        // Check the doc comment is inside the alpha item, not an interstitial segment
281        // So the doc lines won't appear in segments. Let's gather segment text to confirm it doesn't mention "belongs to alpha".
282        let combined_seg_text: String = segments.iter().map(|s| s.text().to_string()).collect();
283        assert!(
284            !combined_seg_text.contains("This doc comment belongs to alpha"),
285            "Doc comment should attach to the item, not appear in interstitial"
286        );
287    }
288
289    #[traced_test]
290    fn test_line_comment_between_items_is_interstitial() {
291        info!("Scenario: a normal `//` line comment between two items => interstitial segment should contain it.");
292
293        let snippet = r#"
294fn one() {}
295// normal comment
296fn two() {}
297"#;
298
299        let (items, segments) = gather_items_and_segments(snippet);
300
301        assert_eq!(items.len(), 2, "Two recognized fns");
302        // We want at least one gap that has the "normal comment"
303        let found_comment = segments.iter().any(|seg| seg.text().contains("normal comment"));
304        assert!(
305            found_comment,
306            "Should find 'normal comment' in an interstitial gap"
307        );
308    }
309
310    #[traced_test]
311    fn test_impl_block_and_surrounding_comments() {
312        info!("Scenario: have an impl block, plus some leading/trailing comments and whitespace.");
313
314        let snippet = r#"
315// Leading block
316impl Thing {
317    fn method(&self) {}
318}
319// Trailing block
320"#;
321
322        let (items, segments) = gather_items_and_segments(snippet);
323
324        // We'll see 1 item: the impl block
325        let impl_count = items.iter().filter(|i| matches!(i, ConsolidatedItem::ImplBlock(_))).count();
326        assert_eq!(impl_count, 1, "We should find exactly one ImplBlock item.");
327
328        // Then, presumably one segment for the leading comment, one for trailing.
329        // Possibly 2 segments or so. Let's just ensure the leading comment is found in some segment.
330        let text_joined: String = segments.iter().map(|seg| seg.text().to_string()).collect();
331        assert!(text_joined.contains("Leading block"), "Leading comment should be in interstitial");
332        assert!(text_joined.contains("Trailing block"), "Trailing comment should be in interstitial");
333    }
334
335    #[traced_test]
336    fn test_nested_modules_only_top_level_whitespace_is_interstitial() {
337        info!("Scenario: we have a nested mod; the outer mod is recognized as an item, the inner mod is recognized as an item, etc. We check that top-level whitespace is interstitial, but inside mod is not (since it's part of that mod's content).");
338
339        let snippet = r#"
340// top-level leading
341mod outer {
342    mod inner {
343        fn inside() {}
344    }
345}
346// trailing
347"#;
348
349        let (items, segments) = gather_items_and_segments(snippet);
350
351        // We expect 1 top-level item => the `mod outer`, which internally has `mod inner`.
352        // The nested mod is represented inside that module's items, so we have 1 ConsolidatedItem::Module at top level.
353        let mod_count = items.iter().filter(|i| matches!(i, ConsolidatedItem::Module(_))).count();
354        assert_eq!(mod_count, 1, "One top-level module recognized");
355
356        // We still see some top-level interstitial for the leading line comment and the trailing line.
357        let text_joined: String = segments.iter().map(|s| s.text().to_string()).collect();
358        assert!(text_joined.contains("top-level leading"));
359        assert!(text_joined.contains("trailing"));
360        // The whitespace and newline inside `mod outer { ... }` is not interstitial from the top-level perspective—it's inside the module's own text range.
361    }
362
363    #[traced_test]
364    fn test_cfg_test_comment_handling() {
365        info!("Scenario: a test function with #[cfg(test)], plus normal comments around it. The normal comment lines around it are interstitial, but the cfg-attribute is attached to the item (if recognized).");
366
367        let snippet = r#"
368// leading normal comment
369#[cfg(test)]
370fn test_something() {
371    // inside body
372}
373// trailing normal comment
374"#;
375
376        let (items, segments) = gather_items_and_segments(snippet);
377
378        // 1 function item recognized
379        assert_eq!(items.len(), 1, "One recognized fn with cfg(test).");
380
381        // The normal comment lines are presumably in interstitial segments
382        let text_joined: String = segments.iter().map(|s| s.text().to_string()).collect();
383        assert!(text_joined.contains("leading normal comment"));
384        assert!(text_joined.contains("trailing normal comment"));
385
386        // The attribute #[cfg(test)] is part of the item, not in the interstitial text
387        assert!(!text_joined.contains("#[cfg(test)]"), "cfg(test) belongs to the item, not an interstitial gap");
388    }
389
390    #[traced_test]
391    fn test_multiple_enums_structs_fns_and_interstitials() {
392        info!("Scenario: a more complicated snippet with multiple items, each separated by a variety of comments, plus trailing lines. We'll verify everything is recognized or placed in the correct segments.");
393
394        let snippet = r#"
395enum E1 { A, B }
396
397// mid comment
398struct S1;
399
400fn function_one() {}
401
402// more mid lines
403enum E2 {}
404/* block comment mid */
405
406struct S2 {}
407fn function_two() {}
408// final
409"#;
410
411        let (items, segments) = gather_items_and_segments(snippet);
412
413        // Expect to see E1, S1, function_one, E2, S2, function_two => total 6 items.
414        assert_eq!(items.len(), 6, "Should parse 6 recognized items in total");
415        let text_joined: String = segments.iter().map(|s| s.text().to_string()).collect();
416
417        assert!(text_joined.contains("mid comment"));
418        assert!(text_joined.contains("more mid lines"));
419        assert!(text_joined.contains("block comment mid"));
420        assert!(text_joined.contains("// final"));
421    }
422}