Skip to content

Latest commit

 

History

5 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

minishell-cpp

A Unix-style command-line interpreter written in C++17, built around object-oriented design principles. It implements its own lexer, parser, command dispatcher and error hierarchy, and supports 11 built-in commands with I/O redirection and pipes.

$ echo "hello world"
hello world
$ echo "one two three" | wc -w
3
$ head -2 data.txt > first_lines.txt

Table of contents


Features

  • 11 built-in commands: echo, prompt, time, date, touch, truncate, rm, wc, tr, head, batch
  • Output redirection: > (overwrite) and >> (append)
  • Input redirection: < reads a file into the command's argument slot
  • Pipes: cmd1 | cmd2 passes the first command's output into the second
  • Batch mode: run a file (or a quoted block) of commands as a script
  • Lexical validation: invalid characters are reported with caret markers under the offending positions, before any command runs
  • Typed exception hierarchy: every failure mode is a distinct class deriving from BaseException
  • Interactive argument capture: commands invoked with no argument read from stdin until EOF
  • Configurable prompt via the prompt command

Build and run

Requirements

  • A C++17 compiler (Clang, GCC or MSVC)
  • CMake 3.27 or newer (set in CMakeLists.txt)

Build

cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build

Run

./build/minishell-cpp

The shell starts with the default prompt $ and reads one command per line.

Note: the interpreter has no exit command. Close it with Ctrl+C. See Known limitations.


Command-line syntax

The general form of a command is:

command [-option] [< infile] [arguments...] [> outfile | >> outfile]

Every parsed line is normalised into a fixed slot vector (std::vector<std::string>). This is the central data structure of the interpreter: the parser never builds an AST, it sorts tokens into positional slots and hands the vector to the executor.

Index Slot Filled by Example
0 Command name first token on the line echo
1 Option token beginning with - -w, -2
2 Input redirect < file (stored with the leading <) <data.txt
3 Output redirect > file or >> file (leading marker kept) >out.txt
4+ Arguments all remaining tokens and quoted strings "hello"

Each command class declares a static validElements vector describing which slots it accepts and how many arguments it takes:

// {slot0, slot1, slot2, slot3, minArgs, maxArgs}
const std::vector<int> Wc::validElements = {true, true, true, true, 1, 1};

ErrorHandler::checkCommandElements enforces this contract before the command runs: a filled slot that the command does not allow raises InvalidElement, and an argument count outside [minArgs, maxArgs] raises InvalidArguments.

Quoting

Text arguments are wrapped in double quotes. Anything not starting with " in the argument slot is treated as a filename, and the file's contents are loaded as the argument:

$ echo "literal text"     # prints the string
$ echo data.txt           # prints the contents of data.txt

Architecture

The interpreter is a pipeline of four singletons plus a family of command objects. Input flows strictly left to right: read, validate, split, parse, dispatch, execute, output.

flowchart TD
    subgraph REPL["REPL loop (main.cpp)"]
        MAIN["main()<br/>while ErrorHandler::getLoop()"]
    end

    subgraph INPUT["InputHandler (singleton)"]
        LISTEN["listen()<br/>print prompt, getline from stdin"]
        FORWARD["forward()<br/>split on unquoted pipes"]
        AWAIT["awaitArg()<br/>read stdin until EOF"]
    end

    subgraph VALIDATE["ErrorHandler (singleton)"]
        SYNTAX["checkSyntax()<br/>character-level lexical scan"]
        CHECKEL["checkCommandElements()<br/>slot and arity contract"]
        HANDLE["handleError()<br/>format and route message"]
    end

    subgraph PARSE["Parser"]
        ACCEPT["accept()<br/>tokenize into the slot vector"]
    end

    subgraph EXEC["CmdExecutor (singleton)"]
        CREATE["create()<br/>name lookup, inject piped or redirected input"]
        FACTORY["construct unique_ptr&lt;ICommand&gt;"]
    end

    subgraph CMDS["Command objects (ICommand)"]
        CMD["Echo, Prompt, Time, Date, Touch,<br/>Truncate, Rm, Wc, Tr, Head, Batch"]
    end

    subgraph OUT["Output layer"]
        OUTPUT["output(argument, filename)"]
        CONSOLE["std::cout"]
        FILE["FileManipulation<br/>write / append"]
        PIPEBUF["CmdExecutor::setPrevOut()<br/>pipe buffer"]
    end

    MAIN --> LISTEN
    LISTEN --> SYNTAX
    SYNTAX -->|"LexicalError"| HANDLE
    SYNTAX -->|"ok"| FORWARD
    FORWARD -->|"one segment per pipe stage"| ACCEPT
    ACCEPT -->|"InvalidElement"| HANDLE
    ACCEPT --> CREATE
    CREATE -->|"CommandNotFound"| HANDLE
    CREATE --> FACTORY
    FACTORY --> CMD
    CMD -.->|"no argument given"| AWAIT
    CMD --> CHECKEL
    CHECKEL -->|"InvalidElement / InvalidArguments"| HANDLE
    CMD --> OUTPUT
    OUTPUT -->|"pipe active"| PIPEBUF
    OUTPUT -->|"slot 3 empty"| CONSOLE
    OUTPUT -->|"slot 3 set"| FILE
    PIPEBUF -.->|"injected into next stage"| CREATE
    HANDLE --> OUTPUT
    OUTPUT --> MAIN
