hero-image

Minishell


Minishell

A small bash-like command interpreter written from scratch in C. It reads a line, splits it into tokens, expands variables and quotes, builds a pipeline of commands with their redirections and runs it with fork, pipe, dup2 and execve. Exit statuses, the environment and terminal signals behave the way they do in a real shell.

Project Requirements

  • Executable: minishell, built by a Makefile that compiles with -Wall -Wextra -Werror and does not relink.
  • Behaviour: bash is the reference. Quoting, expansion, redirections, pipes, exit codes and signals must match it.
  • Global Variables: Exactly one, and it may only hold the number of a received signal.
  • Memory Management: No leaks and no leftover file descriptors, in the parent or in any forked child.
  • The Norm: Every function is 25 lines or less, with at most 5 functions per file.

Features

  • Interactive & Scriptable: A readline prompt with line editing and history. When input is piped in, no prompt is printed and the first syntax error ends the run with status 2, just like a non-interactive bash.
  • Quoting: Single quotes are fully literal. Inside double quotes, everything except $ is literal.
  • Expansion: $VAR, $? and word splitting of unquoted results. Unset variables expand to nothing.
  • Pipelines: cmd1 | cmd2 | cmd3 … of any length, with every stage running in its own process.
  • Redirections: <, >, >> and << heredocs. A heredoc expands variables unless its delimiter is quoted.
  • Builtins: echo -n, cd (including cd -), pwd, export, unset, env and exit.
  • Exit Codes: 127 for command not found, 126 for a file that cannot be executed and 128 + signo for a process killed by a signal.
  • Signals: Ctrl-C gives you a fresh prompt, Ctrl-D exits and Ctrl-\ leaves the shell running but still reaches a running child. Heredocs react the same way.

Implementation Details

1. From Line to Commands

Every line goes through three stages before anything is executed: the tokenizer, the expander and the parser. The result is either an array of commands or an error message, so the REPL has a single place where it reports failures.

t_ms_parse_res	*ms_parse(char *input)
{
	// ...
	// 1. Tokenizer: raw line -> T_WORD, T_PIPE, T_IN, T_OUT, T_HERE_DOC, ...
	tkns = ms_tokenize(input);
	result->source.error_msg = ms_syntax_check(tkns);
	if ((result->source.error_msg != NULL))
		result->exit_code = 2;
	// 2. Expander: $VAR, $?, word splitting and quote removal
	if (result->exit_code == 0)
		result->exit_code = ms_expand(&tkns, &(result->source.error_msg));
	// 3. Parser: tokens -> NULL-terminated array of t_ms_cmd (argv + redirections)
	if (result->exit_code == 0)
		result->source.cmds = build_cmd_arr(tkns);
	// ...
}

2. Quote-Safe Expansion

One subtle case: if a variable’s value contains quotes (export X="'hi'"), echo $X has to print 'hi'. The quote-removal pass must not strip quotes the user never typed. To prevent that, quotes coming from a variable are swapped for invisible marker bytes before quote removal and swapped back afterwards.

// hides the quote characters of a variable value, so that the quote
// removal pass does not eat quotes that the user never typed
char	*ms_quote_hide(char *value)
{
	// ...
	while (result[i] != '\0')
	{
		if (result[i] == '\'')
			result[i] = MS_HIDDEN_SQ;
		else if (result[i] == '\"')
			result[i] = MS_HIDDEN_DQ;
		i++;
	}
	return (result);
}

3. Spawning a Pipeline

Each stage gets a stdin (the read end of the previous pipe) and a stdout (a fresh pipe, or the terminal for the last stage). All stages run at the same time. The shell then waits for all of them, and the status of the last one becomes $?.

while (cmds[0] != NULL)
{
	// the previous pipe's read end becomes this stage's stdin
	ms_close_unless(stdenv[R], STDIN_FILENO);
	stdenv[R] = out_pipe[R];
	// every stage except the last writes into a new pipe
	if (cmds[1] != NULL)
		stdenv[W] = ms_pipe((int *)out_pipe)[W];
	else
		stdenv[W] = STDOUT_FILENO;
	fr = ms_fork();
	if (fr == 0)
		ms_exec_child(cmds[0], stdenv); // dup2, redirections, execve
	add_pid(&result, fr);
	ms_close_unless(out_pipe[W], STDOUT_FILENO);
	cmds++;
}

Builtins that change the shell’s own state (cd, export, unset, exit) run in the parent when they are not part of a pipeline. Everything else is forked.

4. Resource Bookkeeping

Forking copies every open fd and every heap pointer into the child, which makes leaks easy. To avoid them, the shell keeps three small trackers:

  • m3 records every live allocation.
  • bw records every open file descriptor.
  • kg (the “kindergarten”) records every child pid.

Every allocation, open, dup, pipe and fork goes through an ms_safe_* wrapper that registers the result with the matching tracker, so a single call can release everything:

pid_t	ms_fork(void)
{
	// ...
	result = fork();
	if (result < 0 || errno != 0)
		ms_error_out(EXIT_FAILURE, NULL, errno);
	if (result > 0)
	{
		kg_add(result);      // parent: remember the new child
		return (result);
	}
	ms_signal_child();       // child: Ctrl-C / Ctrl-\ end it again
	kg_cleanup(false);       // child: forget the parent's children
	return (0);
}

void	ms_exit(int exit_code)
{
	ms_repl_history_clear();
	ft_bw_cleanup();         // close every tracked fd
	ms_stdenv_close();
	kg_cleanup(true);        // wait for every child still running
	ft_m3_cleanup();         // free every allocation still alive
	exit(exit_code);
}

5. Signals with a Single Global

The signal handler only stores the signal number, which is the safest thing a handler can do. The actual reaction happens later, at a safe point: inside readline’s event hook, which runs regularly while the shell waits for input.

volatile sig_atomic_t	g_ms_signal;

static void	ms_real_sh(int sig)
{
	g_ms_signal = sig;
}

int	ms_rl_main_event_hook(void)
{
	int	sig;

	sig = ms_signal_consume();
	if (sig == SIGINT)
	{
		// Ctrl-C: drop the current line and show a fresh prompt
		(void)write(1, "\n", 1);
		rl_replace_line("", 0);
		rl_on_new_line();
		rl_redisplay();
		ms_signal_listen(0);
	}
	return (0);
}

Heredocs use a different hook that sets rl_done instead. Ctrl-C then cancels the heredoc and the whole command line, and the shell itself keeps running. The collected lines are later fed to the command through a pipe by a small writer process, so no temporary files are needed.


Usage

Compilation

Requires the GNU readline library (sudo apt install libreadline-dev on Debian/Ubuntu). libft is bundled in the repo and built automatically.

make

Running the Shell

  1. Interactive mode:

    ./minishell
    minishell %> export NAME=42
    minishell %> echo "hello $NAME" | tr a-z A-Z
    HELLO 42
    minishell %> cat << EOF
    minishell_heredoc %> the shell level is $SHLVL
    minishell_heredoc %> EOF
    the shell level is 2
    minishell %> exit 42
  2. Non-interactive mode:

    printf 'export GREETING=hi\necho $GREETING world\n' | ./minishell

    Output: hi world


Skills Demonstrated

  • Process Management: Using fork, execve and waitpid, and passing exit statuses and signal deaths back up the same way bash does.
  • File Descriptor Plumbing: Wiring pipes and redirections with dup2 so that every fd is closed in the right process at the right time.
  • Language Parsing: A hand-written tokenizer, expander and parser for shell syntax, with all its quoting edge cases.
  • Signal Handling: sigaction, an async-signal-safe handler and readline event hooks working together without race conditions.
  • Resource Safety: Central trackers for memory, fds and child processes, so a single exit path leaves no leaks, even in forked children.
  • Teamwork: Splitting a large C codebase between two developers under strict style rules and reviewing each other’s work through git.

Authors

arri