This project is a study of how a Unix-like shell works internally. From the outside, a shell looks simple: it accepts a command, runs it, and prints the result. Under the surface, however, a shell connects several important operating-system ideas: parsing, process creation, environment variables, file descriptors, input/output redirection, pipes, exit statuses, and recursive command execution.
This mini-shell is written in C and recreates the core behavior of a Bash-like command interpreter. It can read commands, parse them into structured command trees, execute built-in commands, launch external programs, redirect input and output, connect commands with pipes, evaluate conditional operators, run commands sequentially, and execute commands in parallel.
The project is not meant to replace Bash. Instead, it is designed to make the mechanics of a shell visible. Each feature maps directly to a low-level system concept, which makes the project a compact study of how user commands become operating-system actions.
| Technology | Purpose |
|---|---|
| C | Main language used to implement the shell executor, command handling, redirection, pipes, built-ins, and environment logic. |
| POSIX System Calls | Provides the operating-system interface for fork(), execvp(), waitpid(), pipe(), dup2(), open(), close(), and chdir(). |
| GCC | Compiles the C source files and links the final mini-shell executable. |
| GNU Make | Automates building the parser, object files, and final executable. |
| Flex | Generates the lexer from parser.l, allowing raw command text to be broken into tokens. |
| Bison | Generates the parser from parser.y, allowing tokens to be transformed into a command tree. |
| Bash Scripts | Used by the grading and testing infrastructure. |
| Linux | Target execution environment because the project relies on Unix/POSIX process and file-descriptor behavior. |
| Docker | Provides a consistent checker environment for running the project the same way across machines. |
mini-shell/
├── README.md
│ └── Main project documentation. This is the only README needed for the project.
│
├── Dockerfile
│ └── Defines the Linux-based environment used by the checker.
│
├── local.sh
│ └── Convenience script for running the checker locally through Docker.
│
├── minishell assignment handout.pdf
│ └── Original project handout and specification.
│
├── src/
│ ├── main.c
│ │ └── Starts the shell loop, reads input, sends input to the parser, and
│ │ dispatches parsed commands to the executor.
│ │
│ ├── cmd.c
│ │ └── Core execution file. Handles built-ins, external commands,
│ │ environment assignments, redirection, pipes, sequencing,
│ │ conditionals, and parallel execution.
│ │
│ ├── cmd.h
│ │ └── Declares the command execution interface and shell-specific constants.
│ │
│ ├── utils.c
│ │ └── Converts parser word structures into regular C strings and argv arrays.
│ │
│ ├── utils.h
│ │ └── Declares utility functions and shared helper macros.
│ │
│ └── Makefile
│ └── Builds the parser dependency, compiles the shell source files, and
│ links the final mini-shell executable.
│
├── util/
│ └── parser/
│ ├── parser.l
│ │ └── Flex lexer specification. Converts raw command text into tokens.
│ │
│ ├── parser.y
│ │ └── Bison grammar specification. Converts tokens into command trees.
│ │
│ ├── parser.h
│ │ └── Shared parser data structures used by the executor.
│ │
│ ├── CUseParser.c
│ │ └── C example showing how the generated parser can be used.
│ │
│ ├── UseParser.cpp
│ │ └── C++ parser usage example from the starter utilities.
│ │
│ ├── DisplayStructure.cpp
│ │ └── Utility for visualizing the command structure produced by parsing.
│ │
│ ├── Makefile
│ │ └── Builds the generated parser files and parser object files.
│ │
│ └── tests/
│ └── Parser-specific sample inputs for checking grammar behavior.
│
├── tests/
│ ├── grade.sh
│ │ └── Main grading script that runs the shell against the provided tests.
│ │
│ ├── run_all.sh
│ │ └── Runs all available tests in sequence.
│ │
│ ├── Makefile
│ │ └── Make-based test runner entry point.
│ │
│ └── _test/
│ ├── inputs/
│ │ └── Command scripts that are passed into the mini-shell.
│ │
│ ├── refs/
│ │ └── Expected reference outputs for tests.
│ │
│ ├── run_test.sh
│ │ └── Runs one test case and compares the output.
│ │
│ └── test_lib.sh
│ └── Shared helper functions used by the test runner.
│
├── checker/
│ └── checker.sh
│ └── Checker entry point used by the Docker/local checker workflow.
│
└── samples/
├── sample_1.c
│ └── Small sample C program used for execution experiments.
│
└── sample_2.c
└── Additional sample C program for command execution testing.
This tree intentionally shows only one README file at the root. Internal starter documentation files, such as checker-specific or parser-specific README files, are not part of the displayed documentation structure because this README is meant to serve as the single project-level guide.
This project needs a Unix-like environment with GCC, Make, Flex, and Bison.
On Ubuntu or Debian-based Linux systems, install them with:
sudo apt-get update
sudo apt-get install build-essential flex bisonOn macOS, install the tools with Homebrew:
brew install flex bisonBecause macOS ships older versions of some command-line tools, Linux or Docker is the safest environment for matching the checker behavior.
Move into the source directory:
cd srcBuild the shell:
makeThe src/Makefile first builds the parser from the util/parser/ directory. Then it compiles the shell source files and links them with the generated parser object files.
The build produces the executable:
mini-shell
From inside the src/ directory, run:
./mini-shellYou should see the shell prompt:
>
Example commands:
> echo hello
hello
> NAME=Abha
> echo $NAME
Abha
> echo hello > out.txt
> cat < out.txt
hello
> pwd
> cd ..
> exitThe shell continues accepting commands until exit or quit is entered.
From inside src/, run:
make cleanThis removes generated object files and the shell executable.
From the project root, move into the tests directory:
cd testsRun the grading script:
./grade.shOr use Make:
make checkThe tests run command scripts through the mini-shell and compare the result against expected output.
The repository includes a Docker-based checker so the project can run in a consistent Linux environment.
From the project root, run:
./local.sh checkerThis uses the Dockerfile and checker scripts to build and test the project in a controlled environment.
Docker is the recommended option when the local machine does not have the expected versions of GCC, Flex, Bison, Make, or Bash.
The shell is built as a pipeline. Each layer has one responsibility.
flowchart TD
A[User input line] --> B[main.c reads input]
B --> C[parser.l tokenizes text]
C --> D[parser.y builds grammar structure]
D --> E[command_t parse tree]
E --> F[cmd.c executes command tree]
F --> G{Command type}
G -->|Built-in| H[Run in shell process]
G -->|External command| I[fork child process]
I --> J[Apply redirection]
J --> K[execvp program]
G -->|Pipe| L[Create pipe and fork both sides]
G -->|Operator| M[Recursively execute left and right branches]
H --> N[Return status]
K --> N
L --> N
M --> N
N --> O[main.c decides whether to continue]
The parser does not execute commands. It only transforms command text into a structured tree. The executor in cmd.c walks that tree and decides which system calls are needed.
For example:
echo hello | grep h > result.txtis not treated as one flat string. It becomes a structured command tree:
OP_PIPE
├── command 1: echo hello
└── command 2: grep h > result.txt
The executor sees the pipe node, creates a pipe, forks child processes, connects the standard output of the left command to the standard input of the right command, applies output redirection, and waits for the commands to finish.
The main loop lives in src/main.c.
The lifecycle of one command line is:
- Print the shell prompt.
- Read a line from standard input.
- Send the line to the parser.
- Receive a
command_ttree from the parser. - Pass the tree to
parse_command()incmd.c. - Execute the command tree recursively.
- Free parser memory.
- Continue unless the command returned the special shell-exit code.
Conceptually, the loop behaves like this:
while (true) {
print_prompt();
line = read_line();
parse_line(line, &root);
ret = parse_command(root, 0, NULL);
free_parse_memory();
if (ret == SHELL_EXIT)
break;
}This design keeps the project organized. main.c is responsible for the shell loop, while cmd.c is responsible for execution behavior.
A shell command is a small programming language. It has words, symbols, operators, and grammar rules.
For example:
cat input.txt | grep hello > output.txtcontains:
- command words:
cat,input.txt,grep,hello,output.txt - an operator:
| - a redirection symbol:
>
This project uses Flex and Bison to process that language.
Flex is the lexer generator.
A lexer reads raw characters and groups them into tokens.
For example, it recognizes command text as tokens such as:
WORD echo
WORD hello
PIPE |
REDIRECT_OUTPUT >
ENVIRONMENT_VAR $USER
SEQUENTIAL ;
CONDITIONAL_AND &&
CONDITIONAL_OR ||
PARALLEL &
The lexer specification is stored in:
util/parser/parser.l
Bison is the parser generator.
The parser receives tokens from the lexer and applies grammar rules to build a valid command structure.
The grammar specification is stored in:
util/parser/parser.y
The parser recognizes structures such as:
simple_command
command ; command
command & command
command && command
command || command
command | command
Instead of executing commands immediately, the parser builds a command tree. This matters because shell operators have meaning based on structure and priority.
For example:
false || echo recovered ; echo doneis represented conceptually as:
OP_SEQUENTIAL
├── OP_CONDITIONAL_NZERO
│ ├── false
│ └── echo recovered
└── echo done
The executor can then recursively evaluate each node.
The central parser structures are defined in:
util/parser/parser.h
A word_t represents a shell word.
A shell word is not always just one literal string. It may contain multiple parts, and some parts may need environment-variable expansion.
Example:
echo $USER-groupThis can be represented as one word with multiple parts:
part 1: USER, expand = true
part 2: -group, expand = false
The structure uses:
| Field | Purpose |
|---|---|
string |
Stores the literal text or environment variable name. |
expand |
Indicates whether this part should be expanded with getenv(). |
next_part |
Connects pieces that belong to the same shell word. |
next_word |
Connects separate command arguments. |
The difference between next_part and next_word is important.
next_part = another piece of the same argument
next_word = a separate argument
A simple_command_t represents a command with no shell operator at its root.
Examples:
echo hello
cd src
NAME=value
cat < input.txt > output.txtIt stores:
| Field | Purpose |
|---|---|
verb |
The command name, such as echo, cd, or gcc. |
params |
Command arguments. |
in |
Input redirection target. |
out |
Output redirection target. |
err |
Error redirection target. |
io_flags |
Flags that describe append or truncate behavior. |
A command_t represents a node in the command tree.
If the node has no operator, it wraps a simple_command_t.
If the node has an operator, it has two child commands:
cmd1 operator cmd2
Supported operators include:
OP_NONE
OP_SEQUENTIAL
OP_PARALLEL
OP_CONDITIONAL_ZERO
OP_CONDITIONAL_NZERO
OP_PIPEThis tree structure is what allows recursive execution.
Simple commands are handled in cmd.c.
The main function for simple command execution is:
static int parse_simple(simple_command_t *s, int level, command_t *father)A simple command is classified into one of three categories:
- Built-in command
- Environment variable assignment
- External command
The order matters.
First, the shell expands the command verb using get_word().
Then it checks whether the command is a built-in:
cd
exit
quit
Then it checks whether the command contains =, which represents an environment variable assignment:
NAME=AbhaIf none of those cases match, the shell treats the command as an external program and executes it with fork() and execvp().
Built-in commands are commands that must affect the shell process itself.
This shell implements:
cd
exit
quit
The cd command changes the current working directory.
This command cannot be implemented as a normal external command. If the shell forks a child process and changes directories only inside that child, the parent shell stays in the same directory. That would make cd useless.
Therefore, cd must run directly inside the shell process.
The implementation uses:
chdir(path)It also updates:
PWD
OLDPWD
The logic is:
- Determine the requested path.
- Save the current directory with
getcwd(). - Call
chdir(path). - If successful, update
OLDPWDto the previous directory. - Update
PWDto the new current directory.
The implementation also temporarily saves and restores standard output and standard error so that redirection can still work with cd.
The commands exit and quit stop the shell.
They return the special sentinel value:
#define SHELL_EXIT -100The main shell loop checks for this return value and breaks out of the loop.
External commands are programs outside the mini-shell itself.
Examples:
ls
cat file.txt
gcc main.c -o main
./program
/usr/bin/whoamiThe shell executes external commands using the classic Unix pattern:
fork -> configure child -> execvp -> wait in parent
fork() creates a child process.
After fork(), both the parent and child continue from the same point in the code. The return value tells the program which process it is in.
pid < 0 fork failed
pid == 0 child process
pid > 0 parent process
The child process applies input, output, and error redirection before running the external program.
This is important because redirection should affect the program being executed, not the shell itself.
The child calls:
execvp(argv[0], argv);If the user enters:
ls -lthen the argument vector looks like:
argv[0] = "ls"
argv[1] = "-l"
argv[2] = NULL
execvp() searches the directories listed in PATH, finds the executable, and replaces the child process with that program.
If execvp() returns, execution failed.
The parent process waits for the child:
waitpid(pid, &status, 0);Then it extracts the child process exit status and returns it to the command executor.
Environment variables are name-value pairs attached to a process.
Examples include:
USER
HOME
PATH
PWD
OLDPWD
The mini-shell inherits its environment from the parent shell that started it. Any child programs launched by the mini-shell inherit the mini-shell environment.
Variable expansion is handled in utils.c by:
char *get_word(word_t *s)If a word part has expand == true, the shell calls:
getenv(s->string)If the variable exists, its value is inserted into the command. If it does not exist, it expands to the empty string.
Example:
echo $USERmay become:
echo abhaExample with an undefined variable:
echo $DOES_NOT_EXISTbecomes:
echo ""Assignments are handled in parse_simple().
Example:
NAME=AbhaThe shell detects the = character and calls:
setenv(var_name, var_value, 1);The final argument 1 means that an existing variable should be overwritten.
Example:
MYVAR=3
MYVAR=5
echo $MYVAROutput:
5
Redirection is one of the most important shell features in the project.
Unix processes normally start with three standard file descriptors:
| File Descriptor | Name | Meaning |
|---|---|---|
0 |
STDIN_FILENO |
Standard input |
1 |
STDOUT_FILENO |
Standard output |
2 |
STDERR_FILENO |
Standard error |
Usually:
- standard input comes from the keyboard or input stream
- standard output prints to the terminal
- standard error also prints to the terminal
Redirection changes where those descriptors point.
| Syntax | Meaning |
|---|---|
< file |
Use file as standard input. |
> file |
Send standard output to file, truncating it first. |
2> file |
Send standard error to file, truncating it first. |
&> file |
Send both standard output and standard error to file. |
>> file |
Append standard output to file. |
2>> file |
Append standard error to file. |
Redirection is implemented with:
open()
dup2()
close()For example:
echo hello > out.txtconceptually does this:
- Open
out.txtfor writing. - Create it if it does not exist.
- Truncate it if it already exists.
- Duplicate that file descriptor onto standard output.
- Close the extra descriptor.
- Run the command.
In C, that looks like:
fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, STDOUT_FILENO);
close(fd);After dup2(), anything written to standard output goes into out.txt.
For external commands, redirection happens in the child process after fork() and before execvp().
For built-ins such as cd, redirection must happen temporarily inside the shell process, and then the original descriptors must be restored.
A pipe connects the output of one process to the input of another.
Example:
ls -l | grep .cThe output of ls -l becomes the input of grep .c.
The project implements pipes in cmd.c.
A pipe has two ends:
| Pipe End | Meaning |
|---|---|
pipefd[0] |
Read end |
pipefd[1] |
Write end |
The shell creates a pipe with:
pipe(pipefd);Then it forks two child processes:
- The left child writes into the pipe.
- The right child reads from the pipe.
Conceptually:
cmd1 stdout -> pipe write end -> pipe read end -> cmd2 stdin
The descriptor setup is:
// Left child
dup2(pipefd[1], STDOUT_FILENO);
// Right child
dup2(pipefd[0], STDIN_FILENO);Unused pipe ends must be closed. If unused descriptors stay open, commands can hang because the reading side may never receive end-of-file.
Multiple pipes work because the command tree is recursive.
Example:
ls -l | tr -s ' ' | cut -d ' ' -f 2 | sort | uniqThe parser represents this as nested pipe operations. Since parse_command() can execute command trees recursively, the same pipe logic can handle chains of commands.
The shell supports several operators.
| Operator | Internal Meaning | Behavior |
|---|---|---|
; |
Sequential | Run the left command, then run the right command. |
& |
Parallel | Run both command branches at the same time. |
&& |
Conditional success | Run the right command only if the left command succeeds. |
|| |
Conditional failure | Run the right command only if the left command fails. |
| |
Pipe | Connect the output of the left command to the input of the right command. |
echo first ; echo secondThe shell runs echo first, waits for it to finish, and then runs echo second.
mkdir build && cd buildThe right command runs only if the left command returns 0.
In Unix:
0 success
non-0 failure
cat missing.txt || echo failedThe right command runs only if the left command returns a non-zero status.
sleep 1 & echo helloThe shell forks both branches, allows them to run at the same time, and waits for both to finish.
This is not full Bash job control. In this mini-shell, & acts as a project-specific parallel operator between two command trees.
Every command returns a status code.
By convention:
0 success
non-0 failure
The shell uses these statuses to make decisions.
Example:
true && echo successtrue returns 0, so echo success runs.
Example:
false && echo successfalse returns non-zero, so echo success does not run.
Example:
false || echo recoveredfalse fails, so the right side runs.
Exit statuses are therefore not just error indicators. They are also control-flow signals.
The project includes an automated test suite in:
tests/
The main scripts are:
tests/grade.sh
tests/run_all.sh
tests/_test/run_test.sh
The tests run command scripts through the mini-shell and compare the output against expected reference behavior.
| Test Area | What It Validates |
|---|---|
| Commands without arguments | Basic external command execution. |
| Commands with arguments | Argument parsing and argv construction. |
| Simple redirection | <, >, 2>, and &>. |
| Append redirection | >> and 2>>. |
| Current directory | cd, pwd, relative paths, and invalid paths. |
| Conditional operators | && and ` |
| Sequential commands | ;. |
| Environment variables | Variable assignment and expansion. |
| Single pipe | One producer connected to one consumer. |
| Multiple pipes | Recursive pipe chains. |
| Variables and redirection | Variable expansion inside commands and file names. |
| Overwritten variables | Repeated setenv() behavior. |
| Mixed operators | Combinations of pipes, redirects, and conditionals. |
| Parallel operator | Forked parallel command branches. |
| Big file | Large file and pipe handling. |
| Sleep command | Timing and concurrency behavior. |
fscanf behavior |
Blocking and synchronization behavior. |
| Unknown command | execvp() failure handling. |
The tests compare output directories recursively, so correctness is not only about what appears in the terminal. It also includes which files are created, what those files contain, and whether output and error streams are redirected correctly.
| Function | Purpose |
|---|---|
fork() |
Creates a new child process. |
execvp() |
Replaces a child process with an external program. |
waitpid() |
Waits for a child process to finish. |
WEXITSTATUS() |
Extracts a child process exit code. |
open() |
Opens or creates a file for redirection. |
dup2() |
Duplicates one file descriptor onto another. |
close() |
Closes a file descriptor. |
pipe() |
Creates a one-way communication channel between processes. |
chdir() |
Changes the shell process's current working directory. |
getcwd() |
Reads the current working directory. |
getenv() |
Reads an environment variable. |
setenv() |
Creates or updates an environment variable. |
strcmp() |
Compares command strings. |
fprintf() |
Prints formatted output to a stream such as stderr. |
exit() |
Terminates a process with a status code. |
These APIs are small individually, but together they explain most of what a shell does.
This project intentionally implements a small shell, not all of Bash.
Current limitations include:
- no wildcard expansion such as
*.c - no command substitution such as
$(command) - no subshell syntax with parentheses
- no full Bash job control
- no aliases
- no shell functions
- no full scripting language with
if,for, orwhile - no full
exportbuilt-in - no advanced quoting behavior beyond what the provided parser supports
- no interactive signal-handling layer for job control
These limitations are intentional. The project focuses on the operating-system mechanisms that make shell execution possible.
A shell is one of the clearest examples of the boundary between user-level programs and the operating system.
When a user types:
cat input.txt | grep hello > output.txtit feels like one command. Internally, the shell performs a sequence of precise steps:
- Read the input line.
- Tokenize and parse the command.
- Build a command tree.
- Create processes.
- Create a pipe.
- Rewire file descriptors.
- Replace child processes with external programs.
- Wait for those programs to finish.
- Use exit statuses to determine success or failure.
This project brings those hidden steps into code.
The mini-shell is more than a command runner. It is a compact study of process control, file descriptor management, parsing, inter-process communication, and the design of command-line languages.