6
\$\begingroup\$

I've seen some other implementations of cat (even in Rust) on this site, but none attempted to completely implement the GNU cat CLI as far as I can tell. This version that I am developing is complete (minus the -u parameter that is ignored by GNU cat anyway) and, as far as I can tell, works.

Some problems:

  • It requires proper Unicode unless -A, -v, or -e are passed.
  • The code is a bit of a mess and could use cleaning up. Mainly this involves the way I have divided it into functions as well as my implementation of error handling.
  • I repeat myself a couple of times here and there.

After all, the reason I am doing this is to learn. Could someone show me where my code could be best improved? Be as pedantic as you'd like. Thanks in advance.

common is my own crate which implements the basic argument parsing used here. I can supply it if someone wants it.

main.rs:

mod partial_char;
mod configuration;

use common::Arg;
use std::env;
use std::fs::File;
use std::io;
use std::io::BufRead;
use std::io::BufReader;
use partial_char::PartialChar;
use configuration::Configuration;

fn show_help(program_name: &str) {
    print!("Usage: {0} [options] <file>...
Concatenate FILE(s) to standard output.
With no FILE, or when FILE is -, read standard input.
  Options:
    -A, --show-all          Equivalent to -vET
    -b, --number-nonblank   Number all non-empty output lines, starting with 1
    -e                      Equivalent to -vE
    -E, --show-ends         Display a '$' after the end of each line
    -n, --number            Number all output lines, starting with 1; ignored if -b was passed
    -s, --squeeze-blank     Suppress repeated adjacent blank lines, output one empty line instead of many
    -t                      Equivalent to -vT
    -T, --show-tabs         Display TAB characters as '^I'
    -v, --show-nonprinting  Display control characters (except for LFD and TAB) using '^' notation and precede characters that have the high bit set with 'M-'
  {0} normally reads and writes in binary mode, unless one of -bensAE is passed or standard output is a terminal. An exit status of zero indicates success, and a nonzero value indicates failure.
  ", program_name);
}

fn handle_args(raw_args: Vec<String>) -> Result<(Configuration, Vec<String>), String> {
    let args = common::parse_args(&raw_args[1..]);

    let mut config = Configuration::default();
    let mut files: Vec<String> = vec![];

    for arg in args {
        match arg {
            Arg::Flag(flag) => match flag.as_str() {
                "h" | "help" => {
                    show_help(&raw_args[0]);
                    // I don't like this, but it's simple
                    return Err(String::new());
                }
                "A" | "show-all" => config.show_all(),
                "b" | "number-nonblank" => config.number_nonblanks = true,
                "e" => config.show_nonprinting_and_line_ends(),
                "E" | "show-ends" => config.show_line_ends = true,
                "n" | "number" => config.number_lines(),
                "s" | "squeeze-blank" => config.squeeze_blanks = true,
                "t" => config.show_nonprinting_and_tabs(),
                "T" | "show-tabs" => config.show_tabs = true,
                "v" | "show-nonprinting" => config.show_nonprinting = true,
                _ => {
                    show_help(&raw_args[0]);
                    return Err(String::from("invalid argument"));
                }
            },
            Arg::Positional(file_name) => files.push(file_name),
            Arg::FlagEquals(k, v) => {
                // no x=y flags in cat
                show_help(&raw_args[0]);
                return Err(format!("invalid argument {}={}", k, v));
            }
        }
    }
    if files.len() == 0 {
        files.push("-".to_owned());
    }

    Ok((config, files))
}

fn main() -> Result<(), String> {
    let (config, files) = match handle_args(env::args().collect()) {
        Err(e) => match e.as_str() {
            // help was printed
            "" => return Ok(()),
            // actual error
            _ => return Err(e),
        },
        Ok((config, files)) => (config, files),
    };

    for file in files {
        if file == "-" {
            let stdin_handle = io::stdin();
            let stdin_reader = BufReader::new(stdin_handle);
            cat(stdin_reader, &config).map_err(|e| format!("reading stdin: {}", e))?;
        } else {
            let file_handle = File::open(&file).map_err(|e| format!("opening {}: {}", file, e))?;
            let file_reader = BufReader::new(file_handle);
            cat(file_reader, &config).map_err(|e| format!("reading {}: {}", file, e))?;
        }
    }

    Ok(())
}

/// Prints the prior line to stdout.
///
/// Includes logic to print the line number as well.
///
/// ## Side effects:
///  * May add 1 to `line`.
///  * May append a '$' character to `line_buf`.
///
/// ## Return value:
///
/// A boolean specifying whether the line that just ended was blank.
fn handle_newline(
    config: &Configuration,
    line: &mut usize,
    prev_line_was_blank: bool,
    line_buf: &mut String,
) -> bool {
    let current_line_is_blank =
        (config.number_nonblanks || config.squeeze_blanks) && line_buf.is_empty();
    if config.show_line_ends {
        line_buf.push('$');
    }
    if !(config.squeeze_blanks && current_line_is_blank && prev_line_was_blank) {
        // line should be printed
        if config.number_lines || (config.number_nonblanks && !current_line_is_blank) {
            // number line
            print!("{:6}  ", line);
            *line += 1;
        }
        // print line
        println!("{}", line_buf);
    }
    current_line_is_blank
}

/// Pushes a single byte onto `line_buf`, or modifies `partial_char`
/// in the case that the byte is part of a Unicode character.
///
/// ## Side effects:
///  * May edit `partial_char`'s attributes.
///  * May append to `line_buf`.
///
/// ## Errors:
///
/// Unicode parsing may fail. In this case, a String will be returned.
fn push_byte(
    byte: u8,
    partial_char: &mut PartialChar,
    line_buf: &mut String,
) -> Result<(), String> {
    if partial_char.num_bytes_left == 0 && byte & 0xc0 != 0xc0 {
        line_buf.push(byte as char);
    } else if byte & 0xc0 == 0xc0 {
        // start of Unicode char
        partial_char.start_char(byte)?;
    } else {
        // continued Unicode char
        partial_char.continue_char(byte)?;
        if partial_char.num_bytes_left == 0 {
            // should not panic; validity was checked!
            let uc = partial_char.finish_char().unwrap();
            line_buf.push(uc);
        }
    }
    Ok(())
}

/// Prints the contents of the reader to stdout.
fn cat<F>(mut reader: BufReader<F>, config: &Configuration) -> io::Result<()>
where
    F: io::Read,
{
    let mut line = 1usize;
    let mut prev_line_was_blank = false;
    // builds a UTF-8 char when necessary
    let mut partial_char = PartialChar::new();
    // holds the current line
    let mut line_buf = String::new();
    loop {
        let buf = reader.fill_buf()?;
        let len = buf.len();
        if len == 0 {
            // EOF
            break;
        }
        for c in buf {
            // handle non-printing, if necessary
            let mut c_special_repr = if config.show_nonprinting {
                print_char_escaped(*c)
            } else {
                String::new()
            };
            // handle tab character
            if config.show_tabs && *c == b'\t' {
                c_special_repr += &format!("^I");
            }
            // handle non-special character
            if c_special_repr.is_empty() {
                if *c == b'\n' {
                    let current_line_is_blank =
                        handle_newline(&config, &mut line, prev_line_was_blank, &mut line_buf);
                    prev_line_was_blank = current_line_is_blank;
                } else {
                    push_byte(*c, &mut partial_char, &mut line_buf)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
                }
            } else {
                line_buf.push_str(&c_special_repr);
            }
        }
        reader.consume(len);
    }
    Ok(())
}

/// Converts a byte to its escaped form.
///
/// Bytes with the high bit (`0x80`) set are given the M- prefix.
///
/// Bytes with values from 0..31 (or 127)
/// are control characters, and thus prefixed with ^.
fn print_char_escaped(c: u8) -> String {
    let mut c = c;
    // do non-printing checks
    let (meta, high_bit) = if c & 0x80 != 0 {
        // high bit is set, unset it
        c &= !0x80;
        (format!("M-"), true)
    } else {
        (String::new(), false)
    };
    let c = c;
    // now check for control chars
    let cs = match c {
        // null char
        0 => format!("^@"),
        // control char is represented as letter
        1..=26 => {
            if high_bit {
                // 1 + 64 = 65 = 'A'
                // 26 + 64 = 90 = 'Z'
                format!("^{}", (c + 64) as char)
            } else {
                match c {
                    // TAB, ignore; it is handled by config.show_tabs
                    b'\t' => String::new(),
                    // LF, ignore; it is handled by config.show_line_ends
                    b'\n' => String::new(),
                    // other control chars are treated the same
                    _ => format!("^{}", (c + 64) as char),
                }
            }
        }
        // ESC
        27 => format!("^["),
        // FS
        28 => format!("^\\"),
        // GS
        29 => format!("^]"),
        // RS
        30 => format!("^^"),
        // US
        31 => format!("^_"),
        // DEL
        127 => format!("^?"),
        // printable char
        _ => format!("{}", c as char),
    };
    meta + cs.as_str()
}

configuration.rs:

#[derive(Debug)]
pub struct Configuration {
    pub show_nonprinting: bool,
    pub show_tabs: bool,
    pub squeeze_blanks: bool,
    pub number_lines: bool,
    pub show_line_ends: bool,
    pub number_nonblanks: bool,
}

impl Default for Configuration {
    fn default() -> Self {
        Configuration {
            show_nonprinting: false,
            show_tabs: false,
            show_line_ends: false,
            squeeze_blanks: false,
            number_lines: false,
            number_nonblanks: false,
        }
    }
}

impl Configuration {
    pub fn show_all(&mut self) {
        self.show_nonprinting = true;
        self.show_tabs = true;
        self.show_line_ends = true;
    }

    pub fn show_nonprinting_and_tabs(&mut self) {
        self.show_nonprinting = true;
        self.show_tabs = true;
    }

    pub fn show_nonprinting_and_line_ends(&mut self) {
        self.show_nonprinting = true;
        self.show_line_ends = true;
    }

    pub fn number_lines(&mut self) {
        if !self.number_nonblanks {
            self.number_lines = true;
        }
    }
}

partial_char.rs:

use std::str::FromStr;

pub struct PartialChar {
    bytes: [u8; 6],
    num_bytes: u8,
    pub num_bytes_left: u8,
}

impl PartialChar {
    pub fn new() -> Self {
        Self {
            bytes: [0; 6],
            num_bytes: 0,
            num_bytes_left: 0,
        }
    }

    pub fn start_char(&mut self, byte: u8) -> Result<(), String> {
        // start at third bit
        let mut mask = 0x20;
        self.num_bytes_left = 1;
        while byte & mask != 0 {
            if mask == 0x02 {
                // too many set bits to be valid
                return Err(format!("Invalid UTF-8 start byte: 0x{:2x}", byte));
            }
            self.num_bytes_left += 1;
            mask >>= 1;
        }
        self.num_bytes = 1;
        self.bytes[0] = byte;
        Ok(())
    }

    pub fn continue_char(&mut self, byte: u8) -> Result<(), String> {
        // expected: first two bits are 0b10
        if byte & 0xc0 != 0x80 {
            return Err(format!("Invalid UTF-8 continuation byte: 0x{:2x}", byte));
        }
        self.num_bytes_left -= 1;
        self.bytes[self.num_bytes as usize] = byte;
        self.num_bytes += 1;
        Ok(())
    }

    pub fn finish_char(&self) -> Result<char, String> {
        if self.num_bytes_left != 0 || self.num_bytes == 0 {
            return Err(String::from("does not contain a finished UTF-8 char"));
        }

        // unwrapping here because UTF-8 was already validated
        let c = std::str::from_utf8(&self.bytes[..self.num_bytes as usize]).unwrap();
        Ok(char::from_str(c).unwrap())
    }
}

```
\$\endgroup\$
  • 1
    \$\begingroup\$ This is pretty cool! I think the first thing you could do is to start splitting it up into smaller functions to make it easier to maintain and read. The embedded if conditions is always hard to follow. \$\endgroup\$ – kemicofa ghost Jun 17 at 19:29
  • \$\begingroup\$ Thanks for the compliment. You're talking mainly about the 'cat' function, correct? \$\endgroup\$ – onContentStop Jun 17 at 20:26
  • \$\begingroup\$ I'm talking about every function that is over 15-20 lines of code. Which includes the main and cat functions. So yes :) \$\endgroup\$ – kemicofa ghost Jun 17 at 20:31
  • 1
    \$\begingroup\$ It does seem like a tough restriction, however on the long run smaller bits of code are easier to test (unit/integration testing), easier to maintain, easier to read, reduces code duplications, etc. Any company with coding standards would want this to be refactored. Lets not take into account someone joining your group to maintain this and just take the most simple example of, would you be able to easily remember how this works in a month or two? I'd be impressed. Anyway, keep up the good work! \$\endgroup\$ – kemicofa ghost Jun 17 at 20:48
  • 1
    \$\begingroup\$ @kemicofaghost your first comment could already have been a review ;). A short one, but it's a (30-second) review nonetheless. :) \$\endgroup\$ – Zeta Jun 19 at 8:25

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.