Process State: Working Directories and Environments
How pwd, cd, HOME, and PATH expose process-local state through Haskell's Directory and Environment APIs.
Every process carries a current working directory and an environment. A shell exposes and mutates that state with commands such as pwd and cd, while variables such as HOME and PATH connect user-facing syntax to filesystem locations.
The current directory is implicit process state
pwd reads the directory stored by the operating system for the shell process:
| "pwd" `isPrefixOf` command = do
currentDirectory <- getCurrentDirectory
putStrLn currentDirectory
main
The type shows that this is not a fixed value derivable from arguments:
getCurrentDirectory :: IO FilePath
FilePath is a type synonym for String, so it improves meaning for human readers without creating a distinct compiler-enforced representation.
Relative paths are interpreted against this current directory. If the process is in /work/project, then src/Main.hs refers to /work/project/src/Main.hs. Changing the current directory therefore changes how later relative paths resolve.
cd mutates the parent shell
The first directory-changing branch checks the target before updating process state:
let target = drop 3 command
exists <- doesDirectoryExist target
if exists
then setCurrentDirectory target
else putStrLn ("cd: " ++ target ++ ": No such file or directory")
Both filesystem operations are explicit effects:
doesDirectoryExist :: FilePath -> IO Bool
setCurrentDirectory :: FilePath -> IO ()
The Boolean check controls which action is constructed next. Haskell’s if is an expression, so both branches must have the same type. Here both branches are IO ():
setCurrentDirectory target :: IO ()
putStrLn message :: IO ()
The update occurs in the shell process, so the next REPL iteration observes the new directory. Running an external cd process would change only the child’s directory and lose the result when the child exits.
Tilde expansion is not ordinary path handling
The filesystem does not generally interpret ~ as the home directory. Tilde expansion is shell syntax that must occur before filesystem functions receive the path.
The implementation handles the simplest complete case:
resolvedTarget <-
if target == "~"
then getEnv "HOME"
else pure target
The two branches must again have the same type. getEnv "HOME" has type IO String, while target is only a String. pure target lifts the unchanged value into IO so both alternatives produce IO String.
The resolved path, rather than the original token, must be used consistently:
exists <- doesDirectoryExist resolvedTarget
if exists
then setCurrentDirectory resolvedTarget
else putStrLn ("cd: " ++ target ++ ": No such file or directory")
The original target remains useful in the error because it matches what was entered. The resolved target is the actual filesystem operand.
This implementation expands only an argument equal to ~. Forms such as ~/code require prefix-aware expansion, and ~otheruser requires account database lookup rather than simply reading HOME.
Environment lookup can fail
The type of getEnv does not expose absence:
getEnv :: String -> IO String
If the variable is missing, it raises an I/O exception. lookupEnv makes the optional result explicit:
lookupEnv :: String -> IO (Maybe String)
A bounded home-directory resolver can therefore represent a missing HOME without an exception-driven normal path:
resolveHome :: FilePath -> IO (Maybe FilePath)
resolveHome "~" = lookupEnv "HOME"
resolveHome target = pure (Just target)
The additional Maybe forces the caller to choose an error policy. This is the same modelling principle used by executable lookup: expected absence belongs in the return type.
PATH is environment state too
PATH is read from the same process environment:
path <- getEnv "PATH"
let directories = splitSearchPath path
The shell inherits its initial environment from its parent process, and external children inherit the shell environment unless the launch API overrides it. Current directory and environment therefore form part of the execution context passed through the process tree.
They differ in one important way from ordinary immutable Haskell values. setCurrentDirectory changes global process state, so the result of a later getCurrentDirectory depends on action order. IO preserves that sequencing in the program’s type and structure.
State suggests a future shell model
As more state appears—previous directory, exported variables, exit status, jobs—calling global process APIs throughout the dispatcher becomes harder to reason about. A later design can make the shell-owned portion explicit:
data ShellState = ShellState
{ lastExitCode :: Int
, previousDirectory :: Maybe FilePath
}
Operating-system state still requires IO, but explicit application state can be passed between iterations. The architecture then distinguishes pure state transitions, process-global effects, and child-process execution instead of treating all mutable context as one undifferentiated shell loop.