#!/usr/bin/env ruby # frozen_string_literal: true # sample-script.rb: summarise a small list of expenses by category. # Free sample from 99helpers.com/file-types/rb # Run: ruby sample-script.rb (uses the built-in sample data) # ruby sample-script.rb expenses.txt (lines of: date, category, amount, note) # ruby sample-script.rb --json # # 1. Shebang (line 1): on macOS and Linux, `chmod +x sample-script.rb` then # `./sample-script.rb` runs it with the first `ruby` on your PATH. # 2. Magic comment (line 2): every string literal in this file is frozen # (read-only). It must sit in the first comment section of the file. # 3. require loads a library. json ships with Ruby, so no Gemfile is needed. require "json" # 4. A module groups related constants and methods under one name. module Expenses CURRENCY = "EUR" # 5. Data.define (Ruby 3.2+) creates a small immutable value class. Item = Data.define(:date, :category, :amount, :note) # 6. A squiggly heredoc: <<~ removes the common indentation. SAMPLE = <<~TEXT # date, category, amount, note 2026-10-01, travel, 42.50, Train to Utrecht 2026-10-02, food, 12.80, Lunch with Ada Example 2026-10-03, office, 89.99, Desk lamp 2026-10-05, food, 7.45, Coffee beans 2026-10-06, travel, 18.00, Bike repair 2026-10-07, food, twelve, A typo this script should skip TEXT # 7. Methods return their last expression. A do...end block can have its # own rescue clause; filter_map drops the nils returned by `next`. def self.parse(text) text.each_line.with_index(1).filter_map do |line, number| next if line.strip.empty? || line.start_with?("#") date, category, amount, note = line.split(",", 4).map(&:strip) Item.new(date: date, category: category, amount: Float(amount), note: note) rescue ArgumentError, TypeError warn "line #{number}: skipped, can't read #{line.strip.inspect}" next end end # 8. Blocks and Enumerable: group, add up and sort in one chain. def self.totals(items) items.group_by(&:category) .transform_values { |list| list.sum(&:amount).round(2) } .sort_by { |_category, total| -total } end end # 9. String building: literals are frozen here, so +"" makes a mutable copy. # format() pads columns like printf in C. def report(items) out = +"" Expenses.totals(items).each do |category, total| out << format("%-8s %8.2f %s\n", category, total, Expenses::CURRENCY) end grand = items.sum(&:amount) out << format("%-8s %8.2f %s (%d items)\n", "total", grand, Expenses::CURRENCY, items.size) end # 10. Run only when this file is executed directly, not when another file # loads it with require_relative. if __FILE__ == $PROGRAM_NAME json = ARGV.delete("--json") begin text = ARGV.empty? ? Expenses::SAMPLE : File.read(ARGV.first, encoding: "UTF-8") rescue Errno::ENOENT abort "error: #{ARGV.first} not found" # prints to stderr, exits with status 1 end items = Expenses.parse(text) # 11. to_h on a Data object, then JSON.pretty_generate for --json. puts json ? JSON.pretty_generate(items.map(&:to_h)) : report(items) end