The Big Book of Small EK9 Programs

Adapted from Al Sweigart's The Big Book of Small Python Projects: 81 Easy Practice Programs (No Starch Press, 2021; copyright © 2021 Al Sweigart), which he makes freely available under a Creative Commons Attribution-NonCommercial-ShareAlike licence at inventwithpython.com/bigbookpython. Changes were made: each program was ported from Python to EK9 and remodelled in EK9's idioms.

Project 7 — Caesar Hacker

The Caesar cipher has only 26 possible keys, so breaking one needs no cleverness at all: try every key and read off the one result that comes out as English. Caesar Hacker does exactly that. Give it a shifted message and it prints all 26 decryptions, key 0 through key 25, so a human reader can spot the real one at a glance.

Adapted from project 7 of Al Sweigart's The Big Book of Small Python Projects (No Starch Press, 2021), under CC BY-NC-SA 4.0.

A sample run

The message is a program argument (args.txt holds Khoor, Zruog!), so there is nothing to type at a prompt:

ek9 caesar-hacker.ek9 "Khoor, Zruog!"
Key #0: Khoor, Zruog!
Key #1: Jgnnq, Yqtnf!
Key #2: Ifmmp, Xpsme!
Key #3: Hello, World!
Key #4: Gdkkn, Vnqkc!
Key #5: Fcjjm, Umpjb!
Key #6: Ebiil, Tloia!
Key #7: Dahhk, Sknhz!
Key #8: Czggj, Rjmgy!
Key #9: Byffi, Qilfx!
Key #10: Axeeh, Phkew!
Key #11: Zwddg, Ogjdv!
Key #12: Yvccf, Nficu!
Key #13: Xubbe, Mehbt!
Key #14: Wtaad, Ldgas!
Key #15: Vszzc, Kcfzr!
Key #16: Uryyb, Jbeyq!
Key #17: Tqxxa, Iadxp!
Key #18: Spwwz, Hzcwo!
Key #19: Rovvy, Gybvn!
Key #20: Qnuux, Fxaum!
Key #21: Pmttw, Ewztl!
Key #22: Olssv, Dvysk!
Key #23: Nkrru, Cuxrj!
Key #24: Mjqqt, Btwqi!
Key #25: Lipps, Asvph!

Key 3 is the one that reads as English: Hello, World!.

The program

#!ek9
defines module bigbook.caesarhacker

  defines constant

    Uppercase <- "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    Lowercase <- "abcdefghijklmnopqrstuvwxyz"
    AlphabetSize <- 26

  defines class

    //THE OUTPUT: everything the reader sees goes through here, worded by Say. The message is a
    //program argument, so there is no LineInput.
    Console
      stdout as Stdout: Stdout()
      say as Say: Say("en")

      Console()
        -> words as Say
        say: words

      //The decryption is computed, not worded: Say only labels it with its key.
      showDecryption()
        ->
          key as Integer
          decryption as String

        stdout.println(say.decryptedWith(key, decryption))

      override operator ? as pure
        <- rtn as Boolean: true

  defines function

    //THE GAME: the cipher, with no output or words.

    //The shape of one step of the cipher, so a stream can map a message through it.
    CharacterMap() as pure abstract
      -> ch as Character
      <- rtn as Character?

    //EK9 has no ord/chr: a letter's position is its index in the alphabet, unset when it is
    //not in it - so the guard both finds the position and lets everything else pass through.
    shiftedWithin() as pure
      ->
        ch as Character
        alphabet as String
        shift as Integer
      <- rtn as Character: ch

      if position <- alphabet.indexOf(ch)
        //`mod` (unlike `rem`) is never negative, so a backward shift wraps round the alphabet.
        rtn: alphabet.subString((position + shift) mod AlphabetSize).first()

    shifted() as pure
      ->
        ch as Character
        shift as Integer
      <- rtn as Character: ch.isUpperCase() <- shiftedWithin(ch, Uppercase, shift) : shiftedWithin(ch, Lowercase, shift)

    //Decrypting with a key is shifting every letter back by it.
    decrypted() as pure
      ->
        message as String
        key as Integer
      <- rtn as String?

      back <- (key) is CharacterMap as pure function
        rtn: shifted(ch, -key)
      rtn: cat message | map with back | collect as String

  defines function

    //THE language of the words, copied unchanged from lib/Language.ek9: BIGBOOK_LANG when it
    //is set (fr, zh, ...), otherwise en.
    language()
      <- rtn as String: EnvVars().get("BIGBOOK_LANG") ?: "en"

  //THE WORDS: every word the reader sees. The decryptions are not here: the cipher makes them.
  defines text for "en"

    Say

      decryptedWith()
        ->
          key as Integer
          decryption as String
        `Key #${key}: ${decryption}`

  defines program

    //Caesar Hacker (Big Book project 7). Decrypts the message argument with every key, 0 to 25,
    //so the reader can see which one is English. The game decides; the Console shows, in the
    //reader's words.
    CaesarHacker()
      -> message as String
      //A language with no words of its own is unset: ?: falls back to English.
      console <- Console(Say(language()) ?: Say("en"))

      for key in 0 ... AlphabetSize - 1
        console.showDecryption(key, decrypted(message, key))

How the port works

The original prints each try straight from its loop. The port keeps three things apart, as every port in this book does: the cipher that decrypts, the Console that shows each try, and Say, which holds the words. Here the words are a single line's label, decryptedWith(key, decryption), "Key #3: Hello, World!": the decryption is the cipher's, and the text only labels it. The message is a program argument, so the Console holds no LineInput, only the Stdout and the Say, and has one method, showDecryption. The program is the flow: one try per key, in words chosen with Say(language()) ?: Say("en").

EK9 has no ord/chr, so a cipher cannot turn a letter into a number and back. Instead, a letter's position in the alphabet string is found with alphabet.indexOf(ch), which is unset for any character that is not in the alphabet — punctuation and spaces included. That single guard, if position <- alphabet.indexOf(ch), both finds the position for a real letter and lets everything else fall through unchanged, testing for a position with ? rather than comparing against a sentinel such as -1.

Shifting backwards to decrypt needs a wrap-around that never goes negative, and EK9 gives that for free: mod always returns a result with the same sign as its divisor (-1 mod 26 is 25), while rem can come back negative (-1 rem 26 is -1). Using mod for the wrap means no + 26 correction term is needed anywhere.

CharacterMap is declared as pure abstract — a function type, not a function — and decrypted builds one for a particular key as a dynamic function that captures that key: (key) is CharacterMap as pure function. The message is then run through it with a stream, cat message | map with back | collect as String, rather than an accumulator loop built up character by character.

The whole 26-key sweep is a single for key in 0 ... AlphabetSize - 1 loop with no early exit, and every cipher function — shiftedWithin, shifted, decrypted — is as pure and has exactly one declared result (<- rtn). The message arrives as a typed String program argument rather than through an input() prompt, so there is no validation loop to translate at all — the compiler has already guaranteed a String arrived before CaesarHacker's body runs.