Programming languages · CLI apps · syntax

Ruby, Go, Rust, and Zig for simple CLI apps

A small practical comparison using the same command-line app in four languages, focused on syntax feel, ceremony, and how close each version stays to the task.

Broad claims about language syntax often become vague. A better way to compare languages is to write the same small program in each one and look at the amount of ceremony required.

This note compares Ruby, Go, Rust, and Zig using a minimal CLI app. The program accepts a name, prints a greeting, and supports an optional --shout flag that uppercases the output.

Scope The goal is not performance or exhaustive language evaluation. The goal is syntax feel: how much code is needed, how much noise appears, and how close the experience is to dynamic-language ergonomics.

The CLI behavior

greet Alice
# Hello, Alice

greet --shout Alice
# HELLO, ALICE

Ruby

Ruby is the baseline. It is dynamic, compact, and has a strong scripting feel. For a small CLI, the standard library already gives a pleasant option parser.

#!/usr/bin/env ruby

require "optparse"

options = { shout: false }

parser = OptionParser.new do |opts|
  opts.banner = "Usage: greet [options] NAME"

  opts.on("--shout", "Uppercase the greeting") do
    options[:shout] = true
  end
end

parser.parse!

name = ARGV.first or abort parser.to_s

message = "Hello, #{name}"
message = message.upcase if options[:shout]

puts message

How it feels

Ruby keeps the program close to the problem statement. Parse options, read the positional argument, build a string, optionally transform it, and print it. There is little type ceremony and little structural overhead.

Go

Go is simple and readable, but it tends to be plain rather than expressive. Its standard flag package is enough for this example.

package main

import (
    "flag"
    "fmt"
    "os"
    "strings"
)

func main() {
    shout := flag.Bool("shout", false, "Uppercase the greeting")
    flag.Parse()

    if flag.NArg() < 1 {
        fmt.Fprintln(os.Stderr, "Usage: greet [--shout] NAME")
        os.Exit(1)
    }

    message := fmt.Sprintf("Hello, %s", flag.Arg(0))

    if *shout {
        message = strings.ToUpper(message)
    }

    fmt.Println(message)
}

How it feels

Go is clear, but not especially terse. For this example, error handling is not the main issue, but the language still has visible boilerplate: package declaration, imports, explicit main, pointer flag values, and manual usage handling.

Rust

Rust can be verbose in systems code, but for CLI apps the ecosystem helps a lot. With clap, argument parsing becomes declarative and compact.

use clap::Parser;

#[derive(Parser)]
struct Cli {
    name: String,

    #[arg(long)]
    shout: bool,
}

fn main() {
    let cli = Cli::parse();

    let mut message = format!("Hello, {}", cli.name);

    if cli.shout {
        message = message.to_uppercase();
    }

    println!("{message}");
}

Cargo.toml:

[dependencies]
clap = { version = "4", features = ["derive"] }

How it feels

Rust is surprisingly clean here. The derive macro hides a lot of parsing logic, and the resulting program is readable. It is still more formal than Ruby, but less noisy than Go for this particular CLI example.

Zig

Zig is explicit and low-level. It gives direct control, but small app-glue tasks usually expose more manual detail than Ruby, Go, or Rust.

const std = @import("std");

pub fn main() !void {
    var args = try std.process.argsWithAllocator(std.heap.page_allocator);
    defer args.deinit();

    _ = args.next(); // program name

    var shout = false;
    var name: ?[]const u8 = null;

    while (args.next()) |arg| {
        if (std.mem.eql(u8, arg, "--shout")) {
            shout = true;
        } else {
            name = arg;
        }
    }

    const actual_name = name orelse {
        std.debug.print("Usage: greet [--shout] NAME\n", .{});
        std.process.exit(1);
    };

    if (shout) {
        var buffer: [256]u8 = undefined;
        const upper = std.ascii.upperString(&buffer, actual_name);
        std.debug.print("HELLO, {s}\n", .{upper});
    } else {
        std.debug.print("Hello, {s}\n", .{actual_name});
    }
}

How it feels

Zig has clean error propagation with try, but the rest of the program is visibly more manual. The code exposes allocation, argument iteration, optional handling, fixed buffers, and explicit formatting. This is not bad; it is just not dynamic-language-like.

Comparison

Language Strength in this example Main source of ceremony
Ruby Shortest and closest to the task description Minimal; mostly option parser setup
Go Simple, explicit, easy to read Package/import structure, flag pointer, manual checks
Rust Very clean with clap Structs, derive macros, dependency setup
Zig Precise and low-level Manual argument parsing, allocation, buffers
For a small CLI app

Ruby > Rust > Go > Zig

Why Rust ranks above Go here

Go often feels close to a dynamic language because its syntax is simple. But once error handling or repetitive checks enter the program, the code can become noisy. For CLI apps specifically, Rust's ecosystem can offset some of Rust's inherent explicitness.

With clap, Rust allows the CLI shape to be described as data: a struct with fields and attributes. That can feel more concise than manually coordinating flags, arguments, validation, and usage text.

Final take

If the question is "which language feels closest to Ruby for small scripts and CLI tools," Ruby remains the clear winner. Among the static languages, Rust can be more ergonomic than expected when backed by good libraries. Go is readable but often mechanically repetitive. Zig is a strong systems language, but it is the least suited to this kind of high-level app glue if terseness is the priority.

Sources

  1. Ruby OptionParser documentation, standard-library option parsing for command-line programs.
  2. Go package flag documentation, standard-library command-line flag parsing.
  3. clap derive tutorial, declarative Rust CLI parsing through derive macros.
  4. Zig standard library documentation for std.process.argsWithAllocator.
  5. Zig standard library documentation for std.ascii.upperString.