Soccer Table in Haskell: Problem and Setup

Part 1: Setting up the Cabal Project

I have been looking into the Haskell programming language time and again since 2020. However, I never managed to write a complete non-trivial program in Haskell using proper software engineering techniques, e.g. using a proper build tool and writing unit tests. When I have been learning Haskell, I was mostly dealing with functional techniques and the type system.

Whereas the Hello, World! program is the first one you write when learning any other programming language, in Haskell you have to be more patient. Input and output involve effects, and effectful programming is strongly separated from the pure code in Haskell. However, a real-world program uses both.

When learning a new programming language, it is a good idea to re-implement a program in it you already wrote in another or even in multiple different programming languages. I collected a couple of such stock programs, as I call them. Two are simple board games (Connect Four and Reversi/Othello). But my favourite one is Soccer Table, which I already implemented in more than a dozen languages.

The Problem

Given is a folder with plain text files containing soccer match results, which look as follows:

Manchaster City 0:0 Sunderland
Arsenal 0:3 Fulham
Manchester United 1:0 Crystal Palace
Aston Villa 0:0 Newcastle
Liverpool 2:3 Leeds United
Bskrighton 0:1 Nottingham Forest
Bournemouth 1:0 West Ham United
Chelsea 1:0 Tottenham Hotspur
Brentford 0:3 Burnley
Everton 2:5 Wolverhampton

The team on the left is considered the home team; the other is the away team. The goals scored by each team are denoted between their names, separated by a colon. In the example above, Manchaster City tied against Sunderland, Arsenal lost against Fulham, and Manchester United won against Crystal Palace.

The program shall process an entire folder of such files and output a league table that looks as follows:

  # Team                             P   W   T   L   +   -   =
--------------------------------------------------------------
  1 Manchester United               72  20  12   6  61  33  28
  2 Chelsea                         66  19   9  10  49  34  15
  3 Liverpool                       65  18  11   9  61  40  21
  4 Brighton                        62  17  11  10  55  32  23
  5 Aston Villa                     62  18   8  12  57  37  20
  6 Burnley                         61  16  13   9  41  30  11
  7 Arsenal                         56  16   8  14  47  41   6
  8 Bournemouth                     54  16   6  16  39  40  -1
  9 Everton                         54  15   9  14  38  45  -7
 10 Manchaster City                 49  13  10  15  50  53  -3
 11 Brentford                       49  12  13  13  39  42  -3
 12 Nottingham Forest               48  14   6  18  35  47 -12
 13 Sunderland                      47  11  14  13  37  43  -6
 14 Fulham                          44  11  11  16  42  53 -11
 15 Wolverhampton                   43  10  13  15  37  41  -4
 16 Tottenham Hotspur               43  11  10  17  42  49  -7
 17 Leeds United                    43   9  16  13  40  56 -16
 18 West Ham United                 41  11   8  19  34  48 -14
 19 Crystal Palace                  40  11   7  20  40  59 -19
 20 Newcastle                       40  11   7  20  33  54 -21

The file above represents only a single round, whereas the table shown represents the league after an entire season with 38 rounds having been played. There are nine columns in the table:

  1. # is the league rank,
  2. Team is the team’s name,
  3. P is the number of points scored (3 per win, 1 per tie, 0 per loss),
  4. W is the number of wins,
  5. T is the number of ties,
  6. L is the number of losses,
  7. + is the number of goals scored,
  8. - is the number of goals conceded, and
  9. = is the goal difference (goals scored minus goals conceded).

The game results have been generated with the script and team data from the random-soccer-results repository. The table has been calculated by my Rust implementation of the Soccer Table program.

The sorting/ranking takes place 1) by points, 2) by goal score, 3) by number of wins (all sorted descendingly), and 4) by the team names (sorted ascendingly). Those are not the official league rules, but they produce a well-defined order in all cases and keep the logic reasonably simple.

With the problem stated, the solution can be tackled.

The Setup

Before setting up the project, a working Haskell setup is needed. The Get started page on the official Haskell site provides instructions on how to setup a Haskell development environment for your particular system.

I prefer a simple setup based on OpenBSD and the Vim text editor without any plugins or language servers. There are two different toolchains to manage Haskell projects: Cabal and Stack. Stack is based on Cabal, and since I prefer a simple setup, I will be using Cabal here.

On OpenBSD 7.8, the required software can be installed as follows (with root privileges or using doas/sudo):

pkg_add vim-9.1.1706-no_x11 ghc cabal-install

A minimalistic ~/.vimrc configures Vim for my Haskell endeavours:

set encoding=utf-8
set autoindent
set smartindent
set number
set ruler

syntax off
filetype on

autocmd FileType haskell setlocal tabstop=2 softtabstop=2 shiftwidth=2 expandtab
autocmd FileType cabal setlocal tabstop=2 softtabstop=2 shiftwidth=2 expandtab

