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
- Features
- Build and run
- Command-line syntax
- Architecture
- How a line is processed
- Command reference
- Redirection and pipes
- Error handling
- Project layout
- Known limitations
- 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 | cmd2passes 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
promptcommand
- A C++17 compiler (Clang, GCC or MSVC)
- CMake 3.27 or newer (set in
CMakeLists.txt)
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build./build/minishell-cppThe shell starts with the default prompt $ and reads one command per line.
Note: the interpreter has no
exitcommand. Close it withCtrl+C. See Known limitations.
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.
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
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<ICommand>"]
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
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
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" | 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
Step by step:
main()loops whileErrorHandler::getLoop()is true, catching anyBaseExceptionthat escapes a command.InputHandler::listen()prints the prompt and reads one line. Empty lines are ignored.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 throwsLexicalError, whose message renders the original line with^markers underneath.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.Parser::accept()tokenizes a segment into the slot vector, handling quoted strings,-options,< in,> outand>> out. Assigning the same slot twice throwsInvalidElement.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 throwsCommandNotFound.- 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.
execute()does the work and calls the sharedoutput()helper.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 tostd::cout.
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 |
# 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.txttr 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.
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
$ echo "redirected" > out.txt # overwrite
$ echo "appended" >> out.txt # appendRedirected output is written verbatim. The trailing newline is only added when printing to the
console, so out.txt above contains redirectedappended.
< file loads the file's contents into the argument slot before the command is constructed:
$ echo < data.txt
$ wc -w < data.txt$ echo "piped text" | wc -w
2InputHandler::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.
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.txtCmdExecutor::batchOut holds the filename for the duration of the batch and is cleared afterwards.
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
$ 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.
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
- Singletons (
InputHandler,CmdExecutor,ErrorHandler) use the Meyers idiom: a function localstaticinstance returned byInstance(), which is thread-safe initialisation in C++11 and later. - Command pattern keeps
CmdExecutorfree of per-command logic. Adding a command means adding a class, an entry in the name map, and itsvalidElementscontract. - 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.
These are current behaviours of the code, listed so they are not mistaken for bugs in your environment:
- No exit command.
ErrorHandler::switchLoop()exists but is never called, and nothing maps toexitorquit. On EOF the loop clears the stream state and keeps going, so the shell spins printing prompts forever. UseCtrl+C. truncatedoes nothing. Its constructor reads the filename from slot 2 (the<redirect slot), but its ownvalidElementsforbids that slot, so the filename is always empty. The command silently succeeds without touching the target file. It should read slot 4, asrmandtouchdo.- Lines longer than 512 characters are silently dropped.
listen()truncates the string and then skips execution entirely, with no message. - Leading and trailing pipes are not validated. A
TODOinInputHandler::forward()marks this:| cmdandcmd |are not rejected. ICommandhas no virtual destructor, which the compiler warns about (-Wdelete-abstract-non-virtual-dtor) when theunique_ptr<ICommand>is destroyed. Harmless here because no command holds heap resources, but it should be declared.trwith two arguments callspop_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.- Bare filenames and quoted text are distinguished only by the leading
". A file whose name begins with a quote character cannot be addressed.