EP 01 / BUILDING CAT IN RUST  ·  BUILD

Building cat in Rust

May 22, 2026 · 18 min

We build a real cat replacement — with syntax highlighting, line numbers, and proper terminal detection. The kind of tool you'd actually use. Along the way we cover the ownership concepts and iterator patterns that come up constantly in real Rust.

What this episode covers
Code
// - [x] CLI arguments parsing
// - [x] List themes
// - [x] reading files
// - [x] syntax highlighting
// - [x] real themes
// - [x] line numbers
// - [x] stdin and tty detection
// - [x] refactor
use clap::Parser;

use syntect::easy::HighlightLines;
use syntect::highlighting::Style;
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
use syntect::util::as_24_bit_terminal_escaped;

use std::fs::File;
use std::io::BufRead;
use std::io::BufReader;
use std::io::Read;

use is_terminal::IsTerminal;

const DEFAULT_THEME: &str = "base16-ocean.dark";

#[derive(Debug, Parser)]
struct Args {
    #[arg(short = 'n', long)]
    line_numbers: bool,

    #[arg(long, default_value = DEFAULT_THEME)]
    theme: String,

    #[arg(long)]
    no_color: bool,

    #[arg(long)]
    list_themes: bool,

    files: Vec<String>,
}

fn load_syntax_assets() -> (SyntaxSet, ThemeSet) {
    (
        SyntaxSet::load_defaults_newlines(),
        ThemeSet::load_defaults(),
    )
}

fn build_highlighter<'a>(
    filename: &str,
    syntax_set: &'a SyntaxSet,
    theme_set: &'a ThemeSet,
    theme: &str,
) -> HighlightLines<'a> {
    let syntax = std::path::Path::new(filename)
        .extension()
        .and_then(|ext| syntax_set.find_syntax_by_extension(&ext.to_string_lossy()))
        .unwrap_or_else(|| syntax_set.find_syntax_plain_text());
    let theme = &theme_set
        .themes
        .get(theme)
        .unwrap_or(&theme_set.themes[DEFAULT_THEME]);
    HighlightLines::new(syntax, theme)
}

fn list_themes() {
    let (_, theme_set) = load_syntax_assets();
    println!("Available themes:");
    for theme in theme_set.themes.keys() {
        println!(" - {theme}")
    }
}

fn print_file(
    filename: &str,
    args: &Args,
    syntax_set: &SyntaxSet,
    theme_set: &ThemeSet,
    use_color: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    let input: Box<dyn Read> = if filename == "-" {
        Box::new(std::io::stdin())
    } else {
        Box::new(File::open(filename)?)
    };
    let mut reader = BufReader::new(input);
    let mut line = String::new();
    let mut line_number = 0;
    let mut highlighter: Option<HighlightLines> = if use_color {
        Some(build_highlighter(filename, &syntax_set, &theme_set, &args.theme))
    } else {
        None
    };

    while reader.read_line(&mut line)? > 0 {
        line_number += 1;
        if args.line_numbers {
            print!("{:>6} ", line_number);
        }
        if let Some(ref mut h) = highlighter {
            let ranges: Vec<(Style, &str)> = h.highlight_line(&line, &syntax_set)?;
            let colored = as_24_bit_terminal_escaped(&ranges[..], false);
            print!("{colored}");
        } else {
            print!("{line}");
        }
        line.clear();
    }
    Ok(())
}

fn process(args: Args) {
    let (syntax_set, theme_set) = load_syntax_assets();
    let use_color = std::io::stdout().is_terminal() && !args.no_color;
    let files = if args.files.is_empty() {
        vec!["-".to_string()]
    } else {
        args.files.clone()
    };
    for filename in files.iter() {
        if let Err(e) = print_file(filename, &args, &syntax_set, &theme_set, use_color) {
            eprintln!("unable to process file {filename}: {e}");
        }
    }
}

fn main() {
    let args = Args::parse();
    if args.list_themes {
        list_themes();
    } else {
        process(args);
    }
}
Get the code