← LOGBOOK LOG-449
WORKING · SOFTWARE ·
HASKELLSHELLPOSIXBUILTINSPATHPROCESSESFUNCTIONAL-PROGRAMMING

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.