← LOGBOOK LOG-447
EXPLORING · SOFTWARE ·
HASKELLSHELLCODECRAFTERSFUNCTIONAL-PROGRAMMINGGUARDSCOMMAND-LINE

Building a Shell in Haskell: Guards and Command Dispatch

A small shell loop uses ordered guards to distinguish exit, echo, type, and unknown commands without nested conditional branches.

A shell repeats one operation: print a prompt, read a command, decide what it means, then either stop or return to the prompt. The first CodeCrafters stages implement only built-in commands, so command dispatch is the central piece.

prompt → read line → identify command → print result → prompt

exit is the one path that does not return to the next prompt.

The Shell Loop

main is an IO action because the prompt and command line interact with the terminal. putStr writes the prompt without a newline. hFlush stdout forces it to appear before getLine waits for input.

main :: IO ()
main = do
  putStr "$ "
  hFlush stdout
  command <- getLine
  handleCommand command

getLine produces an IO String; <- binds the resulting String to command inside the do block. The loop itself is formed by calling main again after a command has been handled.

From Nested if to Guards

The first version used nested if expressions:

if command == "exit"
  then pure ()
  else if "echo " `isPrefixOf` command
    then do
      putStrLn (drop 5 command)
      main
    else do
      putStrLn $ command ++ ": command not found"
      main

This is valid Haskell. Each else contains the remaining cases, so the indentation grows as more commands are added.

Guards put those cases alongside one another instead:

handleCommand command
  | command == "exit" = pure ()
  | "echo " `isPrefixOf` command = ...
  | "type " `isPrefixOf` command = ...
  | otherwise = ...

Guards are tried from top to bottom. The first condition that evaluates to True supplies the result. otherwise is the final catch-all; it is simply a name for True.

The behaviour is still conditional branching. The difference is structural: each command case is a peer instead of a branch nested inside the previous case. That fits command dispatch because the program is choosing one handler from an ordered set of handlers.

Built-in Commands

The shell knows its built-ins before it reads any command.

builtInCmds :: [String]
builtInCmds = ["echo", "exit", "type"]

The list is the single source of truth for the type command. Adding a new built-in later means adding a handler and adding its name to this list.

isPrefixOf checks whether one list begins with another. Since String is a list of Char, it checks command prefixes directly.

"echo " `isPrefixOf` "echo hello"
-- True

"echo " `isPrefixOf` "exit"
-- False

The space after echo and type matters. "type" alone would also match input such as typewriter; "type " identifies the command followed by its argument.

Guard-Based Command Handler

handleCommand :: String -> IO ()
handleCommand command
  | command == "exit" = pure ()
  | "echo " `isPrefixOf` command = do
      putStrLn (drop 5 command)
      main
  | "type " `isPrefixOf` command = do
      let target = drop 5 command
      if target `elem` builtInCmds
        then putStrLn $ target ++ " is a shell builtin"
        else putStrLn $ target ++ ": not found"
      main
  | otherwise = do
      putStrLn $ command ++ ": command not found"
      main

drop 5 removes the five characters in echo or type . For echo hello, the remaining string is hello. For type echo, the remaining string is echo.

elem checks whether the target name belongs to builtInCmds. That keeps the test separate from the output format:

type echo  → echo is a shell builtin
type cd    → cd: not found

pure () ends the exit branch with an IO action that has no observable effect. It does not call main, so the program finishes.

One Limitation of Prefix Parsing

isPrefixOf and drop 5 are enough for these first commands, but they assume exactly one space after the command and treat the remainder as one raw string. A real shell needs to recognise whitespace, quoted arguments, escapes, environment variables, and executable paths.

words "type echo"
-- ["type", "echo"]

words is a useful next step for simple space-separated commands, but it does not understand quotes. Shell parsing becomes its own subsystem once commands such as echo "hello world" and external programs are introduced.

The current structure leaves room for that change. main owns the terminal loop; handleCommand owns dispatch; a future parser can turn the raw String into a command name and argument list before dispatch begins.