Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Mini-Shell: Bash-Like Command Interpreter in C

C POSIX GCC GNU Make Flex Bison Bash Linux Docker

Overview

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.


Tech Stack

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.

Project Structure

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.


How to Run the Project

1. Install dependencies

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 bison

On macOS, install the tools with Homebrew:

brew install flex bison

Because macOS ships older versions of some command-line tools, Linux or Docker is the safest environment for matching the checker behavior.


2. Build the project locally

Move into the source directory:

cd src

Build the shell:

make

The 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

3. Run the shell

From inside the src/ directory, run:

./mini-shell

You 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 ..

> exit

The shell continues accepting commands until exit or quit is entered.


4. Clean the build

From inside src/, run:

make clean

This removes generated object files and the shell executable.


5. Run the provided tests

From the project root, move into the tests directory:

cd tests

Run the grading script:

./grade.sh

Or use Make:

make check

The tests run command scripts through the mini-shell and compare the result against expected output.


6. Run with Docker

The repository includes a Docker-based checker so the project can run in a consistent Linux environment.

From the project root, run:

./local.sh checker

This 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.


How the Pieces Connect

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]
Loading

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.txt

is 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 Shell Execution Pipeline

The main loop lives in src/main.c.

The lifecycle of one command line is:

  1. Print the shell prompt.
  2. Read a line from standard input.
  3. Send the line to the parser.
  4. Receive a command_t tree from the parser.
  5. Pass the tree to parse_command() in cmd.c.
  6. Execute the command tree recursively.
  7. Free parser memory.
  8. 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.


Parser Theory and Command Trees

A shell command is a small programming language. It has words, symbols, operators, and grammar rules.

For example:

cat input.txt | grep hello > output.txt

contains:

  • 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

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

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 done

is represented conceptually as:

OP_SEQUENTIAL
├── OP_CONDITIONAL_NZERO
│   ├── false
│   └── echo recovered
└── echo done

The executor can then recursively evaluate each node.


Core Data Structures

The central parser structures are defined in:

util/parser/parser.h

word_t

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-group

This 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

simple_command_t

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.txt

It 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.

command_t

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_PIPE

This tree structure is what allows recursive execution.


Simple Commands

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:

  1. Built-in command
  2. Environment variable assignment
  3. 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=Abha

If none of those cases match, the shell treats the command as an external program and executes it with fork() and execvp().


Built-In Commands

Built-in commands are commands that must affect the shell process itself.

This shell implements:

cd
exit
quit

cd

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:

  1. Determine the requested path.
  2. Save the current directory with getcwd().
  3. Call chdir(path).
  4. If successful, update OLDPWD to the previous directory.
  5. Update PWD to the new current directory.

The implementation also temporarily saves and restores standard output and standard error so that redirection can still work with cd.


exit and quit

The commands exit and quit stop the shell.

They return the special sentinel value:

#define SHELL_EXIT -100

The main shell loop checks for this return value and breaks out of the loop.


External Commands

External commands are programs outside the mini-shell itself.

Examples:

ls
cat file.txt
gcc main.c -o main
./program
/usr/bin/whoami

The shell executes external commands using the classic Unix pattern:

fork -> configure child -> execvp -> wait in parent

Step 1: fork()

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

Step 2: Apply redirection in the child

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.

Step 3: Call execvp()

The child calls:

execvp(argv[0], argv);

If the user enters:

ls -l

then 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.

Step 4: Wait in the parent

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

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

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 $USER

may become:

echo abha

Example with an undefined variable:

echo $DOES_NOT_EXIST

becomes:

echo ""

Variable Assignment

Assignments are handled in parse_simple().

Example:

NAME=Abha

The 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 $MYVAR

Output:

5

File Descriptors and Redirection

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.


Supported Redirection Forms

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.

How Redirection Works

Redirection is implemented with:

open()
dup2()
close()

For example:

echo hello > out.txt

conceptually does this:

  1. Open out.txt for writing.
  2. Create it if it does not exist.
  3. Truncate it if it already exists.
  4. Duplicate that file descriptor onto standard output.
  5. Close the extra descriptor.
  6. 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.


Pipes and Inter-Process Communication

A pipe connects the output of one process to the input of another.

Example:

ls -l | grep .c

The 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:

  1. The left child writes into the pipe.
  2. 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

Multiple pipes work because the command tree is recursive.

Example:

ls -l | tr -s ' ' | cut -d ' ' -f 2 | sort | uniq

The parser represents this as nested pipe operations. Since parse_command() can execute command trees recursively, the same pipe logic can handle chains of commands.


Command Operators

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.

Sequential Execution: ;

echo first ; echo second

The shell runs echo first, waits for it to finish, and then runs echo second.


Conditional Success: &&

mkdir build && cd build

The right command runs only if the left command returns 0.

In Unix:

0     success
non-0 failure

Conditional Failure: ||

cat missing.txt || echo failed

The right command runs only if the left command returns a non-zero status.


Parallel Execution: &

sleep 1 & echo hello

The 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.


Exit Statuses and Control Flow

Every command returns a status code.

By convention:

0     success
non-0 failure

The shell uses these statuses to make decisions.

Example:

true && echo success

true returns 0, so echo success runs.

Example:

false && echo success

false returns non-zero, so echo success does not run.

Example:

false || echo recovered

false fails, so the right side runs.

Exit statuses are therefore not just error indicators. They are also control-flow signals.


Testing and Checker Infrastructure

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 Coverage

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.


Important System Calls and Library Functions

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.


Limitations

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, or while
  • no full export built-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.


Why This Project Matters

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.txt

it feels like one command. Internally, the shell performs a sequence of precise steps:

  1. Read the input line.
  2. Tokenize and parse the command.
  3. Build a command tree.
  4. Create processes.
  5. Create a pipe.
  6. Rewire file descriptors.
  7. Replace child processes with external programs.
  8. Wait for those programs to finish.
  9. 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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages