8000 GitHub - musajoshua/jsh: JSH (Joshua Shell) — a minimalist Unix-like shell written in C to explore how command interpreters work under the hood. · GitHub
[go: up one dir, main page]

Skip to content
musajoshuaPublic

About

JSH (Joshua Shell) — a minimalist Unix-like shell written in C to explore how command interpreters work under the hood.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

15 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

jsh — A Simple Unix Shell in C

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.


Table of Contents


Features

  • REPL — Interactive read-eval-print loop with a jsh $ prompt
  • External commands — Runs any program in $PATH via fork + execvp
  • Built-in commands — cd, exit, pwd run 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+C ignored by the shell, forwarded to children; Ctrl+D exits cleanly
  • Prompt recovery — Detects mid-line cursor and prints a newline before the prompt

Building

Requires gcc and a POSIX-compatible system (Linux / macOS).

make        # build the jsh binary
make clean  # remove object files and binary
make re     # clean + rebuild

The binary is compiled with -Wall -Wextra -Werror -std=c99.

Windows (via WSL)

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

  1. Install WSL (one-time setup — requires Windows 10 version 2004+ or Windows 11):

    wsl --install

    This installs Ubuntu by default. Restart your machine when prompted.

  2. Open the WSL terminal — search for "Ubuntu" in the Start menu, or type wsl in PowerShell/CMD.

  3. Install build tools (first time only):

    sudo apt update && sudo apt install -y gcc make
  4. Navigate to the project and build:

    cd /mnt/c/path/to/shell
    make && ./jsh

Note: /mnt/c/ maps to your C:\ drive, so you can access your Windows files directly from inside WSL.


Usage

./jsh
jsh $ 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.


Architecture

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

REPL Flow

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

Command Dispatch Flow

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

Pipeline Execution Flow

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

I/O Redirection Flow

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

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

Raw-Mode Input Flow

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

Source Files

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

Built-in Commands

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.


Operators

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.


Glob Expansion

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.

About

JSH (Joshua Shell) — a minimalist Unix-like shell written in C to explore how command interpreters work under the hood.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

0