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}