LiterateX Example: Literate Haskell
This is a literate
Haskell version of a TTC
(Textual Type Classes) example that can be found in /examples/prompt
on GitHub. The example implements a command-line interface for entering
credit card details. This version of the example is used to test the
literate Haskell functionality of LiterateX. The source
and Makefile
is also available on GitHub.
The following command can be used to run this example using Nix.
make run-nix
The following command can be used to run this example using Stack.
make run-stack
The following command can be used to load this example in a REPL using Nix.
make ghci-nix
Literate Haskell Notes
When writing Markdown in literate Haskell, one must use setext-style headings because GHC is unable to parse ATX-style headings.
Module Definition and Imports
Since this is an example program that is meant to be explored, everything is exported.
module Main where
-- https://hackage.haskell.org/package/base
import Control.Monad (unless, when)
import Data.Bifunctor (first)
import Data.Char (digitToInt, isDigit, isSpace, toUpper)
import Data.List (dropWhileEnd, intersperse)
import qualified System.IO as IO
import Text.Read (readMaybe)
-- https://hackage.haskell.org/package/time
import qualified Data.Time.Calendar as Calendar
import qualified Data.Time.Clock as Clock
-- https://hackage.haskell.org/package/ttc
import qualified Data.TTC as TTC
CreditCard
The CreditCard
data structure represents all credit card
details, which are implemented using distinct types below. Note that a
Show
instance is derived for debugging purposes only: it
allows a CreditCard
value to be displayed in the REPL.
data CreditCard
= CreditCard
name :: !Name
{ number :: !Number
, expirationDate :: !ExpirationDate
, securityCode :: !SecurityCode
,
}deriving Show
Name
The Name
type represents the name on a credit card. The
Show
instance is derived for debugging purposes only.
newtype Name = Name String
deriving (Eq, Ord, Show)
The Parse
instance parses a Name
from user
input. After any leading and trailing whitespace is stripped and all
lowercase characters are converted to uppercase, a name must meet the
following constraints:
- Only characters between
0x20
(space) and0x5F
(underscore) are allowed. - The name must be between 1 and 26 characters in length.
Note that the normalized string is stored in the Name
data structure.
Reference:
instance TTC.Parse Name where
= TTC.asS $ \s -> first TTC.fromS $ do
parse let name' = map toUpper $ strip s
= filter ((||) <$> (< ' ') <*> (> '_')) name'
invChars null invChars) . Left $
unless ("name has invalid character(s): " ++ intersperse ',' invChars
null name') $ Left "name is empty"
when (length name' > 26) $ Left "name has more than 26 characters"
when (pure $ Name name'
The Render
instance renders a Name
to
display to the user. It simply returns a Textual
representation of the normalized Name
string.
instance TTC.Render Name where
Name name') = TTC.convert name' render (
Number
The Number
type represents a credit card number. The
Show
instance is derived for debugging purposes only.
newtype Number = Number String
deriving (Eq, Ord, Show)
The Parse
instance parses a Number
from
user input. After any space and dash characters are removed, a number
must meet the following constraints:
- Only ASCII digits are allowed.
- The number must be between 8 and 19 characters in length.
- The number must have a valid checksum.
Note that the normalized string is stored in the Number
data structure.
Reference:
instance TTC.Parse Number where
= TTC.asS $ \s -> first TTC.fromS $ do
parse let number' = filter ((&&) <$> (/= ' ') <*> (/= '-')) s
= filter (not . isDigit) number'
invChars = length number'
len null invChars) . Left $
unless ("number has invalid character(s): " ++ intersperse ',' invChars
>= 8) $ Left "number has fewer than 8 characters"
unless (len <= 19) $ Left "number has more than 19 characters"
unless (len $ Left "number checksum is invalid"
unless (luhn number') pure $ Number number'
The Render
instance renders a Number
to
display to the user. It simply returns a Textual
representation of the normalized Number
string.
instance TTC.Render Number where
Number number') = TTC.convert number' render (
The luhn
function implements the Luhn algorithm, which
is the checksum used in credit card numbers.
Reference:
- https://en.wikipedia.org/wiki/Luhn_algorithm
- http://rosettacode.org/wiki/Luhn_test_of_credit_card_numbers#Haskell
luhn :: String -> Bool
luhn= (== 0)
. (`mod` 10)
. sum
. map (uncurry (+) . (`divMod` 10))
. zipWith (*) (cycle [1, 2])
. map digitToInt
. reverse
ExpirationDate
The ExpirationDate
data type represents the expiration
date (year and month) that is shown on a credit card. The
Show
instance is derived for debugging purposes only.
data ExpirationDate
= ExpirationDate
year :: !Year
{ month :: !Month
,
}deriving (Eq, Ord, Show)
The Parse
instance parses an ExpirationDate
from user input, in YYYY-MM
format. Any leading or trailing
whitespace is stripped.
instance TTC.Parse ExpirationDate where
= TTC.asS $ \s -> case break (== '-') (strip s) of
parse '-':month') ->
(year', ExpirationDate <$> TTC.parse year' <*> TTC.parse month'
-> Left $ TTC.fromS "expiration date not in YYYY-MM format" _
The Render
instance renders an
ExpirationDate
to display to the user.
instance TTC.Render ExpirationDate where
ExpirationDate year' month') =
render ($ TTC.render year' ++ "-" ++ TTC.render month' TTC.fromS
The toDay
function converts an
ExpirationDate
to a specific day. A credit card is assumed
to expire on the last day of the specified month.
toDay :: ExpirationDate
-> Calendar.Day
ExpirationDate (Year year') (Month month')) =
toDay (let yearZ = fromIntegral year'
= Calendar.gregorianMonthLength yearZ month'
day in Calendar.fromGregorian yearZ month' day
Year
The Year
type represents the year of an expiration date
on a credit card. The Show
instance is derived for
debugging purposes only.
newtype Year = Year Int
deriving (Eq, Ord, Show)
The Parse
instance parses a Year
from user
input. In this implementation, years from 1900 to 9999 are considered
valid.
instance TTC.Parse Year where
= TTC.asS $ \s -> first TTC.fromS $ do
parse <- maybe (Left "year is not in YYYY format") pure $ readMaybe s
year' >= 1900) $ Left "year is before 1900"
unless (year' <= 9999) $ Left "year is after 9999"
unless (year' pure $ Year year'
The Render
instance renders a Year
to
display to the user.
instance TTC.Render Year where
Year year') = TTC.convert $ show year' render (
Month
The Month
type represents the month of an expiration
date on a credit card. The Show
instance is derived for
debugging purposes only.
newtype Month = Month Int
deriving (Eq, Ord, Show)
The Parse
instance parses a Month
from user
input, where 1
represents January and 12
represents December. Months 1
through 9
are
parsed even if they are not zero-padded.
instance TTC.Parse Month where
= TTC.asS $ \s -> first TTC.fromS $ do
parse <- maybe (Left "month is not in MM format") pure $ readMaybe s
month' >= 1 && month' <= 12) $ Left "month is not in 1-12 range"
unless (month' pure $ Month month'
The Render
instance renders a Month
to
display to the user.
instance TTC.Render Month where
Month month')
render (| month' < 10 = TTC.convert $ '0' : show month'
| otherwise = TTC.convert $ show month'
SecurityCode
The SecurityCode
type represents a credit card security
code. The Show
instance is derived for debugging purposes
only.
newtype SecurityCode = SecurityCode String
deriving (Eq, Ord, Show)
The Parse
instance parses a SecurityCode
from user input. After any leading and trailing whitespace is stripped,
a security code must meet the following constraints:
- Only ASCII digits are allowed.
- The number must be 3 or 4 characters in length.
Reference:
instance TTC.Parse SecurityCode where
= TTC.asS $ \s -> first TTC.fromS $ do
parse let securityCode' = strip s
= filter (not . isDigit) securityCode'
invChars = length securityCode'
len null invChars) . Left $
unless ("security code has invalid character(s): " ++ intersperse ',' invChars
>= 3) $ Left "security code has fewer than 3 characters"
unless (len <= 4) $ Left "security code has more than 4 characters"
unless (len pure $ SecurityCode securityCode'
The Render
instance renders a SecurityCode
to display to the user.
instance TTC.Render SecurityCode where
SecurityCode securityCode') = TTC.convert securityCode' render (
Helper Functions
The strip
function strips any leading or trailing
whitespace from a string.
strip :: String -> String
= dropWhile isSpace . dropWhileEnd isSpace strip
The prompt
function displays a prompt and reads a
response from the user. The response is parsed using the passed parse
function. If there is an error, the error is displayed and the user is
prompted again. Otherwise, the parsed response is returned.
prompt :: (String -> Either String a) -- ^ parse function
-> String -- ^ prompt string
-> IO a
= loop
prompt parse promptString where
= do
loop putStr promptString
IO.hFlush IO.stdout
<- getLine
s case parse s of
Right x -> return x
Left err -> do
putStrLn $ " " ++ err
loop
The promptTTC
function implements a prompt for any type
that has a Render
instance.
promptTTC :: TTC.Parse a
=> String -- ^ prompt string
-> IO a
= prompt TTC.parse promptTTC
The promptCC
function implements the prompts for
entering credit card information.
promptCC :: IO CreditCard
= CreditCard
promptCC <$> promptTTC "Enter the name: "
<*> promptTTC "Enter the number: "
<*>
ExpirationDate
( <$> promptTTC "Enter the expiration year (YYYY): "
<*> promptTTC "Enter the expiration month (MM): "
)<*> promptTTC "Enter the security code: "
Main Function
The main
function prompts the user for fake credit card
details, displays the parsed information, and gives information about
when the card expires relative to the current time.
main :: IO ()
= do
main putStrLn "Please enter some fake credit card details."
<- promptCC
cc putStrLn $ replicate 78 '-'
putStrLn $ "Name: " ++ TTC.render (name cc)
putStrLn $ "Number: " ++ TTC.render (number cc)
putStrLn $ "Expiration date: " ++ TTC.render (expirationDate cc)
putStrLn $ "Security code: " ++ TTC.render (securityCode cc)
putStrLn $ replicate 78 '-'
<- Clock.utctDay <$> Clock.getCurrentTime
today putStrLn . ("This credit card " ++) . (++ "!") $
case toDay (expirationDate cc) of
expiry| expiry > today -> "expires in " ++ diffDays expiry today
| expiry < today -> "expired " ++ diffDays today expiry ++ " ago"
| otherwise -> "expires today"
where
diffDays :: Calendar.Day -> Calendar.Day -> String
= case Calendar.diffDays day1 day2 of
diffDays day1 day2 1 -> "1 day"
-> show n ++ " days" n