12 Days of Christmas in C#

11 minute read article dotnet   csharp   microsoft   advent Comments

Update: December 18, 2022: Per the comment on this post from James Curran, I updated the post and the code in GitHub.

While surfing around GitHub recently, looking for repos related to Christmas, I ran across this and was immediately intrigued. After reading through it, I was inspired and decided to use the idea for one of my two 2022 Advent posts! Check out the post I wrote for the .NET Advent calendar on December 10.

The gist of this is to take a data file that contains the bare minimum information necessary to programmatically generate all 12 verses of The 12 Days of Christmas! It includes some interesting challenges, but for my version, I'm going to simplify things a bit.

In this post, I'll walk you through the code and talk about some of the things I did to overcome the challenges presented in the exercise.

The requirements

  1. Load the data needed for the song from this data file
    Day Day.in.Words Gift.Item Verb Adjective Location
    1 first partridge NA NA in a pear tree
    2 second dove NA turtle NA
    3 third hen NA french NA
    4 fourth bird NA calling NA
    5 fifth ring NA golden NA
    6 sixth goose a-laying NA NA
    7 seventh swan a-swimming NA NA
    8 eighth maid a-milking NA NA
    9 ninth lady dancing NA NA
    10 tenth lord a-leaping NA NA
    11 eleventh piper piping NA NA
    12 twelfth drummer drumming NA NA
  2. Write a function to pluralize a word.
        string pluralizeWord(string word);
        
    All the gifts in the file are singular and must be pluralized. "For example, the word "rings" should not appear anywhere in the function. I should be able to give it any gift and get back the plural of that gift." The function should be able to handle any word, not just the gifts listed in the file.
  3. Write a function that takes in the necessary data from the data file and outputs the line(s) for a single day. Remember, each day looks like this:
        On the {nth} day of Christmas my true love gave to me
        {gift}
        
  4. Use the data in the file to generate the 12 verses of the song.

The Code

To get started, create a new folder for the console application and then change into it:

mkdir 12days
cd 12days
Now create the project
dotnet new console -o 12days.console
With the project created, change into the 12days.console directory and open program.cs in your editor of choice!

Requirement number one is to read the data file in so we can use it later to generate the verses. In my solution, I did the simplest thing that works. Before getting into the requirements, I removed the "hello, world" and replaced it with

Console.WriteLine("The 12 Days of Christmas");
Console.WriteLine();
and then this bit of code to read in all the lines of the file into an array
string[] lines = File.ReadAllLines("xmas.csv");
I'll talk about using the data a bit later in the post.

Pluralizing words

The next challenge to be faced with this project is the idea of pluralizing words. The English language is full of strange, inconsistent rules, and pluralizing isn't as simple as tacking on an 's' or 'es' to a word.

string pluralizeWord(string word)
{
  // what goes here?
}

I Googled and found a decent reference for pluralizing words and wrote the following code.

string pluralize(string word)
{
    if(specialWords.TryGetValue(word, out string specialWord))
    {
        return specialWord;
    }

    // -s, -x, -sh, -ch, -ss or -z
    if(word.EndsWith("s") 
        || word.EndsWith("x") 
        || word.EndsWith("sh") 
        || word.EndsWith("ch") 
        || word.EndsWith("ss") 
        || word.EndsWith("z"))
    {
        return word + "es";
    }

    if(word.EndsWith("y"))
    {
        // example: lady
        var nextToLast = word[^2];
        if(!vowels.Contains(nextToLast))
        {
            return word[..^1] + "ies";
        } 
        else 
        {
            return word + "s";
        }
    }
    return word + "s";
}

This code works for all of the gifts in the data file, but anything beyond that and I offer no guarantees.

Because English has these strange rules, there are some words that don't have any rules for pluralization so you just need to "know" what the plural is. Goose is a great example! Child is another. Because of that, I created a simple dictionary to handle that particular case (allowing for the addition of other words):

