A minimal but functional Unix shell written in C99 as part of the EPITA Advanced C project. jsh supports command execution, built-in commands, I/O redirection, pipes, logical operators, glob expansion, and a raw-mode line editor with history navigation.
- Features
- Building
- Usage
- Architecture
- REPL Flow
- Command Dispatch Flow
- Pipeline Execution Flow
- I/O Redirection Flow
- Raw-Mode Input Flow
- Source Files
- Built-in Commands
- Operators
- Glob Expansion
- REPL — Interactive read-eval-print loop with a
jsh $prompt - External commands — Runs any program in
$PATHviafork+execvp - Built-in commands —
cd,exit,pwdrun in the parent process - I/O redirection —
>(overwrite),>>(append),<(input) - Pipes — Arbitrary-length pipelines (
cmd1 | cmd2 | cmd3 | ...) - Logical operators —
&&(run if previous succeeded),||(run if previous failed),;(always run) - Glob expansion —
*and?wildcards expanded before execution - Raw-mode line editor — Character-by-character input with backspace handling
- History navigation — Up/down arrow keys cycle through command history
- Cursor movement — Left/right arrow keys
- Signal handling —
Ctrl+Cignored by the shell, forwarded to children;Ctrl+Dexits cleanly - Prompt recovery — Detects mid-line cursor and prints a newline before the prompt
Requires gcc and a POSIX-compatible system (Linux / macOS).
make # build the jsh binary
make clean # remove object files and binary
make re # clean + rebuildThe binary is compiled with -Wall -Wextra -Werror -std=c99.
jsh relies on POSIX system calls (fork, execvp, pipe, dup2, termios, etc.) that are not available natively on Windows. To build and run it on Windows, use WSL (Windows Subsystem for Linux):
-
Install WSL (one-time setup — requires Windows 10 version 2004+ or Windows 11):
wsl --installThis installs Ubuntu by default. Restart your machine when prompted.
-
Open the WSL terminal — search for "Ubuntu" in the Start menu, or type
wslin PowerShell/CMD. -
Install build tools (first time only):
sudo apt update && sudo apt install -y gcc make -
Navigate to the project and build:
cd /mnt/c/path/to/shell make && ./jsh
Note:
/mnt/c/maps to yourC:\drive, so you can access your Windows files directly from inside WSL.
./jshjsh $ echo hello world
hello world
jsh $ ls -la | grep .c | wc -l
9
jsh $ echo hi > out.txt && cat out.txt
hi
jsh $ cd /tmp ; pwd
/tmp
jsh $ ls *.c
main.c tokenizer.c executor.c ...
Press Ctrl+D to exit. Press Ctrl+C to interrupt a running command.
include/
shell.h — Public function declarations for all modules
src/
main.c — REPL loop (entry point)
terminal.c — Raw-mode line editor, history, ensure_newline
tokenizer.c — Splits input string into token array
operators.c — Splits tokens on ;, &&, || and dispatches segments
globbing.c — Expands * and ? wildcards into filenames
executor.c — Forks and execs a single external command
pipes.c — Forks and connects a multi-command pipeline
redirections.c — Applies >, >>, < by opening files and dup2'ing fds
builtins.c — Handles cd, exit, pwd in the parent process
The main loop in main.c:
flowchart TD
A["main()"] --> B["Ignore Ctrl+C in the parent shell<br/>so it does not kill our shell process"]
B --> LOOP
subgraph LOOP["Main Loop"]
direction TB
C["Check if cursor is mid-line<br/>if so, print a newline first"]
C --> D["Print the prompt: jsh $"]
D --> E["Read a line of input from the user<br/>using our raw-mode line editor"]
E --> F{"Did the user<br/>press Ctrl+D?<br/>line == NULL"}
F -- "Yes" --> EXIT["Break out of loop"]
F -- "No" --> G{"Is the line empty?<br/>user just pressed Enter"}
G -- "Yes" --> H["Free the line"]
H --> C
G -- "No" --> I["Split the line into tokens<br/>e.g. 'ls -la' becomes 'ls', '-la'"]
I --> J["Free the original line string"]
J --> K["Dispatch tokens to<br/>run_command_line()"]
K --> L["Free all tokens"]
L --> C
end
EXIT --> Z["Print an empty line so the<br/>terminal prompt starts fresh"]
Z --> END["return 0"]
run_command_line() in operators.c splits on operators and dispatches each segment:
flowchart TD
A["run_command_line(tokens, count)"]
A --> B["Walk through tokens left to right"]
subgraph SCAN["Scan for the next operator"]
direction TB
C{"Is the current token<br/>an operator: && or || or ; ?"}
C -- "No, it is a regular token" --> D["Keep scanning forward<br/>to find the end of this segment"]
D --> C
C -- "Yes, or we reached<br/>the end of input" --> E["We now have a complete segment:<br/>the tokens between the previous<br/>operator and this one"]
end
B --> C
E --> F["Expand any wildcards in the segment<br/>e.g. *.c becomes main.c, pipes.c, ..."]
F --> G{"Should we run this segment?<br/>Depends on the previous operator<br/>and whether the last command succeeded"}
G -- "; means always run" --> RUN
G -- "&& and last command succeeded" --> RUN
G -- "|| and last command failed" --> RUN
G -- "&& but last command failed" --> SKIP["Skip this segment entirely"]
G -- "|| but last command succeeded" --> SKIP
subgraph RUN["Execute the segment"]
direction TB
H{"Does the segment<br/>contain a pipe | ?"}
H -- "Yes" --> I["execute_pipeline()<br/>connects commands with pipes"]
H -- "No" --> J{"Is it a builtin command?<br/>cd, exit, or pwd"}
J -- "Yes" --> K["handle_builtin()<br/>runs directly in the parent"]
J -- "No" --> L["execute_command()"]
L --> L2["fork() a child process"]
L2 --> L3["Child runs execvp()<br/>to become the command"]
end
I & K & L3 --> SAVE["Save the exit code<br/>of whatever just ran"]
SKIP --> SAVE
SAVE --> M["Move to the next segment"]
M --> C
SAVE -- "No more tokens" --> RET["return last_exit"]
execute_pipeline() in pipes.c for ls | grep .c | wc -l:
flowchart TD
START["execute_pipeline()<br/>Example: ls | grep .c | wc -l"]
START --> PIPE1["Create pipe 1<br/>This gives us two file descriptors:<br/>fd 3 = read end, fd 4 = write end"]
PIPE1 --> FORK1["fork() — create a copy of the shell"]
FORK1 -- "Child 1 (ls)" --> C1A["Redirect stdout to the pipe's write end<br/>so ls output goes into the pipe"]
C1A --> C1B["Close both pipe fds<br/>already duplicated, not needed"]
C1B --> C1C["execvp: replace this process with ls"]
FORK1 -- "Parent" --> P2A["Close the pipe's write end<br/>only children write to it"]
P2A --> P2B["Save the pipe's read end<br/>as prev_fd for the next command"]
P2B --> PIPE2["Create pipe 2<br/>fd 5
AA3D
= read end, fd 6 = write end"]
PIPE2 --> FORK2["fork() — create another child"]
FORK2 -- "Child 2 (grep)" --> C2A["Redirect stdin to prev_fd<br/>so grep reads ls's output"]
C2A --> C2B["Redirect stdout to pipe 2's write end<br/>so grep output goes to the next pipe"]
C2B --> C2C["Close all extra fds"]
C2C --> C2D["execvp: replace this process with grep .c"]
FORK2 -- "Parent" --> P3A["Close prev_fd and pipe 2's write end"]
P3A --> P3B["Save pipe 2's read end as new prev_fd"]
P3B --> FORK3["fork() — last command, no new pipe needed"]
FORK3 -- "Child 3 (wc)" --> C3A["Redirect stdin to prev_fd<br/>so wc reads grep's output"]
C3A --> C3B["Close prev_fd"]
C3B --> C3C["execvp: replace this process with wc -l"]
FORK3 -- "Parent" --> P4["Close prev_fd"]
P4 --> WAIT["Wait for all 3 children to finish"]
WAIT --> RET["Return the last child's exit code"]
apply_redirections() in redirections.c, running in the child process after fork():
flowchart TD
A["Tokens: echo, hello, >, out.txt"]
A --> B["Scan tokens looking for<br/>redirection operators: > or >> or <"]
B --> C["Found > at index 2<br/>The next token is the filename: out.txt"]
C --> D["Open the file out.txt<br/>Create it if it does not exist<br/>Truncate it if it does"]
D --> E["The OS gives us fd 3<br/>which now points to out.txt"]
E --> F["dup2(3, 1) — retune fd 1<br/>fd 1 (stdout) used to point to the screen<br/>now it points to out.txt"]
F --> G["Close fd 3<br/>we no longer need it,<br/>fd 1 already points to the file"]
G --> H["Remove > and out.txt from the token array<br/>Tokens are now: echo, hello, NULL"]
H --> I["execvp runs echo<br/>echo writes to fd 1 (stdout) as usual<br/>but stdout now goes to out.txt"]
Redirection operators:
| Operator | Flags | Effect |
|---|---|---|
> |
O_CREAT | O_WRONLY | O_TRUNC |
Create/overwrite file |
>> |
O_CREAT | O_WRONLY | O_APPEND |
Create/append to file |
< |
O_RDONLY |
Read input from file |
read_line() in terminal.c:
flowchart TD
A["Save the current terminal settings<br/>so we can restore them later"]
A --> B["Switch to raw mode"]
B --> B1["Disable ECHO:<br/>typed characters are not<br/>automatically printed to screen"]
B1 --> B2["Disable ICANON:<br/>input is delivered one character<br/>at a time instead of waiting<br/>for the user to press Enter"]
B2 --> READ["Read one character from stdin"]
READ --> CHECK{"What type<br/>of character?"}
CHECK -- "Printable<br/>(32 to 126)" --> PRINT["Append the character to our buffer"]
PRINT --> ECHO["Manually echo it to the screen<br/>since ECHO is disabled"]
ECHO --> READ
CHECK -- "Enter" --> TERM["Null-terminate the buffer"]
TERM --> HIST["Save the line to history"]
HIST --> DONE["Restore terminal settings"]
DONE --> RET["Return the buffer to the caller"]
CHECK -- "Backspace<br/>(127)" --> BS["Erase the last character<br/>from the buffer and screen"]
BS --> READ
CHECK -- "Escape sequence<br/>(arrow keys send 3 bytes)" --> ESC["Read the next 2 bytes<br/>to identify which arrow key"]
ESC --> ARROW{"Which<br/>arrow key?"}
ARROW -- "Up" --> UP["Load the previous entry<br/>from command history"]
ARROW -- "Down" --> DOWN["Load the next entry<br/>from command history"]
ARROW -- "Right" --> RIGHT["Move cursor one<br/>position to the right"]
ARROW -- "Left" --> LEFT["Move cursor one<br/>position to the left"]
UP & DOWN --> REDRAW["Clear the current line<br/>and redraw the prompt<br/>with the history entry"]
REDRAW --> READ
RIGHT & LEFT --> READ
CHECK -- "Ctrl+D<br/>(byte 4 — EOF signal)" --> FREE["Free the buffer"]
FREE --> RESTORE["Restore terminal settings"]
RESTORE --> EOF["Return NULL<br/>signals EOF to the caller"]
| File | Lines | Purpose |
|---|---|---|
src/main.c |
44 | Entry point, REPL loop |
src/terminal.c |
246 | Raw-mode input, history, cursor query |
src/tokenizer.c |
88 | Whitespace-based tokenizer |
src/operators.c |
103 | ;, &&, || dispatch |
src/globbing.c |
97 | * and ? wildcard expansion |
src/executor.c |
38 | fork + execvp for single commands |
src/pipes.c |
113 | Multi-stage pipeline execution |
src/redirections.c |
108 | >, >>, < via open + dup2 |
src/builtins.c |
61 | cd, exit, pwd |
include/shell.h |
29 | Shared function declarations |
| Command | Description |
|---|---|
cd [dir] |
Change directory. No argument = $HOME. |
pwd |
Print current working directory. |
exit |
Exit the shell. |
Built-ins run in the parent process. If handle_builtin() returns -1, the command is dispatched as an external program.
| Operator | Behavior | Example |
|---|---|---|
; |
Always run next command | echo a ; echo b |
&& |
Run next only if previous succeeded (exit 0) | gcc main.c && ./a.out |
|| |
Run next only if previous failed (exit != 0) | gcc main.c || echo FAIL |
| |
Pipe stdout of left into stdin of right | ls | grep .c |
Note:
||and|are escaped as\|\|and\|in the table source because|is the Markdown column delimiter.
Before execution, each token is checked for * or ?. If found, glob() expands it into matching filenames. If no matches, the original token is kept as-is.
jsh $ echo *.c
main.c tokenizer.c executor.c builtins.c ...
The shell does the expansion — the command never sees the wildcard.