Prerequisites:
./gradlew).Steps:
Fork this repo, then clone the fork onto your computer.
File → Project Structure → Project SDK../gradlew build
Running the application:
./gradlew run
Running tests:
./gradlew test
The 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:
ResourceExtractor).QuestionBank from data/questions/.VirtualFileSystem, ShellSession, and ExamSession.MainParser for 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:
ShellParser to tokenize and parse raw input into a ParsedPlan (a list of Segment objects connected by operators).ShellSession iterates through segments, looking up each command name in CommandRegistry, executing them, and chaining results via pipes, &&, ||, or ;.ShellSession resolves aliases, expands combined flags (e.g., -la → -l -a), expands glob patterns against the VFS, and expands shell variables ($USER, $HOME, $PWD).ShellLineReader and ShellCompleter provide JLine-based tab-completion and command history support for the interactive shell.Command implementation receives the current ShellSession (for VFS access and session state), parsed arguments, and optional piped stdin. It returns a CommandResult containing 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:
QuestionBank loads question data files from data/questions/ (via QuestionParser) and organizes them by topic.ExamSession orchestrates exam sessions with three entry points: interactive mode, direct CLI args, and single-random-question mode.QuestionInteraction handles all user interaction for non-PRAC questions: printing the question, reading the answer, showing feedback, and recording the outcome.RandomQuestionMode handles the single-random-question mode: picks one random question from any topic and presents it.McqQuestion (multiple choice), FitbQuestion (fill in the blank), and PracQuestion (practical — verified by checking VFS state against Checkpoint objects).ExamResult tracks per-question outcomes and computes scores.The Storage component handles all real disk I/O operations.
The Storage component:
Storage provides static utility methods for file read/write, directory creation, and path management. All persistent data lives under data/.VfsSerializer converts VFS snapshots to/from a custom .env text format, enabling users to save and load shell environments.QuestionParser parses .txt question bank files into Question objects using a pipe-delimited format.ResourceExtractor copies bundled question bank files from the JAR to data/questions/ on first run.StorageException is a checked exception thrown when a storage I/O operation fails, used by Storage, VfsSerializer, and other components.The VFS component provides an in-memory simulated Linux file system.
Key design decisions for VFS:
VirtualFileSystem — shell commands never use java.io or java.nio.file for simulated files. This ensures a fully isolated, deterministic simulation.FileNode objects. Directory holds a LinkedHashMap of children for ordered, O(1) lookup by name.Permission models Unix 9-character permission strings (rwxr-xr-x), supporting both octal and symbolic notation for chmod.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)./home/user, /tmp, and /etc (with a hostname file).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)
ShellSession.runPlan())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.
echo hello | grep h > output.txtStage 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:
echo [hello], no redirectgrep [h], redirect > output.txt[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 as pipedStdin to grepgrep executes, its stdout is written to output.txt via 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:
args[].stdin.session.getVfs().VfsException → return CommandResult.error(...).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:
LinkedHashMap<String, String> within ShellSession.resolveAlias() repeatedly resolves the command name through the alias map. A visited set prevents infinite loops from circular alias definitions.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:
* or ? characters. Single-quoted tokens (marked with a \0 prefix) skip expansion entirely.*.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.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 a VirtualFileSystem object to/from the .env text format. It is a pure conversion utility with no I/O side effects; it takes/returns String content.Storage — handles the actual file I/O (reading from and writing to data/environments/<name>.env on disk). It delegates format conversion to VfsSerializer.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:
SaveCommand calls Storage.save(name, vfs, workingDir).Storage calls VfsSerializer.serialize(vfs, workingDir) to get the .env file content as a String.Storage writes the string to data/environments/myenv.env.When the user runs load myenv:
LoadCommand calls Storage.load(name).Storage reads the file content and calls VfsSerializer.deserialize(content).VfsSerializer reconstructs a VirtualFileSystem and returns it wrapped in a DeserializedVfs record (which also carries the saved working directory).LoadCommand replaces the session’s VFS and current directory with the deserialized values.Save/Load sequence diagram:
.env File FormatEach 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 | is the field delimiter) |
\\ |
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:
# are treated as comments and skipped.| Each remaining line is split on ` | ` (space-pipe-space) into at most 4 fields. |
DIR or FILE).Directory or RegularFile is created at the given path with the given permissions.FILE nodes, the optional fourth field (content) is unescaped via unescapeContent() 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:
completeCommandName(), which iterates CommandRegistry.getAllNames() and all defined aliases, returning entries that start with the typed prefix.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.ExamResult accumulates outcomes and can represent a partial run naturally.Command handling rules (non-PRAC questions):
abort (case-insensitive), QuestionInteraction sets an internal abort flag and signals ExamSession to stop iterating.quit (case-insensitive) or input is null, the question is recorded as skipped (stored with empty answer and counted as incorrect), and the exam continues.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():
VirtualFileSystem via the vfsFactory supplier.ShellSession with this VFS.tempSession.start() — the user types shell commands.exit, the temporary session ends.PracQuestion.checkVfs(tempVfs) which verifies each Checkpoint (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.Checkpoint semantics — 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:
ExamSession creates a fresh VirtualFileSystem via vfsFactory.ExamSession starts a temporary ShellSession over the fresh VFS and lets the user perform the task.PracQuestion.checkVfs() verifies all Checkpoints 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 (in linuxlingo.exam.question)
List<Checkpoint> checkpointsList<SetupItem> setupItemsapplySetup(VirtualFileSystem vfs) is fully implemented using setupItems.PracQuestion.SetupItem (inner static class)
SetupType enum: MKDIR, FILE, PERMtype, path, valueSetupType are implemented in applySetup.Checkpoint (in linuxlingo.exam)
matches(VirtualFileSystem vfs) and NodeType enum.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.ExamSession (in linuxlingo.exam)
PRAC questions via handlePracQuestion(PracQuestion q).q.applySetup(tempVfs) to prepare the environment, starts a temp shell, then checks the final VFS (q.checkVfs(tempVfs)).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?
VirtualFileSystem from outside the exam module would tightly couple Exam to VFS internals.SetupItem provides a small, declarative DSL for “what the environment should look like before the user starts”, which is:
QuestionParser.PracQuestion level.Checkpoint.NodeType?DIR and FILE checks only validate presence and node kind.NOT_EXISTS: “Remove this file/directory.”CONTENT_EQUALS: “Edit a file to contain specific content.”PERM: “Set the permission of this file to 755.”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.
VfsSerializer.Another option was to provide a dedicated subclass or factory per practical question that mutates the VFS imperatively.
.txt files, not in code.exam command continues to delegate to ExamSession, which hides all internal PRAC details.ShellSession is still created with a VirtualFileSystem and Ui.PRAC question may now be non-empty.QuestionParser parses additional fields for PRAC question setup, constructing the appropriate SetupItem instances.Storage itself.PracQuestion instances with explicit setupItems and checkpoints, then run:
applySetup(vfs)vfs as if a user had run commandscheckVfs(vfs) resultsThis design leaves room for future growth:
SetupType values (e.g., COPY, MOVE) can be added without modifying existing question banks.NodeType values can be introduced to validate more sophisticated conditions (e.g., ownership, timestamps).HARD questions) by inspecting difficulty within applySetup.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 .txt files in the questions directory via Storage.listFiles(...).QuestionParser.parseFile(Path file) reads the file, converts each valid line into a concrete Question, and returns a List<Question>.QuestionBank stores the resulting List<Question> under a topic key derived from the filename (e.g., navigation.txt → "navigation").ExamSession later requests questions by topic via questionBank.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:
B.A:text B:text C:text D:text.LinkedHashMap<Character, String> to preserve display order in McqQuestion.present().Parsing logic (exact implementation)
MCQ lines are handled by QuestionParser.parseMcq(...).
ANSWER field is trimmed.IllegalArgumentException("MCQ answer must not be blank").OPTIONS field is trimmed and split using the regex:
(?=[A-D]:)letter ':' textsegment.length() >= 2 and segment.charAt(1) == ':' are accepted.segment.charAt(0) and the value is segment.substring(2).trim().IllegalArgumentException("MCQ options must not be empty").answer.charAt(0).Character.toUpperCase(correctAnswer).IllegalArgumentException("MCQ correct answer not found in options").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 ...
QuestionParser splits answers and stores them in a List<String> used by FitbQuestion.checkAnswer(...).\|) is handled so authors can include literal pipes in answer text.Parsing logic (exact implementation)
FITB lines are handled by QuestionParser.parseFitb(...).
ANSWER field is split using the regex:
(?<!\\)\|| that is not preceded by a backslash (\).acceptedAnswer.trim().replace("\\|", "|")List<String> is empty, parsing fails with:
IllegalArgumentException("FITB accepted answer list must not be empty").Authoring notes (important for validation)
FitbQuestion.checkAnswer(...) uses acceptedAnswers.contains(answer.trim()).pwd|PWD).| 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
ExamSession delegates non-PRAC question attempts to QuestionInteraction.QuestionInteraction.presentQuestionWithResult(...) selects the appropriate input routine based on the runtime type:
McqQuestion -> askValidatedMcqAnswer(...)FitbQuestion -> askValidatedFitbAnswer(...)MCQ input validation (askValidatedMcqAnswer)
Your answer: until a valid input is entered.quit (case-insensitive): skip the questionabort (case-insensitive): abort the entire exam"A", "B", "C", or "D").FITB input validation (askValidatedFitbAnswer)
Your answer:.quit (case-insensitive): skip the questionabort (case-insensitive): abort the entire examGrading logic (checkAnswer)
McqQuestion.checkAnswer(String):
correctAnswerFitbQuestion.checkAnswer(String):
acceptedAnswers (case-sensitive)Skip/abort handling
abort, QuestionInteraction sets an internal examAborted flag, prints Exam aborted., and stops the run.quit or the UI returns null, 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.
Checkpoint object used by PracQuestion.checkVfs(vfs).Checkpoint examples (illustrative):
/home/project:DIR, /home/readme.txt:FILE/tmp/junk:NOT_EXISTS/home/readme.txt:CONTENT_EQUALS=hello/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:
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)?
Alternatives considered:
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.
LinuxLingo provides an interactive Linux shell simulator combined with a quiz system, allowing students to:
| 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 |
| 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.
./gradlew shadowJarjava -jar build/libs/LinuxLingo.jarlinuxlingo> prompt are displayed.helpexit (or quit)shelluser@linuxlingo:/$ are displayed.pwd → /cd /home/user → prompt changes to user@linuxlingo:/home/user$cd - → returns to /, prints /cd ~ → navigates to /home/userls -la → lists files with permissions, types, and sizesmkdir testdir → no output (success)mkdir -p a/b/c → creates nested directoriesls → testdir appears in listingtouch testdir/hello.txt → no output (success)echo "Hello World" > testdir/hello.txt then cat 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 removedecho "line1" | cat → line1echo "apple" | grep apple → appleecho "test output" > /tmp/out.txt then cat /tmp/out.txt → test outputecho "more" >> /tmp/out.txt then cat /tmp/out.txt → test output followed by moreecho "hello" > /tmp/in.txt then cat < /tmp/in.txt → hellogrep hello < /tmp/in.txt → helloecho success && echo "also runs" → both success and also runs printedls /nonexistent && echo "no" → error message only, no is NOT printedls /nonexistent || echo "fallback" → error message, then fallback printedecho a ; echo b → both a and b printed on separate linesecho "hello world" > /tmp/test.txt then grep hello /tmp/test.txt → hello worldwc /tmp/test.txt → line, word, and character countssort / uniq / head / tail — verify expected output on multi-line inputtouch /tmp/secret.txt then chmod 000 /tmp/secret.txt then cat /tmp/secret.txt → Permission denied errortouch a.txt b.txt c.log then ls *.txt → lists a.txt b.txt onlyls ?.txt → lists a.txt b.txt (single-character match)echo $USER → userecho $HOME → /home/userecho $PWD → current working directoryalias ll="ls -la" then ll → same output as ls -launalias ll then ll → ll: command not foundhistory → numbered list of previous commandshistory 3 → shows the last 3 commandshistory -c → clears the command historyman ls → displays usage info for lstree → displays directory tree from current directorywhich ls grep → shows that both commands are availablewhoami → prints userdate +%Y-%m-%d → prints the current date in year-month-day formatecho "a" > /tmp/f1.txt and echo "b" > /tmp/f2.txt then diff /tmp/f1.txt /tmp/f2.txt → shows differencesecho "hello" | tee /tmp/tee.txt → prints hello AND writes it to /tmp/tee.txtmkdi → mkdi: command not found followed by Did you mean 'mkdir'?gre → gre: command not found followed by Did you mean 'grep'?exit → returns to linuxlingo> promptsave myenv → environment saved messageenvlist → myenv appears in the listreset → VFS reset to default stateload myenv → previously created files/directories are restoredenvdelete myenv → environment deleted messageenvlist → myenv no longer appearsexamScore: 7/10 (70%)).exam -t navigation -n 3exam -randomexam -topicsmkdir /home/project).exit to submit.exec "echo hello"hello is printed.save testenv).exec -e testenv "ls"unknowncmdunknowncmd: command not foundcd /nonexistent/pathcd: No such file or directory: /nonexistent/pathgrepgrep: missing patternexam -t nonexistentInvalid topic selection. followed by available topics list.