Skip to main content

coding_tools/
view.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! Pure line-selection helpers behind `ct-view`'s bounded, context-aware reads:
5//! parsing a `--range` spec, expanding `--match` hits into context windows, and
6//! grouping the kept line indices into contiguous runs for display.
7
8/// Parse a `--range` spec into a 0-based inclusive `[start, end]`, clamped to a
9/// file of `total` lines. Returns `None` when the range selects nothing.
10///
11/// Accepts `A:B` (1-based, inclusive), `A:` (to end), `:B` (from start), and a
12/// bare `A` (one line). Lines are 1-based; `0` is an error.
13///
14/// # Examples
15///
16/// ```
17/// use coding_tools::view::parse_range;
18///
19/// assert_eq!(parse_range("2:4", 10).unwrap(), Some((1, 3))); // 0-based, inclusive
20/// assert_eq!(parse_range("3", 10).unwrap(), Some((2, 2)));   // a single line
21/// assert_eq!(parse_range("8:", 10).unwrap(), Some((7, 9)));  // open end
22/// assert_eq!(parse_range(":3", 10).unwrap(), Some((0, 2)));  // open start
23/// assert_eq!(parse_range("9:99", 10).unwrap(), Some((8, 9))); // end clamps to EOF
24/// assert_eq!(parse_range("20:30", 10).unwrap(), None);        // wholly past EOF
25/// assert!(parse_range("0:3", 10).is_err());                   // lines are 1-based
26/// ```
27pub fn parse_range(spec: &str, total: usize) -> Result<Option<(usize, usize)>, String> {
28    if total == 0 {
29        return Ok(None);
30    }
31    let parse_one = |s: &str, what: &str| -> Result<usize, String> {
32        s.trim()
33            .parse::<usize>()
34            .map_err(|_| format!("invalid {what} in range '{spec}'"))
35    };
36
37    let (start1, end1): (usize, usize) = match spec.split_once(':') {
38        Some((a, b)) => {
39            let start = if a.trim().is_empty() {
40                1
41            } else {
42                parse_one(a, "start")?
43            };
44            let end = if b.trim().is_empty() {
45                total
46            } else {
47                parse_one(b, "end")?
48            };
49            (start, end)
50        }
51        None => {
52            let n = parse_one(spec, "line")?;
53            (n, n)
54        }
55    };
56
57    if start1 == 0 {
58        return Err(format!("range lines are 1-based; got 0 in '{spec}'"));
59    }
60    if start1 > total || end1 < start1 {
61        return Ok(None);
62    }
63    Ok(Some((
64        start1 - 1,
65        end1.min(total).saturating_sub(1).max(start1 - 1),
66    )))
67}
68
69/// Expand each hit index by `ctx` lines and merge overlapping/adjacent windows
70/// into a sorted, de-duplicated list of line indices, clamped to `total` lines.
71///
72/// # Examples
73///
74/// ```
75/// use coding_tools::view::expand_and_merge;
76///
77/// // hits at 2 and 3 with 1 line of context overlap into a single run.
78/// assert_eq!(expand_and_merge(&[2, 3], 1, 10), vec![1, 2, 3, 4]);
79/// // distant hits stay separate.
80/// assert_eq!(expand_and_merge(&[1, 8], 0, 10), vec![1, 8]);
81/// // context clamps to the file bounds.
82/// assert_eq!(expand_and_merge(&[0], 2, 3), vec![0, 1, 2]);
83/// ```
84pub fn expand_and_merge(hits: &[usize], ctx: usize, total: usize) -> Vec<usize> {
85    if hits.is_empty() || total == 0 {
86        return Vec::new();
87    }
88    let mut idx: Vec<usize> = Vec::new();
89    let last = total - 1;
90    for &h in hits {
91        let lo = h.saturating_sub(ctx);
92        let hi = (h + ctx).min(last);
93        for i in lo..=hi {
94            if idx.last() != Some(&i) {
95                idx.push(i);
96            }
97        }
98    }
99    idx.sort_unstable();
100    idx.dedup();
101    idx
102}
103
104/// Group sorted indices into contiguous `[start, end]` runs (the display groups
105/// separated by `--` in text output).
106///
107/// # Examples
108///
109/// ```
110/// use coding_tools::view::segments;
111///
112/// assert_eq!(segments(&[1, 2, 3]), vec![(1, 3)]);
113/// assert_eq!(segments(&[1, 2, 5, 6]), vec![(1, 2), (5, 6)]);
114/// assert_eq!(segments(&[]), vec![]);
115/// ```
116pub fn segments(idx: &[usize]) -> Vec<(usize, usize)> {
117    let mut segs: Vec<(usize, usize)> = Vec::new();
118    for &i in idx {
119        match segs.last_mut() {
120            Some(seg) if i == seg.1 + 1 => seg.1 = i,
121            _ => segs.push((i, i)),
122        }
123    }
124    segs
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn range_parsing_handles_open_and_closed_forms() {
133        assert_eq!(parse_range("2:4", 10).unwrap(), Some((1, 3)));
134        assert_eq!(parse_range("3", 10).unwrap(), Some((2, 2)));
135        assert_eq!(parse_range("8:", 10).unwrap(), Some((7, 9)));
136        assert_eq!(parse_range(":3", 10).unwrap(), Some((0, 2)));
137        assert_eq!(parse_range("20:30", 10).unwrap(), None);
138        assert_eq!(parse_range("9:99", 10).unwrap(), Some((8, 9)));
139        assert!(parse_range("0:3", 10).is_err());
140        assert!(parse_range("x", 10).is_err());
141    }
142
143    #[test]
144    fn windows_merge_when_they_overlap() {
145        assert_eq!(expand_and_merge(&[2, 3], 1, 10), vec![1, 2, 3, 4]);
146        assert_eq!(expand_and_merge(&[1, 8], 0, 10), vec![1, 8]);
147        assert_eq!(expand_and_merge(&[0], 2, 3), vec![0, 1, 2]);
148    }
149
150    #[test]
151    fn segments_split_on_gaps() {
152        assert_eq!(segments(&[1, 2, 3]), vec![(1, 3)]);
153        assert_eq!(segments(&[1, 2, 5, 6]), vec![(1, 2), (5, 6)]);
154        assert_eq!(segments(&[]), vec![]);
155    }
156}