Skip to main content

linuxutils_text/
colrm.rs

1use linuxutils_common::man::ManContent;
2
3pub const MAN: ManContent = ManContent::empty();
4
5use clap::Parser;
6use std::{
7    io::{self, BufRead, Write},
8    process::ExitCode,
9};
10
11const TAB_WIDTH: usize = 8;
12
13#[derive(Parser)]
14#[command(name = "colrm", version, about = "Remove columns from a file")]
15pub struct Args {
16    /// First column to remove (1-based)
17    pub first: Option<usize>,
18    /// Last column to remove (1-based, inclusive)
19    pub last: Option<usize>,
20}
21
22pub fn run(args: Args) -> ExitCode {
23    let first = match args.first {
24        Some(0) => {
25            eprintln!("colrm: first column must be at least 1");
26            return ExitCode::FAILURE;
27        }
28        Some(f) => Some(f),
29        None => None,
30    };
31
32    let last = args.last;
33
34    if let (Some(f), Some(l)) = (first, last)
35        && l < f
36    {
37        eprintln!("colrm: last column must be >= first column");
38        return ExitCode::FAILURE;
39    }
40
41    let stdin = io::stdin();
42    let stdout = io::stdout();
43    let mut out = io::BufWriter::new(stdout.lock());
44
45    for line in stdin.lock().lines() {
46        let line = match line {
47            Ok(l) => l,
48            Err(e) => {
49                eprintln!("colrm: {e}");
50                return ExitCode::FAILURE;
51            }
52        };
53
54        if let Err(e) = process_line(&line, first, last, &mut out) {
55            eprintln!("colrm: {e}");
56            return ExitCode::FAILURE;
57        }
58    }
59
60    ExitCode::SUCCESS
61}
62
63fn process_line(
64    line: &str,
65    first: Option<usize>,
66    last: Option<usize>,
67    out: &mut impl Write,
68) -> io::Result<()> {
69    let first = match first {
70        Some(f) => f,
71        None => {
72            writeln!(out, "{line}")?;
73            return Ok(());
74        }
75    };
76
77    // Track visual column position (1-based).
78    let mut col: usize = 1;
79
80    for ch in line.chars() {
81        if ch == '\t' {
82            let next_tab = next_tab_stop(col);
83            // A tab spans from col to next_tab-1 (visually).
84            // We need to output the visible portions of the tab that fall
85            // outside the removed range.
86            for c in col..next_tab {
87                if c < first || last.is_some_and(|l| c > l) {
88                    out.write_all(b" ")?;
89                }
90            }
91            col = next_tab;
92        } else if ch == '\x08' {
93            // Backspace moves column back (like original colrm).
94            if col < first || last.is_some_and(|l| col > l) {
95                write!(out, "{ch}")?;
96            }
97            col = col.saturating_sub(1);
98        } else {
99            if col < first || last.is_some_and(|l| col > l) {
100                write!(out, "{ch}")?;
101            }
102            col += 1;
103        }
104    }
105
106    writeln!(out)?;
107    Ok(())
108}
109
110fn next_tab_stop(col: usize) -> usize {
111    // Columns are 1-based. Tab stops at 9, 17, 25, ...
112    col + TAB_WIDTH - ((col - 1) % TAB_WIDTH)
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    fn run_colrm(
120        input: &str,
121        first: Option<usize>,
122        last: Option<usize>,
123    ) -> String {
124        let mut out = Vec::new();
125        process_line(input, first, last, &mut out).unwrap();
126        String::from_utf8(out).unwrap()
127    }
128
129    #[test]
130    fn no_args_passes_through() {
131        assert_eq!(run_colrm("hello world", None, None), "hello world\n");
132    }
133
134    #[test]
135    fn remove_from_column() {
136        assert_eq!(run_colrm("hello world", Some(3), None), "he\n");
137    }
138
139    #[test]
140    fn remove_range() {
141        assert_eq!(run_colrm("hello world", Some(3), Some(5)), "he world\n");
142    }
143
144    #[test]
145    fn tab_expansion_in_removed_range() {
146        // "ab\tcd" -> visual: "ab      cd" (ab at 1-2, tab fills 3-8, cd at 9-10)
147        // Remove columns 3-8 -> "abcd"
148        assert_eq!(run_colrm("ab\tcd", Some(3), Some(8)), "abcd\n");
149    }
150
151    #[test]
152    fn tab_partial_removal() {
153        // "ab\tcd" -> remove column 4 to 6
154        // Columns 3-8 are the tab. Keep col 3, remove 4-6, keep 7-8, keep cd at 9-10.
155        assert_eq!(run_colrm("ab\tcd", Some(4), Some(6)), "ab   cd\n");
156    }
157
158    #[test]
159    fn empty_line() {
160        assert_eq!(run_colrm("", Some(1), None), "\n");
161    }
162
163    #[test]
164    fn first_beyond_line_length() {
165        assert_eq!(run_colrm("hello", Some(20), None), "hello\n");
166    }
167
168    #[test]
169    fn remove_first_column() {
170        assert_eq!(run_colrm("hello", Some(1), Some(1)), "ello\n");
171    }
172}