Loading

Command pattern

Every command is a class implementing the ICommand interface. CmdExecutor::create maps the command name to an integer through an unordered_map, then constructs the matching object behind a std::unique_ptr<ICommand>. Validation happens in the constructor, work happens in execute().

classDiagram
    class ICommand {
        <<interface>>
        +execute()* void
    }

    class Echo {
        -vector~string~ elements
        -static vector~int~ validElements
        +execute() void
    }
    class Wc {
        -vector~string~ elements
        -static vector~int~ validElements
        +execute() void
    }
    class Tr {
        -vector~string~ elements
        -static vector~int~ validElements
        +execute() void
    }
    class Head {
        -vector~string~ elements
        -static vector~int~ validElements
        +execute() void
    }
    class Batch {
        -vector~string~ elements
        -static vector~int~ validElements
        +execute() void
    }
    class Prompt {
        -string newCmdPrompt
        +execute() void
    }
    class Rm {
        -string fileName
        +execute() void
    }
    class Touch {
        -string fileName
        +execute() void
    }
    class Truncate {
        -string fileName
        +execute() void
    }
    class Time {
        -string outputFile
        +execute() void
    }
    class Date {
        -string outputFile
        +execute() void
    }

    ICommand <|-- Echo
    ICommand <|-- Wc
    ICommand <|-- Tr
    ICommand <|-- Head
    ICommand <|-- Batch
    ICommand <|-- Prompt
    ICommand <|-- Rm
    ICommand <|-- Touch
    ICommand <|-- Truncate
    ICommand <|-- Time
    ICommand <|-- Date

    class CmdExecutor {
        <<singleton>>
        -bool pipe
        -string pipeInOut
        +string batchOut
        +create(elements) void
        +getPrevOut() string
        +setPrevOut(value) void
    }

    CmdExecutor ..> ICommand : creates
Loading

How a line is processed

Walking through echo "one two three" | wc -w:

sequenceDiagram
    autonumber
    participant U as User
    participant IH as InputHandler
    participant EH as ErrorHandler
    participant P as Parser
    participant CE as CmdExecutor
    participant E as Echo
    participant W as Wc

    U->>IH: echo "one two three" &#124; wc -w
    IH->>EH: checkSyntax(line)
    EH-->>IH: ok
    IH->>IH: forward(): split on unquoted pipe
    Note over IH: two segments detected

    IH->>CE: setPipe(true)
    IH->>P: accept("echo \"one two three\" ")
    P->>CE: create(slots)
    CE->>E: new Echo(slots)
    E->>E: execute() calls output()
    Note over E,CE: pipe is active, so the text is<br/>stored in pipeInOut instead of printed

    IH->>CE: setPipe(false)
    IH->>P: accept(" wc -w")
    P->>CE: create(slots)
    Note over CE: getPrevOut() is non-empty,<br/>so it is inserted at slot 4
    CE->>W: new Wc(slots)
    W->>W: execute() counts words
    W-->>U: 3
Loading

