minishell
// As beautiful as a shell — recoder bash, un processus à la fois.
Recoder un shell, c'est affronter quatre monstres d'un coup : un tokenizer
qui découpe l'input en tokens, un expander qui résout les variables et
les quotes, un parser qui construit l'arbre des commandes, et un
executor qui fork, pipe et execve. Ce guide décortique chaque étape
avec le code réel du projet, du readline() au
execve().
Le shell doit afficher un prompt, gérer l'historique, rechercher les exécutables via
PATH, implémenter 7 builtins (echo,
cd, pwd, export,
unset, env, exit),
gérer les pipes |, les redirections < > >> <<,
les variables $VAR et $?, les quotes
' et ", ctrl-C/D/\. Bonus :
&&, ||, wildcard *.
Maximum une variable globale. Norminette exit 0. Pas de leak.
— Manuel de terrain YoRHa, module 153
Le projet
minishell est le projet le plus ambitieux de la branche système 42.
L'objectif : recoder un shell fonctionnel — pas aussi complet que bash, mais suffisamment
pour lancer des commandes, gérer les pipes et redirections, étendre les variables
d'environnement, et implémenter les builtins essentiels. Le sujet (version 6) autorise
readline pour le prompt et l'historique, et la libft.
Le projet suit une architecture classique en 4 étapes : tokenizer (découper
l'input en tokens), expander (résoudre $VAR,
$?, supprimer les quotes), parser (construire
une liste chaînée de t_cmd), et executor
(fork, pipe, execve, builtins). Chaque étape a ses propres pièges que ce guide décortique.
Fonctions autorisées
readline, rl_clear_history,
rl_on_new_line, rl_replace_line,
rl_redisplay, add_history,
printf, malloc,
free, write,
access, open,
read, close,
fork, wait,
waitpid, wait3,
wait4, signal,
sigaction, sigemptyset,
sigaddset, kill,
exit, getcwd,
chdir, stat,
lstat, fstat,
unlink, execve,
dup, dup2,
pipe, opendir,
readdir, closedir,
strerror, perror,
isatty, ttyname,
ttyslot, ioctl,
getenv, tcsetattr,
tcgetattr, tgetent,
tgetflag, tgetnum,
tgetstr, tgoto,
tputs
Le sujet dit explicitement : "The readline() function can cause memory leaks.
You don't have to fix them." Mais votre propre code ne peut pas avoir de leaks.
Utilisez rl_clear_history() à la fin pour nettoyer
l'historique, et free() chaque ligne lue par
readline().
Architecture du code
Le projet suit une architecture modulaire en 4 phases. Chaque phase transforme les données et les passe à la suivante. Cette séparation permet de tester chaque étape indépendamment et de respecter la limite norminette de 5 fonctions par fichier.
Structures de données
typedef enum e_token_type { T_WORD, T_PIPE, T_REDIR_IN, T_REDIR_OUT, T_REDIR_APPEND, T_REDIR_HEREDOC, T_AND, T_OR, T_LPAREN, T_RPAREN, T_EOF } t_token_type; typedef struct s_token /* un token de l'input */ { char *value; /* texte du token (NULL pour opérateurs) */ t_token_type type; /* type du token */ struct s_token *next; } t_token; typedef struct s_redir /* une redirection */ { t_token_type type; /* < > >> << */ char *file; /* nom du fichier / délimiteur */ struct s_redir *next; } t_redir; typedef struct s_cmd /* une commande dans le pipeline */ { char **argv; /* arguments (argv[0] = commande) */ t_redir *redirs; /* liste chaînée de redirections */ int op_before; /* opérateur AVANT cette commande (T_AND/T_OR/T_PIPE) */ struct s_cmd *next; } t_cmd; typedef struct s_shell /* état global du shell */ { char **env; /* copie de l'environnement */ int exit_status; /* $? — statut de la dernière commande */ int running; /* 1 = shell actif, 0 = exit demandé */ } t_shell;
Le champ op_before dans t_cmd est
crucial : il stocke l'opérateur qui précède cette commande dans l'input. Par exemple,
dans true && echo yes, la commande echo
a op_before = T_AND. Cela permet à l'executor de savoir s'il
doit skipper cette commande (si && et exit_status != 0).
Flux principal
while (shell.running) { input = readline(PROMPT); /* affiche le prompt */ if (input == NULL) /* ctrl-D = EOF */ { write(1, "exit\n", 5); break ; } if (input[0] != '\0') ms_process_input(input, &shell); free(input); /* pas de leak */ }
Les testers (zstenger, kichkiro) envoient les commandes via pipe. Dans ce cas,
isatty(STDIN_FILENO) retourne 0. On utilise alors
get_next_line au lieu de readline
(pas de prompt, pas d'echo) pour matcher le comportement de bash en mode pipé.
Tokenizer
Le tokenizer découpe l'input en une liste chaînée de tokens. Il doit reconnaître les
mots, les pipes |, les redirections < > >> <<,
les opérateurs logiques && ||, les parenthèses, et
gérer les quotes simples et doubles (qui empêchent la découpe sur les espaces).
Quotes et opérateurs
int read_word(t_token **tokens, char *input, int *i) { int start; char *word; start = *i; while (input[*i] != '\0' && input[*i] != ' ' && input[*i] != '\t' && input[*i] != '|' && input[*i] != '<' && input[*i] != '>' && input[*i] != '&' && input[*i] != '(' && input[*i] != ')') { if (input[*i] == '\'' || input[*i] == '"') { if (read_quoted(input, i, input[*i]) != 0) return (1); } else (*i)++; } if (*i > start) { word = ft_strndup(input + start, *i - start); add_token(tokens, new_token(word, T_WORD)); } return (0); }
La fonction read_quoted avance jusqu'à trouver la quote
fermante. Si la quote n'est pas fermée, c'est une erreur. Les quotes sont conservées
dans la valeur du token — c'est l'expander qui les supprimera plus tard, car il doit
savoir si le contenu était en single ou double quote pour décider d'étendre ou non
les $VAR.
Syntax errors
Le sujet (version 6) ne demande pas explicitement de gérer les syntax errors, mais bash le fait et les testers vérifient. Notre implémentation détecte les cas suivants et retourne exit code 2 (comme bash) :
| Input | Erreur bash | Notre comportement |
|---|---|---|
| | syntax error near | | ✅ exit 2 |
|| | syntax error near || | ✅ exit 2 |
echo | | syntax error near newline | ✅ exit 2 |
> | syntax error near newline | ✅ exit 2 |
echo > | syntax error near newline | ✅ exit 2 |
echo > < | syntax error near < | ✅ exit 2 |
static int is_operator(t_token_type type) { if (type == T_PIPE || type == T_AND || type == T_OR) return (1); return (0); } static int is_redir(t_token_type type) { if (type == T_REDIR_IN || type == T_REDIR_OUT || type == T_REDIR_APPEND || type == T_REDIR_HEREDOC) return (1); return (0); } int ms_check_syntax(t_token *tokens) { t_token *cur; t_token *prev; if (tokens == NULL) return (0); cur = tokens; prev = NULL; while (cur != NULL) { if (is_operator(cur->type) && (prev == NULL || is_operator(prev->type) || is_redir(prev->type))) return (ms_syntax_error_msg(...)); if (is_redir(cur->type) && (cur->next == NULL || cur->next->type != T_WORD)) return (ms_syntax_error_msg(...)); prev = cur; cur = cur->next; } if (prev != NULL && is_operator(prev->type)) return (ms_syntax_error_msg("newline")); return (0); }
Le message suit le format bash : minishell: syntax error near
unexpected token `X' sur stderr, avec exit code 2. Le tester zstenger compare
ce message avec celui de bash — il doit être identique (ou très proche).
Expander
L'expander parcourt les tokens de type T_WORD et résout
les variables d'environnement. C'est l'étape la plus subtile du projet : il faut
respecter les règles de quotes de bash — les single quotes empêchent
toute expansion, les double quotes permettent l'expansion de
$ mais pas des autres métacaractères.
$VAR et $?
char *expand_dollar(char *str, int *i, t_shell *shell) { char *name; int start; int braces; braces = 0; (*i)++; if (str[*i] == '{' && (*i)++) /* ${VAR} syntax */ braces = 1; if (str[*i] == '?') /* $? = exit status */ { (*i)++; return (ft_itoa(shell->exit_status)); } start = *i; while (str[*i] != '\0' && (ft_isalnum(str[*i]) || str[*i] == '_')) (*i)++; if (braces && str[*i] == '}') (*i)++; if (*i == start) return (ft_strdup("$")); /* $ seul = littéral */ name = ft_strndup(str + start, *i - start - braces); return (get_var_value(name, shell)); }
Quote-aware expansion
Le cœur de l'expander est process_token_expansion qui
parcourt la valeur du token caractère par caractère, en trackant le contexte de quote.
Si on est inside single quotes, $ n'est pas expansé. Si on
est inside double quotes ou hors quotes, $ est expansé.
Wildcard *
Le wildcard * est un bonus. Il doit étendre les noms de
fichiers dans le répertoire courant, triés alphabétiquement (comme bash). On utilise
opendir/readdir pour lister
les fichiers, et un pattern matching récursif pour vérifier si chaque fichier
correspond au pattern.
int match_pattern(char *pattern, char *str) { if (*pattern == '\0' && *str == '\0') return (1); if (*pattern == '*') { while (*pattern == '*') pattern++; if (*pattern == '\0') return (1); /* * à la fin = match tout */ while (*str != '\0') { if (match_pattern(pattern, str)) return (1); str++; } return (match_pattern(pattern, str)); } if (*pattern == '\0' || *str == '\0') return (0); if (*pattern == *str) return (match_pattern(pattern + 1, str + 1)); return (0); }
Bash n'étend pas * vers les fichiers cachés (commençant
par .) sauf si le pattern commence explicitement par
.. Notre implémentation respecte cette règle :
if (entry->d_name[0] != '.' || pattern[0] == '.').
Parser
Le parser transforme la liste de tokens en une liste chaînée de
t_cmd. Chaque t_cmd représente
une commande avec ses arguments, ses redirections, et l'opérateur qui la précède
(op_before). Les tokens de type opérateur
(T_PIPE, T_AND,
T_OR) ne sont pas stockés dans la commande mais dans
op_before de la commande suivante.
int ms_parse(t_cmd **cmds, t_token *tokens) { t_cmd *cur; t_cmd *new; int op_before; *cmds = NULL; cur = NULL; op_before = 0; while (tokens != NULL) { if (parse_command(&new, &tokens, op_before) != 0) return (1); if (new->argv == NULL && new->redirs == NULL) { free(new); break ; } if (*cmds == NULL) *cmds = new; else cur->next = new; cur = new; advance_op(&tokens, &op_before); /* stocke T_AND/T_OR/T_PIPE */ } return (0); }
Dans true && echo yes, la commande true
a op_before = 0 (première commande), et la commande
echo a op_before = T_AND. L'executor
utilise cette valeur pour décider de skipper ou non la commande selon
exit_status.
Executor
L'executor est le cœur du shell. Il parcourt la liste de commandes, fork pour les
commandes externes, gère les pipes entre commandes, et exécute les builtins dans le
processus parent (sans fork) quand il n'y a pas de pipe. Le pattern classique :
fork → dup2 (redir) → execve pour chaque commande, avec
pipe pour connecter stdout d'une commande au stdin de la suivante.
Pipelines
int run_pipeline_loop(t_cmd **cmds, t_shell *shell, int count) { t_cmd *cur; int prev_fd; int pipefd[2]; int i; cur = *cmds; prev_fd = -1; i = -1; while (++i < count) { if (i < count - 1 && do_fork(cur, shell, &prev_fd, pipefd)) return (1); if (i >= count - 1 && do_fork(cur, shell, &prev_fd, NULL)) return (1); if (i < count - 1) prev_fd = pipefd[0]; cur = cur->next; } if (prev_fd >= 0) close(prev_fd); *cmds = cur; return (0); }
Builtins dans le parent
Quand il n'y a qu'une seule commande et que c'est un builtin, on l'exécute directement
dans le processus parent — sans fork. C'est essentiel pour cd
(qui doit modifier le répertoire du parent) et export/
unset (qui doivent modifier l'environnement du parent). On
sauvegarde stdin/stdout, on applique les redirections, on exécute le builtin, puis on
restaure stdin/stdout.
int exec_builtin_in_parent(t_cmd *cmd, t_shell *shell) { int saved_in; int saved_out; int ret; saved_in = dup(STDIN_FILENO); saved_out = dup(STDOUT_FILENO); if (ms_redirect(cmd->redirs) != 0) ret = 1; else ret = ms_exec_builtin(cmd, shell); dup2(saved_in, STDIN_FILENO); dup2(saved_out, STDOUT_FILENO); close(saved_in); close(saved_out); return (ret); }
Court-circuit && et ||
int ms_execute(t_cmd *cmds, t_shell *shell) { t_cmd *cur; int skip; cur = cmds; while (cur != NULL) { skip = 0; if (cur->op_before == T_AND && shell->exit_status != 0) skip = 1; /* && : skip si échec */ else if (cur->op_before == T_OR && shell->exit_status == 0) skip = 1; /* || : skip si succès */ if (!skip) exec_pipeline(&cur, shell); else cur = cur->next; } return (0); }
Builtins
Les 7 builtins obligatoires : echo (avec option
-n), cd (chemin relatif ou
absolu), pwd (sans option), export
(sans option), unset (sans option),
env (sans option ni argument), exit
(sans option). Chaque builtin retourne un exit status qui devient
$?.
| Builtin | Comportement | Exit status |
|---|---|---|
echo | Affiche ses arguments séparés par des espaces. -n supprime le newline final. | 0 |
cd | Change le répertoire courant. Sans argument, va vers $HOME. | 0 si OK, 1 si erreur |
pwd | Affiche le répertoire courant via getcwd. | 0 |
export | Ajoute/modifie une variable d'environnement. Sans argument, affiche toutes les variables triées. | 0 |
unset | Supprime une variable d'environnement. | 0 |
env | Affiche toutes les variables d'environnement. | 0 |
exit | Quitte le shell avec le code donné (ou $? si sans argument). | code donné |
cd est le builtin qui justifie l'exécution dans le parent :
si on fork, le chdir ne modifie que le processus enfant, et
le shell reste dans son répertoire courant. C'est pourquoi les builtins sont exécutés
sans fork quand ils sont seuls (pas de pipe). Dans un pipeline
cd /tmp | echo ok, cd est
exécuté dans un child et n'affecte pas le parent — c'est le comportement de bash.
Redirections
Les 4 redirections obligatoires : < (input),
> (output truncate), >>
(output append), << (heredoc). Elles sont appliquées
via dup2 après le fork, avant l'execve.
| Redirection | Flag open | dup2 cible |
|---|---|---|
< file | O_RDONLY | dup2(fd, STDIN) |
> file | O_WRONLY | O_CREAT | O_TRUNC | dup2(fd, STDOUT) |
>> file | O_WRONLY | O_CREAT | O_APPEND | dup2(fd, STDOUT) |
<< delim | pipe interne | dup2(fd, STDIN) |
Le heredoc << est particulier : il crée un pipe
interne, lit l'input ligne par ligne avec readline("> ")
jusqu'à trouver le délimiteur, écrit tout dans le pipe, puis redirige stdin vers
l'autre bout du pipe.
int ms_heredoc(char *delimiter) { char *line; int fd[2]; if (pipe(fd) < 0) return (ms_perror("pipe")); while (1) { line = readline("> "); if (line == NULL) break ; if (ft_strcmp(line, delimiter) == 0) { free(line); break ; } write(fd[1], line, ft_strlen(line)); write(fd[1], "\n", 1); free(line); } close(fd[1]); return (fd[0]); /* retourne le fd de lecture */ }
Libft & compatibilité
Le projet utilise la libft de l'utilisateur (auteur : rakrouna, 111
fichiers source). Comme cette libft a une API légèrement différente de la libft
standard 42, un fichier de compatibilité ms_libft_compat.c
fournit des wrappers pour les fonctions manquantes ou avec signatures différentes.
| Fonction requise | Libft utilisateur | Wrapper |
|---|---|---|
ft_split(s, c) | ft_strsplit(s, c) | Wrapper direct |
ft_substr(s, start, len) | ft_strsub(s, start, len) | Wrapper avec cast |
ft_calloc(count, size) | ft_memalloc(size) + ft_bzero | Compose 2 fonctions |
ft_atol(str) | Non présente | Implémentation propre |
get_next_line(fd, line) | get_next_line(fd, line, pitcher) (3 args) | ms_gnl.c — GNL propre (2 args) |
ft_strtrim(s, set) | ft_strtrim(s) (1 arg, trim whitespace) | trim_newline() dans read_input.c |
La libft de l'utilisateur a un get_next_line avec 3
arguments (fd, line, pitcher) — une API non-standard. Notre code utilise l'API
standard à 2 arguments (fd, line). Plutôt que de modifier la libft de l'utilisateur,
on crée ms_gnl.c avec notre propre GNL, et on commente
la déclaration dans libft.h pour éviter le conflit de
types. Le Makefile de la libft est modifié pour ne pas compiler
get_next_line.c.
Audit fiche d'évaluation
| Critère fiche | Statut | Détail |
|---|---|---|
Compilation -Wall -Wextra -Werror | ✅ | make -n montre les flags |
| Pas de re-link | ✅ | make 2× → "Nothing to be done" |
Commande simple /bin/ls | ✅ | Recherche via PATH + chemin absolu |
| Commande vide / espaces | ✅ | Pas de crash |
| 0 variable globale (ressources) | ✅ | Tout en structures + paramètres |
echo avec/sans -n | ✅ | |
| exit avec/sans argument | ✅ | Affiche "exit" en interactif seulement |
| cd (chemin absolu/relatif) | ✅ | Maj de $PWD |
| pwd | ✅ | getcwd |
| env | ✅ | Affiche shell->env |
| export (avec/sans args) | ✅ | Sans args : affiche trié avec declare -x |
| unset | ✅ | Supprime de shell->env |
Pipes | | ✅ | fork + pipe + dup2 |
Redirections < > >> | ✅ | open + dup2 |
Heredoc << | ✅ | Pipe interne + readline |
$VAR expansion | ✅ | Hors single quotes |
$? exit status | ✅ | shell->exit_status |
| Single quotes (no expand) | ✅ | echo '$HOME' → $HOME |
| Double quotes (expand $) | ✅ | echo "$HOME" → /home/z |
| ctrl-C (new prompt) | ✅ | sigaction + rl_on_new_line |
| ctrl-D (exit) | ✅ | readline retourne NULL |
| ctrl-\ (nothing) | ✅ | SIG_IGN |
Surprise: echo "'$USER'" | ✅ | → 'z' |
Surprise: echo '"$USER"' | ✅ | → "$USER" |
Bonus: && || | ✅ | Court-circuit via op_before |
Bonus: wildcard * | ✅ | opendir/readdir + tri |
| Norminette exit 0 | ✅ | Tous les fichiers srcs + header |
| Libft de l'utilisateur | ✅ | Auteur: rakrouna, compilée via son Makefile |
Makefile
Le Makefile compile la libft via son propre Makefile, puis compile les sources de
minishell avec les flags -Wall -Wextra -Werror et linke
avec libreadline. Il contient les règles
$(NAME), all,
clean, fclean,
re.
NAME = minishell CC = cc CFLAGS = -Wall -Wextra -Werror -g LDFLAGS = /usr/lib/x86_64-linux-gnu/libreadline.so.8 LIBFT_DIR = libft LIBFT = $(LIBFT_DIR)/libft.a INCLUDES = -I includes -I $(LIBFT_DIR) # Compilation par .o (incrémental, pas de re-link) %.o: %.c $(HEADERS) $(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@ # Link final avec libft + readline $(NAME): $(OBJS) $(LIBFT) $(HEADERS) $(CC) $(CFLAGS) $(INCLUDES) $(OBJS) $(LIBFT) $(LDFLAGS) -o $(NAME) # Compile la libft via son propre Makefile $(LIBFT): make -C $(LIBFT_DIR)
Sur les machines 42, libreadline-dev est installé et
-lreadline suffit. Sur notre environnement, nous avons dû
télécharger les headers et linker directement vers libreadline.so.8.
Sur une machine 42 standard, remplacez
LDFLAGS = /usr/lib/.../libreadline.so.8 par
LDFLAGS = -lreadline.
Pièges & edge cases
| Piège | Symptôme | Solution |
|---|---|---|
| readline corrompt stdin pipé | Premier caractère mangé | Utiliser GNL en mode non-interactif (isatty) |
| Builtin dans un pipe ne modifie pas le parent | cd /tmp | echo ok ne change pas le dir | Comportement bash : builtin forké dans un pipe. C'est normal. |
| Single quote expand $VAR | echo '$HOME' affiche /home/z au lieu de $HOME | Tracker le contexte de quote dans l'expander |
| Syntax errors sans exit code 2 | Tester zstenger : KO sur |, > seul | ms_check_syntax retourne 2 + message bash |
| "exit" affiché en mode pipé | Diff avec bash (bash n'affiche pas "exit" en pipé) | if (isatty(STDIN_FILENO)) avant write("exit") |
| fd leak dans les pipes | Processus zombie, pipe bloqué | Fermer tous les fd non utilisés dans parent ET children |
| Command not found exit code | Bash retourne 127 | exit(127) dans exec_external_child |
${VAR} non supporté | Affiche ${HOME} littéralement | Détecter { après $ et skip à } |
| Wildcard ne trie pas | Ordre non-alphabétique | sort_arr (bubble sort) avant insertion |
| GNL de la libft a 3 args | Conflit de types | ms_gnl.c avec GNL 2-args, commenter déclaration dans libft.h |
| Heredoc sans délimiteur | Boucle infinie | readline retourne NULL sur EOF → break |
| Norminette > 25 lignes/fonction | Erreur norm | Extraire helpers statiques, utiliser j++ inline, ft_calloc au lieu de malloc |
— Commandant White, briefing pré-mission