Dictionary<string, string> specialWords = new Dictionary<string, string>
{
    {"goose", "geese"}
};
I also added this to support my pluralization code:
// per a suggestion from James Curran in the comments, 
// simplifying this.
var vowels = "aeiou";

Is that cheating? Well, requirement number 2 says we can't hard-code words, but unless one of my readers can tell me a way to handle special words like "goose", then I'm going with "it works", and I'm ok with it. If you want a more reliable, robust solution for pluralizing words, you can make life easy by using a library:

dotnet add package Pluralize.NET
and then your code becomes:
using Pluralize.NET;

IPluralize pluralizer = new Pluralizer();
string pluralize(string word)
{
  return pluralizer.Pluralize(word);
}
The code for Pluralizer.NET is on GitHub, and is actually a nice library that allows you to plugin your own rules as well as using the built-in rules. If you do look at the code for Pluralize.NET, pay attention to how *it* handles words like "goose"!

Reading the data and constructing verses

The data is loaded into a string array named "lines", there's a method to pluralize words, so now it's time to start generating the verses! Before jumping into the code, I want to walk through an approach so there's a map for getting from "data is loaded" to the full 12 verses.

Since the data in the file is ordered, I want to iterate over the lines in the file (skipping the first), building up each verse, and then displaying what's been generated. At a high level, this is what I want to do:

for each line
  split into the elements
  pass the elements to a method that will construct the output for a single day
  build up the verse
  output

Starting with a loop, split each line from the array into the separate elements:

foreach(var line in lines.Skip(1))
{
  // 0=day, 1=Day.In.Words, 2=Gift, 3=Verb, 4=Adjective, 5=Location
  var dayElements = line.Split(",");

  // pass the data into a method to generate a portion of the song
  var dayPortion = getOutputForDay(dayElements);
}
I questioned whether I should do the split in the loop or in getOutputForDay, but decided it wasn't the responsiblity of getOutputForDay so I left it in the loop and passed in the array of elements instead.

I did look at some CSV parsing libraries on Nuget, but most of them seemed like overkill for this exercise, so I did some very basic manipulation of the data to pull out the individual elements.

In the following code, I'm handling the cases where it contains "NA" since in those cases I want nothing (a blank). Since the CSV is all strings, I am casting some of the data to more appropriate types. You'll notice the call to "pluralize", but only if the gift is on days 2 through 12 (I don't want "A partridges in a pear tree").

(string line1, string line2) getOutputForDay(string[] day) 
{
    var actualDay = Convert.ToInt16(day[0]);
    string gift = actualDay > 1 ? pluralize(day[2]) : day[2];
    var doing = day[3] == "NA" ? "" : day[3];
    var adjective = day[4] == "NA" ? "" : day[4];
    var location = day[5] == "NA" ? "" : day[5];

    string numberOfThings = numberToText(actualDay);

    var line1 = $"On the {day[1]} day of Christmas my true love gave to me";

    var line2 = new[] { numberOfThings, adjective, gift, doing, location }
      .Where(s => !string.IsNullOrEmpty(s))
      .Aggregate("", (acc, cur) => acc + " " + cur)
      .Trim();  

    return (line1, line2);
}
Notice the numberToText function? That's to convert the number of gifts (element 0 "Day") to a string representation of the number, so instead of the number 2 showing up, it'll be the word "two". That's nothing more than a wrapper around a dictionary lookup:
var numWordDict = new Dictionary<int, string>
{
    { 1, "A" },
    { 2, "two" },
    { 3, "three" },
    { 4, "four" },
    { 5, "five" },
    { 6, "six" },
    { 7, "seven" },
    { 8, "eight" },
    { 9, "nine" },
    { 10, "ten" },
    { 11, "eleven" },
    { 12, "twelve" }
};

string numberToText(int number)
{
    return numWordDict.TryGetValue(number, out var value) ? value : "??";
}

Finally, notice how I decided to return a tuple? There's a reason for that. If I had just returned a single string that contained one day, I would have run into trouble when I tried to build the whole song because of the way the song is constructed. For example, while the first verse is:

On the first day of Christmas my true love gave to me 
A partridge in a pear tree
The second verse only cares about "a partridge in a pear tree":
On the second day of Christmas my true love gave to me 
two turtle doves 
and a partridge in a pear tree
Back in the main loop, I'm able to use the tuple to construct a full verse because I kept track of the previous line. Here's a new version of the loop. I'm now keeping track of the current day as well as the previous "line 2". I'm also storing the verses in a dictionary so I can dump everything out at once or just as easily just, output a single verse:
var verses = new Dictionary<int, string>();
int currentDay = 0;
string previousLine2 = "";
foreach(var line in lines.Skip(1))
{
    currentDay++;
    var day = line.Split(',');

    (string line1, string line2) = getOutputForDay(day);
    line2 = line2 + Environment.NewLine + previousLine2;

    verses.Add(currentDay, line1 + Environment.NewLine + line2);

    previousLine2 = currentDay == 1 ? line2.Replace("A", "a").Insert(0, "And ") : line2;
}

Finally, to display the entire song, I simply iterated over the dictionary!

foreach(int verseNumber in verses.Keys)
{
    Console.WriteLine(verses[verseNumber]);
}
the output:
The 12 Days of Christmas

On the first day of Christmas my true love gave to me
A partridge in a pear tree

On the second day of Christmas my true love gave to me
two turtle doves
And a partridge in a pear tree

On the third day of Christmas my true love gave to me
three french hens
two turtle doves
And a partridge in a pear tree

On the fourth day of Christmas my true love gave to me
four calling birds
three french hens
two turtle doves
And a partridge in a pear tree

On the fifth day of Christmas my true love gave to me
five golden rings
four calling birds
three french hens
two turtle doves
And a partridge in a pear tree

On the sixth day of Christmas my true love gave to me
six geese a-laying
five golden rings
four calling birds
three french hens
two turtle doves
And a partridge in a pear tree

On the seventh day of Christmas my true love gave to me
seven swans a-swimming
six geese a-laying
five golden rings
four calling birds
three french hens
two turtle doves
And a partridge in a pear tree

On the eighth day of Christmas my true love gave to me
eight maids a-milking
seven swans a-swimming
six geese a-laying
five golden rings
four calling birds
three french hens
two turtle doves
And a partridge in a pear tree

On the ninth day of Christmas my true love gave to me
nine ladies dancing
eight maids a-milking
seven swans a-swimming
six geese a-laying
five golden rings
four calling birds
three french hens
two turtle doves
And a partridge in a pear tree

On the tenth day of Christmas my true love gave to me
ten lords a-leaping
nine ladies dancing
eight maids a-milking
seven swans a-swimming
six geese a-laying
five golden rings
four calling birds
three french hens
two turtle doves
And a partridge in a pear tree

On the eleventh day of Christmas my true love gave to me
eleven pipers piping
ten lords a-leaping
nine ladies dancing
eight maids a-milking
seven swans a-swimming
six geese a-laying
five golden rings
four calling birds
three french hens
two turtle doves
And a partridge in a pear tree

On the twelfth day of Christmas my true love gave to me
twelve drummers drumming
eleven pipers piping
ten lords a-leaping
nine ladies dancing
eight maids a-milking
seven swans a-swimming
six geese a-laying
five golden rings
four calling birds
three french hens
two turtle doves
And a partridge in a pear tree

Wrap-up

And there it is! The 12 Days of Christmas built with the bare minimum data. This was a fun little exercise, and I hope you enjoyed it!

You can check out the code on GitHub.

Shoutout to my friends Brian Friesen and Matt Davis for reviewing this and making suggestions that have improved both the content and the code!

Updated:

Comments