diff --git a/CHANGELOG.md b/CHANGELOG.md index a833a92..94debe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ --- +## 🔤 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* + +- **`sort`, `uniq` et `cut` fonctionnent sur des fichiers**, plus seulement derrière un pipe. Avant, `sort fichier.txt` répondait « commande introuvable ». +- `sort` range les lignes comme un vrai Linux en français ou en anglais : majuscules et minuscules ensemble, la ponctuation ne compte qu'à égalité. Il connaît `-r`, `-n`, `-h`, `-u`, `-f`, `-d`, `-b`, `-k`, `-t`, `-o`, `-c` et `-s`. Le tri par colonne (`sort -t, -k2n`) suit les mêmes règles que GNU, jusqu'au départage des lignes égales. +- `uniq` fusionne les lignes voisines, compte (`-c`), garde les doublons (`-d`, `-D`) ou les lignes uniques (`-u`). `cut` garde des colonnes (`-d`, `-f`, `--complement`, `--output-delimiter`) ou des octets (`-c`, `-b`) : comme sur Linux, une lettre accentuée compte pour deux. +- Les messages d'erreur sont ceux de GNU (`sort: cannot read: …`, `cut: you must specify a list…`). Une option réelle mais pas encore simulée affiche une note, jamais un résultat faux. +- **Sous Windows** : `Sort-Object` (et son alias `sort`) trie du texte en ignorant la casse, avec `-Descending`, `-Unique`, `-CaseSensitive`, `-Top` et `-Bottom`. `Get-Unique`, `Select-Object -Unique` et `Group-Object` (l'équivalent de `uniq -c`) arrivent. +- **La page Référence passe de 76 à 79 commandes** : `sort`, `uniq` et `cut`, avec leurs équivalents PowerShell, des exemples à essayer et les erreurs fréquentes. +- Sur la page d'accueil, la feuille de route annonce la suite du parcours : des bases jusqu'au niveau intermédiaire. + +--- + ## 🤖 2 octobre 2026 — Ce que lisent les moteurs IA reste à jour *Fichiers `llms.txt` et `llms-full.txt` · métadonnées de la page d'accueil · Phase 5d, lot 0b* diff --git a/STORY.md b/STORY.md index 7977060..22a31c9 100644 --- a/STORY.md +++ b/STORY.md @@ -1275,6 +1275,16 @@ Le garde-fou écrit ce matin compare ces fichiers au curriculum lui-même. Il a On a aussi vérifié que le test sait échouer : en remettant volontairement un mauvais compteur dans chaque fichier, il passe au rouge à chaque fois. Les lots suivants ajouteront des modules et des leçons. Ces fichiers ne pourront plus rester en arrière. +### Trier comme un vrai Linux (2 octobre 2026) + +On pensait que trier des lignes serait la partie facile. Le premier essai a montré le contraire : selon la langue du système, `sort` ne range pas les lignes de la même façon. Sur un Ubuntu en français ou en anglais, `.cache` se range comme « cache », et `Pomme` arrive entre `ecole` et `Zèbre`. Sur un serveur réglé en `C`, les majuscules passent toutes avant les minuscules. + +Pour savoir lequel imiter, il fallait un vrai Linux. L'Ubuntu installé sous WSL sur la machine de @thierry ne connaissait que la langue `C`. On y a ajouté l'anglais, le français de France et celui de Belgique, puis on a trié la même liste de 69 lignes piégées dans chacune. Le tri du navigateur ne donnait pas le même résultat. Il a fallu ignorer la ponctuation, puis les symboles comme `+`, avant de retrouver l'ordre exact de glibc, ligne pour ligne. + +PowerShell a réservé sa propre surprise. `Sort-Object` ignore vraiment la casse : deux lignes qui ne diffèrent que par une majuscule gardent leur ordre de départ. Au-delà de seize lignes, l'algorithme de .NET peut les échanger. Les fichiers des leçons sont plus courts, et le code le dit. + +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. + --- ## Épilogue ouvert diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 45c7b65..49a52ee 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 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 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). > @@ -168,6 +168,8 @@ Full rationale and scope: [`docs/strategy/2026-10-01-bases-vers-intermediaire.md - [x] **Lot 0** — this plan, roadmap and delivery checklist (#415) - [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 - [ ] **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 330c8cf..42672d3 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 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 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. @@ -254,6 +254,10 @@ Détail, périmètre et justification : [`docs/strategy/2026-10-01-bases-vers-in - [x] **Lot 0** — plan, roadmap, procédure de livraison (#415) - [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` + - [ ] 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` - [ ] **Lot 3** — `sed` et `awk` (sous-ensemble débutant) - [ ] **Lot 4** — module « Enquêter dans les journaux » (missions sur des journaux fictifs) diff --git a/index.html b/index.html index 3d31464..dfd1408 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 76 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 79 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 51e14a0..26e014a 100644 --- a/src/app/data/commandCatalogue.ts +++ b/src/app/data/commandCatalogue.ts @@ -335,6 +335,57 @@ const baseCatalogue: BaseCategory[] = [ summary: 'Compter lignes, mots et caractères', commonErrors: [], }, + { + id: 'sort', + name: 'sort', + category: 'search', + level: 1, + recommendedFor: ['linux', 'macos'], + variants: [ + { environment: 'windows', command: 'Get-Content fichier.txt | Sort-Object', shell: 'PowerShell' }, + ], + compatibility: ['linux', 'macos', 'windows'], + syntax: 'sort [options] [fichier...]', + summary: 'Trier des lignes', + commonErrors: [ + 'Trier des nombres sans -n : sort compare les caractères un à un, donc 10 passe avant 9.', + "Écrire sort fichier.txt dans PowerShell : sort y est Sort-Object, qui attend des lignes par un pipe. Écrire Get-Content fichier.txt | Sort-Object.", + ], + }, + { + id: 'uniq', + name: 'uniq', + category: 'search', + level: 1, + recommendedFor: ['linux', 'macos'], + variants: [ + { environment: 'windows', command: 'Get-Content fichier.txt | Sort-Object | Get-Unique', shell: 'PowerShell' }, + { environment: 'windows', command: 'Get-Content fichier.txt | Group-Object', shell: 'PowerShell' }, + ], + compatibility: ['linux', 'macos', 'windows'], + syntax: 'uniq [options] [fichier]', + summary: 'Fusionner ou compter les lignes répétées', + commonErrors: [ + "Utiliser uniq sur un fichier non trié : uniq ne fusionne que des lignes voisines. Trier d'abord : sort fichier.txt | uniq.", + ], + }, + { + id: 'cut', + name: 'cut', + category: 'search', + level: 1, + recommendedFor: ['linux', 'macos'], + variants: [ + { environment: 'windows', command: "Get-Content fichier.csv | ForEach-Object { $_.Split(',')[0] }", shell: 'PowerShell' }, + ], + compatibility: ['linux', 'macos', 'windows'], + syntax: 'cut -d séparateur -f champs [fichier...]', + summary: 'Garder certaines colonnes de chaque ligne', + commonErrors: [ + "Oublier -d : sans lui, cut coupe sur la tabulation, pas sur l'espace ni sur la virgule.", + 'Compter les lettres avec -c : GNU cut compte des octets, et une lettre accentuée en occupe deux.', + ], + }, ], }, @@ -1347,6 +1398,19 @@ const OFFICIAL_DOCS: Record = { ], find: [{ label: 'man7.org (Linux)', url: `${M1}find.1.html` }], wc: [{ label: 'man7.org (Linux)', url: `${M1}wc.1.html` }], + sort: [ + { label: 'man7.org (Linux)', url: `${M1}sort.1.html` }, + { label: 'Microsoft Learn — Sort-Object (Windows)', url: `${PS}microsoft.powershell.utility/sort-object` }, + ], + uniq: [ + { label: 'man7.org (Linux)', url: `${M1}uniq.1.html` }, + { label: 'Microsoft Learn — Get-Unique (Windows)', url: `${PS}microsoft.powershell.utility/get-unique` }, + { label: 'Microsoft Learn — Group-Object (Windows)', url: `${PS}microsoft.powershell.utility/group-object` }, + ], + cut: [ + { label: 'man7.org (Linux)', url: `${M1}cut.1.html` }, + { label: 'Microsoft Learn — ForEach-Object (Windows)', url: `${PS}microsoft.powershell.core/foreach-object` }, + ], // systeme echo: [ { label: 'man7.org (Linux)', url: `${M1}echo.1.html` }, diff --git a/src/app/data/commandExamples.ts b/src/app/data/commandExamples.ts index c865c7f..46560c0 100644 --- a/src/app/data/commandExamples.ts +++ b/src/app/data/commandExamples.ts @@ -117,6 +117,28 @@ export const COMMAND_EXAMPLES: Record = { { command: 'ls | wc -l', explanation: 'Avec un pipe, wc compte ce qu\'une autre commande produit : ici, le nombre d\'éléments du dossier.', environments: UNIX }, { command: '(Get-Content documents\\notes.txt).Count', explanation: 'Nombre de lignes du fichier : Get-Content renvoie une liste de lignes, .Count les compte.', environments: WIN }, ], + sort: [ + { command: 'sort documents/notes.txt', explanation: 'Affiche les lignes dans l\'ordre alphabétique, sans modifier le fichier. Sur un système en français ou en anglais, majuscules et minuscules sont rangées ensemble, et la ponctuation ne compte qu\'à égalité.', environments: UNIX }, + { command: 'sort -r documents/notes.txt', explanation: '-r inverse l\'ordre : de Z à A.', environments: UNIX }, + { command: 'sort -t= -k2 projets/.env', explanation: '-t= coupe chaque ligne sur le signe =, -k2 trie sur le deuxième morceau : ici, les valeurs des variables. Les commentaires, sans =, ont une clé vide et passent en premier.', environments: UNIX }, + { command: 'ps aux | sort -k3 -rn | head -3', explanation: 'Les processus qui utilisent le plus le processeur : -k3 trie sur la 3e colonne (%CPU), -n compare des nombres, -r met les plus grands en haut. La ligne de titre n\'est pas un nombre : elle compte comme 0 et se glisse parmi les processus.', environments: UNIX }, + { command: 'Get-Content documents\\notes.txt | Sort-Object', explanation: 'L\'équivalent PowerShell : Get-Content lit les lignes, Sort-Object les trie. Il ignore la casse : deux lignes qui ne diffèrent que par une majuscule restent dans leur ordre de départ.', environments: WIN }, + { command: 'Get-Content documents\\notes.txt | Sort-Object -Descending', explanation: '-Descending inverse l\'ordre, comme sort -r.', environments: WIN }, + { command: 'Get-Process | Sort-Object CPU -Descending | Select-Object -First 3', explanation: 'Sur un tableau, Sort-Object trie par une colonne : ici, les trois processus qui ont utilisé le plus de temps processeur.', environments: WIN }, + ], + uniq: [ + { command: 'sort .bashrc .zshrc | uniq -d', explanation: 'Les lignes présentes dans les deux fichiers : sort les rend voisines, uniq -d ne garde que celles qui se répètent.', environments: UNIX }, + { command: 'sort .bashrc .zshrc | uniq -c', explanation: '-c écrit devant chaque ligne combien de fois elle apparaît.', environments: UNIX }, + { command: 'sort .bashrc .zshrc | uniq -c | sort -rn', explanation: 'Le classique pour trouver ce qui revient le plus souvent : compter avec uniq -c, puis trier ces nombres du plus grand au plus petit.', environments: UNIX }, + { command: 'Get-Content documents\\notes.txt | Sort-Object | Get-Unique', explanation: 'L\'équivalent PowerShell de sort | uniq. Get-Unique, comme uniq, ne retire que les doublons voisins.', environments: WIN }, + { command: 'Get-Content documents\\notes.txt | Group-Object -NoElement', explanation: 'L\'équivalent de uniq -c : un tableau avec le nombre de fois que chaque ligne apparaît. Sans -NoElement, une colonne Group liste les lignes de chaque groupe.', environments: WIN }, + ], + cut: [ + { command: 'cut -d= -f1 projets/.env', explanation: '-d= coupe chaque ligne sur le signe =, -f1 garde le premier morceau : les noms des variables. Une ligne sans = (un commentaire) est affichée telle quelle.', environments: UNIX }, + { command: 'cut -d= -f1 projets/.env | sort', explanation: 'Les noms des variables, triés.', environments: UNIX }, + { command: 'cut -c1-3 documents/notes.txt', explanation: 'Les trois premiers octets de chaque ligne. Une lettre accentuée en occupe deux : « Tâches » donne « Tâ ».', environments: UNIX }, + { command: "Get-Content projets\\.env | ForEach-Object { $_.Split('=')[0] }", explanation: 'L\'équivalent PowerShell : pour chaque ligne ($_), Split coupe sur le = et [0] garde le premier morceau.', environments: WIN }, + ], // ─── Système ────────────────────────────────────────────── echo: [ @@ -453,6 +475,7 @@ export const NOT_SIMULATED: Record = { tree: ['linux', 'macos', 'windows'], less_more: ['linux', 'macos', 'windows'], find: ['linux', 'macos', 'windows'], + cut: ['windows'], getcomputerinfo: ['windows'], alias: ['linux', 'macos', 'windows'], id: ['linux', 'macos', 'windows'], diff --git a/src/app/data/commands/textTools.ts b/src/app/data/commands/textTools.ts new file mode 100644 index 0000000..779c403 --- /dev/null +++ b/src/app/data/commands/textTools.ts @@ -0,0 +1,623 @@ +import type { OutputLine } from './types'; +import type { PsArgs, PsCmdlet } from './psParams'; +import { parsePsArgs, psNotSimulated } from './psParams'; + +/** + * `sort`, `uniq` and `cut` as GNU coreutils 9.4 runs them on Ubuntu 24.04 in + * an en_US.UTF-8 locale, and the PowerShell 7.6 cmdlets that do the same jobs + * (`Sort-Object`, `Get-Unique`, `Select-Object -Unique`, `Group-Object`). + * Every behaviour here was captured in those real shells on 2 October 2026. + * + * macOS ships BSD versions of these three tools; with no Mac to check them on, + * the macOS terminal shows the GNU behaviour. + */ + +export type ReadText = (path: string) => { content: string } | { error: 'missing' | 'directory' }; + +export interface TextToolResult { + lines: OutputLine[]; + status: number; + /** `sort -o file` and `uniq in out` write their result to a file instead of the screen. */ + writeTo?: { path: string; lines: string[] }; +} + +const out = (texts: string[]): OutputLine[] => texts.map((text) => ({ text, type: 'output' })); +const err = (...texts: string[]): OutputLine[] => texts.map((text) => ({ text, type: 'error' })); +const tryHelp = (cmd: string) => `Try '${cmd} --help' for more information.`; +const notSimulated = (cmd: string, option: string): TextToolResult => ({ + lines: [{ text: `L'option ${option} de ${cmd} n'est pas simulée dans ce terminal d'entraînement.`, 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. + */ +function waitsForKeyboard(cmd: string, operands: string[], stdin: string | undefined): TextToolResult | null { + if (stdin !== undefined || (operands.length && !operands.includes('-'))) return null; + return { + lines: [{ text: `${cmd} attend du texte tapé au clavier quand il n'a ni fichier ni pipe. Donnez-lui un fichier (${cmd} fichier.txt) ou la sortie d'une autre commande (cat fichier.txt | ${cmd}).`, type: 'info' }], + status: 0, + }; +} + +/** + * The lines of a text as the simulator stores it: files and pipes hold lines + * joined by "\n" with no final newline, so a trailing "\n" is an empty last line + * (the convention `cat` and `wc` follow). + */ +export function textLines(text: string): string[] { + return text === '' ? [] : text.split('\n'); +} + +// ─── Collation ───────────────────────────────────────────────────────────── + +const glibcCollator = new Intl.Collator('en', { ignorePunctuation: true }); +const utf8 = new TextEncoder(); + +/** Byte order, the C locale's: uppercase before lowercase, accents last. */ +function compareBytes(a: string, b: string): number { + const x = utf8.encode(a); + const y = utf8.encode(b); + for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] - y[i]; + return x.length - y.length; +} + +/** + * glibc 2.39's order in en_US.UTF-8 and fr_FR.UTF-8 (identical on a list of + * 69 tricky lines): punctuation, symbols and spaces count only to break ties + * (`.cache` sorts as "cache", `+7` as "7"), case comes after accents, and two + * different lines are never equal. + */ +export function compareLocale(a: string, b: string): number { + const strip = (s: string) => s.replace(/\p{S}/gu, ''); + return glibcCollator.compare(strip(a), strip(b)) || glibcCollator.compare(a, b) || compareBytes(a, b); +} + +// ─── Options, GNU style ──────────────────────────────────────────────────── + +interface OptionSpec { + /** Short options that take no value. */ + flags: string; + /** Short options that take a value (`-k2`, `-k 2`). */ + valued: string; + /** Real options this simulator does not act on: a note, rather than a wrong result. */ + unsupported: string; + /** Long name → short letter (or the long name itself) and whether it takes a value. */ + long: Record; + unsupportedLong: string[]; +} + +type Parsed = { options: Array<{ key: string; value?: string }>; operands: string[] }; + +/** getopt_long as GNU tools use it: grouped flags (`-rn`), values attached or not, options after operands, `--`. */ +function getopt(cmd: string, args: string[], spec: OptionSpec): Parsed | TextToolResult { + const status = cmd === 'sort' ? 2 : 1; + const fail = (...texts: string[]): TextToolResult => ({ lines: err(...texts), status }); + const options: Parsed['options'] = []; + const operands: string[] = []; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--') { operands.push(...args.slice(i + 1)); break; } + if (arg.startsWith('--')) { + const [name, inline] = arg.slice(2).split(/=(.*)/s, 2) as [string, string | undefined]; + // Own keys only: `--constructor` must not find Object's. + 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, + }; + } + return fail(`${cmd}: unrecognized option '${arg}'`, tryHelp(cmd)); + } + if (known.valued) { + const value = inline ?? args[i + 1]; + if (value === undefined) return fail(`${cmd}: option '--${name}' requires an argument`, tryHelp(cmd)); + if (inline === undefined) i++; + options.push({ key: known.key, value }); + } else options.push({ key: known.key }); + continue; + } + if (!arg.startsWith('-') || arg === '-') { operands.push(arg); continue; } + for (let j = 1; j < arg.length; j++) { + const c = arg[j]; + if (spec.valued.includes(c)) { + const value = j + 1 < arg.length ? arg.slice(j + 1) : args[i + 1]; + if (value === undefined) return fail(`${cmd}: option requires an argument -- '${c}'`, tryHelp(cmd)); + if (j + 1 >= arg.length) i++; + options.push({ key: c, value }); + break; + } + if (spec.flags.includes(c)) { options.push({ key: c }); continue; } + if (spec.unsupported.includes(c)) return notSimulated(cmd, `-${c}`); + return fail(`${cmd}: invalid option -- '${c}'`, tryHelp(cmd)); + } + } + return { options, operands }; +} + +/** Reads the operands (`-` is standard input), GNU style: every file, or the error of the first one that fails. */ +function readInputs( + operands: string[], + stdin: string | undefined, + read: ReadText, + failure: (path: string, why: 'missing' | 'directory') => string, +): { lines: string[] } | { error: string; path: string } { + const names = operands.length ? operands : ['-']; + const lines: string[] = []; + for (const name of names) { + if (name === '-') { lines.push(...textLines(stdin ?? '')); continue; } + const file = read(name); + if ('error' in file) return { error: failure(name, file.error), path: name }; + lines.push(...textLines(file.content)); + } + return { lines }; +} + +// ─── sort ────────────────────────────────────────────────────────────────── + +interface Ordering { blanks: boolean; dictionary: boolean; fold: boolean; numeric: boolean; human: boolean; reverse: boolean } +interface SortKey { startField: number; startChar: number; endField: number; endChar: number; hasEnd: boolean; ordering: Ordering; blanksAtEnd: boolean } + +const ORDERING_LETTERS = 'bdfhnr'; +const noOrdering = (): Ordering => ({ blanks: false, dictionary: false, fold: false, numeric: false, human: false, reverse: false }); +const hasOrdering = (o: Ordering) => o.blanks || o.dictionary || o.fold || o.numeric || o.human || o.reverse; + +function applyOrdering(o: Ordering, letter: string) { + if (letter === 'b') o.blanks = true; + else if (letter === 'd') o.dictionary = true; + else if (letter === 'f') o.fold = true; + else if (letter === 'h') o.human = true; + else if (letter === 'n') o.numeric = true; + else if (letter === 'r') o.reverse = true; +} + +/** `-k2`, `-k2,2n`, `-k2.3`: a key, or GNU's message for a malformed one. */ +function parseKey(spec: string): SortKey | string { + const bad = (why: string) => `sort: ${why}: invalid field specification ‘${spec}’`; + const start = spec.match(/^(\d+)(?:\.(\d+))?([A-Za-z]*)/); + if (!start) return `sort: invalid number at field start: invalid count at start of ‘${spec}’`; + const startField = parseInt(start[1], 10); + if (startField === 0) return bad('field number is zero'); + if (start[2] !== undefined && parseInt(start[2], 10) === 0) return bad('character offset is zero'); + const ordering = noOrdering(); + let blanksAtEnd = false; + for (const letter of start[3]) { + if (!ORDERING_LETTERS.includes(letter)) return 'gMRVi'.includes(letter) ? `unsupported:${letter}` : bad('stray character in field spec'); + applyOrdering(ordering, letter); + } + let rest = spec.slice(start[0].length); + let endField = 0; + let endChar = 0; + let hasEnd = false; + if (rest.startsWith(',')) { + const end = rest.match(/^,(\d+)(?:\.(\d+))?([A-Za-z]*)/); + if (!end) return bad("invalid number after ','"); + endField = parseInt(end[1], 10); + if (endField === 0) return bad('field number is zero'); + endChar = end[2] !== undefined ? parseInt(end[2], 10) : 0; + for (const letter of end[3]) { + if (!ORDERING_LETTERS.includes(letter)) return 'gMRVi'.includes(letter) ? `unsupported:${letter}` : bad('stray character in field spec'); + if (letter === 'b') blanksAtEnd = true; + else applyOrdering(ordering, letter); + } + hasEnd = true; + rest = rest.slice(end[0].length); + } + if (rest) return bad('stray character in field spec'); + return { startField, startChar: start[2] !== undefined ? parseInt(start[2], 10) : 1, endField, endChar, hasEnd, ordering, blanksAtEnd }; +} + +const isBlank = (c: string) => c === ' ' || c === '\t'; + +/** The part of `line` a key covers, as GNU sort's begfield and limfield find it. */ +function keyText(line: string, key: SortKey, tab: string | undefined): string { + const lim = line.length; + // Start: skip startField - 1 fields, then startChar - 1 characters. + let p = 0; + for (let n = key.startField - 1; p < lim && n > 0; n--) { + if (tab !== undefined) { + while (p < lim && line[p] !== tab) p++; + if (p < lim) p++; + } else { + while (p < lim && isBlank(line[p])) p++; + while (p < lim && !isBlank(line[p])) p++; + } + } + if (key.ordering.blanks) while (p < lim && isBlank(line[p])) p++; + p = Math.min(lim, p + key.startChar - 1); + if (!key.hasEnd) return line.slice(p); + // End: past endField fields (the whole field when no character is given). + let e = 0; + let words = key.endChar === 0 ? key.endField : key.endField - 1; + while (e < lim && words-- > 0) { + if (tab !== undefined) { + while (e < lim && line[e] !== tab) e++; + if (e < lim && (words > 0 || key.endChar !== 0)) e++; + } else { + while (e < lim && isBlank(line[e])) e++; + while (e < lim && !isBlank(line[e])) e++; + } + } + if (key.endChar !== 0) { + if (key.blanksAtEnd) while (e < lim && isBlank(line[e])) e++; + e = Math.min(lim, e + key.endChar); + } + return e <= p ? '' : line.slice(p, e); +} + +/** A number as `sort -n` reads it: blanks, an optional minus, digits (the thousands comma skipped) and a decimal point. */ +function leadingNumber(text: string): { value: number; rest: string } { + const m = text.match(/^[ \t]*(-?)(\d(?:\d|,(?=\d))*)?(?:\.(\d*))?/); + const digits = (m?.[2] ?? '').replace(/,/g, ''); + const decimals = m?.[3] ?? ''; + if (!digits && !decimals) return { value: 0, rest: text }; + const value = parseFloat(`${digits || '0'}.${decimals || '0'}`) * (m![1] ? -1 : 1); + return { value, rest: text.slice(m![0].length) }; +} + +const HUMAN_UNITS = 'KMGTPEZYRQ'; + +function compareKeyText(a: string, b: string, o: Ordering): number { + if (o.numeric || o.human) { + const x = leadingNumber(a); + const y = leadingNumber(b); + if (o.human) { + const unit = (n: { value: number; rest: string }) => { + // No suffix ranks below K: 500 < 1K (indexOf('') would give the rank of K). + const suffix = (n.rest[0] ?? '').replace('k', 'K'); + const order = suffix ? HUMAN_UNITS.indexOf(suffix) + 1 : 0; + return n.value < 0 ? -order : order; + }; + const diff = unit(x) - unit(y); + if (diff) return diff; + } + return x.value - y.value; + } + let x = a; + let y = b; + if (o.dictionary) { x = x.replace(/[^\p{L}\p{N} \t]/gu, ''); y = y.replace(/[^\p{L}\p{N} \t]/gu, ''); } + if (o.fold) { x = x.toUpperCase(); y = y.toUpperCase(); } + return compareLocale(x, y); +} + +const SORT_SPEC: OptionSpec = { + flags: 'bdfhnrusc', + valued: 'kto', + unsupported: 'gMRVzimSTC', + long: { + 'ignore-leading-blanks': { key: 'b', valued: false }, 'dictionary-order': { key: 'd', valued: false }, + 'ignore-case': { key: 'f', valued: false }, 'human-numeric-sort': { key: 'h', valued: false }, + 'numeric-sort': { key: 'n', valued: false }, reverse: { key: 'r', valued: false }, unique: { key: 'u', valued: false }, + stable: { key: 's', valued: false }, key: { key: 'k', valued: true }, 'field-separator': { key: 't', valued: true }, + output: { key: 'o', valued: true }, + }, + unsupportedLong: [ + 'general-numeric-sort', 'month-sort', 'random-sort', 'version-sort', 'zero-terminated', 'ignore-nonprinting', + 'merge', 'parallel', 'buffer-size', 'temporary-directory', 'compress-program', 'files0-from', 'debug', 'random-source', 'sort', 'check', + ], +}; + +/** `sort` in GNU coreutils 9.4 (en_US.UTF-8). `stdin` is undefined when nothing is piped in. */ +export function runSort(args: string[], stdin: string | undefined, read: ReadText): TextToolResult { + const parsed = getopt('sort', args, SORT_SPEC); + if ('status' in parsed) return parsed; + const global = noOrdering(); + const keys: SortKey[] = []; + let tab: string | undefined; + let unique = false; + let stable = false; + let check = false; + let output: string | undefined; + for (const { key, value } of parsed.options) { + if (key === 'k') { + const k = parseKey(value!); + if (typeof k === 'string') { + if (k.startsWith('unsupported:')) return notSimulated('sort', `-k…${k.slice(12)}`); + return { lines: err(k), status: 2 }; + } + keys.push(k); + } else if (key === 't') { + if (value === '') return { lines: err('sort: empty tab'), status: 2 }; + if ([...value!].length > 1) return { lines: err(`sort: multi-character tab ‘${value}’`), status: 2 }; + tab = value; + } else if (key === 'u') unique = true; + else if (key === 's') stable = true; + else if (key === 'c') check = true; + else if (key === 'o') output = value; + else applyOrdering(global, key); + } + // A key with no ordering letters of its own takes the global ones, -r included. + for (const k of keys) if (!hasOrdering(k.ordering)) k.ordering = { ...global }; + if (!keys.length) keys.push({ startField: 1, startChar: 1, endField: 0, endChar: 0, hasEnd: false, ordering: { ...global }, blanksAtEnd: false }); + // Options are checked first: `sort -k x` fails before it would read anything. + const waiting = waitsForKeyboard('sort', parsed.operands, stdin); + if (waiting) return waiting; + + const input = readInputs(parsed.operands, stdin, read, (p, why) => why === 'missing' + ? `sort: cannot read: ${p}: No such file or directory` + : `sort: read failed: ${p}: Is a directory`); + if ('error' in input) return { lines: err(input.error), status: 2 }; + + const compareKeys = (a: string, b: string) => { + for (const k of keys) { + const diff = compareKeyText(keyText(a, k, tab), keyText(b, k, tab), k.ordering); + if (diff) return k.ordering.reverse ? -diff : diff; + } + return 0; + }; + // With every key equal, the whole lines decide, unless -s or -u; a global -r reverses that too. + const compare = (a: string, b: string) => { + const diff = compareKeys(a, b); + if (diff || stable || unique) return diff; + const last = compareLocale(a, b); + return global.reverse ? -last : last; + }; + + if (check) { + const name = parsed.operands[0] ?? '-'; + for (let i = 1; i < input.lines.length; i++) { + const diff = compare(input.lines[i - 1], input.lines[i]); + if (diff > 0 || (unique && diff === 0)) return { lines: err(`sort: ${name}:${i + 1}: disorder: ${input.lines[i]}`), status: 1 }; + } + return { lines: [], status: 0 }; + } + + const sorted = [...input.lines].sort(compare); + const result = unique ? sorted.filter((line, i) => i === 0 || compareKeys(sorted[i - 1], line) !== 0) : sorted; + if (output !== undefined) return { lines: [], status: 0, writeTo: { path: output, lines: result } }; + return { lines: out(result), status: 0 }; +} + +// ─── uniq ────────────────────────────────────────────────────────────────── + +const UNIQ_SPEC: OptionSpec = { + flags: 'cdDui', + valued: '', + unsupported: 'fswz', + long: { + count: { key: 'c', valued: false }, repeated: { key: 'd', valued: false }, unique: { key: 'u', valued: false }, + 'ignore-case': { key: 'i', valued: false }, + }, + unsupportedLong: ['all-repeated', 'group', 'skip-fields', 'skip-chars', 'check-chars', 'zero-terminated'], +}; + +/** `uniq` in GNU coreutils 9.4: neighbouring lines only, compared byte for byte (`-i` folds ASCII letters only). */ +export function runUniq(args: string[], stdin: string | undefined, read: ReadText): TextToolResult { + const parsed = getopt('uniq', args, UNIQ_SPEC); + if ('status' in parsed) return parsed; + const waiting = waitsForKeyboard('uniq', parsed.operands.slice(0, 1), stdin); + if (waiting) return waiting; + const has = (k: string) => parsed.options.some((o) => o.key === k); + if (parsed.operands.length > 2) return { lines: err(`uniq: extra operand ‘${parsed.operands[2]}’`, tryHelp('uniq')), status: 1 }; + const [inputName, outputName] = parsed.operands; + const input = readInputs(inputName ? [inputName] : [], stdin, read, (p, why) => why === 'missing' + ? `uniq: ${p}: No such file or directory` + : `uniq: error reading '${p}': Is a directory`); + if ('error' in input) return { lines: err(input.error), status: 1 }; + + const fold = (s: string) => (has('i') ? s.replace(/[A-Z]/g, (c) => c.toLowerCase()) : s); + const groups: { lines: string[] }[] = []; + for (const line of input.lines) { + const last = groups[groups.length - 1]; + if (last && fold(last.lines[0]) === fold(line)) last.lines.push(line); + else groups.push({ lines: [line] }); + } + const repeated = has('d') || has('D'); + const result: string[] = []; + for (const g of groups) { + if (repeated && g.lines.length < 2) continue; + if (has('u') && g.lines.length > 1) continue; + if (has('D')) { result.push(...g.lines); continue; } + result.push(has('c') ? `${String(g.lines.length).padStart(7)} ${g.lines[0]}` : g.lines[0]); + } + if (outputName !== undefined && outputName !== '-') { + return { lines: [], status: 0, writeTo: { path: outputName, lines: result } }; + } + return { lines: out(result), status: 0 }; +} + +// ─── cut ─────────────────────────────────────────────────────────────────── + +const CUT_SPEC: OptionSpec = { + flags: 'sn', + valued: 'bcfd', + unsupported: 'z', + long: { + bytes: { key: 'b', valued: true }, characters: { key: 'c', valued: true }, fields: { key: 'f', valued: true }, + delimiter: { key: 'd', valued: true }, 'only-delimited': { key: 's', valued: false }, + complement: { key: 'complement', valued: false }, 'output-delimiter': { key: 'output-delimiter', valued: true }, + }, + unsupportedLong: ['zero-terminated'], +}; + +type Range = { from: number; to: number }; + +/** `1,3`, `2-`, `-3`, `2-4`: the ranges, or GNU's message. */ +function parseList(list: string, fields: boolean): Range[] | string[] { + const ranges: Range[] = []; + for (let i = 0; i < list.length;) { + const m = list.slice(i).match(/^(\d*)(-?)(\d*)/)!; + const [whole, a, dash, b] = m; + const next = list[i + whole.length]; + if (next !== undefined && next !== ',' ) { + const bad = list.slice(i + whole.length); + return [`cut: ${fields ? 'invalid field value' : 'invalid byte/character position'} ‘${bad}’`, tryHelp('cut')]; + } + if (!a && !b) return [dash ? 'cut: invalid range with no endpoint: -' : `cut: ${fields ? 'fields' : 'byte/character positions'} are numbered from 1`, tryHelp('cut')]; + const from = a ? parseInt(a, 10) : 1; + const to = dash ? (b ? parseInt(b, 10) : Infinity) : from; + if (from === 0 || to === 0) return [`cut: ${fields ? 'fields' : 'byte/character positions'} are numbered from 1`, tryHelp('cut')]; + if (to < from) return ['cut: invalid decreasing range', tryHelp('cut')]; + ranges.push({ from, to }); + i += whole.length + 1; + } + return ranges; +} + +const decoder = new TextDecoder(); + +/** `cut` in GNU coreutils 9.4, where `-c` counts bytes like `-b` (`cut -c1-5` on "Tâches" prints "Tâch"). */ +export function runCut(args: string[], stdin: string | undefined, read: ReadText): TextToolResult { + const parsed = getopt('cut', args, CUT_SPEC); + if ('status' in parsed) return parsed; + const fail = (...texts: string[]): TextToolResult => ({ lines: err(...texts), status: 1 }); + const lists = parsed.options.filter((o) => 'bcf'.includes(o.key) && o.key.length === 1); + if (lists.length > 1 && new Set(lists.map((l) => l.key)).size > 1) return fail('cut: only one list may be specified', tryHelp('cut')); + if (!lists.length) return fail('cut: you must specify a list of bytes, characters, or fields', tryHelp('cut')); + const mode = lists[lists.length - 1].key; + const fields = mode === 'f'; + const delimiter = parsed.options.filter((o) => o.key === 'd').pop()?.value; + if (delimiter !== undefined && !fields) return fail('cut: an input delimiter may be specified only when operating on fields', tryHelp('cut')); + // An empty delimiter (`-d ''`) is the NUL character for GNU cut. + if (delimiter !== undefined && [...delimiter].length > 1) return fail('cut: the delimiter must be a single character', tryHelp('cut')); + const ranges = parseList(lists[lists.length - 1].value!, fields); + if (typeof ranges[0] === 'string') return fail(...(ranges as string[])); + const complement = parsed.options.some((o) => o.key === 'complement'); + const onlyDelimited = parsed.options.some((o) => o.key === 's'); + const selected = (n: number) => (ranges as Range[]).some((r) => n >= r.from && n <= r.to) !== complement; + + const waiting = waitsForKeyboard('cut', parsed.operands, stdin); + if (waiting) return waiting; + const outputDelimiter = parsed.options.filter((o) => o.key === 'output-delimiter').pop()?.value; + const cutLine = (line: string): string | undefined => { + if (fields) { + const sep = delimiter === '' ? '\0' : delimiter ?? '\t'; + if (!line.includes(sep)) return onlyDelimited ? undefined : line; + return line.split(sep).filter((_, i) => selected(i + 1)).join(outputDelimiter ?? sep); + } + const parts: number[][] = []; + utf8.encode(line).forEach((byte, i) => { + if (!selected(i + 1)) return; + const last = parts[parts.length - 1]; + // --output-delimiter goes between ranges that do not touch. + if (last && (outputDelimiter === undefined || selected(i))) last.push(byte); + else parts.push([byte]); + }); + return parts.map((p) => decoder.decode(new Uint8Array(p))).join(outputDelimiter ?? ''); + }; + + // Like GNU cut, each file in turn: a missing one is reported and the others still print. + const lines: OutputLine[] = []; + let status = 0; + for (const name of parsed.operands.length ? parsed.operands : ['-']) { + let text: string; + if (name === '-') text = stdin ?? ''; + else { + const file = read(name); + if ('error' in file) { + lines.push(...err(`cut: ${name}: ${file.error === 'missing' ? 'No such file or directory' : 'Is a directory'}`)); + status = 1; + continue; + } + text = file.content; + } + for (const line of textLines(text)) { + const kept = cutLine(line); + if (kept !== undefined) lines.push({ text: kept, type: 'output' }); + } + } + return { lines, status }; +} + +// ─── PowerShell ──────────────────────────────────────────────────────────── + +/** Parameters as PowerShell 7.6 lists them (`(Get-Command Sort-Object).Parameters`, 2 October 2026). */ +export const SORT_OBJECT: PsCmdlet = { + name: 'Sort-Object', + own: [ + { name: 'Stable' }, { name: 'Descending' }, { name: 'Unique' }, { name: 'Top', type: 'System.Int32' }, + { name: 'Bottom', type: 'System.Int32' }, { name: 'InputObject', type: 'System.Management.Automation.PSObject' }, + { name: 'Property', type: 'System.Object[]' }, { name: 'Culture', type: 'System.String' }, { name: 'CaseSensitive' }, + ], + positions: ['Property'], +}; + +export const GET_UNIQUE: PsCmdlet = { + name: 'Get-Unique', + own: [ + { name: 'InputObject', type: 'System.Management.Automation.PSObject' }, { name: 'AsString' }, { name: 'OnType' }, + { name: 'CaseInsensitive' }, + ], + positions: [], +}; + +export const GROUP_OBJECT: PsCmdlet = { + name: 'Group-Object', + own: [ + { name: 'NoElement' }, { name: 'AsHashTable', aliases: ['AHT'] }, { name: 'AsString' }, + { name: 'InputObject', type: 'System.Management.Automation.PSObject' }, { name: 'Property', type: 'System.Object[]' }, + { name: 'Culture', type: 'System.String' }, { name: 'CaseSensitive' }, + ], + positions: ['Property'], +}; + +/** .NET's comparison in the en-US culture: punctuation counts, case does not (unless -CaseSensitive). */ +const psIgnoreCase = new Intl.Collator('en-US', { sensitivity: 'accent' }); +const psCaseSensitive = new Intl.Collator('en-US', { sensitivity: 'variant', caseFirst: 'lower' }); + +const psFail = (line: OutputLine): TextToolResult => ({ lines: [line], status: 1 }); + +/** + * `Sort-Object` on lines of text (`Get-Content f | Sort-Object`). Ties keep + * their input order: true of PowerShell's sort up to 16 lines, which is all a + * lesson file holds (beyond that, .NET's introsort may swap lines that differ + * only by case). + */ +export function psSortLines(args: string[], lines: string[]): TextToolResult { + const parsed = parsePsArgs(SORT_OBJECT, args); + if (!('switches' in parsed)) return psFail(parsed); + // A property of plain text lines (`Sort-Object Length`) is not simulated: say so rather than sort by the text. + const note = psNotSimulated(SORT_OBJECT, parsed, ['Property', 'Culture', 'InputObject']); + if (note) return { lines: note, status: 0 }; + const collator = parsed.switches.has('CaseSensitive') ? psCaseSensitive : psIgnoreCase; + const direction = parsed.switches.has('Descending') ? -1 : 1; + let sorted = [...lines].sort((a, b) => direction * collator.compare(a, b)); + if (parsed.switches.has('Unique')) sorted = sorted.filter((line, i) => !sorted.slice(0, i).some((s) => collator.compare(s, line) === 0)); + return { lines: out(topOrBottom(parsed, sorted)), status: 0 }; +} + +function topOrBottom(parsed: PsArgs, rows: string[]): string[] { + if (parsed.values.Top !== undefined) return rows.slice(0, parseInt(parsed.values.Top, 10) || 0); + if (parsed.values.Bottom !== undefined) return rows.slice(Math.max(0, rows.length - (parseInt(parsed.values.Bottom, 10) || 0))); + return rows; +} + +/** `Get-Unique`: drops a line equal to the one before it, case included unless -CaseInsensitive. */ +export function psGetUnique(args: string[], lines: string[]): TextToolResult { + const parsed = parsePsArgs(GET_UNIQUE, args); + if (!('switches' in parsed)) return psFail(parsed); + const note = psNotSimulated(GET_UNIQUE, parsed, ['InputObject']); + if (note) return { lines: note, status: 0 }; + const same = parsed.switches.has('CaseInsensitive') ? (a: string, b: string) => psIgnoreCase.compare(a, b) === 0 : (a: string, b: string) => a === b; + return { lines: out(lines.filter((line, i) => i === 0 || !same(lines[i - 1], line))), status: 0 }; +} + +/** + * `Group-Object` on lines of text: one row per distinct line (case ignored), + * groups in sorted order, formatted as PowerShell 7.6's table. + */ +export function psGroupLines(args: string[], lines: string[]): TextToolResult { + const parsed = parsePsArgs(GROUP_OBJECT, args); + if (!('switches' in parsed)) return psFail(parsed); + const note = psNotSimulated(GROUP_OBJECT, parsed, ['Property', 'AsHashTable', 'Culture', 'InputObject']); + if (note) return { lines: note, status: 0 }; + const collator = parsed.switches.has('CaseSensitive') ? psCaseSensitive : psIgnoreCase; + const groups: string[][] = []; + for (const line of lines) { + const group = groups.find((g) => collator.compare(g[0], line) === 0); + if (group) group.push(line); + else groups.push([line]); + } + groups.sort((a, b) => collator.compare(a[0], b[0])); + if (!groups.length) return { lines: [], status: 0 }; + const name = (s: string) => (s.length > 25 ? `${s.slice(0, 24)}…` : s); + const rows = parsed.switches.has('NoElement') + ? ['Count Name', '----- ----', ...groups.map((g) => `${String(g.length).padStart(5)} ${name(g[0])}`)] + : ['Count Name Group', '----- ---- -----', + ...groups.map((g) => `${String(g.length).padStart(5)} ${name(g[0]).padEnd(25)} {${g.join(', ')}}`)]; + return { lines: out(['', ...rows, '']), status: 0 }; +} diff --git a/src/app/data/landingContent.ts b/src/app/data/landingContent.ts index 9fdf2ac..24cf5e6 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 = 76; +export const TOTAL_COMMANDS = 79; /** Count of environments with `status: 'active'` in `types/curriculum.ts`. */ export const ACTIVE_ENVIRONMENTS_COUNT = 3; @@ -140,6 +140,15 @@ export const ROADMAP_IN_PROGRESS: readonly RoadmapGroup[] = [ 'Une variante PowerShell pour chaque leçon', ], }, + { + 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', + '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', + ], + }, ]; export const ROADMAP_PLANNED: readonly RoadmapGroup[] = [ @@ -170,7 +179,6 @@ export const ROADMAP_PLANNED: readonly RoadmapGroup[] = [ items: [ 'Intégration LMS (LTI 1.3 : Moodle, Canvas…)', 'Tuteur IA V1.5 — modèles filtrés par rôle, isolation Web Worker', - 'Parcours avancés (Docker, scripting, IA augmentée)', ], }, { diff --git a/src/app/data/terminalEngine.ts b/src/app/data/terminalEngine.ts index 90c8f18..a965278 100644 --- a/src/app/data/terminalEngine.ts +++ b/src/app/data/terminalEngine.ts @@ -10,6 +10,9 @@ import { handleAiHelp } from './commands/ai'; 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 type { ReadText } from './commands/textTools'; import { parseCommandLine, isPlainCommand, isNullDevice } from './commands/shellSyntax'; import type { Stage, Fd } from './commands/shellSyntax'; import { UNIX_DEFAULT_PATH, varsForEnv } from './commands/shellVars'; @@ -1441,6 +1444,94 @@ function lineCount(args: string[], fallback: number): number { return attached ? parseInt(attached.replace(/^-n?/, ''), 10) : fallback; } +/** + * `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 { + 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); + if (!result.writeTo) return { lines: result.lines, newState: state, status: result.status }; + const typed = result.writeTo.path; + const path = resolvePath(state, typed); + const parent = getNode(state.root, path.slice(0, -1)); + const why = !parent || parent.type !== 'directory' ? 'No such file or directory' + : getNode(state.root, path)?.type === 'directory' ? 'Is a directory' : undefined; + if (why) { + const text = cmd === 'sort' ? `sort: open failed: ${typed}: ${why}` : `${cmd}: ${typed}: ${why}`; + return { lines: [{ text, type: 'error' }], newState: state, status: cmd === 'sort' ? 2 : 1 }; + } + return { lines: [], newState: { ...state, root: writeFileAt(state.root, path, result.writeTo.lines.join('\n'), false) }, status: 0 }; +} + +/** 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] === ''; + const trail = input.length > 1 && input[input.length - 1] === ''; + const { header, rows } = splitHeader(input.slice(lead ? 1 : 0, trail ? -1 : undefined)); + return { lead, header, rows, trail }; +} + +/** + * One cell of a simulated PowerShell table. Group-Object's Name can hold + * spaces, so its columns are read by position (Count is 5 wide, Name 25); + * other tables have one-word cells. + */ +function tableCell(row: string, columns: string[], column: number): string { + if (columns[0] === 'count' && columns[1] === 'name') { + if (column === 0) return row.slice(0, 5).trim(); + if (column === 1) return (columns.length === 2 ? row.slice(6) : row.slice(6, 31)).trim(); + return row.slice(32); + } + return row.trim().split(/\s+/)[column] ?? ''; +} + +/** Script blocks (`Sort-Object { [int]$_ }`) are not simulated: say so rather than print a false error. */ +function scriptBlockNote(cmdlet: string, args: string[]): OutputLine[] | null { + return args.some((a) => a.startsWith('{')) + ? [{ text: `Les blocs de script { … } de ${cmdlet} ne sont pas simulés dans ce terminal d'entraînement.`, type: 'info' }] + : null; +} + +/** + * `Sort-Object` (and `sort`, its alias in PowerShell): plain lines of text + * through psSortLines; a table by one of its columns (`Get-Process | + * Sort-Object CPU -Descending`). Ties keep their order, as in PowerShell. + */ +function psSortObject(args: string[], input: string[]): { lines: OutputLine[]; status?: number } { + const scriptBlock = scriptBlockNote('Sort-Object', args); + if (scriptBlock) return { lines: scriptBlock }; + const table = framedTable(input); + // Nothing came down the pipe (the command before it failed): nothing to sort, nothing to say. + if (input.every((l) => l === '')) { + const parsed = parsePsArgs(SORT_OBJECT, args); + return 'switches' in parsed ? { lines: [] } : { lines: [parsed], status: 1 }; + } + if (!table.header.length) { + const result = psSortLines(args, input); + return { lines: result.lines, status: result.status }; + } + const parsed = parsePsArgs(SORT_OBJECT, args); + if (!('switches' in parsed)) return { lines: [parsed], status: 1 }; + const columns = table.header[0].trim().split(/\s+/).map((h) => h.toLowerCase()); + const prop = parsed.values.Property?.toLowerCase(); + // Get-Process's Name is an alias of its ProcessName column. + const wanted = prop === 'name' && !columns.includes('name') && columns.includes('processname') ? 'processname' : prop; + const column = wanted ? columns.indexOf(wanted) : -1; + const cell = (row: string) => (column >= 0 ? tableCell(row, columns, column) : row); + const rows = table.rows.filter(Boolean); + const numeric = rows.length > 0 && rows.every((r) => !Number.isNaN(parseFloat(cell(r)))); + const direction = parsed.switches.has('Descending') ? -1 : 1; + let sorted = [...rows].sort((a, b) => direction * compareValues(cell(a), cell(b), numeric)); + if (parsed.values.Top !== undefined) sorted = sorted.slice(0, parseInt(parsed.values.Top, 10) || 0); + else if (parsed.values.Bottom !== undefined) sorted = sorted.slice(Math.max(0, sorted.length - (parseInt(parsed.values.Bottom, 10) || 0))); + return { lines: outLines([...(table.lead ? [''] : []), ...table.header, ...sorted, ...(table.trail ? [''] : [])]) }; +} + /** * A command that reads standard input (the right side of `|`, or `< file`). * Commands that do not read it run as usual; unknown readers pass the text through. @@ -1488,28 +1579,29 @@ function runFilter(state: TerminalState, text: string, stdin: string, env: Termi return { ...same(outLines(matches)), status: matches.length ? 0 : 1 }; } - case 'sort': { - // `-r`, `-n`, `-k3`, and combined forms such as `-k3rn` or `-rn`. - const joined = flags.join(''); - const keyArg = args.find((a) => /^-k\d/.test(a)) ?? (args.includes('-k') ? `-k${args[args.indexOf('-k') + 1]}` : undefined); - const key = keyArg ? parseInt(keyArg.slice(2), 10) : 0; - const numeric = /n/.test(joined); - const reverse = /r/.test(joined); - const field = (l: string) => (key ? l.trim().split(/\s+/)[key - 1] ?? '' : l); - const sorted = input.filter(Boolean).sort((a, b) => compareValues(field(a), field(b), numeric)); - if (reverse) sorted.reverse(); - return same(outLines(sorted)); + case 'sort': + case 'uniq': + case 'cut': + // Under PowerShell, sort is Sort-Object; uniq and cut do not exist there. + if (env !== 'windows') return runTextTool(state, cmd, args, stdin); + if (cmd !== 'sort') return runSimple(state, text, env); + return { ...psSortObject(args, input), newState: state }; + + case 'sort-object': + return { ...psSortObject(args, input), newState: state }; + + case 'get-unique': + case 'gu': { + const result = psGetUnique(args, input); + return { lines: result.lines, newState: state, status: result.status }; } - case 'sort-object': { - const { header, rows } = splitHeader(input.filter(Boolean)); - const prop = operands[0]?.toLowerCase(); - const column = prop && header.length ? header[0].trim().split(/\s+/).findIndex((h) => h.toLowerCase() === prop) : -1; - const cell = (row: string) => (column >= 0 ? row.trim().split(/\s+/)[column] ?? '' : row); - const numeric = rows.length > 0 && rows.every((r) => !Number.isNaN(parseFloat(cell(r)))); - const sorted = [...rows].sort((a, b) => compareValues(cell(a), cell(b), numeric)); - if (flags.some((f) => f.toLowerCase().startsWith('-desc'))) sorted.reverse(); - return same(outLines([...header, ...sorted])); + case 'group-object': + case 'group': { + const scriptBlock = scriptBlockNote('Group-Object', args); + if (scriptBlock) return same(scriptBlock); + const result = psGroupLines(args, input); + return { lines: result.lines, newState: state, status: result.status }; } case 'head': @@ -1521,25 +1613,26 @@ function runFilter(state: TerminalState, text: string, stdin: string, env: Termi } case 'select-object': { - const { header, rows } = splitHeader(input); + // A table keeps its title and the blank lines around it; only its rows are picked. + const table = framedTable(input); + const { lead, header, trail } = table; + const framed = header.length > 0; + // Plain lines keep their blank lines: only a table's frame is set aside. + const rows = framed ? table.rows : input; const first = psParam(args, '-first'); const last = psParam(args, '-last'); const skip = parseInt(psParam(args, '-skip') ?? '0', 10) || 0; let picked = rows.slice(skip); - if (first !== undefined) picked = picked.slice(0, parseInt(first, 10) || 0); - else if (last !== undefined) picked = picked.slice(Math.max(0, picked.length - (parseInt(last, 10) || 0))); - return same(outLines([...header, ...picked])); - } - - case 'uniq': { - const groups: { line: string; count: number }[] = []; - for (const line of input) { - const prev = groups[groups.length - 1]; - if (prev && prev.line === line) prev.count++; - else groups.push({ line, count: 1 }); + // -Last picks before -Unique (`-Unique -Last 1` is the last line); -First after it. + if (last !== undefined && first === undefined) picked = picked.slice(Math.max(0, picked.length - (parseInt(last, 10) || 0))); + // -Unique keeps the first of each line, case included (-CaseInsensitive ignores it). + if (args.some((a) => /^-u(n(i(q(ue?)?)?)?)?$/i.test(a))) { + const caseInsensitive = args.some((a) => /^-caseinsensitive$/i.test(a)); + const key = (l: string) => (caseInsensitive ? l.toLowerCase() : l); + picked = picked.filter((line, i) => picked.findIndex((other) => key(other) === key(line)) === i); } - const counted = flags.some((f) => f.includes('c')); - return same(outLines(groups.map((g) => (counted ? `${String(g.count).padStart(7)} ${g.line}` : g.line)))); + if (first !== undefined) picked = picked.slice(0, parseInt(first, 10) || 0); + return same(outLines([...(framed && lead ? [''] : []), ...header, ...picked, ...(framed && trail ? [''] : [])])); } case 'measure-object': @@ -1611,7 +1704,6 @@ function runFilter(state: TerminalState, text: string, stdin: string, env: Termi case 'sed': case 'awk': - case 'cut': case 'tr': case 'xargs': // Not simulated yet: say so rather than pretend the text was transformed. @@ -2269,6 +2361,12 @@ const PS_ALIASES: Record = { h: 'get-history', }; +/** PowerShell cmdlets that only work on piped input, by every name they answer to. */ +const INPUT_CMDLETS = new Map([ + ['sort', SORT_OBJECT], ['sort-object', SORT_OBJECT], ['get-unique', GET_UNIQUE], ['gu', GET_UNIQUE], + ['group-object', GROUP_OBJECT], ['group', GROUP_OBJECT], +]); + /** The command name as typed (not lower-cased): that is what a real shell repeats. */ function commandNotFound(typed: string, state: TerminalState): CommandOutput { return { lines: [{ text: `${typed}: commande introuvable. Tapez 'help' pour la liste des commandes.`, type: 'error' }], newState: state }; @@ -2373,6 +2471,16 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com } } + if ((cmd === 'sort' || cmd === 'uniq' || cmd === 'cut') && env !== 'windows') return runTextTool(newState, cmd, args, undefined); + // 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. + const inputCmdlet = env === 'windows' ? INPUT_CMDLETS.get(cmd) : undefined; + if (inputCmdlet) { + const parsed = parsePsArgs(inputCmdlet, args); + return { lines: 'switches' in parsed ? [] : [parsed], newState }; + } + switch (cmd) { case 'pwd': return { lines: cmdPwd(newState, env), newState }; diff --git a/src/test/terminalEngine.test.ts b/src/test/terminalEngine.test.ts index 917624f..d5b2839 100644 --- a/src/test/terminalEngine.test.ts +++ b/src/test/terminalEngine.test.ts @@ -3488,3 +3488,235 @@ describe('reference replay gaps — engine matches the real shells', () => { expect(run(['Get-ScheduledTask'], 'windows').lines[1].text).toMatch(/^TaskPath\s+TaskName\s+State$/); }); }); + +// ─── sort, uniq, cut and their PowerShell counterparts ────────────────────── +// Expected values: GNU coreutils 9.4 on Ubuntu 24.04 (WSL 2) in en_US.UTF-8, and +// PowerShell 7.6.6 in en-US, both run on 2 October 2026 in a sandbox holding the +// same files. +describe('sort, uniq, cut (Phase 5d, lot 1)', () => { + const FRUITS = 'pomme\nbanane\npomme\nPomme\ncerise\nbanane\nbanane'; + const AGES = 'alice,32,Liège\nbob,7,Namur\ncharlie,120,Bruxelles\ndavid,32,Anvers'; + function home(extra: Record = {}): TerminalState { + const s = createInitialState(); + const user = (s.root.children.home as typeof s.root).children.user as typeof s.root; + const notes = (user.children.documents as typeof s.root).children['notes.txt']; + for (const [name, content] of Object.entries({ 'fruits.txt': FRUITS, 'ages.csv': AGES, ...extra })) { + user.children[name] = { ...notes, content } as typeof notes; + } + return { ...s, cwd: ['home', 'user'] }; + } + const sh = (cmd: string, env: 'linux' | 'macos' | 'windows' = 'linux', state = home()) => + processCommand(state, cmd, env).lines.map((l) => l.text); + + it('sort orders lines as glibc does in a UTF-8 locale: case and punctuation only break ties', () => { + expect(sh('sort fruits.txt')).toEqual(['banane', 'banane', 'banane', 'cerise', 'pomme', 'pomme', 'Pomme']); + expect(sh('sort documents/notes.txt')).toEqual([ + '1. Apprendre les commandes bash', '2. Pratiquer la navigation', '3. Maîtriser les permissions', + 'Fin du fichier', 'Mes notes importantes', 'Tâches du jour:', + ]); + expect(sh('sort .bashrc .zshrc')).toEqual([ + 'alias la="ls -a"', 'alias la="ls -a"', 'alias ll="ls -la"', 'alias ll="ls -la"', '# Configuration Bash', + '# Configuration Zsh (Oh My Zsh)', 'export EDITOR=nano', 'export PATH=$PATH:/usr/local/bin', + 'export PATH=$PATH:/usr/local/bin', 'export ZSH="$HOME/.oh-my-zsh"', 'plugins=(git node npm)', 'ZSH_THEME="robbyrussell"', + ]); + // Lines from the 69 sorted by glibc 2.39: punctuation and symbols count last, then accents, then case. + const tricky = ['banane', 'Pomme', 'apple', '#commentaire', '_tiret', 'École', 'ecole', '10', '9', '2 mots', 'Zèbre', '.cache', '+7', '-5', 'a-b', 'ab', 'a b', 'AB']; + expect(sh('sort tricky.txt', 'linux', home({ 'tricky.txt': tricky.join('\n') }))).toEqual([ + '10', '2 mots', '-5', '+7', '9', 'a b', 'a-b', 'ab', 'AB', 'apple', 'banane', '.cache', '#commentaire', 'ecole', 'École', 'Pomme', '_tiret', 'Zèbre', + ]); + }); + + it('sort options: -r, -u, -f, -n, -h, -d, long forms, options after the file', () => { + expect(sh('sort -r documents/notes.txt')[0]).toBe('Tâches du jour:'); + expect(sh('sort fruits.txt -r')).toEqual(['Pomme', 'pomme', 'pomme', 'cerise', 'banane', 'banane', 'banane']); + expect(sh('sort --reverse fruits.txt')).toEqual(sh('sort -r fruits.txt')); + expect(sh('sort -u fruits.txt')).toEqual(['banane', 'cerise', 'pomme', 'Pomme']); + expect(sh('sort -fu fruits.txt')).toEqual(['banane', 'cerise', 'pomme']); + expect(sh('sort -r -n -u fruits.txt')).toEqual(['pomme']); + expect(sh('sort -n n.txt', 'linux', home({ 'n.txt': '10\n-5\n3.5\nabc\n\n2\n-0.5\n+4' }))).toEqual(['-5', '-0.5', '', '+4', 'abc', '2', '3.5', '10']); + expect(sh('sort -h h.txt', 'linux', home({ 'h.txt': '1.5K\tlogs\n200M\tvideos\n12K\tdocs\n1.2G\tbackup\n4.0K\tnotes\n999\tbrut' }))) + .toEqual(['999\tbrut', '1.5K\tlogs', '4.0K\tnotes', '12K\tdocs', '200M\tvideos', '1.2G\tbackup']); + // en_US reads the comma as a thousands separator: 1,5K is 15K. + expect(sh('sort -h h.txt', 'linux', home({ 'h.txt': '1,5K\tlogs\n200M\tvideos\n12K\tdocs\n1,2G\tbackup\n4,0K\tnotes' }))) + .toEqual(['12K\tdocs', '1,5K\tlogs', '4,0K\tnotes', '200M\tvideos', '1,2G\tbackup']); + expect(sh('sort -d d.txt', 'linux', home({ 'd.txt': 'b-2\na_1\nb.1' }))).toEqual(['a_1', 'b.1', 'b-2']); + }); + + it('sort -k and -t: fields, per-key options, and the whole line breaking ties', () => { + expect(sh('sort -t, -k2 ages.csv')).toEqual(['charlie,120,Bruxelles', 'david,32,Anvers', 'alice,32,Liège', 'bob,7,Namur']); + expect(sh('sort -t, -k2n ages.csv')).toEqual(['bob,7,Namur', 'alice,32,Liège', 'david,32,Anvers', 'charlie,120,Bruxelles']); + expect(sh('sort -t, -k2nr ages.csv')).toEqual(['charlie,120,Bruxelles', 'alice,32,Liège', 'david,32,Anvers', 'bob,7,Namur']); + // A global -r does not reach a key with its own options, but reverses the tie-break. + expect(sh('sort -rt, -k2n ages.csv')).toEqual(['bob,7,Namur', 'david,32,Anvers', 'alice,32,Liège', 'charlie,120,Bruxelles']); + expect(sh('sort -t, -k2,2n -k1,1r ages.csv')).toEqual(['bob,7,Namur', 'david,32,Anvers', 'alice,32,Liège', 'charlie,120,Bruxelles']); + expect(sh('sort -u -t, -k2,2n ages.csv')).toEqual(['bob,7,Namur', 'alice,32,Liège', 'charlie,120,Bruxelles']); + expect(sh('sort -t, -k2.2 ages.csv')).toEqual(['charlie,120,Bruxelles', 'david,32,Anvers', 'alice,32,Liège', 'bob,7,Namur']); + expect(sh('sort -k2 documents/notes.txt')).toEqual([ + '1. Apprendre les commandes bash', 'Fin du fichier', 'Tâches du jour:', '3. Maîtriser les permissions', + 'Mes notes importantes', '2. Pratiquer la navigation', + ]); + const ps = home({ 'ps.txt': 'user 1234 0.0 0.2 bash\nuser 2048 0.1 1.2 node\nuser 5678 0.0 0.1 ps aux' }); + expect(sh('sort -k3rn ps.txt', 'linux', ps)).toEqual([ + 'user 2048 0.1 1.2 node', 'user 1234 0.0 0.2 bash', 'user 5678 0.0 0.1 ps aux', + ]); + expect(sh('sort -k3 -rn ps.txt', 'linux', ps)).toEqual([ + 'user 2048 0.1 1.2 node', 'user 5678 0.0 0.1 ps aux', 'user 1234 0.0 0.2 bash', + ]); + }); + + it('sort reads a pipe, writes with -o, checks with -c, and fails like GNU sort', () => { + expect(sh('cut -d= -f1 projets/.env | sort')).toEqual([ + 'API_KEY', 'DB_HOST', 'DB_NAME', 'DB_PASSWORD', 'DB_PORT', 'DB_USER', '# NE JAMAIS committer ce fichier !', 'NODE_ENV', + "# Variables d'environnement du projet", + ]); + expect(sh('sort fruits.txt - < ages.csv')[0]).toBe('alice,32,Liège'); + expect(sh('sort -o trie.txt fruits.txt; cat trie.txt')).toEqual(['banane', 'banane', 'banane', 'cerise', 'pomme', 'pomme', 'Pomme']); + expect(sh('sort -c fruits.txt')).toEqual(['sort: fruits.txt:2: disorder: banane']); + expect(sh('sort absent.txt')).toEqual(['sort: cannot read: absent.txt: No such file or directory']); + expect(sh('sort fruits.txt absent.txt')).toEqual(['sort: cannot read: absent.txt: No such file or directory']); + expect(sh('sort documents')).toEqual(['sort: read failed: documents: Is a directory']); + expect(sh('sort -o absent/x.txt fruits.txt')).toEqual(['sort: open failed: absent/x.txt: No such file or directory']); + expect(sh('sort -x fruits.txt')).toEqual(["sort: invalid option -- 'x'", "Try 'sort --help' for more information."]); + expect(sh('sort --foo fruits.txt')).toEqual(["sort: unrecognized option '--foo'", "Try 'sort --help' for more information."]); + expect(sh('sort --constructor fruits.txt')).toEqual(["sort: unrecognized option '--constructor'", "Try 'sort --help' for more information."]); + expect(sh('cut --toString -f1 ages.csv')).toEqual(["cut: unrecognized option '--toString'", "Try 'cut --help' for more information."]); + expect(sh('sort -k')).toEqual(["sort: option requires an argument -- 'k'", "Try 'sort --help' for more information."]); + expect(sh('sort -k ages.csv')).toEqual(['sort: invalid number at field start: invalid count at start of ‘ages.csv’']); + expect(sh('sort -k0 fruits.txt')).toEqual(['sort: field number is zero: invalid field specification ‘0’']); + expect(sh('sort -k2.0 fruits.txt')).toEqual(['sort: character offset is zero: invalid field specification ‘2.0’']); + expect(sh('sort -k2x fruits.txt')).toEqual(['sort: stray character in field spec: invalid field specification ‘2x’']); + expect(sh('sort -t ages.csv')).toEqual(['sort: multi-character tab ‘ages.csv’']); + expect(processCommand(home(), 'sort absent.txt', 'linux').lines[0].type).toBe('error'); + }); + + it('uniq merges neighbouring lines only, compared byte for byte', () => { + expect(sh('uniq fruits.txt')).toEqual(['pomme', 'banane', 'pomme', 'Pomme', 'cerise', 'banane']); + expect(sh('uniq -c fruits.txt')).toEqual([' 1 pomme', ' 1 banane', ' 1 pomme', ' 1 Pomme', ' 1 cerise', ' 2 banane']); + expect(sh('uniq -ic fruits.txt')).toEqual([' 1 pomme', ' 1 banane', ' 2 pomme', ' 1 cerise', ' 2 banane']); + expect(sh('uniq -d -c fruits.txt')).toEqual([' 2 banane']); + expect(sh('uniq -D fruits.txt')).toEqual(['banane', 'banane']); + expect(sh('sort fruits.txt | uniq -c')).toEqual([' 3 banane', ' 1 cerise', ' 2 pomme', ' 1 Pomme']); + expect(sh('sort fruits.txt | uniq -c | sort -rn')).toEqual([' 3 banane', ' 2 pomme', ' 1 Pomme', ' 1 cerise']); + expect(sh('sort fruits.txt | uniq -u')).toEqual(['cerise', 'Pomme']); + expect(sh('sort .bashrc .zshrc | uniq -d')).toEqual(['alias la="ls -a"', 'alias ll="ls -la"', 'export PATH=$PATH:/usr/local/bin']); + // -i folds ASCII letters only: é and É stay different. + expect(sh('uniq -i e.txt', 'linux', home({ 'e.txt': 'Ecole\nécole\nÉcole' }))).toEqual(['Ecole', 'école', 'École']); + expect(sh('uniq fruits.txt sortie.txt; cat sortie.txt')).toEqual(['pomme', 'banane', 'pomme', 'Pomme', 'cerise', 'banane']); + expect(sh('uniq absent.txt')).toEqual(['uniq: absent.txt: No such file or directory']); + expect(sh('uniq documents')).toEqual(["uniq: error reading 'documents': Is a directory"]); + expect(sh('uniq a b c')).toEqual(['uniq: extra operand ‘c’', "Try 'uniq --help' for more information."]); + expect(sh('uniq fruits.txt absent/x.txt')).toEqual(['uniq: absent/x.txt: No such file or directory']); + }); + + it('cut keeps fields, or bytes (-c counts bytes in GNU cut 9.4)', () => { + expect(sh('cut -d= -f1 projets/.env').slice(2)).toEqual(['DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'API_KEY', 'NODE_ENV']); + expect(sh('cut -s -d= -f2 projets/.env')[0]).toBe('localhost'); + expect(sh('cut -d, -f1,3 ages.csv')).toEqual(['alice,Liège', 'bob,Namur', 'charlie,Bruxelles', 'david,Anvers']); + expect(sh('cut -d, -f3,1 ages.csv')).toEqual(sh('cut -d, -f1,3 ages.csv')); + expect(sh('cut -d, -f2- ages.csv')).toEqual(['32,Liège', '7,Namur', '120,Bruxelles', '32,Anvers']); + expect(sh('cut -d, -f2 --complement ages.csv')).toEqual(['alice,Liège', 'bob,Namur', 'charlie,Bruxelles', 'david,Anvers']); + expect(sh("cut -d, -f1,2 --output-delimiter=' | ' ages.csv")[0]).toBe('alice | 32'); + expect(sh('cut --delimiter=, --fields=1 ages.csv')).toEqual(['alice', 'bob', 'charlie', 'david']); + expect(sh('cut -f1 ages.csv')).toEqual(AGES.split('\n')); + expect(sh('cut -c1-5 documents/notes.txt')).toEqual(['Mes n', 'Tâch', '1. Ap', '2. Pr', '3. Ma', 'Fin d']); + expect(sh('cut -c2-3,5 fruits.txt')[0]).toBe('ome'); + expect(sh('cut -d, -f2 ages.csv | sort -n | uniq -c')).toEqual([' 1 7', ' 2 32', ' 1 120']); + const words = home({ 'w.txt': 'a b c\nd' }); + expect(sh("cut -d' ' -f3 w.txt", 'linux', words)).toEqual(['', 'd']); + expect(sh("cut -d' ' -f2 -s w.txt", 'linux', words)).toEqual(['b']); + }); + + it('cut fails like GNU cut', () => { + const help = "Try 'cut --help' for more information."; + expect(sh('cut ages.csv')).toEqual(['cut: you must specify a list of bytes, characters, or fields', help]); + expect(sh('cut -d, ages.csv')).toEqual(['cut: you must specify a list of bytes, characters, or fields', help]); + expect(sh('cut -d, -f0 ages.csv')).toEqual(['cut: fields are numbered from 1', help]); + expect(sh('cut -d ab -f1 ages.csv')).toEqual(['cut: the delimiter must be a single character', help]); + expect(sh('cut -d, -f1-2,x ages.csv')).toEqual(['cut: invalid field value ‘x’', help]); + expect(sh('cut -c fruits.txt')).toEqual(['cut: invalid byte/character position ‘fruits.txt’', help]); + expect(sh('cut -d, -f3-1 ages.csv')).toEqual(['cut: invalid decreasing range', help]); + expect(sh('cut -d, -f- ages.csv')).toEqual(['cut: invalid range with no endpoint: -', help]); + expect(sh('cut -c1 -f1 ages.csv')).toEqual(['cut: only one list may be specified', help]); + expect(sh('cut -c1 -d, ages.csv')).toEqual(['cut: an input delimiter may be specified only when operating on fields', help]); + expect(sh('cut -f')).toEqual(["cut: option requires an argument -- 'f'", help]); + expect(sh('cut -d, -f1 absent.txt')).toEqual(['cut: absent.txt: No such file or directory']); + expect(sh('cut -d, -f1 documents')).toEqual(['cut: documents: Is a directory']); + }); + + it('with neither a file nor a pipe, says what to give instead of waiting for the keyboard', () => { + const out = processCommand(home(), 'sort', 'linux').lines; + expect(out).toHaveLength(1); + expect(out[0].type).toBe('info'); + expect(processCommand(home(), 'sort -g fruits.txt', 'linux').lines[0].type).toBe('info'); + }); + + it('PowerShell: Sort-Object ignores case and keeps ties in order; -Descending, -Unique, -CaseSensitive', () => { + const ps = (cmd: string) => sh(cmd, 'windows'); + expect(ps('Get-Content fruits.txt | Sort-Object')).toEqual(['banane', 'banane', 'banane', 'cerise', 'pomme', 'pomme', 'Pomme']); + expect(ps('Get-Content fruits.txt | Sort-Object -Descending')).toEqual(['pomme', 'pomme', 'Pomme', 'cerise', 'banane', 'banane', 'banane']); + expect(ps('Get-Content fruits.txt | sort -Descending')).toEqual(ps('Get-Content fruits.txt | Sort-Object -Descending')); + expect(ps('Get-Content fruits.txt | Sort-Object -Unique')).toEqual(['banane', 'cerise', 'pomme']); + expect(ps('Get-Content fruits.txt | sort -u')).toEqual(['banane', 'cerise', 'pomme']); + expect(ps('Get-Content fruits.txt | Sort-Object -CaseSensitive -Unique')).toEqual(['banane', 'cerise', 'pomme', 'Pomme']); + expect(ps('Get-Content documents\\notes.txt | Sort-Object')).toEqual(sh('sort documents/notes.txt')); + // Without input, sort is Sort-Object -Property fruits.txt with nothing to sort. + expect(ps('sort fruits.txt')).toEqual([]); + }); + + it('PowerShell: Get-Unique, Select-Object -Unique and Group-Object', () => { + const ps = (cmd: string) => sh(cmd, 'windows'); + expect(ps('Get-Content fruits.txt | Get-Unique')).toEqual(['pomme', 'banane', 'pomme', 'Pomme', 'cerise', 'banane']); + expect(ps('Get-Content fruits.txt | Sort-Object | Get-Unique')).toEqual(['banane', 'cerise', 'pomme', 'Pomme']); + expect(ps('Get-Content fruits.txt | Select-Object -Unique')).toEqual(['pomme', 'banane', 'Pomme', 'cerise']); + expect(ps('Get-Content fruits.txt | Group-Object')).toEqual([ + '', + 'Count Name Group', + '----- ---- -----', + ' 3 banane {banane, banane, banane}', + ' 1 cerise {cerise}', + ' 3 pomme {pomme, pomme, Pomme}', + '', + ]); + expect(ps('Get-Content fruits.txt | Group-Object | Sort-Object Count -Descending')).toEqual([ + '', + 'Count Name Group', + '----- ---- -----', + ' 3 banane {banane, banane, banane}', + ' 3 pomme {pomme, pomme, Pomme}', + ' 1 cerise {cerise}', + '', + ]); + expect(ps('Get-Content fruits.txt | Group-Object -NoElement')).toEqual(['', 'Count Name', '----- ----', ' 3 banane', ' 1 cerise', ' 3 pomme', '']); + }); + + // Found by terminal-fidelity-auditor on 2 October 2026; expected values from the same real shells. + it('audit fixes: sort -h without a suffix, cut -d \'\', --help, script blocks', () => { + const sizes = home({ 'sizes.txt': '1K\n2M\n500\n3G\n10K\n1.5K' }); + expect(sh('sort -h sizes.txt', 'linux', sizes)).toEqual(['500', '1K', '1.5K', '10K', '2M', '3G']); + expect(sh('sort -hr sizes.txt', 'linux', sizes)).toEqual(['3G', '2M', '10K', '1.5K', '1K', '500']); + expect(sh("cut -d '' -f1 fruits.txt")).toEqual(FRUITS.split('\n')); + for (const cmd of ['sort --help', 'uniq --help', 'cut --help']) { + const out = processCommand(home(), cmd, 'linux').lines; + expect(out.map((l) => l.type), cmd).toEqual(['info']); + } + expect(processCommand(home(), 'Get-Content fruits.txt | Sort-Object { $_.Length }', 'windows').lines.map((l) => l.type)).toEqual(['info']); + }); + + it('audit fixes: PowerShell tables keep their frame through Select-Object, Name is ProcessName', () => { + const ps = (cmd: string, state = home()) => sh(cmd, 'windows', state); + const words = home({ 'words.txt': 'the cat\nthe dog\nthe cat\na bird\nthe dog\nthe cat' }); + expect(ps('Get-Content words.txt | Group-Object -NoElement | Select-Object -First 1', words)).toEqual(['', 'Count Name', '----- ----', ' 1 a bird', '']); + expect(ps('Get-Content words.txt | Group-Object -NoElement | Sort-Object Name -Descending', words)).toEqual([ + '', 'Count Name', '----- ----', ' 2 the dog', ' 3 the cat', ' 1 a bird', '', + ]); + expect(ps('Get-Content fruits.txt | Select-Object -Unique -Last 1')).toEqual(['banane']); + const names = (cmd: string) => ps(cmd).slice(2).map((row) => row.trim().split(/\s+/).pop()); + expect(names('Get-Process | Sort-Object Name')).toEqual(['node', 'pwsh', 'WindowsTerminal']); + expect(names('Get-Process | Sort-Object Name -Descending')).toEqual(['WindowsTerminal', 'pwsh', 'node']); + }); + + 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']); + }); +});