Articles: Caesar Cipher Tutorial

Encode and decode text using a Caesar cipher.

Let's see an example to get a feel for the language. I'll show you how I wrote the following program, which can encode and decode a message using a simple cipher.

alphabet = chars("abcdefghijklmnopqrstuvwxyz")

-- Shift a single letter 13 places

fn shift(letter)
  index = List.indexOf(Str.toLower(letter), alphabet)

  if index == none then
    letter
  else
    shifted = alphabet[(index + 13) % 26]

    if Str.isUpper(letter) then
      Str.toUpper(shifted)
    else
      shifted
    end
  end
end

-- Encode or decode a message with the ROT13 cipher
-- https://en.wikipedia.org/wiki/ROT13

fn cipher(message)
  message
    | chars
    $ shift
    | join("")
end

-- What does this print?

"Uryyb jbeyq!"
  | cipher
  | print

The code above implements the ROT13 cipher: a special case of the Caesar cipher where each letter in a message is replaced with the 13th letter after it in the English alphabet, wrapping around to the start if needed.

before encoding: a b c d e f g h i j k l m n o p q r s t u v w x y z
after encoding:  n o p q r s t u v w x y z a b c d e f g h i j k l m

For example, the ROT13 encoding of the word "cat" is "png".


Shifting Letters

To implement the cipher, we start by defining the variable alphabet as a list containing the 26 letters of the English alphabet. We do this using the chars(string) function to split a string of these letters into a list.

alphabet = chars("abcdefghijklmnopqrstuvwxyz")
alphabet =
["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"]

We can use the List.indexOf function to get the index of a character within alphabet. For example, the letter "i" (the 9th letter in the alphabet) will have index 8 (Pointless lists are 0-indexed).

List.indexOf( "i",alphabet)
8

In addition to finding the index of a letter, we can also do the reverse: get a letter from the alphabet based on its index.

alphabet[8]
"i"

At its core, our cipher will do the following for each letter:

We can write a few lines of code that do just that.

letter = "i"
index = List.indexOf(letter, alphabet)
shifted = alphabet[(index + 13) % 26]
shifted =
"v"

Here, we're using (index + 13) % 26 as our shifted index value. When index + 13 is greater than or equal to 26, the modulus operator will wrap the number back around to get a value between 0 and 25. If we didn't use the modulus operator here, our code wouldn't work correctly for letters in the second half of the alphabet.

Let's put this code into a new function called shift. The function will take a single letter and return the corresponding ROT13 encoded letter. We can use the fn keyword to define a new function.

fn shift(letter)
  index = List.indexOf(letter, alphabet)
  shifted = alphabet[(index + 13) % 26]
  shifted
end

We can call shift to make sure it's working properly.

shift("c")
shift("a")
shift("t")
"p"
"n"
"g"

Shifting Whole Strings

Ultimately we want to be able to encode an entire string at once, instead of character-by-character. We'll define a new function cipher that takes a string message, calls the shift function for each letter in message, joins the shifted letters together, and returns the resulting encoded string.

fn cipher(message)
  message
    | chars
    $ shift
    | join("")
end

Like before, we're using the chars(string) function to transform a string (message) into a list of characters; however, this time we're calling it using pipeline syntax. In this syntax, the pipe | operator calls the function that comes after it with the argument value that comes before it.

"cat" | chars
["c", "a", "t"]

In Pointless, the following two forms are equivalent.

chars(message)
message | chars

There's a second form of the pipeline syntax which uses the map $ operator. Like the pipe | operator, the map $ operator calls the function that comes after it with the argument value that comes before it, with a twist:

"cat" | chars $ shift
["p", "n", "g"]

As a final step, the cipher function takes the list of shifted letters and passes them to the join(values, sep) function, which joins them together into a single string with the separator sep in between. We don't want anything between the letters, so we'll use an empty separator "".

"cat" | chars $ shift | join("")
"png"

Unlike chars and shift, join takes two arguments, values and sep. The pipe | operator will supply the first argument (values), but we need to specify a value for sep as well.

In Pointless, the following two forms are equivalent.

join(shifted, "")
shifted | join("")

Encoding and Decoding

Now let's call cipher to make sure it's working properly.

cipher("cat")
cipher("png")
"png"
"cat"

This example shows us something interesting: if we pass an encoded string (like "png") to cipher, we get back the original unencoded string! This happens because the ROT13 cipher uses 13 as the shift value. When we call cipher on an encoded string, the encoded letters (which were already shifted 13 places from the original letters) are shifted 13 places again, bringing the total shift amount to 26. Since our alphabet is 26 letters long, this process wraps each letter back to its original value. This means that we can use cipher for both encoding and decoding!

We can verify this by calling cipher twice on the same message and checking that we get the original message back.

cipher(cipher("cat"))
"cat"

Uppercase Letters

Currently, our code looks like this.

alphabet = chars("abcdefghijklmnopqrstuvwxyz")

fn shift(letter)
  index = List.indexOf(letter, alphabet)
  shifted = alphabet[(index + 13) % 26]
  shifted
end

fn cipher(message)
  message
    | chars
    $ shift
    | join("")
end

We still need to make a couple of changes. The first issue is uppercase letters: the indexOf function that we use in shift is case-sensitive, so our code won't work for messages containing uppercase letters. We can solve this by having shift convert letter to lowercase before finding its index.

fn shift(letter)
  index = List.indexOf(Str.toLower(letter), alphabet)
  shifted = alphabet[(index + 13) % 26]
  shifted
end

This change allows cipher to handle uppercase letters, but we end up losing information about which letters were capitalized in the original message.

cipher("Cat")
cipher(cipher("Cat"))
"png"
"cat"

We'll update the definition for our cipher so that uppercase letters in message get translated to uppercase letters in the final encoded string.

before encoding: a b c d e f g h i j k l m n o p q r s t u v w x y z
after encoded:   n o p q r s t u v w x y z a b c d e f g h i j k l m

before encoding: A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
after encoded:   N O P Q R S T U V W X Y Z A B C D E F G H I J K L M

We can add this behavior to shift using a conditional expression. This conditional uses the isUpper(string) function (from the str module) to check whether letter is uppercase and convert shifted to uppercase when necessary.

fn shift(letter)
  index = List.indexOf(Str.toLower(letter), alphabet)
  shifted = alphabet[(index + 13) % 26]

  if Str.isUpper(letter) then
    Str.toUpper(shifted)
  else
    shifted
  end
end

Our cipher function now preserves letter case.

cipher("Cat")
cipher(cipher("Cat"))
"Png"
"Cat"

Non-Alphabetic Characters

The final piece to consider is non-alphabetic characters. Currently, if we call shift with a non-alphabetic character (like "!"), the call to List.indexOf will return none. This will cause an error later when we try to do math with the none value as though it were a number. We can fix this by having shift check whether List.indexOf returned none and returning letter unmodified if it did.

fn shift(letter)
  index = List.indexOf(Str.toLower(letter), alphabet)

  if index == none then
    letter
  else
    shifted = alphabet[(index + 13) % 26]

    if Str.isUpper(letter) then
      Str.toUpper(shifted)
    else
      shifted
    end
  end
end

Now shift and cipher will translate alphabetic characters and pass non-alphabetic characters through unmodified.

cipher("Cat!")
cipher(cipher("Cat!"))
"Png!"
"Cat!"

Our code is now complete!


Wrapping Up

So, what's the secret message?

cipher("Uryyb jbeyq!")
"Hello world!"