Yes, you saw it right: I don’t use syntax highlighting. Both Haskell and Cabal files are indented with two spaces.

Furthermore, I set my GHCi (the Haskell REPL) prompt to λ in ~/.ghci:

:set prompt "λ "

Having the environment ready, the project can be set up.

The Project

Cabal provides an interactive project initialization tool, which can be invoked using cabal init. This provides a fully-configured project configuration right from the start.

I would rather like to build up my project incrementally. Therefore, I will write my Cabal file from scratch.

But first, a project folder and repository is needed:

mkdir soccer-table
cd soccer-table
git init

Next, a very basic Cabal file is created (soccer-table.cabal):

cabal-version: 3.14
name:          soccer-table
version:       0.1.0.0
license:       NONE
author:        Patrick Bucher
maintainer:    patrick.bucher@mailbox.org

common compilation
  ghc-options: -Wall
  default-language: Haskell2010

library
  import:           compilation
  exposed-modules:  SoccerTable
  build-depends:    base
  hs-source-dirs:   src

executable soccer-table
  import:           compilation
  main-is:          Main.hs
  build-depends:    soccer-table
                  , base
  hs-source-dirs:   app

A Cabal file is basically a collection of key-value pairs, which can be nested under sections with additional names.

The top section defines the version of the Cabal specification being used. Since the installed cabal tool has version 3.14.1.1 (check cabal --version), the most recent option is 3.14. Cabal uses the Haskell PVP (Haskell Package Versioning Policy), which is similar to semantic versioning, but uses four numbers: generation.major.minor.patch instead of just major.minor.patch. Since minor and patch versions do not affect compatibility, the cabal-version is only indicated as generation.major, thus 3.14.

The name of the project is the same as the enclosing folder. Many Haskell projects are named using the CamelCase convention, but using lower-case letters with dashes is legal, too.

The version count starts at 0.1.0.0. No license has been picked yet. Here, SPDX identifiers and expressions can be used, e.g. GPL-3.0-only or Apache-2.0 OR MIT.

I also added my name as the author and my email as the maintainer.

The next part is a common section, which allows to define re-usable options. Here, options for the compiler ghc are defined to activate all (common) warnings. Also the version of the language being used (Haskell2010) is commonly specified. Those common sections can be referred to using import in subsequent sections.

The next section defines a library. Most of the application code should end up there, with only the interactive code being left to the executable (see next section). The options belonging to the library are indented. The common compilation settings are being re-used with import.

The library exposes a module called SoccerTable, which will be shown later. The library requires the Haskell base modul, for which no version is specified yet. (During development, the most recent one will be used. A version specifier shall be added later.) The hs-source-dirs option specifies the name of the directory where the library code ends up, for which src is a common convention.

Multiple executable sections can be defined, which require an additional name for the resulting binary file. Here, soccer-table is being used. The main module will be located in a file called Main.hs. To build the executable, the soccer-table package (as defined in this very Cabal file) is required, as well as the base package. (By convention, the comma is written on the next line, so that the old line does not need to be touched when adding a new dependency, which keeps the git blame output clean). The main source code file is being looked up in the app folder.

The Boilerplate Code

With the package being declared, some boilerplate code is needed to test the setup. First, the src and app folders need to be created:

mkdir src app

The SoccerTable module is defined in src/SoccerTable.hs:

module SoccerTable (greet) where

greet :: String -> String
greet whom = "Hello, " <> whom <> "!"

It defines and exports a function called greet, which produces a string message.

The executable module is defined in app/Main.hs:

module Main where
import qualified SoccerTable as ST (greet)

main :: IO ()
main =
  print $ ST.greet "Soccer Table"

The greet function from the SoccerTable module (aliased as ST) is imported as qualified, so that it needs to be referred to as ST.greet. The main function invokes that function and prints the resulting message to the standard output.

With everything in place, the boilerplate program can be run as follows:

cabal run

Which prints:

"Hello, Soccer Table!"

It is possible to build the library and the executable seperately:

cabal build lib:soccer-table
cabal build exe:soccer-table

The build artifacts end up in a folder called dist-newstyle, which shall be excluded from the Git repository:

echo dist-newstyle/ >> .gitignore

Then we are ready for our initial commit:

git add app src soccer-table.cabal
git commit -m 'initial commit: project setup and boilerplate code'

The repository can be found on GitHub under patrickbucher/soccer-table-haskell and will be updated soon.

Conclusion and Next Step

First, the problem—the Soccer Table stock program—has been introduced. Then, a Haskell project has been setup using Cabal with a minimalistic Cabal package specification file. And lastly, enough boilerplate code has been written to demonstrate the project setup.

In the next part, the library logic to turn match results into a table will be written. Processing the files (input) and formatting the table (output) will be handled in a subsequent article.