- Acknowledgements
- Setting Up, Getting Started
- Design
- Implementation
- Shell Parsing and Execution
- Command Execution with Piping and Redirection
- Alias Resolution and Variable Expansion
- Glob Expansion
- Command Suggestion ("Did you mean?")
- VFS Environment Persistence
- Exam Session Flow
- Exam Component — Practical Questions
- Question Parsing and Loading
- Resource Extraction on First Run
- Appendix A: Product Scope
- Appendix B: User Stories
- Appendix C: Non-Functional Requirements
- Appendix D: Glossary
- Appendix E: Instructions for Manual Testing
- AddressBook-Level3 (AB3) — Project structure and Developer Guide format adapted from SE-EDU.
- PlantUML — Used for UML diagram generation.
- Gradle Shadow Plugin — Used for building fat JARs.
- JLine 3 — Used for tab-completion and command history in the interactive shell.
Prerequisites:
- JDK 17 or above.
- Gradle 7.x (wrapper included — use
./gradlew).
Steps:
-
Fork this repo, then clone the fork onto your computer.
-
Configure your IDE (IntelliJ IDEA is recommended):
- Ensure IntelliJ is set to use JDK 17:
File → Project Structure → Project SDK. - Import the project as a Gradle project
- Ensure IntelliJ is set to use JDK 17:
-
Building the project:
./gradlew buildRunning the application:
./gradlew runRunning tests:
./gradlew testThe Architecture Diagram below gives a high-level overview of LinuxLingo.
Main components of the architecture:
LinuxLingo (the main class) is in charge of app launch. At startup, it:
- Extracts bundled question bank resources to disk (via
ResourceExtractor). - Loads the
QuestionBankfromdata/questions/. - Creates the shared
VirtualFileSystem,ShellSession, andExamSession. - Delegates to
MainParserfor interactive mode, or handles one-shot CLI commands directly.
The bulk of the app's work is done by the following components:
| Component | Responsibility |
|---|---|
| CLI | Handles user I/O (Ui) and top-level command dispatch (MainParser). |
| Shell | Parses and executes shell commands in a simulated Linux environment. |
| Exam | Manages exam sessions — question presentation, answer checking, scoring. |
| Storage | Reads/writes data on the real file system (question banks, VFS snapshots). |
| VFS | In-memory virtual file system that all shell commands operate on. |
How the components interact with each other:
The following sequence diagram shows the interactions when the user launches the app in interactive mode and types shell:
The CLI component consists of two classes: Ui and MainParser.
Ui is the single point of contact for all user-facing I/O. It wraps Scanner for input and PrintStream for output and error streams. All components use Ui instead of directly calling System.in/System.out/System.err, making testing easier (injectable streams).
MainParser implements the top-level REPL loop. It reads user input and dispatches to one of: shell (enter Shell Simulator), exam (start an exam), exec (one-shot shell command), help, or exit/quit.
The Shell component handles command parsing, execution, and the interactive REPL. It is the largest component in LinuxLingo.
The following class diagram shows the key classes. For clarity, only representative command implementations are shown; the full set of 35 commands follows the same Command interface.
The Shell component:
- Uses
ShellParserto tokenize and parse raw input into aParsedPlan(a list ofSegmentobjects connected by operators). ShellSessioniterates through segments, looking up each command name inCommandRegistry, executing them, and chaining results via pipes,&&,||, or;.- Before command lookup,
ShellSessionresolves aliases, expands combined flags (e.g.,-la→-l -a), expands glob patterns against the VFS, and expands shell variables ($USER,$HOME,$PWD). ShellLineReaderandShellCompleterprovide JLine-based tab-completion and command history support for the interactive shell.- Each
Commandimplementation receives the currentShellSession(for VFS access and session state), parsed arguments, and optional piped stdin. It returns aCommandResultcontaining stdout, stderr, and an exit code.
Supported commands (35 total, grouped below by user-facing function):
| Category | Commands |
|---|---|
| Navigation | cd, ls, pwd |
| File Operations | mkdir, touch, rm, cp, mv, cat, echo, diff, tee |
| Text Processing | head, tail, grep, find, wc, sort, uniq |
| Permissions | chmod |
| Information | man, tree, which, whoami, date |
| Alias & History | alias, unalias, history |
| Environment | save, load, reset, envlist, envdelete |
| Utility | help, clear |
The Exam component handles question presentation, answer checking, and score tracking.
The Exam component:
QuestionBankloads question data files fromdata/questions/(viaQuestionParser) and organizes them by topic.ExamSessionorchestrates exam sessions with three entry points: interactive mode, direct CLI args, and single-random-question mode.QuestionInteractionhandles all user interaction for non-PRAC questions: printing the question, reading the answer, showing feedback, and recording the outcome.RandomQuestionModehandles the single-random-question mode: picks one random question from any topic and presents it.- Three question types are supported:
McqQuestion(multiple choice),FitbQuestion(fill in the blank), andPracQuestion(practical — verified by checking VFS state againstCheckpointobjects). ExamResulttracks per-question outcomes and computes scores.
The Storage component handles all real disk I/O operations.
The Storage component:
Storageprovides static utility methods for file read/write, directory creation, and path management. All persistent data lives underdata/.VfsSerializerconverts VFS snapshots to/from a custom.envtext format, enabling users to save and load shell environments.QuestionParserparses.txtquestion bank files intoQuestionobjects using a pipe-delimited format.ResourceExtractorcopies bundled question bank files from the JAR todata/questions/on first run.StorageExceptionis a checked exception thrown when a storage I/O operation fails, used byStorage,VfsSerializer, and other components.
The VFS component provides an in-memory simulated Linux file system.
Key design decisions for VFS:
- All path operations go through
VirtualFileSystem— shell commands never usejava.ioorjava.nio.filefor simulated files. This ensures a fully isolated, deterministic simulation. - The VFS uses a tree structure of
FileNodeobjects.Directoryholds aLinkedHashMapof children for ordered, O(1) lookup by name. Permissionmodels Unix 9-character permission strings (rwxr-xr-x), supporting both octal and symbolic notation forchmod.deepCopy()is provided at every level (VFS, Directory, RegularFile) to enable snapshot-based features (e.g., creating a temp VFS for PRAC exam questions, saving environments).- The default VFS tree contains
/home/user,/tmp, and/etc(with ahostnamefile).
The following object diagram illustrates a concrete VFS state after the user has created a file hello.txt under /home/user:
This section describes some noteworthy details on how certain features are implemented.
The shell parsing pipeline transforms the raw user input string like echo hello | grep h > out.txt into a structured execution plan (ParsedPlan) that the execution engine can act on. Understanding this pipeline is essential before working on any feature that involves parsing or command execution.
The ShellParser.parse() method runs the input through two stages: tokenization, then plan building
The tokenizer reads the input one character at a time using a state machine with three states:
| State | Behaviour |
|---|---|
NORMAL |
Accumulates characters into tokens; whitespace flushes the current token. Special characters (|, >, >>, &&, ;) produce operator tokens. Quote characters switch state. |
IN_SINGLE_QUOTE |
All characters are literal until the closing ' |
IN_DOUBLE_QUOTE |
All characters are literal until the closing " |
After tokenization, the token list is split into Segment objects at inter-segment operators (PIPE, AND, SEMICOLON , OR).
Within each segment, REDIRECT / APPEND / INPUT_REDIRECT tokens consume the next WORD token as the redirect target file.
The parser handles two lookahead cases during tokenization in NORMAL state: || and >> are
distinguished from | and > by peeking at the next character before emitting a token.
A lone & character (not followed by another &) is treated as a literal word character
rather than an operator, which matches standard shell behaviour.
Note: Single-quoted tokens (e.g.
'*.txt') are marked with a\0prefix by the tokenizer (SINGLE_QUOTE_MARKER). This flag is checked byexpandGlobs()andexpandVariables()to suppress expansion, matching standard shell behaviour. The marker is stripped before the argument reaches the command.
Once the flat token list is produced, buildPlan() walks through it and groups tokens into Segment objects. Each Segment holds a command name, its arguments, and optional redirect information. Operator tokens (PIPE, AND, SEMICOLON, OR) act as delimiters between segments and are recorded separately in the operators list.
The parser maintains an Expecting flag to handle redirect targets: when a REDIRECT or APPEND token is seen, the very next WORD token is consumed as the redirect file path rather than as a command argument. The same pattern applies to INPUT_REDIRECT (<).
The result is a ParsedPlan with the following invariant, enforced by an assertion:
operators.size()is always exactlysegments.size() - 1
This means a plan with three segments always has exactly two operators connecting them, making the execution engine's iteration straightforward.
Class Diagram
Object Diagram (example instance)
All parsing ultimately feeds into runPlan(), the core method that iterates the ParsedPlan and chains commands together. It is worth understanding its structure because most enhancements to the shell (new operators, alias resolution, input redirect) are implemented here.
The engine tracks two pieces of state across iterations: pipedStdin (the stdout of the previous command, forwarded when the operator was PIPE) and lastExitCode (used to evaluate && and || conditions). The loop processes one Segment per iteration:
runPlan() Sequence Diagram:
Exit propagation: The shouldExit() flag on CommandResult causes runPlan() to set
running = false and break the loop immediately.
Stage 1 (Tokenization): produces the flat token list:
| # | Value | Type |
|---|---|---|
| 1 | echo |
WORD |
| 2 | hello |
WORD |
| 3 | | |
PIPE |
| 4 | grep |
WORD |
| 5 | h |
WORD |
| 6 | > |
REDIRECT |
| 7 | output.txt |
WORD |
Stage 2 (Plan building): splits on the PIPE and consumes output.txt
as a redirect target, yielding a ParsedPlan with:
- Segment 1:
echo [hello], no redirect - Segment 2:
grep [h], redirect> output.txt - Operator:
[PIPE]
Stage 3 (Execution): (runPlan()): the sequence diagram below shows
how ShellSession orchestrates the two segments. Key points to note:
echo's stdout ("hello") is forwarded aspipedStdintogrep- After
grepexecutes, its stdout is written tooutput.txtvia the VFS rather than printed to the terminal (the result is replaced with an empty success)
Operator semantics:
| Operator | Symbol | Behavior |
|---|---|---|
PIPE |
| | stdout of segment N becomes stdin of segment N+1 |
AND |
&& |
Segment N+1 runs only if segment N succeeded (exit code 0) |
OR |
|| |
Segment N+1 runs only if segment N failed (exit code ≠ 0) |
SEMICOLON |
; |
Segment N+1 always runs regardless of exit code |
All 35 commands follow the same implementation pattern:
- Parse flags and arguments from
args[]. - Determine input source: file arguments take priority over piped
stdin. - Call VFS methods on
session.getVfs(). - Catch
VfsException→ returnCommandResult.error(...). - Return
CommandResult.success(output).
The following activity diagram shows the input resolution logic for commands that support both file arguments and piped stdin (e.g., cat, head, tail, grep, sort, uniq, wc):
Input redirection (<) is handled by the execution engine before command execution: if a segment has an inputRedirect file, the engine reads that file's content from the VFS and passes it as stdin to the command.
Aliases allow users to define shortcuts for commonly used commands (e.g., alias ll='ls -la'). The alias system is implemented in ShellSession:
- Aliases are stored in a
LinkedHashMap<String, String>withinShellSession. - Before each command lookup,
resolveAlias()repeatedly resolves the command name through the alias map. A visited set prevents infinite loops from circular alias definitions. - Aliases persist only within the current shell session and are not saved across restarts.
Variable expansion supports a limited set of shell variables:
| Variable | Value |
|---|---|
$USER |
user |
$HOME |
/home/user |
$PWD |
Current working directory |
$? |
Exit code of the last command |
Variables inside single-quoted strings are not expanded (single-quoted tokens are marked with a \0 prefix during tokenization). The expandVariablesInString() method scans each argument character-by-character, recognises $ followed by an alphanumeric name or ?, and substitutes the resolved value.
Glob patterns (* and ?) in command arguments are expanded against the VFS before the command receives them:
- Detection: Each argument is checked for
*or?characters. Single-quoted tokens (marked with a\0prefix) skip expansion entirely. - Matching: For patterns without a path separator (e.g.,
*.txt), only immediate children of the current directory are matched. For patterns with path separators (e.g.,/home/*.txt),VirtualFileSystem.findByName()searches the specified subtree. - Fallback: If no VFS paths match a glob pattern, the literal pattern is passed through unchanged (standard shell behaviour).
Matching uses VirtualFileSystem.matchesWildcard(), which converts * → .* and ? → . to build a regex.
When a command name is not found in the registry, ShellSession.suggestCommand() computes the Levenshtein edit distance between the mistyped input and every registered command name using dynamic programming. If the closest match has an edit distance ≤ 2, a hint like Did you mean 'ls'? is displayed alongside the error.
Users can save and load VFS snapshots through the save, load, reset, envlist, and envdelete commands. The VfsSerializer class handles serialization and deserialization of VFS state to and from a plain-text .env file format.
The persistence system is split into two responsibilities:
VfsSerializer— converts aVirtualFileSystemobject to/from the.envtext format. It is a pure conversion utility with no I/O side effects; it takes/returnsStringcontent.Storage— handles the actual file I/O (reading from and writing todata/environments/<name>.envon disk). It delegates format conversion toVfsSerializer.
This separation means VfsSerializer can be unit-tested independently of the filesystem, and the storage layer can be swapped without touching serialization logic.
When the user runs save myenv, the following sequence occurs:
SaveCommandcallsStorage.save(name, vfs, workingDir).StoragecallsVfsSerializer.serialize(vfs, workingDir)to get the.envfile content as aString.Storagewrites the string todata/environments/myenv.env.
When the user runs load myenv:
LoadCommandcallsStorage.load(name).Storagereads the file content and callsVfsSerializer.deserialize(content).VfsSerializerreconstructs aVirtualFileSystemand returns it wrapped in aDeserializedVfsrecord (which also carries the saved working directory).LoadCommandreplaces the session's VFS and current directory with the deserialized values.
Save/Load sequence diagram:
Each environment snapshot is a human-readable text file:
# LinuxLingo Virtual File System Snapshot
# Saved: 2026-03-27T14:30:00
# Working Directory: /home/user
#
# Format: TYPE | PATH | PERMISSIONS | CONTENT
DIR | / | rwxr-xr-x
DIR | /home | rwxr-xr-x
FILE | /etc/hostname | rw-r--r-- | linuxlingo
Each non-comment line encodes one VFS node:
| Field | Description |
|---|---|
TYPE |
DIR or FILE |
PATH |
Absolute path within the VFS |
PERMISSIONS |
9-character Unix permission string e.g. rwxr-xr-x |
CONTENT |
File content (omitted for directories); special characters escaped |
Escaping rules for file content:
| Escape sequence | Meaning |
|---|---|
\n |
Newline character |
| |
Literal pipe character (since ` |
\\ |
Literal backslash |
VfsSerializer.serialize() performs a depth-first walk of the VFS tree (parents before children) using a recursive helper. For each node it emits a TYPE | PATH | PERMISSIONS [| CONTENT] line. File content is passed through escapeContent() before writing to ensure the pipe delimiter is never ambiguous.
VfsSerializer.deserialize() reads the file line by line:
- Lines starting with
#are treated as comments and skipped. - Each remaining line is split on
|(space-pipe-space) into at most 4 fields. - The first field determines node type (
DIRorFILE). - A
DirectoryorRegularFileis created at the given path with the given permissions. - For
FILEnodes, the optional fourth field (content) is unescaped viaunescapeContent()before being stored.
If a line is malformed (wrong number of fields, unrecognised type), it is silently skipped to allow forward compatibility with future format extensions.
Why a plain-text format instead of JSON/binary?
A human-readable format means users can inspect and hand-edit saved environments in a text editor without needing special tools. It also makes the format trivially diffable in version control.
Why is VfsSerializer stateless?
All methods are static. There is no mutable state, which makes the class thread-safe and easy to test: pass in a VirtualFileSystem, get back a String; pass in a String, get back a DeserializedVfs. No setup or teardown required.
Alternative considered — serialising to JSON:
Using a JSON library (e.g., Gson) would have reduced boilerplate but added a dependency and produced a less human-readable output. Given the simple, fixed schema of the VFS, a bespoke text format was preferred.
LinuxLingo's interactive shell provides tab completion for command names and VFS paths, as well as persistent command history (up/down arrow keys). This is implemented using the JLine 3 library via two classes: ShellLineReader and ShellCompleter.
| Class | Responsibility |
|---|---|
ShellLineReader |
Wraps JLine's LineReader; manages the terminal lifecycle, history, and graceful fallback to a dumb terminal |
ShellCompleter |
Implements JLine's Completer interface; supplies command-name and VFS-path candidates |
ShellSession creates a ShellLineReader at startup and calls readLine(prompt) on each iteration of the REPL loop. ShellLineReader creates a ShellCompleter internally and registers it with JLine so that pressing Tab triggers completion automatically.
ShellCompleter.complete() is called by JLine each time the user presses Tab. It inspects the parsed input line to decide which kind of completion to offer:
- Word index 0 (command position): delegates to
completeCommandName(), which iteratesCommandRegistry.getAllNames()and all defined aliases, returning entries that start with the typed prefix. - Word index > 0 (argument position): delegates to
completePath(), which resolves the partial path against the VFS to return matching file and directory names.
Path completion detail (completePath):
The partial path typed by the user is split at the last / into a directory part and a name prefix:
| Partial input | Directory part | Name prefix |
|---|---|---|
ho |
. (cwd) |
ho |
/home/u |
/home |
u |
/ |
/ |
`` (empty) |
VirtualFileSystem.listDirectory() is called on the directory part, and only children whose names start with the name prefix are included. Hidden files (names starting with .) are suppressed unless the user's prefix itself starts with ., matching standard shell behaviour. Directories have a trailing / appended to their candidate value so that pressing Tab on a directory name immediately positions the cursor for the next path component.
ShellLineReader configures JLine with a DefaultHistory instance. History is in-memory only for the current session (not persisted to disk). Users navigate history with the up/down arrow keys. The getHistory() and addToHistory() methods on ShellLineReader are exposed for unit testing without a real terminal.
ShellLineReader.create() first attempts to open a system terminal via TerminalBuilder. If that fails (e.g., in a CI environment or when stdin is not a TTY), it falls back to ShellLineReader.createDumb(), which creates a dumb terminal. In dumb mode, tab completion and arrow-key history are unavailable, but the shell still accepts plain line input via standard Scanner-style reading, ensuring one-shot mode and testing remain unaffected.
The following diagram shows what happens when a user presses Tab mid-input:
User ShellLineReader JLine LineReader ShellCompleter VFS
| [Tab key] | | | |
|----------------> | | | |
| | readLine(prompt) | | |
| |-------------------> | | |
| | | complete(line) | |
| | | -----------------> | |
| | | | listDirectory() |
| | | | --------------> |
| | | | <-------------- |
| | | [candidates] | |
| | | <----------------- | |
| [completions displayed] | | |
| <----------------- | | |
Why JLine instead of a custom implementation?
JLine handles terminal raw-mode switching, ANSI escape sequences, cross-platform terminal detection, and history navigation — hundreds of edge cases that would be prohibitively expensive to replicate correctly. Using JLine lets the implementation focus on the completion logic specific to LinuxLingo (VFS paths, aliases) rather than terminal I/O plumbing.
Why split ShellLineReader and ShellCompleter?
Separating the two classes allows ShellCompleter to be unit-tested independently of JLine's terminal infrastructure using the getCommandCompletions() and getPathCompletions() helper methods, which return plain SortedSet<String> without requiring a real terminal or JLine objects.
Alternative considered — reading from System.in directly:
A plain BufferedReader on System.in was used in early iterations. It was replaced by JLine because it provided no completion or history, making the shell significantly less usable. The fallback dumb-terminal path in ShellLineReader preserves this simpler behaviour for non-interactive contexts.
The exam module supports three entry modes and three question types.
Interactive exam flow:
During an exam run, the user can control the session without exiting the whole application:
quit— skip the current (non-PRAC) question and continue to the next.abort— terminate the exam immediately (partial score is shown for attempted questions).
These commands are interpreted centrally by QuestionInteraction, not by individual Question subclasses. This keeps question types focused on domain logic (present() / checkAnswer(...)) while the exam UX policy lives in the interaction layer.
Where the logic lives:
ExamSession.runExam(...)owns the question loop.QuestionInteraction.presentQuestionWithResult(...)reads the raw user input and applies the skip/abort policy before grading.ExamResultaccumulates outcomes and can represent a partial run naturally.
Command handling rules (non-PRAC questions):
- If the user types
abort(case-insensitive),QuestionInteractionsets an internal abort flag and signalsExamSessionto stop iterating. - If the user types
quit(case-insensitive) or input isnull, the question is recorded as skipped (stored with empty answer and counted as incorrect), and the exam continues. - Otherwise, the answer is passed to
Question.checkAnswer(...)and the result is recorded.
This design prevents control-flow logic from being duplicated across modes (interactive vs direct vs random), since the same QuestionInteraction logic is reused.
The following sequence diagram illustrates how quit and abort are handled for non-PRAC questions during an exam run:
PRAC question handling:
For practical questions, ExamSession.handlePracQuestion():
- Creates a fresh
VirtualFileSystemvia thevfsFactorysupplier. - Creates a temporary
ShellSessionwith this VFS. - Calls
tempSession.start()— the user types shell commands. - When the user types
exit, the temporary session ends. - Calls
PracQuestion.checkVfs(tempVfs)which verifies eachCheckpoint(expected path + node type, content, or permissions).
This section documents the configurable VFS setup and richer checkpoint validation features for practical (PRAC) questions. The goal is to support more realistic, multi-step shell scenarios while keeping the public Exam API stable for the rest of the system.
The feature is implemented via two extensions:
PracQuestion.SetupItem— declarative VFS setup instructions applied before the user starts typing commands.- Extended
Checkpointsemantics — new node checks (NOT_EXISTS,CONTENT_EQUALS,PERM) to validate more aspects of the final VFS state.
These changes are internal to the exam module and transparent to callers such as ExamSession and QuestionBank.
At a high level, the flow for a PRAC question becomes:
ExamSessioncreates a freshVirtualFileSystemviavfsFactory.ExamSessionstarts a temporaryShellSessionover the fresh VFS and lets the user perform the task.- When the user exits the shell,
PracQuestion.checkVfs()verifies allCheckpoints against the final VFS state.
The feature is implemented by extending the existing PracQuestion and Checkpoint classes; no new top-level types are introduced.
Key classes (Exam sub-package):
-
PracQuestion(inlinuxlingo.exam.question)- Represents a practical question, with:
List<Checkpoint> checkpointsList<SetupItem> setupItems
applySetup(VirtualFileSystem vfs)is fully implemented usingsetupItems.
- Represents a practical question, with:
-
PracQuestion.SetupItem(inner static class)- Declared with:
SetupTypeenum:MKDIR,FILE,PERM- Fields:
type,path,value
- Concrete semantics for each
SetupTypeare implemented inapplySetup.
- Declared with:
-
Checkpoint(inlinuxlingo.exam)- Exposes
matches(VirtualFileSystem vfs)andNodeTypeenum. - Fully supports all
NodeTypes:DIR— assert that a directory exists.FILE— assert that a file exists.NOT_EXISTS— assert that a path is absent.CONTENT_EQUALS— assert file content equality.PERM— assert file/dir permissions.
- Exposes
-
ExamSession(inlinuxlingo.exam)- Orchestrates
PRACquestions viahandlePracQuestion(PracQuestion q). - Creates a fresh VFS, calls
q.applySetup(tempVfs)to prepare the environment, starts a temp shell, then checks the final VFS (q.checkVfs(tempVfs)).
- Orchestrates
This keeps the public interface between modules unchanged: the CLI, Shell, and Storage components continue to treat the Exam module as a black box that exposes ExamSession and QuestionBank only.
Why introduce SetupItem?
- Many realistic shell exercises require a non-trivial starting state (pre-existing directories, files, permissions).
- Encoding the initial environment directly in
VirtualFileSystemfrom outside the exam module would tightly couple Exam to VFS internals. SetupItemprovides a small, declarative DSL for “what the environment should look like before the user starts”, which is:- Easy to generate from question bank text via
QuestionParser. - Easy to reason about and test at the
PracQuestionlevel.
- Easy to generate from question bank text via
- Existing
DIRandFILEchecks only validate presence and node kind. - New types enable richer questions:
NOT_EXISTS: “Remove this file/directory.”CONTENT_EQUALS: “Edit a file to contain specific content.”PERM: “Set the permission of this file to 755.”
- This keeps the final-state validation declarative and human-readable in the question bank.
One alternative was to define named “VFS templates” elsewhere (e.g., serialized trees stored by the Storage component), and let PracQuestion refer to them by ID.
- Pros:
- Reuse complex environments across multiple questions.
- Potentially faster to load via
VfsSerializer.
- Cons:
- Introduces a tight coupling between Exam and Storage/VFS serialization.
- Makes individual questions harder to understand without looking up the template definition.
- Reason rejected: For this project scale, per-question declarative setup keeps the exam bank self-contained and easier for new contributors to maintain.
Another option was to provide a dedicated subclass or factory per practical question that mutates the VFS imperatively.
- Pros:
- Maximum flexibility (any arbitrary VFS mutation is possible).
- Cons:
- Scales poorly — new questions require new Java code.
- Blurs separation between content (questions) and logic (exam engine).
- Reason rejected: Conflicts with the existing question-bank-driven design, where content lives in
.txtfiles, not in code.
-
CLI Component
- No changes required. The
examcommand continues to delegate toExamSession, which hides all internal PRAC details.
- No changes required. The
-
Shell Component
- No public API changes;
ShellSessionis still created with aVirtualFileSystemandUi. - Only observable difference is that the initial VFS contents for a
PRACquestion may now be non-empty.
- No public API changes;
-
Storage Component
QuestionParserparses additional fields forPRACquestion setup, constructing the appropriateSetupIteminstances.- No changes are required in
Storageitself.
-
Testing
- Unit tests can construct
PracQuestioninstances with explicitsetupItemsandcheckpoints, then run:applySetup(vfs)- mutate
vfsas if a user had run commands - assert on
checkVfs(vfs)results
- This isolates exam logic from the interactive shell during automated testing.
- Unit tests can construct
This design leaves room for future growth:
- Additional
SetupTypevalues (e.g.,COPY,MOVE) can be added without modifying existing question banks. - Additional
NodeTypevalues can be introduced to validate more sophisticated conditions (e.g., ownership, timestamps). - A future version could allow difficulty-based setup (e.g., more complex initial states for
HARDquestions) by inspectingdifficultywithinapplySetup.
This section explains how question bank files under data/questions/ are converted into in-memory Question objects.
At runtime, question data flows through the pipeline below:
QuestionBank.load(Path directory)enumerates.txtfiles in the questions directory viaStorage.listFiles(...).- For each file,
QuestionParser.parseFile(Path file)reads the file, converts each valid line into a concreteQuestion, and returns aList<Question>. QuestionBankstores the resultingList<Question>under a topic key derived from the filename (e.g.,navigation.txt→"navigation").ExamSessionlater requests questions by topic viaquestionBank.getQuestions(topic, count, random).
Data flow:
Each non-empty, non-comment line in a question bank file is expected to be pipe-delimited with up to 6 fields:
TYPE | DIFFICULTY | QUESTION_TEXT | ANSWER | OPTIONS | EXPLANATION
QuestionParser trims fields and normalises TYPE/DIFFICULTY before constructing the corresponding question object.
| Field | Meaning | Used by |
|---|---|---|
TYPE |
MCQ, FITB, or PRAC |
Dispatch to parseMcq / parseFitb / parsePrac |
DIFFICULTY |
EASY, MEDIUM, HARD |
Stored on Question object; used for display/filtering |
QUESTION_TEXT |
Prompt shown to user | Question.present() |
ANSWER |
Correct answer (format depends on type) | checkAnswer(...) or Checkpoint list |
OPTIONS |
Type-specific extra data | MCQ options / PRAC setup items |
EXPLANATION |
Shown after grading | Question.getExplanation() |
The following diagrams zoom in on the MCQ/FITB-specific parsing and model structure:
- Answer format: a single letter, e.g.
B. - Options format:
A:text B:text C:text D:text. - Parsed into
LinkedHashMap<Character, String>to preserve display order inMcqQuestion.present().
Parsing logic (exact implementation)
MCQ lines are handled by QuestionParser.parseMcq(...).
- Answer validation
- The
ANSWERfield is trimmed. - If the trimmed answer is blank, parsing fails with
IllegalArgumentException("MCQ answer must not be blank").
- The
- Options parsing
- The
OPTIONSfield is trimmed and split using the regex:(?=[A-D]:)
- Each split segment is then trimmed and interpreted as:
letter ':' text
- Only segments where
segment.length() >= 2andsegment.charAt(1) == ':'are accepted. - The option map key is
segment.charAt(0)and the value issegment.substring(2).trim().
- The
- Options validation
- If no valid options are parsed, parsing fails with
IllegalArgumentException("MCQ options must not be empty").
- If no valid options are parsed, parsing fails with
- Correct answer must exist in options
- The correct answer character is taken as
answer.charAt(0). - It is uppercased for the lookup:
Character.toUpperCase(correctAnswer). - If the resulting letter is not a key in the parsed options map, parsing fails with
IllegalArgumentException("MCQ correct answer not found in options").
- The correct answer character is taken as
How invalid MCQ entries are handled
QuestionParser.parseFile(...) catches RuntimeException thrown from parseMcq(...).
If parsing fails, the line is skipped (with a logged warning) and the parser continues loading subsequent lines.
Example line:
MCQ | EASY | Which command prints the current directory? | B | A:cd B:pwd C:ls D:dir | 'pwd' stands for ...
- Answer format: one or more accepted answers.
QuestionParsersplits answers and stores them in aList<String>used byFitbQuestion.checkAnswer(...).- Escaping (notably
\|) is handled so authors can include literal pipes in answer text.
Parsing logic (exact implementation)
FITB lines are handled by QuestionParser.parseFitb(...).
- Splitting into accepted answers
- The raw
ANSWERfield is split using the regex:(?<!\\)\|- i.e., split on
|that is not preceded by a backslash (\).
- The raw
- Trimming and unescaping
- Each split part is processed as:
acceptedAnswer.trim().replace("\\|", "|")
- Empty strings after trimming are ignored.
- Each split part is processed as:
- Accepted-answer list must be non-empty
- If the resulting
List<String>is empty, parsing fails with:IllegalArgumentException("FITB accepted answer list must not be empty").
- If the resulting
Authoring notes (important for validation)
- Accepted answers are compared as an exact string match after trimming.
FitbQuestion.checkAnswer(...)usesacceptedAnswers.contains(answer.trim()).- This means the match is case-sensitive unless the question bank explicitly provides variants (e.g.,
pwd|PWD).
- If an accepted answer itself needs to contain a literal
|character, it must be escaped as\|in the bank.
How invalid FITB entries are handled
As with MCQ, any RuntimeException thrown while parsing a FITB entry causes the line to be skipped (with a logged warning).
This section documents how user input is validated during an exam run. It is separate from question-bank parsing.
The following sequence diagram shows the end-to-end interaction when a user answers an MCQ/FITB question (including reprompts, skip, and abort behavior):
Where it happens
ExamSessiondelegates non-PRAC question attempts toQuestionInteraction.QuestionInteraction.presentQuestionWithResult(...)selects the appropriate input routine based on the runtime type:McqQuestion->askValidatedMcqAnswer(...)FitbQuestion->askValidatedFitbAnswer(...)
MCQ input validation (askValidatedMcqAnswer)
- The question is printed once, then the user is repeatedly prompted for
Your answer:until a valid input is entered. - The following inputs are treated as control commands:
quit(case-insensitive): skip the questionabort(case-insensitive): abort the entire exam
- Otherwise, the input is valid only if:
- after trimming it is exactly one character, and
- that character (uppercased) is in the range A-D.
- A valid MCQ input is normalized to uppercase (i.e., returns
"A","B","C", or"D").
FITB input validation (askValidatedFitbAnswer)
- The question is printed once, then the user is repeatedly prompted for
Your answer:. - The same control commands are supported:
quit(case-insensitive): skip the questionabort(case-insensitive): abort the entire exam
- Otherwise, the input is valid only if it is non-empty after trimming.
- The original user input is returned (not the trimmed version). Grading later trims it again.
Grading logic (checkAnswer)
- MCQ grading is implemented in
McqQuestion.checkAnswer(String):- trims user answer
- requires length 1
- normalizes to uppercase
- checks it is within A-D
- compares it to the stored
correctAnswer
- FITB grading is implemented in
FitbQuestion.checkAnswer(String):- trims user answer
- checks membership in
acceptedAnswers(case-sensitive)
Skip/abort handling
- If the user input is
abort,QuestionInteractionsets an internalexamAbortedflag, printsExam aborted., and stops the run. - If the user input is
quitor the UI returnsnull, the question is considered skipped:ExamResult.addResult(question, "", false)is recorded (empty stored answer, incorrect)
Example line:
FITB | EASY | To print the current directory: ___ | pwd|PWD | | pwd prints the current working directory.
- Answer format: comma-separated checkpoints, each describing an expected VFS state.
- Each checkpoint becomes a
Checkpointobject used byPracQuestion.checkVfs(vfs).
Checkpoint examples (illustrative):
- Existence/type:
/home/project:DIR,/home/readme.txt:FILE - Non-existence:
/tmp/junk:NOT_EXISTS - Content:
/home/readme.txt:CONTENT_EQUALS=hello - Permission:
/home/secret.txt:PERM=rwxr-x---
PRAC also supports an optional setup list in the OPTIONS field (semicolon-separated), which is parsed into PracQuestion.SetupItem objects for PracQuestion.applySetup(vfs).
QuestionParser is intentionally forgiving:
- Malformed lines are skipped (with a logged warning) rather than failing the entire file.
- Invalid difficulty values are normalised (defaulting to a safe value) so contributors can iterate quickly on content without breaking the app.
QuestionBank.load(...)skips empty question banks and continues loading other files so one bad topic does not take down the full exam feature.
This matches the project’s goal of allowing question banks to be edited by non-developers without requiring perfect formatting.
Why a pipe-delimited text format (instead of JSON/YAML)?
- Easy to edit in any text editor.
- Diff-friendly in version control.
- Minimal syntax overhead for simple question banks.
Alternatives considered:
- Embedding questions in Java code: rejected because it couples content to implementation and makes non-developer contributions difficult.
- Using JSON/YAML: rejected due to added verbosity and higher risk of syntax errors for casual contributors.
- A more complex DSL: rejected because it increases parser complexity and maintenance cost.
ResourceExtractor ensures that bundled question bank files are available on disk.
This design respects user customizations — once the questions directory exists, bundled resources are never overwritten. The five bundled question files cover: file-management, navigation, permissions, piping-redirection, and text-processing.
- Computer Science students learning Linux command-line basics.
- Prefer an interactive, hands-on approach over reading documentation.
- Comfortable typing commands in a terminal-like interface.
- Want a safe sandbox environment to practice Linux commands without affecting real systems.
- Desire immediate feedback on their Linux command knowledge through quizzes.
LinuxLingo provides an interactive Linux shell simulator combined with a quiz system, allowing students to:
- Practice Linux commands (navigation, file operations, text processing, permissions) in a safe virtual file system.
- Test their knowledge through multiple question types (MCQ, fill-in-the-blank, practical).
- Save and restore VFS environments for continued practice.
- Learn without needing access to a real Linux machine.
| Priority | As a … | I want to … | So that I can … |
|---|---|---|---|
*** |
new user | see a help menu | learn what commands are available |
*** |
student | practice basic navigation commands (cd, ls, pwd) | become familiar with Linux file system navigation |
*** |
student | create and manipulate files and directories | learn file management in Linux |
*** |
student | take an exam on a specific topic | test my knowledge of Linux commands |
*** |
student | see my exam score after completing a quiz | know how well I understand the material |
** |
student | use piping to chain commands | understand how data flows between commands |
** |
student | use output redirection (>, >>) | learn how to save command output to files |
** |
student | use input redirection (<) | learn how to feed file content into commands |
** |
student | save my VFS environment | continue practicing from where I left off |
** |
student | load a previously saved environment | restore my practice workspace |
** |
student | practice practical questions in a real shell | apply my knowledge hands-on |
** |
student | use text processing commands (grep, sort, wc) | learn data manipulation on the command line |
** |
student | change file permissions with chmod | understand Linux permission model |
** |
student | define command aliases | create shortcuts for frequently used commands |
** |
student | view my command history | recall and re-use previous commands |
** |
student | use glob patterns (*.txt) | match multiple files at once |
** |
student | use conditional execution (&&, ||) | understand command chaining logic |
** |
student | use shell variables ($USER, $HOME, $PWD) | understand how variables work in a shell |
** |
student | execute a single shell command without entering the shell simulator | quickly test or demonstrate one command |
* |
student | take a random question | get a quick knowledge check |
* |
student | list and delete saved environments | manage my saved workspaces |
* |
student | abort an exam that I do not want to continue | return to the main menu without finishing every question |
* |
student | read manual pages with man |
learn about a command's usage |
* |
student | see a directory tree with tree |
visualize the file system structure |
* |
student | get "Did you mean?" suggestions on typos | quickly correct mistyped commands |
* |
student | use tab-completion in the shell | type commands more efficiently |
* |
student | compare files with diff |
learn to find differences between files |
* |
student | use tee to save and display output |
understand pipeline data capture |
- Portability: Should work on any mainstream OS (Windows, Linux, macOS) with Java 17 or above installed.
- Performance: All shell commands should execute in under 100ms. VFS operations should handle file systems with up to 1000 nodes without noticeable lag.
- Usability: A user familiar with basic Linux commands should be able to complete the core shell workflow (enter shell, navigate directories, create a file, and inspect output) by following the User Guide.
- Reliability: The application should handle all invalid inputs gracefully (no crashes) and provide descriptive error messages.
- Testability: Core parsing, command execution, exam logic, and VFS logic should remain testable in isolation through unit tests.
- Data Integrity: VFS snapshots saved to disk must be losslessly restorable. Escaping rules must preserve file content containing newlines, pipes, and backslashes.
- Safety/Isolation: Shell commands must operate only on the in-memory VFS and must not create, modify, or delete the user's real files.
| Term | Definition |
|---|---|
| VFS | Virtual File System — an in-memory tree structure simulating a Linux file system. No real files on disk are created or modified by shell commands. |
| Shell Session | An interactive REPL where users type Linux-like commands that operate on the VFS. |
| Exam Session | A quiz session where users answer questions about Linux commands. |
| MCQ | Multiple Choice Question — presents options A/B/C/D; user selects one. |
| FITB | Fill In The Blank — user types a free-form answer checked against accepted answers. |
| PRAC | Practical question — user performs tasks in a temporary shell; VFS state is verified against checkpoints. |
| Checkpoint | An expected condition on a VFS path (existence, type, content, or permissions) used to verify PRAC question answers. |
| SetupItem | A declarative VFS initialization instruction (create directory, create file, set permissions) applied before a PRAC question begins. |
| REPL | Read-Eval-Print Loop — an interactive loop that repeatedly reads user input, executes it, and prints the result. |
| Segment | A single command with its arguments and optional redirect info, part of a ParsedPlan. |
| ParsedPlan | The structured result of parsing a shell input: a list of Segments connected by operators. |
| Token | A smallest meaningful unit produced by the shell tokenizer, such as a word, operator, or redirect symbol. |
| Environment (.env) | A text file storing a serialized VFS snapshot and working directory, saved under data/environments/. |
| Piping | Connecting the stdout of one command to the stdin of the next using the pipe character (|). |
| Redirection | Directing command output to a file (>/>>) or reading input from a file (<). |
| Glob | A wildcard pattern (*, ?) used to match multiple file names in the VFS. |
| Levenshtein Distance | A string-edit distance metric used by command suggestion logic to find the closest valid command to a mistyped input. |
| Alias | A user-defined shortcut for a command name, stored in the shell session. |
| Question Bank | A collection of question files (.txt) organized by topic under data/questions/. |
Note: These instructions provide a starting point for testers. Testers are expected to do more exploratory testing.
-
Initial launch
- Ensure Java 17+ is installed.
- Build the project:
./gradlew shadowJar - Run:
java -jar build/libs/LinuxLingo.jar - Expected: Welcome banner and
linuxlingo>prompt are displayed.
-
Help command
- Input:
help - Expected: List of available top-level commands (shell, exam, exec, help, exit) is displayed.
- Input:
-
Exit
- Input:
exit(orquit) - Expected: Prints "Goodbye!" and application terminates.
- Input:
-
Entering the shell
- Input:
shell - Expected: Welcome message and shell prompt
user@linuxlingo:/$are displayed.
- Input:
-
Basic navigation
pwd→/cd /home/user→ prompt changes touser@linuxlingo:/home/user$cd -→ returns to/, prints/cd ~→ navigates to/home/userls -la→ lists files with permissions, types, and sizes
-
File and directory operations
mkdir testdir→ no output (success)mkdir -p a/b/c→ creates nested directoriesls→testdirappears in listingtouch testdir/hello.txt→ no output (success)echo "Hello World" > testdir/hello.txtthencat testdir/hello.txt→Hello Worldcp testdir/hello.txt testdir/copy.txt→ file copiedmv testdir/copy.txt testdir/moved.txt→ file renamedrm -r testdir→ directory and contents removed
-
Piping
echo "line1" | cat→line1echo "apple" | grep apple→apple
-
Output redirection
echo "test output" > /tmp/out.txtthencat /tmp/out.txt→test outputecho "more" >> /tmp/out.txtthencat /tmp/out.txt→test outputfollowed bymore
-
Input redirection
echo "hello" > /tmp/in.txtthencat < /tmp/in.txt→hellogrep hello < /tmp/in.txt→hello
-
Conditional execution
echo success && echo "also runs"→ bothsuccessandalso runsprintedls /nonexistent && echo "no"→ error message only,nois NOT printedls /nonexistent || echo "fallback"→ error message, thenfallbackprintedecho a ; echo b→ bothaandbprinted on separate lines
-
Text processing
echo "hello world" > /tmp/test.txtthengrep hello /tmp/test.txt→hello worldwc /tmp/test.txt→ line, word, and character countssort/uniq/head/tail— verify expected output on multi-line input
-
Permissions
touch /tmp/secret.txtthenchmod 000 /tmp/secret.txtthencat /tmp/secret.txt→Permission deniederror
-
Glob patterns
touch a.txt b.txt c.logthenls *.txt→ listsa.txt b.txtonlyls ?.txt→ listsa.txt b.txt(single-character match)
-
Shell variables
echo $USER→userecho $HOME→/home/userecho $PWD→ current working directory
-
Aliases
alias ll="ls -la"thenll→ same output asls -launalias llthenll→ll: command not found
-
Command history
- Run a few commands, then
history→ numbered list of previous commands history 3→ shows the last 3 commandshistory -c→ clears the command history
- Run a few commands, then
-
Info commands
man ls→ displays usage info forlstree→ displays directory tree from current directorywhich ls grep→ shows that both commands are availablewhoami→ printsuserdate +%Y-%m-%d→ prints the current date in year-month-day format
-
Diff and tee
echo "a" > /tmp/f1.txtandecho "b" > /tmp/f2.txtthendiff /tmp/f1.txt /tmp/f2.txt→ shows differencesecho "hello" | tee /tmp/tee.txt→ printshelloAND writes it to/tmp/tee.txt
-
Command suggestion
mkdi→mkdi: command not foundfollowed byDid you mean 'mkdir'?gre→gre: command not foundfollowed byDid you mean 'grep'?
-
Exiting the shell
exit→ returns tolinuxlingo>prompt
-
Save environment
- Enter shell, create some files/directories.
save myenv→ environment saved messageenvlist→myenvappears in the list
-
Load environment
reset→ VFS reset to default stateload myenv→ previously created files/directories are restored
-
Delete environment
envdelete myenv→ environment deleted messageenvlist→myenvno longer appears
-
Interactive exam
- Input (at main prompt):
exam - Expected: List of topics is displayed with question counts.
- Select a topic by number.
- Enter number of questions (or press Enter for all).
- Expected: Questions are presented one at a time with feedback after each.
- Expected: Final score summary (e.g.,
Score: 7/10 (70%)).
- Input (at main prompt):
-
Direct exam with CLI args
- Input:
exam -t navigation -n 3 - Expected: 3 questions from the "navigation" topic.
- Input:
-
Random question
- Input:
exam -random - Expected: One random question is presented.
- Input:
-
List topics
- Input:
exam -topics - Expected: All available topics listed with question counts.
- Input:
-
PRAC question (if available in question bank)
- When a PRAC question appears, a temporary shell session opens.
- Perform the required task (e.g.,
mkdir /home/project). - Type
exitto submit. - Expected: Feedback on whether the VFS matches the expected state.
-
Basic exec
- Input (at main prompt):
exec "echo hello" - Expected:
hellois printed.
- Input (at main prompt):
-
Exec with saved environment
- First save an environment in the shell (e.g.,
save testenv). - Input:
exec -e testenv "ls" - Expected: Directory listing from the saved environment.
- First save an environment in the shell (e.g.,
-
Unknown command in shell
- Input:
unknowncmd - Expected:
unknowncmd: command not found
- Input:
-
Invalid path
- Input:
cd /nonexistent/path - Expected:
cd: No such file or directory: /nonexistent/path
- Input:
-
Missing operand
- Input:
grep - Expected:
grep: missing pattern
- Input:
-
Invalid exam topic
- Input:
exam -t nonexistent - Expected:
Invalid topic selection.followed by available topics list.
- Input: