Shell Built-ins, PATH Search, and Process Execution
The execution boundary between commands handled by the shell process and external programs resolved and launched from PATH.
A shell command can either mutate or inspect the shell process itself, or ask the operating system to run another program. That boundary determines which commands must be built-ins and which can be external executables.
The current command set records the built-ins explicitly:
builtInCmds :: [String]
builtInCmds = ["echo", "exit", "type", "pwd", "cd"]
type uses this list before searching PATH:
if target `elem` builtInCmds
then putStrLn (target ++ " is a shell builtin")
else do
result <- findExecutableInPath target
case result of
Nothing -> putStrLn (target ++ ": not found")
Just path -> putStrLn (target ++ " is " ++ path)
This ordering matches command resolution: a built-in with a given name takes precedence over an executable of the same name for this shell.
Why built-ins exist
echo and pwd could be separate executables because their useful result is output. cd and exit cannot have the required effect when implemented only as child processes.
Processes have independent execution state. If a child changes its current directory, the parent shell’s directory is unchanged. If a child exits, only that child terminates. Therefore:
cd must change the shell process
exit must terminate the shell process
Implementing them in the command dispatcher is not merely an optimization. It is required by process isolation.
External commands split into program and arguments
The fallback branch decomposes an input line:
case words command of
[] -> main
program : args -> do
result <- findExecutableInPath program
case result of
Nothing ->
putStrLn (program ++ ": command not found")
Just _ ->
callProcess program args
main
For ls -la /tmp, the values become:
program = "ls"
args = ["-la", "/tmp"]
The empty-list branch matters because a blank line produces no program. Pattern matching makes that invalid execution state explicit before a process call is attempted.
callProcess receives arguments as a list rather than as a single command string:
callProcess :: FilePath -> [String] -> IO ()
This preserves argument boundaries. The process library does not need to pass the combined text through another shell merely to split it again. Avoiding an extra shell layer also avoids accidental interpretation of metacharacters such as ;, *, or $().
Synchronous execution controls prompt order
callProcess waits for the child process to finish. Only then does execution reach the recursive main call and print another prompt:
read command
-> launch child
-> wait for child
-> print next prompt
That is the correct baseline for a foreground command. An asynchronous launch would let the shell immediately print another prompt while the child was still writing to the same terminal. Job control, background processes, signals, and process groups require a richer model that comes later.
The child inherits the shell’s standard streams by default. Its output appears in the same terminal without the Haskell program manually reading and forwarding it.
Lookup and execution should agree
The shell first verifies the command with findExecutableInPath, then calls:
callProcess program args
The successful path is currently discarded as Just _. That means lookup and launch are two distinct resolution steps: the custom function proves that a candidate exists, while the process library performs its own search when given the bare program name.
A tighter invariant would execute the exact resolved file:
case result of
Nothing ->
putStrLn (program ++ ": command not found")
Just executablePath ->
callProcess executablePath args
Now the file reported by lookup is exactly the file launched. It also avoids a race in which PATH or the filesystem changes between custom lookup and the library’s resolution, although that race is not important for the learning-stage shell.
Command resolution is a policy
The growing dispatcher implements a specific policy:
parsed command
-> known built-in? handle inside shell
-> otherwise search PATH from left to right
-> executable found? launch and wait
-> otherwise print command-not-found
This policy is separate from the mechanics of directory traversal and process creation. As the shell grows, the separation can become explicit types:
data Resolution
= Builtin BuiltinCommand
| Executable FilePath
| NotFound
Resolution can then be tested as a decision over inputs, while execution remains the effectful consumer. That division continues the functional boundary already present in the REPL: pure interpretation followed by controlled effects.
The implementation follows the CodeCrafters Build Your Own Shell track, with each stage adding one observable part of command resolution and execution.