Step by step:

  1. main() loops while ErrorHandler::getLoop() is true, catching any BaseException that escapes a command.
  2. InputHandler::listen() prints the prompt and reads one line. Empty lines are ignored.
  3. ErrorHandler::checkSyntax() scans the raw line character by character, tracking quote state, and collects the positions of every illegal character. If any are found it throws LexicalError, whose message renders the original line with ^ markers underneath.
  4. InputHandler::forward() splits the line on pipe characters that are outside quotes, calling the parser once per segment. Every segment except the last runs with the pipe flag set.
  5. Parser::accept() tokenizes a segment into the slot vector, handling quoted strings, -options, < in, > out and >> out. Assigning the same slot twice throws InvalidElement.
  6. CmdExecutor::create() looks the command name up in its map. Before construction it injects the piped input from the previous stage, and the contents of any < redirect, into slot 4. An unknown name throws CommandNotFound.
  7. The command constructor validates its slots and arity, resolves a bare filename argument into file contents, and falls back to reading stdin when no argument was supplied.
  8. execute() does the work and calls the shared output() helper.
  9. output() routes the result: into the pipe buffer if a pipe is active, into a file if slot 3 is set, into the active batch output file if one is set, otherwise to std::cout.

Command reference

Legend: opt = accepts an -option, in = accepts <, out = accepts > / >>.

Command Options opt in out Args Description
echo no yes yes 1 Print a string, or the contents of a file
prompt no no no 1 Change the shell prompt
time no no yes 0 Current time as HH:MM:SS
date no no yes 0 Current date as YYYY-MM-DD
touch no no no 1 Create an empty file, fails if it exists
truncate no no no 1 Empty a file's contents (see limitations)
rm no no no 1 Delete a file
wc -w, -c yes yes yes 1 Count words (-w) or characters (-c)
tr no yes yes 2 to 3 Regex find and replace
head -N yes yes yes 1 First N lines of the input
batch no yes yes 1 Execute a file or block of commands

Examples

# echo: literal text, file contents, or piped input
$ echo "hello world"
hello world
$ echo data.txt              # prints the file
$ echo < data.txt            # same, via input redirection

# prompt: change the shell prompt
$ prompt "mysh>"
mysh>

# time and date
$ time
22:02:27
$ date
2026-08-12

# wc: count words or characters
$ wc -w "one two three"
3
$ wc -c "abcd"
4

# head: first N lines
$ head -2 data.txt
line one
line two

# tr: regex replace (3 args) or delete (2 args)
$ tr "hello world" "o" "0"
hell0 w0rld
$ tr "hello world" "l+" "L"
heLo worLd
$ tr "hello world" "o"
hell wrld

# file management
$ touch made.txt
$ rm made.txt

# batch: run a script file
$ batch script.txt
batched one
batched two

# batch with a collected output file
$ batch script.txt > log.txt

tr uses std::regex with the default ECMAScript grammar, so the pattern argument supports full regular expression syntax. Both the pattern and the replacement must be quoted.

Reading from stdin

echo, head, wc, tr and batch fall back to InputHandler::awaitArg() when no argument is supplied. This reads stdin until EOF, so terminate the input with Ctrl+D:

$ echo
these lines are
collected until EOF
^D
these lines are
collected until EOF

Redirection and pipes

Output redirection

$ echo "redirected" > out.txt     # overwrite
$ echo "appended" >> out.txt      # append

Redirected output is written verbatim. The trailing newline is only added when printing to the console, so out.txt above contains redirectedappended.

Input redirection

< file loads the file's contents into the argument slot before the command is constructed:

$ echo < data.txt
$ wc -w < data.txt

Pipes

$ echo "piped text" | wc -w
2

InputHandler::forward() splits on every | that is not inside quotes. For each segment except the last, CmdExecutor sets its pipe flag, which makes output() store the result in the internal pipeInOut buffer instead of writing it out. The next create() call drains that buffer with getPrevOut() and inserts it as the next command's argument.

Redirecting inside a pipe is rejected with InvalidPipeUsage.

Batch output collection

When batch is given an output redirect, that file becomes the destination for every command in the script, including error messages:

$ batch script.txt > log.txt

CmdExecutor::batchOut holds the filename for the duration of the batch and is cleared afterwards.


Error handling

All errors derive from BaseException, which prefixes every message with Error - . Exceptions propagate out of the command and are caught either by the main() loop or, for commands inside a script, by Batch::execute(), so a single bad line never terminates the shell or aborts a batch.

