From abf3c6ba4a01e15c401f53c0ca31d5337a0929ed Mon Sep 17 00:00:00 2001 From: "Thierry V." <46031203+thierryvm@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:00:46 +0200 Subject: [PATCH] feat(terminal): find, xargs and rmdir as GNU runs them; wc widths and grep -l from the real tools find did not exist; it now walks the tree with -name/-iname/-path/-regex, -type (lists too), -size, -empty, -maxdepth/-mindepth, !, -a, -o and parentheses, -print, -exec with ; and +, and -delete (bottom-up, on the tree as it is now). Outputs, errors and exit codes come from findutils 4.9.0, coreutils 9.4 and grep 3.11 on Ubuntu 24.04 (WSL 2), captured on 2 October 2026 in a copy of the simulator's home generated from createInitialState. Folder order follows NTFS (Git Bash): ext4 order changes from one machine to another, and the reference says to add | sort. - xargs: -n, -L, -I/-i, -t, -r, long forms, quotes, exit 123/127; shell builtins (cd, export...) are not programs for find -exec or xargs, and what those commands run cannot change the learner's directory. - rmdir: empty folders only, -p, -v, --ignore-fail-on-non-empty, GNU's messages. In PowerShell, rmdir and rd are Remove-Item. - wc: GNU wc.c widths (one count of one file unpadded, total size digits, 7 for a pipe or a folder), a total line, -m, -L. Two old tests and the wc lesson expected the wrong width; corrected from captures in Git Bash and Ubuntu alike. - grep on files: -v, -c, -l, -L, and exit status 0/1/2. - bash backslash removal in the Linux/macOS terminal (`\;`, `\(`, `\"` inside double quotes); Windows paths keep their backslashes. - Windows: find is find.exe ("File not found - *.txt", or "FIND: Parameter format not correct", exit 2), with a pointer to Get-ChildItem -Recurse. - Reference page: xargs and rmdir, new find examples and pitfalls (79 -> 81 commands). terminal-fidelity-auditor compared about 480 commands; its findings on this code are fixed and tested, the others recorded in docs/plan.md (no globbing at all yet, $?, printf, more grep options). code-reviewer's two important findings (a malformed class threw, -exec cd changed the cwd) are fixed and tested. Phase 5d, lot 1, PR 2. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 14 + STORY.md | 10 + docs/ROADMAP.md | 5 +- docs/plan.md | 5 +- index.html | 2 +- src/app/data/commandCatalogue.ts | 49 ++- src/app/data/commandExamples.ts | 15 +- src/app/data/commands/find.ts | 472 +++++++++++++++++++++++++++ src/app/data/commands/shellSyntax.ts | 6 + src/app/data/commands/textTools.ts | 97 +++++- src/app/data/curriculum.ts | 2 +- src/app/data/landingContent.ts | 4 +- src/app/data/terminalEngine.ts | 182 ++++++++++- src/test/terminalEngine.test.ts | 216 +++++++++++- 14 files changed, 1038 insertions(+), 41 deletions(-) create mode 100644 src/app/data/commands/find.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 94debe7..fbd8107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ --- +## 🔎 2 octobre 2026 — Chercher des fichiers et enchaîner : `find`, `xargs`, `rmdir` + +*Phase 5d, lot 1 · sorties vérifiées sur Ubuntu 24.04 (findutils 4.9, coreutils 9.4, grep 3.11)* + +- **`find` fonctionne** : il cherche par nom (`-name`, `-iname`, `-path`, `-regex`), par type, par taille et par profondeur, trouve les fichiers vides, combine les tests (`!`, `-o`, parenthèses), lance une commande sur chaque résultat (`-exec … \;` ou `+`) et supprime (`-delete`). Avant, `find` répondait « commande introuvable ». +- **`xargs` transforme les lignes d'un pipe en arguments** (`find . -name "*.md" | xargs wc -l`), avec `-n`, `-L`, `-I {}` et `-t`, et les vrais messages d'erreur. +- **`rmdir`** supprime un dossier vide et refuse un dossier plein, comme sur un vrai système. +- **`wc` aligne ses colonnes comme GNU** et ajoute une ligne `total` quand il compte plusieurs fichiers. `grep` sur des fichiers connaît `-v`, `-c`, `-l` et `-L`, et rend le bon code de sortie. +- Le terminal Linux retire la barre oblique inverse comme bash : `find . -exec cat {} \;` et `\(` fonctionnent. +- Sous Windows, `find` répond comme `find.exe`, l'outil de recherche de texte de Windows, et le terminal indique `Get-ChildItem -Recurse` à la place. `rmdir` et `rd` y sont des alias de `Remove-Item`. +- **La page Référence passe de 79 à 81 commandes** : `xargs` et `rmdir`, et de nouveaux exemples pour `find`. + +--- + ## 🔤 2 octobre 2026 — Trier, compter et découper du texte : `sort`, `uniq`, `cut` *Phase 5d, lot 1 · sorties vérifiées sur Ubuntu 24.04 (coreutils 9.4) et PowerShell 7.6* diff --git a/STORY.md b/STORY.md index 22a31c9..9f9b7a9 100644 --- a/STORY.md +++ b/STORY.md @@ -1285,6 +1285,16 @@ PowerShell a réservé sa propre surprise. `Sort-Object` ignore vraiment la cass Quatre séries de captures, plus de 150 commandes, ont fixé chaque détail : `cut -c` compte des octets (« Tâches » coupé à 5 donne « Tâch »), un `-r` global inverse aussi le départage des lignes égales, mais pas un `-r` écrit dans la clé. Un des tests existants a même trouvé un bug que nous n'avions pas vu : une commande nommée `constructor` remontait jusqu'au prototype d'un objet JavaScript. +### Ce que `find` a révélé (2 octobre 2026) + +`find` n'avait jamais existé dans le simulateur. En l'écrivant, on a buté sur une question simple : dans quel ordre l'afficher ? Sur l'Ubuntu de WSL, `find .` a rendu les fichiers dans un ordre qui ne ressemble à rien. Sur un disque Linux, cet ordre dépend d'un calcul interne et change d'une machine à l'autre. Le simulateur suit donc l'ordre de Git Bash sous Windows, alphabétique, et la page Référence conseille d'ajouter `| sort` quand l'ordre compte. + +Les vraies sorties de `find` et de `xargs` ont ensuite fait remonter trois défauts plus anciens. `find . -exec cat {} \;`, l'usage le plus courant, ne marchait pas, parce que le terminal ne retirait pas la barre oblique inverse comme le fait bash. `grep -l` affichait les lignes au lieu des noms de fichiers. Et `wc` n'alignait pas ses colonnes comme le vrai : deux de nos propres tests attendaient même la mauvaise largeur, et la leçon `wc` la montrait. Tout a été corrigé à partir des captures, y compris le texte de la leçon. + +Une capture a aussi mal tourné. Une commande passée à WSL a été découpée autrement que prévu, et le décor de test s'est écrit dans le vrai dossier personnel de l'Ubuntu, par-dessus deux fichiers de configuration. Rien n'a été supprimé : les fichiers écrits par erreur sont en quarantaine, les originaux d'Ubuntu ont été remis, et le script refuse désormais de s'exécuter ailleurs que dans un dossier temporaire. + +Dernière découverte, la plus importante pour la suite : le terminal Linux ne remplace pas les jokers. `ls *.txt` ou `rm documents/*` échouent, alors que c'est l'un des premiers réflexes de bash. Ce sera la prochaine correction. + --- ## Épilogue ouvert diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 49a52ee..c1831e8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,7 +2,7 @@ > **Decision (1 October 2026, @thierry):** Terminal Learning becomes **the reference for learning the terminal from scratch up to an intermediate level**, within what a faithful simulation can do. Job-specific tools (offensive security, network analysis, OSINT) are not simulated; the site points to the free platforms that already teach them. Ordered plan: [Phase 5d](#phase-5d--from-the-basics-to-an-intermediate-level--planned-1-october-2026). Delivery checklist: [`docs/processes/curriculum-delivery-checklist.md`](processes/curriculum-delivery-checklist.md). > -> **Last update:** 2 October 2026 — **Phase 5d, Lot 1, PR 1** (#417): `sort`, `uniq`, `cut` faithful to GNU coreutils 9.4 (captured on WSL's Ubuntu), `Sort-Object`, `Get-Unique`, `Group-Object`; reference now 79 commands. Before: **Lot 0b** (#416): `llms.txt`, `llms-full.txt` and the `index.html` metadata are checked against the curriculum by a test; 10 stale lesson titles fixed. Next: Lot 1 (`sort`, `uniq`, `cut`). +> **Last update:** 2 October 2026 — **Phase 5d, Lot 1, PR 2** (#418): `find`, `xargs`, `rmdir`, GNU `wc` widths, `grep -l`; reference now 81 commands. Next: globbing (`ls *.txt`). Before: **PR 1** (#417): `sort`, `uniq`, `cut` faithful to GNU coreutils 9.4 (captured on WSL's Ubuntu), `Sort-Object`, `Get-Unique`, `Group-Object`; reference now 79 commands. Before: **Lot 0b** (#416): `llms.txt`, `llms-full.txt` and the `index.html` metadata are checked against the curriculum by a test; 10 stale lesson titles fixed. Next: Lot 1 (`sort`, `uniq`, `cut`). > > **Previous update:** 1 October 2026 CEST — 🩺 **Full check-up (P1 terminal fidelity, THI-353), continued**: #396 every reference example runs · #397 tables, blank lines and mobile keys · #398 the installed app clears iOS 26's edge blur · #399 git reads real files, outputs match Git 2.56 · **#400 real git branches** (commit graph, ids identical to real git, merges and real conflicts; two fidelity audits, two code reviews) · #401 the landing page only lists commands the terminal runs and the lessons teach, and its terminal preview prints what real shells print. Planned: a **customising the terminal** module (idea from @thierry, #402). **Multi-step exercises checked on the terminal state** (the conflict lesson now resolves a real conflict end to end; no more auto-advance, a « Suivant » button instead). **Git module as steps** (init, first commit, diff, branch, merge). **Remote repositories as in real git** (`push`, `pull`, `fetch`, `clone`, `remote`, branch tracking in `status` and `branch -vv`, #406). **A remote where a colleague pushes** (`fetch`/`pull` bring real commits, a push is rejected with "fetch first", `gh` says it is not simulated; theory gaps 146 → 120). **GitHub module as steps** (remote, push & pull, fetch, pull request, merge strategies, GitHub Actions; #410, theory gaps 120 → 90). **The top of the installed iPhone app is clear at last** (#411, confirmed on @thierry's iPhone). **#413 Windows fidelity** (`ls`/`Get-ChildItem` print PowerShell 7.6's real table, `rm`/`cp`/`mv` are the cmdlets with their parameters — `rm -rf` is refused —, PowerShell's error messages, reference page updated; bash shown on Windows 34 → 32). Next: the ~20 commands taught but not simulated yet (`find`, `sort`, `diff`, `tar`, `Where-Object`…) and the remaining Windows debt (`New-Item`'s table, case-insensitive paths). > @@ -169,7 +169,8 @@ Full rationale and scope: [`docs/strategy/2026-10-01-bases-vers-intermediaire.md - [x] **Lot 0b** — counter guards: `llms.txt`, `llms-full.txt` and `index.html` metadata checked by a test (`src/test/publicCounters.test.ts`, #416); 10 stale lesson titles fixed in `llms-full.txt` - [ ] **Lot 1** — search and sort: `sort`, `uniq`, `cut`, `find`, `xargs`, `diff`, useful `wc`/`grep` options; PowerShell `Sort-Object`, `Where-Object`, `Select-Object` (3–4 PRs) - [x] PR 1 (#417): `sort`, `uniq`, `cut` on files and pipes (GNU coreutils 9.4, en_US.UTF-8), `Sort-Object`, `Get-Unique`, `Select-Object -Unique`, `Group-Object`; reference 76 → 79 commands - - [ ] PR 2: `find`, `xargs` · PR 3: `diff`, `Where-Object`, `Compare-Object` · PR 4: lesson(s) on sorting, counting and cutting + - [x] PR 2 (#418): `find` (tests, `-exec`, `-delete`, documented NTFS order), `xargs`, `rmdir`, `wc` with GNU widths, `grep -v/-c/-l/-L` on files, bash backslash removal; reference 79 → 81 + - [ ] PR 2b: pathname globbing (`*`, `?`, `[...]`) in the Linux/macOS terminal · PR 3: `diff`, `Where-Object`, `Compare-Object` · PR 4: lesson(s) on searching, sorting, counting and cutting - [ ] **Lot 2** — archives and disk: `tar`, `gzip`, `zip`/`unzip`, `Compress-Archive`, `du`, `df`, `which`/`type`/`Get-Command` (1–2 PRs) - [ ] **Lot 3** — `sed` and `awk`, the subset a beginner uses, with an honest note beyond it (1–2 PRs) - [ ] **Lot 4** — "Investigating logs" module: fictional auth and web server logs, step-by-step missions without the answer (2–3 PRs) diff --git a/docs/plan.md b/docs/plan.md index 42672d3..8c789a7 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -2,7 +2,7 @@ > **Décision du 1er octobre 2026 (@thierry)** : Terminal Learning devient **la référence pour apprendre le terminal de zéro jusqu'au niveau intermédiaire**, dans les limites d'une simulation fidèle. Les outils de métier (sécurité offensive, analyse réseau, OSINT) ne sont pas simulés : le site oriente vers les plateformes gratuites qui les enseignent. Plan ordonné : [`docs/strategy/2026-10-01-bases-vers-intermediaire.md`](strategy/2026-10-01-bases-vers-intermediaire.md) · procédure de livraison : [`docs/processes/curriculum-delivery-checklist.md`](processes/curriculum-delivery-checklist.md). > -> Dernière mise à jour : **2 octobre 2026** — **Phase 5d, lot 1, PR 1** (#417) : `sort`, `uniq`, `cut` fidèles à GNU coreutils 9.4 (capturés sur l'Ubuntu de WSL), `Sort-Object`, `Get-Unique`, `Group-Object` ; Référence 79 commandes. Avant : **lot 0b** (#416) : `llms.txt`, `llms-full.txt` et les métadonnées d'`index.html` sont comparés au curriculum par un test ; 10 titres de leçon périmés corrigés. Suite : lot 1 (`sort`, `uniq`, `cut`). +> Dernière mise à jour : **2 octobre 2026** — **Phase 5d, lot 1, PR 2** (#418) : `find`, `xargs`, `rmdir`, `wc` aux largeurs GNU, `grep -l` ; Référence 81 commandes. Suite : les jokers (`ls *.txt`). Avant : **PR 1** (#417) : `sort`, `uniq`, `cut` fidèles à GNU coreutils 9.4 (capturés sur l'Ubuntu de WSL), `Sort-Object`, `Get-Unique`, `Group-Object` ; Référence 79 commandes. Avant : **lot 0b** (#416) : `llms.txt`, `llms-full.txt` et les métadonnées d'`index.html` sont comparés au curriculum par un test ; 10 titres de leçon périmés corrigés. Suite : lot 1 (`sort`, `uniq`, `cut`). > > Précédente mise à jour : **1er octobre 2026 CEST** — **🩺 Grand Check-up, P1 « fidélité du terminal » (THI-353), suite** : #396 exemples de la Référence tous exécutables · #397 tableaux, lignes vides et touches du clavier mobile · #398 haut de l'app installée dégagé du flou d'iOS 26 · #399 git lit les vrais fichiers, sorties identiques à Git 2.56 · **#400 vraies branches Git** (graphe de commits, identifiants identiques au vrai Git, fusions et vrais conflits ; deux audits de fidélité et deux revues de code) · #401 page d'accueil : ne promet plus que des commandes simulées · #402 module « Personnaliser son terminal » annoncé · **exercices en plusieurs étapes vérifiés sur l'état du terminal** (le conflit Git se résout de A à Z, plus de saut automatique : bouton « Suivant »). · **module Git en étapes** (init, premier commit, diff, branche, fusion) · **dépôts distants identiques au vrai Git** (`push`, `pull`, `fetch`, `clone`, `remote`, suivi de branche dans `status` et `branch -vv`, #406) · **dépôt distant où un collègue pousse** (`fetch`/`pull` ramènent de vrais commits, push refusé « fetch first », `gh` dit qu'il n'est pas simulé ; écarts théorie 146 → 120) · **module GitHub en étapes** (remote, push & pull, fetch, Pull Request, stratégies de merge, GitHub Actions ; #410, écarts théorie 120 → 90) · **haut de l'app installée sur iPhone enfin net** (#411, confirmé sur l'iPhone de @thierry). **#413 fidélité Windows** (`ls`/`Get-ChildItem` affichent le vrai tableau de PowerShell 7.6, `rm`/`cp`/`mv` sont les cmdlets avec leurs paramètres — `rm -rf` refusé —, erreurs de PowerShell, page Référence à jour ; bash montré sous Windows 34 → 32). Suite : les ~20 commandes enseignées mais pas encore simulées (`find`, `sort`, `diff`, `tar`, `Where-Object`…) et la dette Windows restante (tableau de `New-Item`, chemins insensibles à la casse). > Précédent : **26 septembre 2026 CEST** — **🩺 Grand Check-up, P1 « fidélité du terminal » (THI-353)** : #389 vraie couche shell (**198/198 exercices sans ligne rouge**) · #390 sorties d'un vrai shell · #391 rejeu permanent de la théorie · #392 `cp`/`mv` vers un dossier + chemins Windows natifs (écarts théorie **174 → 162**) · #393 agents d'audit remis à jour + `terminal-fidelity-auditor` · roadmap publique corrigée. Suite : messages d'erreur PowerShell, page Référence, écarts restants. @@ -255,7 +255,8 @@ Détail, périmètre et justification : [`docs/strategy/2026-10-01-bases-vers-in - [x] **Lot 0b** — garde-fous des compteurs (`llms.txt`, `llms-full.txt`, métadonnées d'`index.html`) : `src/test/publicCounters.test.ts` (#416), 10 titres périmés corrigés dans `llms-full.txt` - [ ] **Lot 1** — chercher et trier : `sort`, `uniq`, `cut`, `find`, `xargs`, `diff` ; `Sort-Object`, `Where-Object`, `Select-Object` - [x] PR 1 (#417) : `sort`, `uniq`, `cut` sur fichiers et pipes (GNU coreutils 9.4, en_US.UTF-8), `Sort-Object`, `Get-Unique`, `Select-Object -Unique`, `Group-Object` ; Référence 76 → 79 commandes. Dette notée : `Get-Content a, b` (plusieurs fichiers) et `ForEach-Object` non simulés ; sous Windows, une commande inconnue garde le message pédagogique « commande introuvable » au lieu de celui de PowerShell ; **`--help` est faux pour toutes les autres commandes** (`ls --help` liste le dossier, `cat --help` cherche un fichier) : à traiter dans une PR dédiée (`sort`, `uniq` et `cut` affichent déjà une note) ; cas rares de `Group-Object` (`-AsString` sans `-AsHashTable`, `| Measure-Object` compte les lignes du tableau) - - [ ] PR 2 : `find`, `xargs` + - [x] PR 2 (#418) : `find` (tests, `-exec`, `-delete`, ordre NTFS documenté), `xargs`, `rmdir`, `wc` aux largeurs GNU, `grep -v/-c/-l/-L` sur fichiers, barre oblique inverse retirée comme bash ; Référence 79 → 81. Dette notée : **aucun joker n'est développé sous Linux** (`ls *.txt`, `rm documents/*`) → PR suivante ; `ls` avec plusieurs dossiers n'affiche que le premier ; largeur de la colonne taille de `ls -l` ; `wc` sous Windows (PowerShell n'en a pas). Relevé par l'audit de fidélité du 2 octobre : `$?` n'est pas développé (les codes de sortie, désormais justes, restent invisibles) ; `printf`, `echo -e` et `echo -n` ; options de `grep` `-q`, `-o`, `-h`, `-H`, `-m`, `-s`, `-F`, l'alternance `\|`, et `-l`/`-L` sur l'entrée standard (PR `grep` dédiée, utile au lot 4) ; parenthèses non citées (`find . ( -name x )` doit être une erreur de syntaxe) ; `wc < fichier` compte comme un pipe ; `chmod` affiche un message alors que le vrai est silencieux + - [ ] PR 2b : développement des jokers (`*`, `?`, `[...]`) dans le terminal Linux/macOS - [ ] PR 3 : `diff`, `Where-Object`, `Compare-Object` - [ ] PR 4 : leçon(s) « Trier, compter, découper » dans les trois environnements - [ ] **Lot 2** — archives et disque : `tar`, `gzip`, `zip`, `Compress-Archive`, `du`, `df`, `which`/`Get-Command` diff --git a/index.html b/index.html index dfd1408..1ba4705 100644 --- a/index.html +++ b/index.html @@ -216,7 +216,7 @@ "name": "Combien de commandes et de leçons sont disponibles ?", "acceptedAnswer": { "@type": "Answer", - "text": "Terminal Learning propose 66 leçons réparties en 11 modules progressifs : navigation, fichiers, lecture, permissions, processus, redirection, variables, réseau/SSH, Git, GitHub et l'IA comme outil dev. Plus de 79 commandes documentées avec syntaxe, exemples, erreurs courantes et variantes Linux/macOS/Windows." + "text": "Terminal Learning propose 66 leçons réparties en 11 modules progressifs : navigation, fichiers, lecture, permissions, processus, redirection, variables, réseau/SSH, Git, GitHub et l'IA comme outil dev. Plus de 81 commandes documentées avec syntaxe, exemples, erreurs courantes et variantes Linux/macOS/Windows." } }, { diff --git a/src/app/data/commandCatalogue.ts b/src/app/data/commandCatalogue.ts index 26e014a..16be321 100644 --- a/src/app/data/commandCatalogue.ts +++ b/src/app/data/commandCatalogue.ts @@ -205,6 +205,22 @@ const baseCatalogue: BaseCategory[] = [ "Taper rm -rf dans PowerShell : rm y est Remove-Item, qui ne connaît pas -rf. Écrire Remove-Item dossier -Recurse", ], }, + { + id: 'rmdir', + name: 'rmdir', + category: 'fichiers', + level: 1, + recommendedFor: ['linux', 'macos', 'windows'], + variants: [ + { environment: 'windows', command: 'rmdir dossier', shell: 'PowerShell' }, + ], + compatibility: ['linux', 'macos', 'windows'], + syntax: 'rmdir dossier', + summary: 'Supprimer un dossier vide', + commonErrors: [ + "S'étonner de « Directory not empty » : rmdir ne supprime que les dossiers vides. Vider le dossier d'abord, ou utiliser rm -r en relisant la commande.", + ], + }, ], }, @@ -313,9 +329,30 @@ const baseCatalogue: BaseCategory[] = [ { environment: 'windows', command: 'where nomcommande', shell: 'CMD' }, ], compatibility: ['linux', 'macos', 'windows'], - syntax: 'find . -name motif', + syntax: 'find [dossier...] [tests] [actions]', summary: 'Trouver des fichiers ou commandes', - commonErrors: [], + commonErrors: [ + "Oublier les guillemets autour du motif : sans eux, le shell remplace *.txt par les fichiers du dossier courant avant que find le voie. Écrire find . -name \"*.txt\".", + 'Sur macOS, find exige un dossier de départ : find . -name "*.txt", et non find -name "*.txt".', + "Taper find dans PowerShell : c'est find.exe, l'outil de recherche de texte de Windows. Pour chercher des fichiers : Get-ChildItem -Recurse -Filter *.txt.", + "Compter sur l'ordre des résultats : find les donne dans l'ordre du disque, qui change d'une machine à l'autre. Ajouter | sort pour un ordre stable.", + ], + }, + { + id: 'xargs', + name: 'xargs', + category: 'search', + level: 1, + recommendedFor: ['linux', 'macos'], + variants: [ + { environment: 'windows', command: 'Get-ChildItem -Recurse -Filter *.md | ForEach-Object { Get-Content $_ }', shell: 'PowerShell' }, + ], + compatibility: ['linux', 'macos', 'windows'], + syntax: 'commande | xargs [options] commande [arguments]', + summary: "Lancer une commande avec les lignes reçues d'un pipe", + commonErrors: [ + 'Passer des noms de fichiers qui contiennent des espaces : xargs coupe sur les espaces. Utiliser find … -exec commande {} + à la place.', + ], }, { id: 'wc', @@ -1397,6 +1434,14 @@ const OFFICIAL_DOCS: Record = { { label: 'Microsoft Learn — Select-String (Windows)', url: `${PS}microsoft.powershell.utility/select-string` }, ], find: [{ label: 'man7.org (Linux)', url: `${M1}find.1.html` }], + xargs: [ + { label: 'man7.org (Linux)', url: `${M1}xargs.1.html` }, + { label: 'Microsoft Learn — ForEach-Object (Windows)', url: `${PS}microsoft.powershell.core/foreach-object` }, + ], + rmdir: [ + { label: 'man7.org (Linux)', url: `${M1}rmdir.1.html` }, + { label: 'Microsoft Learn — Remove-Item (Windows)', url: `${PS}microsoft.powershell.management/remove-item` }, + ], wc: [{ label: 'man7.org (Linux)', url: `${M1}wc.1.html` }], sort: [ { label: 'man7.org (Linux)', url: `${M1}sort.1.html` }, diff --git a/src/app/data/commandExamples.ts b/src/app/data/commandExamples.ts index 46560c0..1db0bc6 100644 --- a/src/app/data/commandExamples.ts +++ b/src/app/data/commandExamples.ts @@ -81,6 +81,10 @@ export const COMMAND_EXAMPLES: Record = { { command: 'Remove-Item documents\\rapport.md', explanation: 'Supprime le fichier définitivement, sans passer par la corbeille.', environments: WIN }, { command: 'Remove-Item downloads -Recurse', explanation: '-Recurse supprime un dossier et tout ce qu\'il contient.', environments: WIN }, ], + rmdir: [ + { command: 'rmdir downloads', explanation: 'Supprime un dossier vide. Avec un dossier qui contient quelque chose, rmdir refuse : c\'est une sécurité, là où rm -r supprimerait tout.', environments: UNIX }, + { command: 'rmdir downloads', explanation: 'Dans PowerShell, rmdir est un alias de Remove-Item : un dossier vide est supprimé ; pour un dossier plein, PowerShell demande une confirmation.', environments: WIN }, + ], // ─── Lecture de fichiers ────────────────────────────────── cat: [ @@ -109,8 +113,17 @@ export const COMMAND_EXAMPLES: Record = { find: [ { command: 'find . -name "*.txt"', explanation: 'Cherche, depuis le dossier courant (.) et dans tous ses sous-dossiers, les fichiers dont le nom finit par .txt. Les guillemets empêchent le shell de remplacer * trop tôt.', environments: UNIX }, { command: 'find . -type d', explanation: 'Liste uniquement les dossiers.', environments: UNIX }, + { command: 'find . -maxdepth 1 -type f', explanation: '-maxdepth 1 reste dans le dossier courant, sans descendre dans les sous-dossiers : ici, les fichiers (cachés compris) du dossier personnel.', environments: UNIX }, + { command: 'find . -empty', explanation: 'Les fichiers et dossiers vides : ici, le dossier downloads.', environments: UNIX }, + { command: 'find . -name "*.md" -exec wc -l {} +', explanation: '-exec lance une commande sur ce que find trouve : {} est remplacé par les chemins, et + les donne tous en une fois. Avec \\; à la place de +, la commande tourne une fois par fichier.', environments: UNIX }, { command: 'Get-ChildItem -Recurse -Filter *.txt', explanation: 'L\'équivalent PowerShell : cherche les fichiers .txt dans le dossier courant et tous ses sous-dossiers.', environments: WIN }, ], + xargs: [ + { command: 'find . -name "*.md" | xargs wc -l', explanation: 'xargs transforme les lignes reçues par le pipe en arguments : wc -l reçoit les deux chemins, comme si on les avait tapés.', environments: UNIX }, + { command: 'find . -name "*.txt" | xargs grep -l Apprendre', explanation: 'Les fichiers trouvés par find qui contiennent le mot : grep -l n\'affiche que leur nom.', environments: UNIX }, + { command: 'echo "a b c" | xargs -n 1 echo', explanation: '-n 1 lance la commande une fois par argument : trois echo, trois lignes.', environments: UNIX }, + { command: 'Get-ChildItem -Recurse -Filter *.md | ForEach-Object { Get-Content $_ }', explanation: 'PowerShell n\'a pas besoin de xargs : le pipe transmet des objets, et ForEach-Object lance une commande pour chacun ($_).', environments: WIN }, + ], wc: [ { command: 'wc documents/notes.txt', explanation: 'Compte les lignes, les mots et les octets du fichier, dans cet ordre.', environments: UNIX }, { command: 'wc -l documents/notes.txt', explanation: '-l ne compte que les lignes.', environments: UNIX }, @@ -474,8 +487,8 @@ export const COMMAND_EXAMPLES: Record = { export const NOT_SIMULATED: Record = { tree: ['linux', 'macos', 'windows'], less_more: ['linux', 'macos', 'windows'], - find: ['linux', 'macos', 'windows'], cut: ['windows'], + xargs: ['windows'], getcomputerinfo: ['windows'], alias: ['linux', 'macos', 'windows'], id: ['linux', 'macos', 'windows'], diff --git a/src/app/data/commands/find.ts b/src/app/data/commands/find.ts new file mode 100644 index 0000000..b6a39bf --- /dev/null +++ b/src/app/data/commands/find.ts @@ -0,0 +1,472 @@ +import type { FSNode, OutputLine } from './types'; +import { helpNote } from './textTools'; + +/** + * `find` and `xargs` as GNU findutils 4.9.0 runs them on Ubuntu 24.04 (WSL 2), + * captured on 2 October 2026 in a copy of the simulator's home. + * + * Order: a real `find` lists a folder in the order the file system stores it, + * which on Linux (ext4) changes from one machine to another. The simulator + * lists each folder by name, ignoring case, as Windows' NTFS does (Git Bash's + * `find`); lessons pipe into `sort` when the order matters. + * + * macOS ships BSD find, which needs a starting folder (`find . -name x`); with + * no Mac to check it on, the macOS terminal shows the GNU behaviour. + */ + +export interface FindDeps { + /** The node at a path typed by the learner, in the current state (it changes as -exec and -delete run). */ + node(typed: string): FSNode | undefined; + /** Runs one simple command (no pipe, no redirection) in the current state. */ + run(line: string): { lines: OutputLine[]; ok: boolean; notFound: boolean }; +} + +export interface FindResult { lines: OutputLine[]; status: number } + +const err = (text: string): OutputLine => ({ text, type: 'error' }); +const out = (text: string): OutputLine => ({ text, type: 'output' }); +const note = (text: string): FindResult => ({ lines: [{ text, type: 'info' }], status: 0 }); +const utf8 = new TextEncoder(); + +/** A word for the command line `-exec` and `xargs` build: quoted, so spaces and `;` stay inside it. */ +export const shellWord = (word: string) => (/^[\w./@%+=:,-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`); + +/** fnmatch without flags, as -name uses it: `*` and `?` also match a leading dot. */ +function globRegex(pattern: string, ignoreCase: boolean): RegExp { + let re = ''; + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]; + if (c === '*') re += '.*'; + else if (c === '?') re += '.'; + else if (c === '[') { + const end = pattern.indexOf(']', i + 2); + if (end < 0) { re += '\\['; continue; } + let body = pattern.slice(i + 1, end); + if (body.startsWith('!')) body = `^${body.slice(1)}`; + re += `[${body.replace(/\\/g, '\\\\')}]`; + i = end; + } else if (c === '\\' && i + 1 < pattern.length) re += `\\${pattern[++i]}`.replace(/^\\(\w)$/, '$1'); + else re += c.replace(/[.+^${}()|\\]/g, '\\$&'); + } + try { + return new RegExp(`^${re}$`, ignoreCase ? 'is' : 's'); + } catch { + // A pattern fnmatch cannot read (`[z-a]`) matches nothing, without an error. + return /(?!)/; + } +} + +/** find's -regex speaks Emacs: `\(`, `\)` and `\|` group and alternate, bare ( ) | { } are literal. */ +function emacsRegex(pattern: string): string { + let re = ''; + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]; + const next = pattern[i + 1]; + if (c === '\\' && next !== undefined && '()|'.includes(next)) { re += next; i++; } + else if (c === '\\' && next !== undefined) { re += c + next; i++; } + else if ('()|{}'.includes(c)) re += `\\${c}`; + else re += c; + } + return re; +} + +// ─── Expression ──────────────────────────────────────────────────────────── + +interface Entry { path: string; name: string; node: FSNode; depth: number } + +type Expr = + | { kind: 'and' | 'or'; left: Expr; right: Expr } + | { kind: 'not'; expr: Expr } + | { kind: 'test'; test: (e: Entry) => boolean } + | { kind: 'print' } + | { kind: 'delete' } + | { kind: 'exec'; argv: string[]; batch: boolean; id: number } + | { kind: 'true' }; + +/** Tests and actions this simulator does not act on: a note rather than a wrong answer. */ +const NOT_SIMULATED = new Map([ + ['-mtime', 1], ['-mmin', 1], ['-atime', 1], ['-amin', 1], ['-ctime', 1], ['-cmin', 1], + ['-perm', 1], ['-user', 1], ['-group', 1], ['-ok', -1], ['-okdir', -1], + ['-execdir', -1], ['-print0', 0], ['-printf', 1], ['-fprint', 1], ['-ls', 0], ['-links', 1], ['-inum', 1], + ['-samefile', 1], ['-depth', 0], ['-prune', 0], ['-quit', 0], ['-xdev', 0], ['-mount', 0], ['-follow', 0], +]); + +const SIZE_UNITS: Record = { b: 512, c: 1, w: 2, k: 1024, M: 1024 ** 2, G: 1024 ** 3 }; + +function sizeOf(node: FSNode): number { + return node.type === 'directory' ? 4096 : node.content === '' ? 0 : utf8.encode(node.content).length + 1; +} + +interface Parsed { expr: Expr; maxDepth: number; minDepth: number; hasAction: boolean; deletes: boolean } + +/** Parses the expression after the starting points, as GNU find does: `!`, `-a`, `-o`, parentheses. */ +function parseExpression(tokens: string[], deps: FindDeps): Parsed | FindResult { + let i = 0; + let maxDepth = Infinity; + let minDepth = 0; + let hasAction = false; + let deletes = false; + let execs = 0; + let failure: FindResult | undefined; + // A "not simulated" note gives way to a real error later in the line (`-mtime 1 -bogus`). + let notice: FindResult | undefined; + const fail = (text: string) => { failure ??= { lines: [err(text)], status: 1 }; return { kind: 'true' } as Expr; }; + const value = () => (i >= tokens.length ? undefined : tokens[i++]); + + const primary = (): Expr => { + if (i >= tokens.length) return fail(`find: expected an expression after '${tokens[i - 1]}'`); + const t = tokens[i++]; + if (t === '(') { + const inner = or(); + if (tokens[i] !== ')') return fail("find: invalid expression; you have too many '('"); + i++; + return inner; + } + if (t === '!' || t === '-not') return { kind: 'not', expr: primary() }; + const needs = (name: string) => { + const v = value(); + if (v === undefined) fail(`find: missing argument to \`${name}'`); + return v ?? ''; + }; + switch (t) { + case '-name': + case '-iname': { + const re = globRegex(needs(t), t === '-iname'); + return { kind: 'test', test: (e) => re.test(e.name) }; + } + case '-path': + case '-ipath': + case '-wholename': { + const re = globRegex(needs(t), t === '-ipath'); + return { kind: 'test', test: (e) => re.test(e.path) }; + } + case '-type': { + const kind = needs(t); + if (!kind) return { kind: 'true' }; + // findutils 4.9 takes a list (`-type f,d`). + const kinds = kind.split(','); + const bad = kinds.find((k) => !/^[bcdpflsD]$/.test(k)); + if (bad !== undefined) return fail(`find: Unknown argument to -type: ${bad}`); + // The simulator has files and folders only: no links, devices, pipes or sockets. + return { kind: 'test', test: (e) => kinds.includes(e.node.type === 'file' ? 'f' : 'd') }; + } + case '-empty': + return { kind: 'test', test: (e) => (e.node.type === 'directory' ? Object.keys(e.node.children).length === 0 : e.node.content === '') }; + case '-size': { + const spec = needs(t); + const m = spec.match(/^([+-]?)(\d+)([bcwkMG]?)$/); + if (!spec) return { kind: 'true' }; + if (!m) return fail(`find: Invalid argument \`${spec}' to -size`); + const unit = SIZE_UNITS[m[3] || 'b']; + const n = parseInt(m[2], 10); + // Sizes are rounded up to the unit: -size -1k only matches empty files. + return { + kind: 'test', + test: (e) => { + const units = Math.ceil(sizeOf(e.node) / unit); + return m[1] === '+' ? units > n : m[1] === '-' ? units < n : units === n; + }, + }; + } + case '-maxdepth': + case '-mindepth': { + const v = needs(t); + if (!/^\d+$/.test(v)) return fail(`find: Expected a positive decimal integer argument to ${t}, but got ‘${v}’`); + if (t === '-maxdepth') maxDepth = parseInt(v, 10); + else minDepth = parseInt(v, 10); + return { kind: 'true' }; + } + case '-print': + hasAction = true; + return { kind: 'print' }; + case '-delete': + hasAction = true; + deletes = true; + return { kind: 'delete' }; + case '-exec': { + const argv: string[] = []; + while (i < tokens.length && tokens[i] !== ';' && !(tokens[i] === '+' && tokens[i - 1] === '{}')) argv.push(tokens[i++]); + if (i >= tokens.length || argv.length === 0) return fail("find: missing argument to `-exec'"); + const batch = tokens[i] === '+'; + i++; + hasAction = true; + return { kind: 'exec', argv, batch, id: execs++ }; + } + case '-regex': + case '-iregex': { + // The whole path must match. + const pattern = needs(t); + try { + const re = new RegExp(`^(?:${emacsRegex(pattern)})$`, t === '-iregex' ? 'is' : 's'); + return { kind: 'test', test: (e) => re.test(e.path) }; + } catch { + notice ??= note(`Cette expression de ${t} n'est pas simulée dans ce terminal d'entraînement.`); + return { kind: 'true' }; + } + } + case '-newer': { + // Times are not simulated, but a missing reference file fails as in find. + const reference = needs(t); + if (reference && !deps.node(reference)) return fail(`find: ‘${reference}’: No such file or directory`); + notice ??= note("Le test -newer de find n'est pas simulé dans ce terminal d'entraînement : les fichiers n'y ont pas de date de modification."); + return { kind: 'true' }; + } + case '-true': + return { kind: 'true' }; + case '-false': + return { kind: 'test', test: () => false }; + default: { + if (NOT_SIMULATED.has(t)) { + // Skip its arguments too: one value, or a whole command up to `;` (-ok, -execdir). + const arity = NOT_SIMULATED.get(t)!; + if (arity === 1) i++; + else if (arity === -1) { while (i < tokens.length && tokens[i] !== ';' && tokens[i] !== '+') i++; i++; } + notice ??= note(`Le test ${t} de find n'est pas simulé dans ce terminal d'entraînement.`); + return { kind: 'true' }; + } + if (t?.startsWith('-')) return fail(`find: unknown predicate \`${t}'`); + return fail(`find: paths must precede expression: \`${t}'`); + } + } + }; + + const and = (): Expr => { + let left = primary(); + while (i < tokens.length && tokens[i] !== ')' && tokens[i] !== '-o' && tokens[i] !== '-or') { + if (tokens[i] === '-a' || tokens[i] === '-and') i++; + left = { kind: 'and', left, right: primary() }; + } + return left; + }; + const or = (): Expr => { + let left = and(); + while (tokens[i] === '-o' || tokens[i] === '-or') { + i++; + left = { kind: 'or', left, right: and() }; + } + return left; + }; + + const expr: Expr = tokens.length ? or() : { kind: 'true' }; + if (!failure && i < tokens.length) fail(`find: unexpected extra predicate '${tokens[i]}'`); + if (failure) return failure; + if (notice) return notice; + return { expr, maxDepth, minDepth, hasAction, deletes }; +} + +/** Folders listed by name, case ignored, like NTFS (see the note at the top). */ +const byName = (a: string, b: string) => { + const x = a.toUpperCase(); + const y = b.toUpperCase(); + return x < y ? -1 : x > y ? 1 : a < b ? -1 : a > b ? 1 : 0; +}; + +/** `find [path...] [expression]` (GNU findutils 4.9.0). */ +export function runFind(args: string[], deps: FindDeps): FindResult { + if (args[0] === '--help' || args[0] === '--version') return helpNote('find', args[0] === '--help' ? 'help' : 'version'); + const firstExpr = args.findIndex((a) => a.startsWith('-') || a === '(' || a === '!'); + const starts = firstExpr < 0 ? args : args.slice(0, firstExpr); + const parsed = parseExpression(firstExpr < 0 ? [] : args.slice(firstExpr), deps); + if ('status' in parsed) return parsed; + const expr: Expr = parsed.hasAction ? parsed.expr : { kind: 'and', left: parsed.expr, right: { kind: 'print' } }; + + const lines: OutputLine[] = []; + let status = 0; + const batches = new Map(); + const runExec = (argv: string[], paths: string[]) => { + const words = argv.flatMap((a) => (a === '{}' && paths.length > 1 ? paths : [a.split('{}').join(paths[0])])); + const result = deps.run(words.map(shellWord).join(' ')); + if (result.notFound) { lines.push(err(`find: ‘${words[0]}’: No such file or directory`)); return false; } + lines.push(...result.lines); + return result.ok; + }; + + const evaluate = (e: Expr, entry: Entry): boolean => { + switch (e.kind) { + case 'and': return evaluate(e.left, entry) && evaluate(e.right, entry); + case 'or': return evaluate(e.left, entry) || evaluate(e.right, entry); + case 'not': return !evaluate(e.expr, entry); + case 'test': return e.test(entry); + case 'true': return true; + case 'print': lines.push(out(entry.path)); return true; + case 'exec': + if (!e.batch) return runExec(e.argv, [entry.path]); + if (!batches.has(e.id)) batches.set(e.id, { argv: e.argv, paths: [] }); + batches.get(e.id)!.paths.push(entry.path); + return true; + case 'delete': { + // Deleted bottom-up (-delete implies -depth); a folder goes once it is empty. find never deletes ".". + if (entry.path === '.') return true; + const live = deps.node(entry.path); + if (!live) return true; + if (live.type === 'directory' && Object.keys(live.children).length) { + lines.push(err(`find: cannot delete ‘${entry.path}’: Directory not empty`)); + status = 1; + return false; + } + deps.run(`rm -r -- ${shellWord(entry.path)}`); + return true; + } + } + }; + + const visit = (entry: Entry) => { + const children = entry.node.type === 'directory' && entry.depth < parsed.maxDepth + ? Object.keys(entry.node.children).sort(byName).map((name) => ({ + path: entry.path.endsWith('/') ? `${entry.path}${name}` : `${entry.path}/${name}`, + name, + node: (entry.node as Extract).children[name], + depth: entry.depth + 1, + })) + : []; + if (!parsed.deletes && entry.depth >= parsed.minDepth) evaluate(expr, entry); + for (const child of children) visit(child); + // After its contents went, a folder is tested as it is now (`-type d -empty -delete` removes parents too). + if (parsed.deletes && entry.depth >= parsed.minDepth) { + const live = deps.node(entry.path); + if (live) evaluate(expr, { ...entry, node: live }); + } + }; + + for (const start of starts.length ? starts : ['.']) { + const node = deps.node(start); + if (!node) { + lines.push(err(`find: ‘${start}’: No such file or directory`)); + status = 1; + continue; + } + const name = start === '/' ? '/' : start.replace(/\/+$/, '').split('/').pop() || start; + visit({ path: start, name, node, depth: 0 }); + } + for (const { argv, paths } of batches.values()) if (paths.length && !runExec(argv, paths)) status = 1; + return { lines, status }; +} + +// ─── xargs ───────────────────────────────────────────────────────────────── + +/** Splits xargs input into arguments: blanks and newlines separate, quotes and backslashes group. */ +function xargsWords(text: string): string[] | string { + const words: string[] = []; + let word = ''; + let inWord = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === "'" || c === '"') { + const end = text.indexOf(c, i + 1); + const nl = text.indexOf('\n', i + 1); + if (end < 0 || (nl >= 0 && nl < end)) { + return `xargs: unmatched ${c === "'" ? 'single' : 'double'} quote; by default quotes are special to xargs unless you use the -0 option`; + } + word += text.slice(i + 1, end); + inWord = true; + i = end; + } else if (c === '\\' && i + 1 < text.length) { + word += text[++i]; + inWord = true; + } else if (/\s/.test(c)) { + if (inWord) words.push(word); + word = ''; + inWord = false; + } else { + word += c; + inWord = true; + } + } + if (inWord) words.push(word); + return words; +} + +const XARGS_HELP = "Try 'xargs --help' for more information."; + +/** `xargs [options] [command [initial-arguments]]` (GNU findutils 4.9.0). `stdin` is undefined without a pipe. */ +export function runXargs(args: string[], stdin: string | undefined, deps: FindDeps): FindResult { + const fail = (...texts: string[]): FindResult => ({ lines: texts.map(err), status: 1 }); + let maxArgs: number | undefined; + let maxLines: number | undefined; + let replace: string | undefined; + let noRunIfEmpty = false; + let trace = false; + let i = 0; + for (; i < args.length; i++) { + const a = args[i]; + if (a === '--') { i++; break; } + if (!a.startsWith('-') || a === '-') break; + if (a === '--help' || a === '--version') return helpNote('xargs', a === '--help' ? 'help' : 'version'); + if (a === '--no-run-if-empty') { noRunIfEmpty = true; continue; } + if (a === '--verbose') { trace = true; continue; } + const long = a.match(/^--(max-args|max-lines|replace)(?:=(.*))?$/); + if (long) { + if (long[1] === 'replace') { replace = long[2] ?? '{}'; maxLines = 1; continue; } + const v = long[2] ?? args[++i]; + if (v === undefined) return fail(`xargs: option '--${long[1]}' requires an argument`, XARGS_HELP); + if (!/^\d+$/.test(v)) return fail(`xargs: invalid number "${v}" for -${long[1] === 'max-args' ? 'n' : 'L'} option`, XARGS_HELP); + if (parseInt(v, 10) < 1) return fail(`xargs: value ${v} for -${long[1] === 'max-args' ? 'n' : 'L'} option should be >= 1`, XARGS_HELP); + if (long[1] === 'max-args') maxArgs = parseInt(v, 10); else maxLines = parseInt(v, 10); + continue; + } + if (a.startsWith('--')) return fail(`xargs: unrecognized option '${a}'`, XARGS_HELP); + for (let j = 1; j < a.length; j++) { + const c = a[j]; + if ('nLI'.includes(c)) { + const v = j + 1 < a.length ? a.slice(j + 1) : args[++i]; + if (v === undefined) return fail(`xargs: option requires an argument -- '${c}'`, XARGS_HELP); + if (c === 'I') { replace = v; maxLines = 1; } + else { + const n = parseInt(v, 10); + if (!/^\d+$/.test(v)) return fail(`xargs: invalid number "${v}" for -${c} option`, XARGS_HELP); + if (n < 1) return fail(`xargs: value ${v} for -${c} option should be >= 1`, XARGS_HELP); + if (c === 'n') maxArgs = n; else maxLines = n; + } + break; + } + // -i is the old spelling of -I {}. + if (c === 'i') { replace = j + 1 < a.length ? a.slice(j + 1) : '{}'; maxLines = 1; break; } + if (c === 'r') noRunIfEmpty = true; + else if (c === 't') trace = true; + else if (c === 'x') continue; + else if ('0dPpsaE'.includes(c)) return note(`L'option -${c} de xargs n'est pas simulée dans ce terminal d'entraînement.`); + else return fail(`xargs: invalid option -- '${c}'`, XARGS_HELP); + } + } + const command = args.slice(i).length ? args.slice(i) : ['echo']; + if (stdin === undefined) { + return note(`xargs attend du texte tapé au clavier quand il n'a pas de pipe. Donnez-lui la sortie d'une autre commande, par exemple find . -name "*.txt" | xargs ${command.join(' ')}.`); + } + + // Each command gets a group of arguments: all of them, -n at a time, or -L / -I lines at a time. + let groups: string[][]; + if (maxLines !== undefined) { + const lines = stdin.split('\n').filter((l) => l.trim() !== ''); + groups = []; + for (let k = 0; k < lines.length; k += maxLines) { + const chunk = lines.slice(k, k + maxLines); + if (replace !== undefined) groups.push([chunk[0].replace(/^\s+/, '')]); + else { + const words = xargsWords(chunk.join('\n')); + if (typeof words === 'string') return fail(words); + groups.push(words); + } + } + } else { + const words = xargsWords(stdin); + if (typeof words === 'string') return fail(words); + groups = []; + for (let k = 0; k < words.length; k += maxArgs ?? (words.length || 1)) groups.push(words.slice(k, k + (maxArgs ?? words.length))); + } + // Without input, GNU xargs still runs the command once (unless -r); -I never does. + if (!groups.length && !noRunIfEmpty && replace === undefined) groups = [[]]; + + const lines: OutputLine[] = []; + let status = 0; + for (const group of groups) { + const argv = replace !== undefined ? command.map((w) => w.split(replace!).join(group[0])) : [...command, ...group]; + const line = argv.map(shellWord).join(' '); + // -t writes each command on standard error before running it. + if (trace) lines.push(err(argv.map(shellWord).join(' '))); + const result = deps.run(line); + if (result.notFound) return { lines: [...lines, err(`xargs: ${command[0]}: No such file or directory`)], status: 127 }; + lines.push(...result.lines); + if (!result.ok) status = 123; + } + return { lines, status }; +} diff --git a/src/app/data/commands/shellSyntax.ts b/src/app/data/commands/shellSyntax.ts index 962a3b3..dd71606 100644 --- a/src/app/data/commands/shellSyntax.ts +++ b/src/app/data/commands/shellSyntax.ts @@ -54,6 +54,12 @@ function tokenize(line: string, env: TerminalEnv): { tokens: Token[] } | { error const next = line[i + 1]; if (quote) { + // In bash, `\"` and `\\` inside double quotes do not end the string (`echo "say \"hi\""`). + if (quote === '"' && escape === '\\' && ch === '\\' && (next === '"' || next === '\\')) { + word += ch + next; + i++; + continue; + } word += ch; if (ch === quote) quote = ''; continue; diff --git a/src/app/data/commands/textTools.ts b/src/app/data/commands/textTools.ts index 779c403..443edda 100644 --- a/src/app/data/commands/textTools.ts +++ b/src/app/data/commands/textTools.ts @@ -29,6 +29,14 @@ const notSimulated = (cmd: string, option: string): TextToolResult => ({ status: 0, }); +/** `--help` and `--version`: what a real system prints is long; say where to look instead of faking it. */ +export function helpNote(cmd: string, option: 'help' | 'version'): TextToolResult { + const text = option === 'help' + ? `Sur un vrai système, ${cmd} --help affiche le mode d'emploi complet de ${cmd}. Ici, la page Référence en donne l'essentiel, avec des exemples à essayer.` + : `Sur un vrai système, ${cmd} --version affiche sa version et sa licence. Ce terminal d'entraînement imite GNU ${cmd === 'find' || cmd === 'xargs' ? 'findutils 4.9' : 'coreutils 9.4'}.`; + return { lines: [{ text, type: 'info' }], status: 0 }; +} + /** * With neither a file nor a pipe, the real tool waits for text typed at the * keyboard (until Ctrl+D); the practice terminal says what to give it instead. @@ -105,12 +113,7 @@ function getopt(cmd: string, args: string[], spec: OptionSpec): Parsed | TextToo const known = Object.prototype.hasOwnProperty.call(spec.long, name) ? spec.long[name] : undefined; if (!known) { if (spec.unsupportedLong.includes(name)) return notSimulated(cmd, `--${name}`); - if (name === 'help') { - return { - lines: [{ text: `Sur un vrai système, ${cmd} --help affiche le mode d'emploi complet de ${cmd}. Ici, la page Référence en donne l'essentiel, avec des exemples à essayer.`, type: 'info' }], - status: 0, - }; - } + if (name === 'help' || name === 'version') return helpNote(cmd, name); return fail(`${cmd}: unrecognized option '${arg}'`, tryHelp(cmd)); } if (known.valued) { @@ -523,6 +526,88 @@ export function runCut(args: string[], stdin: string | undefined, read: ReadText return { lines, status }; } +// ─── wc ──────────────────────────────────────────────────────────────────── + +const WC_SPEC: OptionSpec = { + flags: 'lwcmL', + valued: '', + unsupported: '', + long: { + lines: { key: 'l', valued: false }, words: { key: 'w', valued: false }, bytes: { key: 'c', valued: false }, + chars: { key: 'm', valued: false }, 'max-line-length': { key: 'L', valued: false }, + }, + unsupportedLong: ['files0-from', 'total'], +}; + +/** + * The counts of a text as the simulator stores it. A file's content has no + * final newline here, but stands for a file that ends with one (what an + * editor writes): `wc` counts that newline, as it did on the captured files. + */ +function wcCounts(text: string) { + if (text === '') return { l: 0, w: 0, m: 0, c: 0, L: 0 }; + const lines = text.split('\n'); + const width = (line: string) => [...line].reduce((col, ch) => (ch === '\t' ? col + 8 - (col % 8) : col + 1), 0); + return { + l: lines.length, + w: text.split(/\s+/).filter(Boolean).length, + m: [...text].length + 1, + c: utf8.encode(text).length + 1, + L: Math.max(...lines.map(width)), + }; +} + +/** + * `wc` in GNU coreutils 9.4. Widths follow wc.c: one count of one input is + * not padded; otherwise every count is as wide as the total size of the files + * in digits, at least 7 when an input is not a regular file (a pipe, a folder). + */ +export function runWc(args: string[], stdin: string | undefined, read: ReadText): TextToolResult { + const parsed = getopt('wc', args, WC_SPEC); + if ('status' in parsed) return parsed; + const waiting = waitsForKeyboard('wc', parsed.operands, stdin); + if (waiting) return waiting; + const asked = new Set(parsed.options.map((o) => o.key)); + // Printed in this order whatever the order typed; none asked means lines, words, bytes. + const keys = (['l', 'w', 'm', 'c', 'L'] as const).filter((k) => asked.has(k)); + if (!keys.length) keys.push('l', 'w', 'c'); + + type Input = { name?: string; counts?: ReturnType; error?: string; regular: boolean }; + const inputs: Input[] = (parsed.operands.length ? parsed.operands : ['-']).map((name) => { + if (name === '-') return { counts: wcCounts(stdin ?? ''), regular: false, name: parsed.operands.length ? '-' : undefined }; + const file = read(name); + if ('content' in file) return { name, counts: wcCounts(file.content), regular: true }; + return file.error === 'directory' + ? { name, error: `wc: ${name}: Is a directory`, counts: wcCounts(''), regular: false } + : { name, error: `wc: ${name}: No such file or directory`, regular: false }; + }); + + // wc.c's compute_number_width: skipped when the first input could not be opened. + let width = 1; + if (!(keys.length === 1 && inputs.length === 1) && inputs[0].counts) { + const opened = inputs.filter((i) => i.counts); + let total = opened.filter((i) => i.regular).reduce((sum, i) => sum + i.counts!.c, 0); + while (total >= 10) { width++; total = Math.floor(total / 10); } + // Standard input counts as a pipe here (`wc < file` reads a regular file on a real system). + if (opened.some((i) => !i.regular)) width = Math.max(width, 7); + } + const row = (counts: ReturnType, name?: string) => + [...keys.map((k) => String(counts[k]).padStart(width)), ...(name !== undefined ? [name] : [])].join(' '); + + const lines: OutputLine[] = []; + const total = { l: 0, w: 0, m: 0, c: 0, L: 0 }; + let status = 0; + for (const input of inputs) { + if (input.error) { lines.push(...err(input.error)); status = 1; } + if (!input.counts) continue; + lines.push({ text: row(input.counts, input.name), type: 'output' }); + for (const k of ['l', 'w', 'm', 'c'] as const) total[k] += input.counts[k]; + total.L = Math.max(total.L, input.counts.L); + } + if (inputs.length > 1) lines.push({ text: row(total, 'total'), type: 'output' }); + return { lines, status }; +} + // ─── PowerShell ──────────────────────────────────────────────────────────── /** Parameters as PowerShell 7.6 lists them (`(Get-Command Sort-Object).Parameters`, 2 October 2026). */ diff --git a/src/app/data/curriculum.ts b/src/app/data/curriculum.ts index 4d6ff5c..b71028f 100644 --- a/src/app/data/curriculum.ts +++ b/src/app/data/curriculum.ts @@ -876,7 +876,7 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '$ wc documents/notes.txt\n 6 22 143 documents/notes.txt', + content: '$ wc documents/notes.txt\n 6 22 143 documents/notes.txt', label: 'wc complet (lignes mots octets)', }, { diff --git a/src/app/data/landingContent.ts b/src/app/data/landingContent.ts index 24cf5e6..ca6c3a4 100644 --- a/src/app/data/landingContent.ts +++ b/src/app/data/landingContent.ts @@ -15,7 +15,7 @@ import type { SelectedEnvironment } from '../context/EnvironmentContext'; // `src/test/landingTotals.test.ts` re-imports both and fails if these // constants get out of sync with the actual catalogue/curriculum. export const TOTAL_LESSONS = 66; -export const TOTAL_COMMANDS = 79; +export const TOTAL_COMMANDS = 81; /** Count of environments with `status: 'active'` in `types/curriculum.ts`. */ export const ACTIVE_ENVIRONMENTS_COUNT = 3; @@ -143,7 +143,7 @@ export const ROADMAP_IN_PROGRESS: readonly RoadmapGroup[] = [ { group: 'Des bases au niveau intermédiaire', items: [ - 'Trier, compter et découper du texte : sort, uniq et cut sont là ; find, xargs et diff arrivent', + 'Chercher, trier, compter et découper : sort, uniq, cut, find et xargs sont là ; diff arrive', 'Archives et disque, sed et awk, les éditeurs nano et vim', 'Enquêter dans des journaux, écrire ses premiers scripts', 'Une mission finale sans indice, puis une page « Et après ? » vers des labos gratuits pour chaque métier', diff --git a/src/app/data/terminalEngine.ts b/src/app/data/terminalEngine.ts index a965278..55ab40b 100644 --- a/src/app/data/terminalEngine.ts +++ b/src/app/data/terminalEngine.ts @@ -11,8 +11,10 @@ import { cmdEnv, handleEnv } from './commands/env'; import { handleWindows } from './commands/windows'; import { GET_CHILD_ITEM, parsePsArgs, psNotSimulated } from './commands/psParams'; import type { PsCmdlet } from './commands/psParams'; -import { GET_UNIQUE, GROUP_OBJECT, SORT_OBJECT, psGetUnique, psGroupLines, psSortLines, runCut, runSort, runUniq } from './commands/textTools'; +import { GET_UNIQUE, GROUP_OBJECT, SORT_OBJECT, helpNote, psGetUnique, psGroupLines, psSortLines, runCut, runSort, runUniq, runWc } from './commands/textTools'; import type { ReadText } from './commands/textTools'; +import { runFind, runXargs } from './commands/find'; +import type { FindDeps } from './commands/find'; import { parseCommandLine, isPlainCommand, isNullDevice } from './commands/shellSyntax'; import type { Stage, Fd } from './commands/shellSyntax'; import { UNIX_DEFAULT_PATH, varsForEnv } from './commands/shellVars'; @@ -334,6 +336,15 @@ function parseArgs(input: string, expand?: { vars: Record; env: i++; continue; } + // Outside quotes, bash drops a backslash and keeps the next character as typed (`find . -exec cat {} \;`, + // `\(`, `\*`); inside double quotes, only before $ ` " and \. PowerShell's escape is the backtick: + // there a backslash separates folders. + if (expand && !windows && ch === '\\' && i + 1 < input.length && (!inQuote || (quoteChar === '"' && '`"\\'.includes(input[i + 1])))) { + current += input[i + 1]; + if (!inQuote) quoted = true; + i++; + continue; + } // `~` at the start of an unquoted word is the home folder (`echo ~/documents`). if (expand && !windows && !inQuote && ch === '~' && !current && !quoted && /^(?:$|[/\s])/.test(input.slice(i + 1, i + 2))) { current += capValue(Object.prototype.hasOwnProperty.call(expand.vars, 'HOME') ? expand.vars.HOME : '/home/user'); @@ -904,10 +915,10 @@ function buildGrepRegex( } } -function cmdGrep(state: TerminalState, args: string[]): OutputLine[] { +function cmdGrep(state: TerminalState, args: string[]): { lines: OutputLine[]; status?: number } { const flags = args.filter((a) => a.startsWith('-')); const rest = args.filter((a) => !a.startsWith('-')); - if (rest.length < 2) return [{ text: 'grep: usage: grep [OPTIONS] PATTERN FILE', type: 'error' }]; + if (rest.length < 2) return { lines: [{ text: 'grep: usage: grep [OPTIONS] PATTERN FILE', type: 'error' }], status: 2 }; const [pattern, ...paths] = rest; const showLineNumbers = flags.some((f) => f.includes('n')); @@ -915,7 +926,7 @@ function cmdGrep(state: TerminalState, args: string[]): OutputLine[] { const recursive = flags.some((f) => /^-[^-]*[rR]/.test(f) || f === '--recursive'); const regexResult = buildGrepRegex(pattern, ignoreCase ? 'i' : ''); - if (!regexResult.ok) return [regexResult.error]; + if (!regexResult.ok) return { lines: [regexResult.error], status: 2 }; const regex = regexResult.regex; // Files to search, as grep names them: a directory given with -r expands to its @@ -939,15 +950,30 @@ function cmdGrep(state: TerminalState, args: string[]): OutputLine[] { // Like grep: the file name prefixes each match as soon as several files can match. const withName = recursive || paths.length > 1; + const invert = flags.some((f) => /^-[^-]*v/.test(f)); + const count = flags.some((f) => /^-[^-]*c/.test(f)); + const listMatching = flags.some((f) => /^-[^-]*l/.test(f)); + const listOthers = flags.some((f) => /^-[^-]*L/.test(f)); const out: OutputLine[] = []; + let selected = false; for (const { name, content } of targets) { - content.split('\n').forEach((line, i) => { - if (!regex.test(line)) return; + const hits = content.split('\n').map((line, i) => ({ line, i })).filter(({ line }) => regex.test(line) !== invert); + // -l / -L list file names (GNU grep 3.11, 2 October 2026), -c counts per file. + if (listMatching || listOthers) { + if ((hits.length > 0) === listMatching) out.push({ text: name, type: 'output' }); + // grep 3.11: with -L too, the exit status says whether a line matched, not whether a name was listed. + if (hits.length) selected = true; + continue; + } + if (hits.length) selected = true; + if (count) { out.push({ text: `${withName ? `${name}:` : ''}${hits.length}`, type: 'output' }); continue; } + for (const { line, i } of hits) { const prefix = `${withName ? `${name}:` : ''}${showLineNumbers ? `${i + 1}:` : ''}`; out.push({ text: `${prefix}${line}`, type: 'output' }); - }); + } } - return [...out, ...errors]; + // Exit status: 0 when a line (or file) was selected, 1 when none, 2 after an error. + return { lines: [...out, ...errors], status: errors.length ? 2 : selected ? 0 : 1 }; } function cmdHeadTail(state: TerminalState, args: string[], cmd: 'head' | 'tail'): OutputLine[] { @@ -1448,13 +1474,13 @@ function lineCount(args: string[], fallback: number): number { * `sort`, `uniq` and `cut` (GNU): from their files, from standard input, or * both (`-`). `sort -o` and `uniq in out` write a file instead of the screen. */ -function runTextTool(state: TerminalState, cmd: 'sort' | 'uniq' | 'cut', args: string[], stdin: string | undefined): CommandOutput { +function runTextTool(state: TerminalState, cmd: 'sort' | 'uniq' | 'cut' | 'wc', args: string[], stdin: string | undefined): CommandOutput { const read: ReadText = (p) => { const node = getNode(state.root, resolvePath(state, p)); if (!node) return { error: 'missing' }; return node.type === 'directory' ? { error: 'directory' } : { content: node.content }; }; - const result = (cmd === 'sort' ? runSort : cmd === 'uniq' ? runUniq : runCut)(args, stdin, read); + const result = ({ sort: runSort, uniq: runUniq, cut: runCut, wc: runWc })[cmd](args, stdin, read); if (!result.writeTo) return { lines: result.lines, newState: state, status: result.status }; const typed = result.writeTo.path; const path = resolvePath(state, typed); @@ -1468,6 +1494,103 @@ function runTextTool(state: TerminalState, cmd: 'sort' | 'uniq' | 'cut', args: s return { lines: [], newState: { ...state, root: writeFileAt(state.root, path, result.writeTo.lines.join('\n'), false) }, status: 0 }; } +/** Builtins of bash with no program of the same name on Ubuntu (`ls /usr/bin/cd` fails). */ +const SHELL_BUILTINS = new Set(['cd', 'export', 'alias', 'unalias', 'unset', 'source', '.', 'exit', 'set', 'history', 'pushd', 'popd', 'dirs', 'jobs', 'fg', 'bg', 'umask', 'read', 'eval', 'exec', 'shopt', 'declare', 'local', 'type', 'hash']); + +/** + * What `find -exec`, `find -delete` and `xargs` need: the node at a path and a + * way to run one command, both in a state that changes as those commands run. + */ +function commandRunner(start: TerminalState, env: TerminalEnv): { deps: FindDeps; state: () => TerminalState } { + let s = start; + return { + deps: { + node: (typed) => getNode(s.root, resolvePath(s, typed)) ?? undefined, + run: (line) => { + const name = parseArgs(line, expansionVars(s, env))[0] ?? ''; + // find and xargs start programs: a shell builtin (cd, export…) is not one. + if (SHELL_BUILTINS.has(name)) return { lines: [], ok: false, notFound: true }; + const result = runSimple(s, line, env); + s = result.newState; + const notFound = result.lines.length === 1 && result.lines[0].text.startsWith(`${name}: commande introuvable.`); + const ok = result.status !== undefined ? result.status === 0 : !result.lines.some((l) => l.type === 'error'); + return { lines: result.lines, ok, notFound }; + }, + }, + state: () => s, + }; +} + +const runHelpNote = (cmd: string, option: string) => { + const note = helpNote(cmd, option === '--help' ? 'help' : 'version'); + return { lines: note.lines, status: note.status }; +}; + +/** `rmdir`: removes empty folders only (GNU coreutils 9.4's messages, captured 2 October 2026). */ +function cmdRmdir(state: TerminalState, args: string[]): CommandOutput { + const fail = (...texts: string[]): CommandOutput => ({ lines: texts.map((text) => ({ text, type: 'error' as const })), newState: state, status: 1 }); + const help = "Try 'rmdir --help' for more information."; + let parents = false; + let verbose = false; + let ignoreNonEmpty = false; + const dirs: string[] = []; + let options = true; + for (const a of args) { + if (options && a === '--') { options = false; continue; } + if (options && a.startsWith('--')) { + if (a === '--parents') parents = true; + else if (a === '--verbose') verbose = true; + else if (a === '--ignore-fail-on-non-empty') ignoreNonEmpty = true; + else if (a === '--help' || a === '--version') return { ...runHelpNote('rmdir', a), newState: state }; + else return fail(`rmdir: unrecognized option '${a}'`, help); + continue; + } + if (options && a.startsWith('-') && a !== '-') { + for (const c of a.slice(1)) { + if (c === 'p') parents = true; + else if (c === 'v') verbose = true; + else return fail(`rmdir: invalid option -- '${c}'`, help); + } + continue; + } + dirs.push(a); + } + if (!dirs.length) return fail('rmdir: missing operand', help); + + let s = state; + const lines: OutputLine[] = []; + let status = 0; + const remove = (dir: string, parent = false): boolean => { + const node = getNode(s.root, resolvePath(s, dir)); + const last = dir.replace(/\/+$/, '').split('/').pop(); + const why = last === '.' || last === '..' ? 'Invalid argument' + : !node ? 'No such file or directory' + : node.type !== 'directory' ? 'Not a directory' + : Object.keys(node.children).length ? 'Directory not empty' : undefined; + if (why) { + if (!(why === 'Directory not empty' && ignoreNonEmpty)) { + lines.push({ text: `rmdir: failed to remove ${parent ? 'directory ' : ''}'${dir}': ${why}`, type: 'error' }); + status = 1; + } + return false; + } + if (verbose) lines.push({ text: `rmdir: removing directory, '${dir}'`, type: 'output' }); + const { newRoot } = cmdRm(s, ['-r', '--', dir]); + if (newRoot) s = { ...s, root: newRoot }; + return true; + }; + for (const dir of dirs) { + if (!remove(dir) || !parents) continue; + // -p: then each parent named in the path, a/b/c → a/b → a. + let path = dir.replace(/\/+$/, ''); + while (path.includes('/')) { + path = path.slice(0, path.lastIndexOf('/')); + if (!path || !remove(path, true)) break; + } + } + return { lines, newState: s, status }; +} + /** A PowerShell table as the simulator prints it, with the blank lines that frame it (`Group-Object`). */ function framedTable(input: string[]): { lead: boolean; header: string[]; rows: string[]; trail: boolean } { const lead = input[0] === ''; @@ -1547,6 +1670,7 @@ function runFilter(state: TerminalState, text: string, stdin: string, env: Termi switch (cmd) { case 'wc': { + if (env !== 'windows') return runTextTool(state, 'wc', args, stdin); const { lines, words, bytes } = textCounts(stdin); if (flags.some((f) => f.includes('l'))) return same(outLines([String(lines)])); if (flags.some((f) => f.includes('w'))) return same(outLines([String(words)])); @@ -1702,10 +1826,16 @@ function runFilter(state: TerminalState, text: string, stdin: string, env: Termi // `git diff | claude "…"`: the lessons teach the pattern; the tool runs on the learner's machine. return same([{ text: `(claude reçoit ${input.length} ligne(s) par le pipe — Claude Code n'est pas simulé dans ce terminal, essayez-le sur votre machine.)`, type: 'info' }]); + case 'xargs': { + if (env === 'windows') return runSimple(state, text, env); + const runner = commandRunner(state, env); + const result = runXargs(args, stdin, runner.deps); + return { lines: result.lines, newState: { ...state, root: runner.state().root, git: runner.state().git }, status: result.status }; + } + case 'sed': case 'awk': case 'tr': - case 'xargs': // Not simulated yet: say so rather than pretend the text was transformed. return same([...outLines(input), { text: `(${cmd} n'est pas encore simulé : le texte passe tel quel.)`, type: 'info' }]); @@ -2352,6 +2482,8 @@ const PS_ALIASES: Record = { cp: 'copy-item', mv: 'move-item', rm: 'remove-item', + rmdir: 'remove-item', + rd: 'remove-item', cd: 'set-location', chdir: 'set-location', mkdir: 'md', @@ -2448,7 +2580,7 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com // Dependencies for Windows/macOS alias handler const winDeps: WindowsCmdDeps = { cmdPwd, cmdCd, cmdLs, childItems: cmdChildItem, cmdCat, cmdMkdir, cmdTouch, - cmdCp, cmdMv, cmdRm, cmdEcho, cmdGrep, cmdEnv, + cmdCp, cmdMv, cmdRm, cmdEcho, cmdGrep: (s, a) => cmdGrep(s, a).lines, cmdEnv, childCount: (s, p) => { const node = getNode(s.root, resolvePath(s, p)); return node?.type === 'directory' ? Object.keys(node.children).length : undefined; @@ -2472,6 +2604,28 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com } if ((cmd === 'sort' || cmd === 'uniq' || cmd === 'cut') && env !== 'windows') return runTextTool(newState, cmd, args, undefined); + if ((cmd === 'find' || cmd === 'xargs') && env !== 'windows') { + const runner = commandRunner(newState, env); + const result = cmd === 'find' ? runFind(args, runner.deps) : runXargs(args, undefined, runner.deps); + // The commands they start are other processes: only the files they change come back. + return { lines: result.lines, newState: { ...newState, root: runner.state().root, git: runner.state().git }, status: result.status }; + } + if (cmd === 'rmdir' && env !== 'windows') return cmdRmdir(newState, args); + if (cmd === 'find' && env === 'windows') { + // In PowerShell, find is Windows' find.exe, a text search: a Linux-style call fails, exit 2. + // Captured on a French Windows: `find . -name "*.md"` takes *.md for a file to search + // (« Fichier introuvable - *.md »); other forms give « FIND : format incorrect de paramètre ». + const nameAt = args.findIndex((a) => /^-i?name$/i.test(a)); + const missing = nameAt >= 0 ? args[nameAt + 1] : undefined; + return { + lines: [ + { text: missing ? `File not found - ${missing}` : 'FIND: Parameter format not correct', type: 'error' }, + { text: "Dans PowerShell, find est find.exe, l'outil de recherche de texte de Windows. Pour chercher des fichiers : Get-ChildItem -Recurse -Filter *.txt", type: 'info' }, + ], + newState, + status: 2, + }; + } // With nothing piped in, these cmdlets have nothing to sort or group: PowerShell prints nothing // (`sort fruits.txt` is Sort-Object -Property fruits.txt), unless a parameter is wrong. // A Map, not an object literal: a typed `constructor` must not find Object's own. @@ -2596,9 +2750,8 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com } case 'grep': { - const lines = cmdGrep(newState, args); // Exit 1 when nothing matched, so `grep x f || …` works. - return { lines, newState, status: lines.some((l) => l.type === 'output') ? 0 : 1 }; + return { ...cmdGrep(newState, args), newState }; } case 'head': @@ -2608,6 +2761,7 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com return { lines: cmdTail(newState, args), newState }; case 'wc': + if (env !== 'windows') return runTextTool(newState, 'wc', args, undefined); return { lines: cmdWc(newState, args), newState }; case 'chmod': { diff --git a/src/test/terminalEngine.test.ts b/src/test/terminalEngine.test.ts index d5b2839..ab747fd 100644 --- a/src/test/terminalEngine.test.ts +++ b/src/test/terminalEngine.test.ts @@ -2104,10 +2104,10 @@ describe('wc', () => { expect(result.lines[0].text).toContain('test.txt'); }); - it('returns error when no operand given', () => { + it('with neither a file nor a pipe, says what to give it (the real wc waits for the keyboard)', () => { + // Until 2 October 2026 this printed "wc: missing file operand", which no real wc says. const result = processCommand(makeState(), 'wc'); - expect(result.lines[0].type).toBe('error'); - expect(result.lines[0].text).toContain('missing file operand'); + expect(result.lines.map((l) => l.type)).toEqual(['info']); }); }); @@ -3134,16 +3134,18 @@ describe('theory ↔ terminal: engine fidelity', () => { }); it('wc counts bytes (UTF-8) and the final newline, like the real wc', () => { - // Real wc on this exact content: "6 22 143" (â and î are two bytes each). + // Real wc on this exact content: 6 22 143 (â and î are two bytes each). Widths, as captured on + // 2 October 2026 in Git Bash (coreutils 8.32) and Ubuntu (9.4) alike: the number of digits of the + // file size (143 → 3); one count of one file is not padded; a pipe gets at least 7. const s = createInitialState(); - expect(out(s, 'wc documents/notes.txt')).toBe(' 6 22 143 documents/notes.txt'); - expect(out(s, 'wc -c documents/notes.txt')).toBe(' 143 documents/notes.txt'); + expect(out(s, 'wc documents/notes.txt')).toBe(' 6 22 143 documents/notes.txt'); + expect(out(s, 'wc -c documents/notes.txt')).toBe('143 documents/notes.txt'); const e = build('linux', 'touch vide.txt', 'echo x > x.txt'); - expect(out(e, 'wc vide.txt')).toBe(' 0 0 0 vide.txt'); - expect(out(e, 'wc x.txt')).toBe(' 1 1 2 x.txt'); - // Real: printf 'pomme\npoire\n' | wc -> 2 2 12 + expect(out(e, 'wc vide.txt')).toBe('0 0 0 vide.txt'); + expect(out(e, 'wc x.txt')).toBe('1 1 2 x.txt'); + // Real: printf 'pomme\npoire\n' | wc -> " 2 2 12" const p = build('linux', 'echo pomme > f', 'echo poire >> f'); - expect(out(p, 'cat f | wc')).toBe('2 2 12'); + expect(out(p, 'cat f | wc')).toBe(' 2 2 12'); }); it('ls sorts like ls (no directories-first) and marks nothing, -F adds the /', () => { @@ -3715,8 +3717,202 @@ describe('sort, uniq, cut (Phase 5d, lot 1)', () => { expect(names('Get-Process | Sort-Object Name -Descending')).toEqual(['WindowsTerminal', 'pwsh', 'node']); }); + it('wc: GNU widths, a total for several files, -m and -L, a folder', () => { + expect(sh('wc -l documents/notes.txt')).toEqual(['6 documents/notes.txt']); + expect(sh('wc -lw documents/notes.txt')).toEqual([' 6 22 documents/notes.txt']); + expect(sh('wc documents/notes.txt documents/rapport.md')).toEqual([ + ' 6 22 143 documents/notes.txt', ' 10 28 180 documents/rapport.md', ' 16 50 323 total', + ]); + expect(sh('wc -c projets/.env .bashrc')).toEqual(['224 projets/.env', '108 .bashrc', '332 total']); + expect(sh('cat documents/notes.txt | wc')).toEqual([' 6 22 143']); + expect(sh('cat documents/notes.txt | wc -l')).toEqual(['6']); + expect(sh('cat documents/notes.txt | wc -lc')).toEqual([' 6 143']); + expect(sh('wc -m documents/notes.txt')).toEqual(['141 documents/notes.txt']); + expect(sh('wc -L documents/notes.txt')).toEqual(['31 documents/notes.txt']); + expect(sh('wc -l documents/notes.txt absent')).toEqual([' 6 documents/notes.txt', 'wc: absent: No such file or directory', ' 6 total']); + expect(sh('wc documents')).toEqual(['wc: documents: Is a directory', ' 0 0 0 documents']); + expect(sh('wc -x documents/notes.txt')).toEqual(["wc: invalid option -- 'x'", "Try 'wc --help' for more information."]); + }); + it('macOS shows the GNU behaviour too (no Mac to check the BSD tools on)', () => { expect(sh('sort fruits.txt', 'macos')).toEqual(sh('sort fruits.txt')); expect(sh('cut -d, -f1 ages.csv', 'macos')).toEqual(['alice', 'bob', 'charlie', 'david']); }); }); + +// ─── find, xargs, rmdir, grep -l ──────────────────────────────────────────── +// Expected values: GNU findutils 4.9.0, coreutils 9.4 and grep 3.11 on Ubuntu 24.04 (WSL 2), +// 2 October 2026, in a copy of the simulator's home generated from createInitialState. +// Order: ext4 lists a folder in an order that changes between machines; the simulator +// follows NTFS (Git Bash's find on the same files): by name, case ignored. +describe('find, xargs, rmdir (Phase 5d, lot 1)', () => { + const sh = (cmd: string, env: 'linux' | 'macos' | 'windows' = 'linux', state = createInitialState()) => + processCommand(state, cmd, env).lines.map((l) => l.text); + + it('find walks the tree depth first, folders by name (Git Bash on NTFS)', () => { + expect(sh('find .')).toEqual([ + '.', './.bashrc', './.profile', './.zshrc', './documents', './documents/notes.txt', './documents/rapport.md', + './downloads', './projets', './projets/.env', './projets/README.md', './projets/script.sh', + ]); + expect(sh('find documents projets')).toEqual([ + 'documents', 'documents/notes.txt', 'documents/rapport.md', 'projets', 'projets/.env', 'projets/README.md', 'projets/script.sh', + ]); + expect(sh('find documents/ -type f')).toEqual(['documents/notes.txt', 'documents/rapport.md']); + expect(sh('find -name "*.md"')).toEqual(['./documents/rapport.md', './projets/README.md']); + }); + + it('find tests: -name, -iname, -path, -type, -maxdepth, -mindepth, -empty, -size, !, -o, parentheses', () => { + expect(sh('find . -name "*.txt"')).toEqual(['./documents/notes.txt']); + expect(sh("find . -iname 'readme*'")).toEqual(['./projets/README.md']); + expect(sh('find . -name "*.TXT"')).toEqual([]); + expect(sh("find . -name 'not?s.txt'")).toEqual(['./documents/notes.txt']); + expect(sh("find . -name '[nr]*'")).toEqual(['./documents/notes.txt', './documents/rapport.md']); + expect(sh('find . -path "./projets/*"')).toEqual(['./projets/.env', './projets/README.md', './projets/script.sh']); + expect(sh('find . -type d')).toEqual(['.', './documents', './downloads', './projets']); + expect(sh('find . -maxdepth 1 -type d')).toEqual(['.', './documents', './downloads', './projets']); + expect(sh('find . -mindepth 1 -maxdepth 1 -type f')).toEqual(['./.bashrc', './.profile', './.zshrc']); + // "." is named "." and matches .* + expect(sh("find . -maxdepth 1 -name '.*'")).toEqual(['.', './.bashrc', './.profile', './.zshrc']); + expect(sh('find . -maxdepth 0')).toEqual(['.']); + expect(sh('find . -mindepth 2')).toEqual([ + './documents/notes.txt', './documents/rapport.md', './projets/.env', './projets/README.md', './projets/script.sh', + ]); + expect(sh('find . -empty')).toEqual(['./downloads']); + expect(sh('find . -type f -empty')).toEqual([]); + expect(sh('find . -name "*.txt" -o -name "*.md"')).toEqual(['./documents/notes.txt', './documents/rapport.md', './projets/README.md']); + expect(sh('find . \\( -name "*.txt" -o -name "*.md" \\) -type f')).toEqual(['./documents/notes.txt', './documents/rapport.md', './projets/README.md']); + expect(sh('find . ! -name "*.txt" -type f')).toEqual(sh('find . -not -name "*.txt" -type f')); + expect(sh('find . ! -name "*.txt" -type f')).toEqual([ + './.bashrc', './.profile', './.zshrc', './documents/rapport.md', './projets/.env', './projets/README.md', './projets/script.sh', + ]); + expect(sh("find . -type f -name '*.sh' -o -type d -name 'doc*'")).toEqual(['./documents', './projets/script.sh']); + // Sizes round up to the unit: 512-byte blocks by default, -1k only matches empty files. + expect(sh('find . -size +100c -type f')).toEqual([ + './.bashrc', './.zshrc', './documents/notes.txt', './documents/rapport.md', './projets/.env', './projets/README.md', + ]); + expect(sh('find . -size -1k -type f')).toEqual([]); + expect(sh('find . -size 1 -type f')).toHaveLength(8); + expect(sh("find . -regex '.*md'")).toEqual(['./documents/rapport.md', './projets/README.md']); + expect(sh('find . -type l')).toEqual([]); + }); + + it('find actions: -exec with \\; and +, -print, -delete', () => { + expect(sh('find documents -name notes.txt -exec cat {} \\;')).toEqual([ + 'Mes notes importantes', 'Tâches du jour:', '1. Apprendre les commandes bash', '2. Pratiquer la navigation', + '3. Maîtriser les permissions', 'Fin du fichier', + ]); + expect(sh('find . -name "*.md" -exec wc -l {} +')).toEqual([' 10 ./documents/rapport.md', ' 6 ./projets/README.md', ' 16 total']); + expect(sh("find . -name '*.md' -print -exec wc -l {} \\;")).toEqual([ + './documents/rapport.md', '10 ./documents/rapport.md', './projets/README.md', '6 ./projets/README.md', + ]); + expect(sh("find . -name '*.txt' -exec grep -l Apprendre {} +")).toEqual(['./documents/notes.txt']); + expect(sh('find . -name "*.txt" -delete; find . -name "*.txt" | wc -l')).toEqual(['0']); + expect(sh('find downloads -empty -delete; ls -d downloads')).toEqual(["ls: cannot access 'downloads': No such file or directory"]); + expect(sh('find documents -delete; ls')).toEqual(['downloads projets']); + }); + + it('find fails like GNU find', () => { + expect(sh('find absent')).toEqual(['find: ‘absent’: No such file or directory']); + expect(sh('find . -name')).toEqual(["find: missing argument to `-name'"]); + expect(sh('find . -name a -name')).toEqual(["find: missing argument to `-name'"]); + expect(sh('find . -foo')).toEqual(["find: unknown predicate `-foo'"]); + expect(sh('find . -type x')).toEqual(['find: Unknown argument to -type: x']); + expect(sh('find . -exec echo {}')).toEqual(["find: missing argument to `-exec'"]); + expect(sh('find . -maxdepth -1')).toEqual(['find: Expected a positive decimal integer argument to -maxdepth, but got ‘-1’']); + expect(sh('find . -newer x')).toEqual(['find: ‘x’: No such file or directory']); + expect(sh('find . absent')[12]).toBe('find: ‘absent’: No such file or directory'); + expect(processCommand(createInitialState(), 'find . -mtime -1', 'linux').lines.map((l) => l.type)).toEqual(['info']); + }); + + it('xargs turns piped lines into arguments', () => { + expect(sh('find . -name "*.txt" | xargs wc -l')).toEqual(['6 ./documents/notes.txt']); + expect(sh('echo "a b c" | xargs echo')).toEqual(['a b c']); + expect(sh('echo "a b c" | xargs -n 1 echo')).toEqual(['a', 'b', 'c']); + expect(sh('echo "a b" | xargs -t -n 1 echo')).toEqual(['echo a', 'a', 'echo b', 'b']); + expect(sh("echo 'documents/notes.txt' | xargs -I {} cp {} copie.txt; ls copie.txt")).toEqual(['copie.txt']); + expect(sh('echo "x y" | xargs -I {} echo [{}]')).toEqual(['[x y]']); + expect(sh('echo | xargs echo vide')).toEqual(['vide']); + expect(sh('find . -name "*.md" | xargs rm; find . -name "*.md" | wc -l')).toEqual(['0']); + expect(sh('echo a | xargs -z echo')).toEqual(["xargs: invalid option -- 'z'", "Try 'xargs --help' for more information."]); + expect(sh('echo a b | xargs -n 0 echo')).toEqual(['xargs: value 0 for -n option should be >= 1', "Try 'xargs --help' for more information."]); + expect(sh('echo a | xargs -I')).toEqual(["xargs: option requires an argument -- 'I'", "Try 'xargs --help' for more information."]); + expect(sh('echo "it\'s" | xargs echo')).toEqual(['xargs: unmatched single quote; by default quotes are special to xargs unless you use the -0 option']); + expect(sh('echo a | xargs nexistepas')).toEqual(['xargs: nexistepas: No such file or directory']); + }); + + it('rmdir removes empty folders only; grep -l, -L, -c on files', () => { + expect(sh('rmdir downloads; ls')).toEqual(['documents projets']); + expect(sh('rmdir documents')).toEqual(["rmdir: failed to remove 'documents': Directory not empty"]); + expect(sh('rmdir documents/x')).toEqual(["rmdir: failed to remove 'documents/x': No such file or directory"]); + expect(sh('grep -l Apprendre documents/notes.txt documents/rapport.md')).toEqual(['documents/notes.txt']); + expect(sh('grep -L Apprendre documents/notes.txt documents/rapport.md')).toEqual(['documents/rapport.md']); + expect(sh('grep -c Apprendre documents/notes.txt documents/rapport.md')).toEqual(['documents/notes.txt:1', 'documents/rapport.md:0']); + expect(sh('grep -li apprendre documents/notes.txt')).toEqual(['documents/notes.txt']); + expect(processCommand(createInitialState(), 'grep -l Apprendre documents/rapport.md', 'linux').status).toBe(1); + }); + + it('Windows: find is find.exe, a text search', () => { + // Captured on a French Windows: « Fichier introuvable - *.txt » and « FIND : format incorrect de paramètre ». + const named = processCommand(createInitialState(), 'find . -name "*.txt"', 'windows'); + expect(named.lines[0]).toEqual({ text: 'File not found - *.txt', type: 'error' }); + expect(named.status).toBe(2); + expect(processCommand(createInitialState(), 'find . -type f', 'windows').lines[0].text).toBe('FIND: Parameter format not correct'); + }); + + // Second audit (terminal-fidelity-auditor, 2 October 2026); expected values captured on Ubuntu the same evening. + it('audit fixes: -delete sees the tree as it is now, Emacs -regex, find errors, documents//', () => { + expect(sh('mkdir -p x/y; find x -type d -empty -delete; ls -d x')).toEqual(["ls: cannot access 'x': No such file or directory"]); + // "." is never reported; the folders that keep files are. + expect(sh('find . -type d -delete')).toEqual([ + 'find: cannot delete ‘./documents’: Directory not empty', 'find: cannot delete ‘./projets’: Directory not empty', + ]); + expect(sh("find . -regex '.*\\.\\(md\\|sh\\)'")).toEqual(['./documents/rapport.md', './projets/README.md', './projets/script.sh']); + expect(sh("find . -regex '.*\\.(md\\|sh)'")).toEqual([]); + expect(sh('find . -size abc')).toEqual(["find: Invalid argument `abc' to -size"]); + expect(sh('find . -name x -a')).toEqual(["find: expected an expression after '-a'"]); + expect(sh('find . -name x -o')).toEqual(["find: expected an expression after '-o'"]); + expect(sh('find . !')).toEqual(["find: expected an expression after '!'"]); + expect(sh('find documents// -type f')).toEqual(['documents//notes.txt', 'documents//rapport.md']); + expect(sh('find . -name notes.txt -exec nosuchcmd {} \\;')).toEqual(['find: ‘nosuchcmd’: No such file or directory']); + }); + + // Code review, 2 October 2026; expected values captured on Ubuntu. + it('review fixes: bad class, -type list, builtins under -exec/xargs, an error beats a note', () => { + expect(sh("find . -name '[z-a]'")).toEqual([]); + expect(sh('find . -type f,d -maxdepth 1')).toEqual(['.', './.bashrc', './.profile', './.zshrc', './documents', './downloads', './projets']); + expect(sh('find . -type x,f')).toEqual(['find: Unknown argument to -type: x']); + const s = createInitialState(); + const cd = processCommand(s, 'find . -maxdepth 0 -exec cd documents \\;', 'linux'); + expect(cd.lines.map((l) => l.text)).toEqual(['find: ‘cd’: No such file or directory']); + expect(cd.newState.cwd).toEqual(s.cwd); + expect(sh('find . -maxdepth 0 -exec export X=1 \\;')).toEqual(['find: ‘export’: No such file or directory']); + expect(sh('echo documents | xargs cd || echo KO')).toEqual(['xargs: cd: No such file or directory', 'KO']); + expect(sh('find . -mtime 1 -bogus')).toEqual(["find: unknown predicate `-bogus'"]); + }); + + it('audit fixes: rmdir -p, -v, --ignore-fail-on-non-empty, ".", options', () => { + expect(sh('mkdir -p a/b/c; rmdir -p a/b/c; ls -d a')).toEqual(["ls: cannot access 'a': No such file or directory"]); + expect(sh('mkdir -p a/b/c; touch a/x; rmdir -p a/b/c; ls a')).toEqual(["rmdir: failed to remove directory 'a': Directory not empty", 'x']); + expect(sh('mkdir -p a/b/c; rmdir -pv a/b/c')).toEqual([ + "rmdir: removing directory, 'a/b/c'", "rmdir: removing directory, 'a/b'", "rmdir: removing directory, 'a'", + ]); + expect(sh('rmdir -v downloads')).toEqual(["rmdir: removing directory, 'downloads'"]); + expect(sh('rmdir .')).toEqual(["rmdir: failed to remove '.': Invalid argument"]); + expect(sh('rmdir -z downloads')).toEqual(["rmdir: invalid option -- 'z'", "Try 'rmdir --help' for more information."]); + expect(sh('rmdir --ignore-fail-on-non-empty documents')).toEqual([]); + expect(sh('rmdir')).toEqual(['rmdir: missing operand', "Try 'rmdir --help' for more information."]); + }); + + it('audit fixes: xargs -n x, -i, --max-args, quoted -t; bash \\" in double quotes; grep -L status', () => { + expect(sh('echo a b c | xargs -n x echo')).toEqual(['xargs: invalid number "x" for -n option', "Try 'xargs --help' for more information."]); + expect(sh('echo a b | xargs -i echo [{}]')).toEqual(['[a b]']); + expect(sh('echo a b c | xargs --max-args=2 echo')).toEqual(['a b', 'c']); + expect(sh("echo 'a b' | xargs -t -I X echo X hello")).toEqual(["echo 'a b' hello", 'a b hello']); + expect(sh('echo "say \\"hi\\""')).toEqual(['say "hi"']); + // grep 3.11: -L exits 0 when a line matched, whether or not a name was listed. + expect(processCommand(createInitialState(), 'grep -L Fin documents/notes.txt', 'linux').status).toBe(0); + const listed = processCommand(createInitialState(), 'grep -L zzz documents/notes.txt', 'linux'); + expect(listed.lines.map((l) => l.text)).toEqual(['documents/notes.txt']); + expect(listed.status).toBe(1); + }); +});