classDiagram
    class exception {
        <<std>>
    }
    class BaseException {
        -string message
        +what() const char*
    }
    class InvalidElement
    class InvalidArguments
    class UnknownOption
    class CommandNotFound
    class LexicalError {
        -string commandLine
        -vector~int~ positions
        -formatMessage() string
    }
    class InvalidPipeUsage
    class OutputInPipe
    class InputInPipe
    class FileException {
        -string path
    }
    class FileNotFound
    class FileAlreadyExists
    class FileCreationFailed
    class FileDeletionFailed

    exception <|-- BaseException
    BaseException <|-- InvalidElement
    BaseException <|-- InvalidArguments
    BaseException <|-- UnknownOption
    BaseException <|-- CommandNotFound
    BaseException <|-- LexicalError
    BaseException <|-- InvalidPipeUsage
    InvalidPipeUsage <|-- OutputInPipe
    InvalidPipeUsage <|-- InputInPipe
    BaseException <|-- FileException
    FileException <|-- FileNotFound
    FileException <|-- FileAlreadyExists
    FileException <|-- FileCreationFailed
    FileException <|-- FileDeletionFailed
Loading

Error messages in practice

$ foobar "x"
Error - Command not found:foobar

$ echo "a" & "b"
Error - Lexical error:
echo "a" & "b"
         ^

$ wc -x "abc"
Error - Unknown option:-x

$ echo "a" "b"
Error - Command echo accepts maximum 1 argument(s)

$ rm nosuchfile.txt
Error - Error manipulating file =>File doesnt exist:nosuchfile.txt

LexicalError is the most elaborate: it stores every offending column and rebuilds a caret line underneath the original input, so multiple bad characters are all flagged at once.

Because handleError() routes its message through the same output() helper the commands use, error text honours an active batch output file and lands in the log rather than on the console.


Project layout

minishell-cpp/
├── CMakeLists.txt
├── main.cpp                     REPL loop and top-level exception handling
├── include/
│   ├── ICommand.h               abstract command interface
│   ├── InputHandler.h           prompt, stdin reading, pipe splitting
│   ├── Parser.h                 tokenizer
│   ├── CmdExecutor.h            command factory, pipe buffer, batch output
│   ├── ErrorHandler.h           lexical check, slot check, error routing
│   ├── ErrorTypes.h             exception hierarchy
│   ├── FileManipulation.h       filesystem helpers
│   ├── Functions.h              shared helpers (output, quoting, char classes)
│   ├── AllCommands.h            aggregate include
│   └── Commands/                one header per command
│       ├── Echo.h    Prompt.h   Time.h     Date.h
│       ├── Touch.h   Truncate.h Rm.h       Wc.h
│       └── Tr.h      Head.h     Batch.h
└── src/
    ├── inputHandler.cpp  parser.cpp  cmdexecutor.cpp
    ├── errorhandler.cpp  functions.cpp  fileManipulation.cpp
    └── commands/                one implementation per command

Design notes

  • Singletons (InputHandler, CmdExecutor, ErrorHandler) use the Meyers idiom: a function local static instance returned by Instance(), which is thread-safe initialisation in C++11 and later.
  • Command pattern keeps CmdExecutor free of per-command logic. Adding a command means adding a class, an entry in the name map, and its validElements contract.
  • Static contract vectors put each command's slot and arity rules next to its implementation rather than in a central validation function.
  • Shared output() is the single exit point for all text, which is what makes pipes, file redirection and batch log collection work uniformly for commands and error messages alike.

Known limitations

These are current behaviours of the code, listed so they are not mistaken for bugs in your environment:

  1. No exit command. ErrorHandler::switchLoop() exists but is never called, and nothing maps to exit or quit. On EOF the loop clears the stream state and keeps going, so the shell spins printing prompts forever. Use Ctrl+C.
  2. truncate does nothing. Its constructor reads the filename from slot 2 (the < redirect slot), but its own validElements forbids that slot, so the filename is always empty. The command silently succeeds without touching the target file. It should read slot 4, as rm and touch do.
  3. Lines longer than 512 characters are silently dropped. listen() truncates the string and then skips execution entirely, with no message.
  4. Leading and trailing pipes are not validated. A TODO in InputHandler::forward() marks this: | cmd and cmd | are not rejected.
  5. ICommand has no virtual destructor, which the compiler warns about (-Wdelete-abstract-non-virtual-dtor) when the unique_ptr<ICommand> is destroyed. Harmless here because no command holds heap resources, but it should be declared.
  6. tr with two arguments calls pop_back() on an empty string, which is undefined behaviour. It happens to work with the current standard library, but the two-argument delete form is fragile.
  7. Bare filenames and quoted text are distinguished only by the leading ". A file whose name begins with a quote character cannot be addressed.

About

Unix-style shell interpreter in C++17 with a hand-written lexer, parser, pipes, and I/O redirection.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages