From 71d6eab5634988df2f44cd1302f5970c3fe34d9d Mon Sep 17 00:00:00 2001 From: "Thierry V." <46031203+thierryvm@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:23:31 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(terminal):=20a=20real=20shell=20layer?= =?UTF-8?q?=20=E2=80=94=20lists,=20pipelines=20and=20redirections=20(THI-3?= =?UTF-8?q?53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine only knew `echo > file` and two-stage pipes split on every `|`. `ls fichier 2> erreurs.txt` printed the error anyway, `tee` wrote nothing, and `chmod +x x && ./x`, cited by the lessons, ran as one broken command. - shellSyntax.ts: quote-aware parsing of `;` `&&` `||` `|` and redirections `>` `>>` `2>` `2>&1` `>&2` `&>` `<` (PowerShell `*>`, `2>$null`) - runLine / runPipeline: stdout and stderr routed separately (screen, next command, file, /dev/null), targets opened before the command runs, pipeline stages in a subshell (only files persist), history records the line once - runFilter: wc, grep -v/-c, sort -k/-n/-r, head, tail, uniq -c, tee -a, Tee-Object, Out-File, Set/Add-Content, Out-Null, Measure-Object, Select-Object, Sort-Object, Select-String, findstr, Stop-Process - ls writes one name per line into a pipe or a file, like a real ls (`ls | wc -l` counted 1); `ls -1` added - Get-Item (PowerShell) - KNOWN_DESYNCS ratchet: 3 -> 0 Co-Authored-By: Claude Opus 5.5 --- src/app/data/commands/shellSyntax.ts | 190 +++++++++++ src/app/data/commands/windows.ts | 21 ++ src/app/data/terminalEngine.ts | 455 +++++++++++++++++++++------ src/test/lessonFidelity.test.ts | 13 +- src/test/shellLayer.test.ts | 299 ++++++++++++++++++ src/test/terminalEngine.test.ts | 66 ++++ 6 files changed, 942 insertions(+), 102 deletions(-) create mode 100644 src/app/data/commands/shellSyntax.ts create mode 100644 src/test/shellLayer.test.ts diff --git a/src/app/data/commands/shellSyntax.ts b/src/app/data/commands/shellSyntax.ts new file mode 100644 index 0000000..962a3b3 --- /dev/null +++ b/src/app/data/commands/shellSyntax.ts @@ -0,0 +1,190 @@ +import type { TerminalEnv } from './types'; + +/** + * Shell syntax of one command line: lists (`;`, `&&`, `||`), pipelines (`|`) + * and redirections (`>`, `>>`, `2>`, `2>&1`, `&>`, `<`, PowerShell `*>`). + * Pure parsing — nothing is executed here. Quotes protect every operator, so + * `grep "a|b" f` or `git commit -m "x > y"` stay single commands. + */ + +export type Fd = 1 | 2; + +export type Redirect = + /** `>`, `>>`, `2>`, `&>` (fd 'both'): send a stream to a file. */ + | { kind: 'file'; fd: Fd | 'both'; append: boolean; target: string } + /** `2>&1`, `>&2`: point `fd` wherever `to` currently points. */ + | { kind: 'dup'; fd: Fd; to: Fd }; + +export interface Stage { + /** The command without its redirections, quotes kept for the argument parser. */ + text: string; + redirects: Redirect[]; + /** `< file`: read standard input from a file. */ + stdinFile?: string; +} + +export interface ListItem { + /** Operator that joins this pipeline to the previous one (`null` for the first). */ + op: ';' | '&&' | '||' | null; + stages: Stage[]; +} + +export type ParseResult = { ok: true; list: ListItem[] } | { ok: false; error: string }; + +type Token = + | { t: 'word'; raw: string } + | { t: 'op'; op: '|' | '||' | '&&' | ';' } + | { t: 'redir'; fd: Fd | 'both'; append: boolean; dupTo?: Fd; inFile?: boolean }; + +const OPERATOR_CHARS = new Set(['|', ';', '&', '<', '>']); + +/** Split into words and operators; quoted text is copied verbatim, quotes included. */ +function tokenize(line: string, env: TerminalEnv): { tokens: Token[] } | { error: string } { + const tokens: Token[] = []; + const escape = env === 'windows' ? '`' : '\\'; + let word = ''; + let quote = ''; + const flush = () => { + if (word) tokens.push({ t: 'word', raw: word }); + word = ''; + }; + + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + const next = line[i + 1]; + + if (quote) { + word += ch; + if (ch === quote) quote = ''; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + word += ch; + continue; + } + // An escaped operator is a literal character (`find … -exec rm {} \;`). + if (ch === escape && next !== undefined && (OPERATOR_CHARS.has(next) || next === '"' || next === "'")) { + word += ch + next; + i++; + continue; + } + if (ch === ' ' || ch === '\t') { + flush(); + continue; + } + + // Redirections that start with an fd number or `&` / `*` — only at a word boundary, + // so `a2>f` stays `a2` + `>f` like in bash. + if (!word) { + const m = /^(&>>|&>|\*>>|\*>&1|\*>|[12]?>>|[12]?>&[12]|[12]?>|<)/.exec(line.slice(i)); + if (m) { + const op = m[1]; + i += op.length - 1; + if (op === '<') tokens.push({ t: 'redir', fd: 1, append: false, inFile: true }); + else if (op === '*>&1') tokens.push({ t: 'redir', fd: 2, append: false, dupTo: 1 }); + else if (op.startsWith('&') || op.startsWith('*')) tokens.push({ t: 'redir', fd: 'both', append: op.endsWith('>>') }); + else { + const fd: Fd = op.startsWith('2') ? 2 : 1; + const dup = /&([12])$/.exec(op); + if (dup) tokens.push({ t: 'redir', fd, append: false, dupTo: Number(dup[1]) as Fd }); + else tokens.push({ t: 'redir', fd, append: op.endsWith('>>') }); + } + continue; + } + } else if (ch === '>' || ch === '<') { + // `echo hi>f`: the redirection ends the current word. + flush(); + i--; + continue; + } + + if (ch === '|' || ch === ';' || (ch === '&' && next === '&')) { + flush(); + if (ch === '|' && next === '|') { tokens.push({ t: 'op', op: '||' }); i++; } + else if (ch === '&') { tokens.push({ t: 'op', op: '&&' }); i++; } + else tokens.push({ t: 'op', op: ch as '|' | ';' }); + continue; + } + word += ch; + } + if (quote) return { error: `unexpected EOF while looking for matching \`${quote}'` }; + flush(); + return { tokens }; +} + +export function parseCommandLine(line: string, env: TerminalEnv): ParseResult { + const tok = tokenize(line, env); + if ('error' in tok) return { ok: false, error: tok.error }; + const { tokens } = tok; + + const list: ListItem[] = []; + let stages: Stage[] = []; + let words: string[] = []; + let redirects: Redirect[] = []; + let stdinFile: string | undefined; + let pendingOp: ListItem['op'] = null; + + const endStage = (next: string): string | null => { + if (words.length === 0) return `syntax error near unexpected token \`${next}'`; + stages.push({ text: words.join(' '), redirects, ...(stdinFile !== undefined ? { stdinFile } : {}) }); + words = []; + redirects = []; + stdinFile = undefined; + return null; + }; + + for (let i = 0; i < tokens.length; i++) { + const tk = tokens[i]; + if (tk.t === 'word') { + words.push(tk.raw); + } else if (tk.t === 'redir') { + if (tk.dupTo !== undefined) { + redirects.push({ kind: 'dup', fd: tk.fd as Fd, to: tk.dupTo }); + continue; + } + const target = tokens[i + 1]; + if (!target || target.t !== 'word') { + return { ok: false, error: `syntax error near unexpected token \`${target && target.t === 'op' ? target.op : 'newline'}'` }; + } + i++; + const path = unquote(target.raw); + if (tk.inFile) stdinFile = path; + else redirects.push({ kind: 'file', fd: tk.fd, append: tk.append, target: path }); + } else if (tk.op === '|') { + const err = endStage('|'); + if (err) return { ok: false, error: err }; + } else { + const err = endStage(tk.op); + if (err) return { ok: false, error: err }; + list.push({ op: pendingOp, stages }); + stages = []; + pendingOp = tk.op; + } + } + if (words.length === 0) { + // A list may end with `;` (`ls;`), not with `|`, `&&` or `||`. + if (stages.length === 0 && pendingOp === ';') return { ok: true, list }; + return { ok: false, error: 'syntax error near unexpected token `newline\'' }; + } + endStage('newline'); + list.push({ op: pendingOp, stages }); + return { ok: true, list }; +} + +function unquote(raw: string): string { + const q = raw[0]; + return (q === '"' || q === "'") && raw.endsWith(q) && raw.length >= 2 ? raw.slice(1, -1) : raw; +} + +/** True when the line needs the shell layer at all (fast path for plain commands). */ +export function isPlainCommand(list: ListItem[]): boolean { + return list.length === 1 && list[0].stages.length === 1 + && list[0].stages[0].redirects.length === 0 && list[0].stages[0].stdinFile === undefined; +} + +/** `/dev/null` (Unix) and `$null` / `NUL` (PowerShell) swallow what is written to them. */ +export function isNullDevice(target: string, env: TerminalEnv): boolean { + if (env === 'windows') return /^(\$null|nul)$/i.test(target); + return target === '/dev/null'; +} diff --git a/src/app/data/commands/windows.ts b/src/app/data/commands/windows.ts index 7f5d9d2..2c05803 100644 --- a/src/app/data/commands/windows.ts +++ b/src/app/data/commands/windows.ts @@ -17,6 +17,12 @@ export interface WindowsCmdDeps { const EXECUTION_POLICIES = ['Restricted', 'AllSigned', 'RemoteSigned', 'Unrestricted', 'Bypass', 'Undefined', 'Default']; +/** `-Path x`, `-LiteralPath x`, or the first positional argument. */ +function psPath(args: string[]): string | undefined { + const i = args.findIndex((a) => ['-path', '-literalpath'].includes(a.toLowerCase())); + return i >= 0 ? args[i + 1] : args.find((a) => !a.startsWith('-')); +} + /** * Handles PowerShell aliases and Windows/macOS-specific commands. * Returns null if the command is not handled by this module (caller falls through to default). @@ -51,6 +57,20 @@ export function handleWindows( } return { lines: deps.cmdLs(newState, args), newState }; + // ── Get-Item: the item itself (not its content), or a "cannot find path" error ── + case 'get-item': + case 'gi': { + if (env !== 'windows') return null; + const target = psPath(args); + if (!target) return { lines: [{ text: 'Get-Item: indiquez un chemin, par exemple Get-Item documents', type: 'error' }], newState }; + if (deps.cmdLs(newState, [target]).some((l) => l.type === 'error')) { + const cwd = deps.cmdPwd(newState, env)[0]?.text ?? ''; + const full = /^([a-z]:|[\\/~])/i.test(target) ? target : `${cwd}\\${target.replace(/\//g, '\\')}`; + return { lines: [{ text: `Get-Item: Cannot find path '${full}' because it does not exist.`, type: 'error' }], newState }; + } + return { lines: [{ text: target, type: 'output' }], newState }; + } + // ── cat equivalents ─────────────────────────────────────────────────────── case 'get-content': case 'gc': @@ -298,6 +318,7 @@ export const WINDOWS_COMMANDS = new Set([ 'get-location', 'gl', 'set-location', 'sl', 'get-childitem', 'gci', 'dir', 'get-content', 'gc', + 'get-item', 'gi', 'new-item', 'ni', 'copy-item', 'cpi', 'copy', 'move-item', 'mi', 'move', diff --git a/src/app/data/terminalEngine.ts b/src/app/data/terminalEngine.ts index b3ec5a5..3a6ea8e 100644 --- a/src/app/data/terminalEngine.ts +++ b/src/app/data/terminalEngine.ts @@ -8,6 +8,8 @@ import { handleNetwork } from './commands/network'; import { handleAiHelp } from './commands/ai'; import { cmdEnv, handleEnv } from './commands/env'; import { handleWindows } from './commands/windows'; +import { parseCommandLine, isPlainCommand, isNullDevice } from './commands/shellSyntax'; +import type { Stage, Fd } from './commands/shellSyntax'; import type { WindowsCmdDeps } from './commands/windows'; // ─── Initial Filesystem ─────────────────────────────────────────────────────── @@ -335,7 +337,11 @@ function cmdLs(state: TerminalState, args: string[]): OutputLine[] { } if (!longFormat) { + // Into a pipe or a file, a real `ls` writes one plain name per line + // (that is what `ls | wc -l` counts); `-1` asks for it on screen too. + if (!stdoutIsTerminal) return entries.map(([name]) => ({ text: name, type: 'output' as const })); const names = entries.map(([name, n]) => (n.type === 'directory' ? name + '/' : name)); + if (flags.some((f) => f.includes('1'))) return names.map((text) => ({ text, type: 'output' as const })); return [{ text: names.join(' '), type: 'output' }]; } @@ -763,27 +769,6 @@ function cmdGetJob(): OutputLine[] { // cmdCrontab → moved to ./commands/env.ts -function cmdEchoRedirect( - state: TerminalState, - text: string, - filePath: string, - append: boolean -): { lines: OutputLine[]; newRoot?: DirectoryNode } { - const newRoot = deepCloneRoot(state.root); - const resolved = resolvePath(state, filePath); - const parentPath = resolved.slice(0, -1); - const name = resolved[resolved.length - 1]; - const parent = getNode(newRoot, parentPath) as DirectoryNode; - if (!parent || parent.type !== 'directory') { - return { lines: [{ text: `bash: ${filePath}: No such file or directory`, type: 'error' }] }; - } - const existing = parent.children[name]; - const existingContent = existing?.type === 'file' ? existing.content : ''; - const newContent = append ? existingContent + (existingContent ? '\n' : '') + text : text; - parent.children[name] = makeFile(newContent); - return { lines: [], newRoot }; -} - // ─── Scripts ────────────────────────────────────────────────────────────────── /** PowerShell's $PROFILE for the simulated user (C:\Users\user = ~ in this filesystem). */ @@ -865,57 +850,362 @@ function runScript(state: TerminalState, call: ScriptCall, env: TerminalEnv): Co } } -function cmdPipe(state: TerminalState, left: string, right: string, env: TerminalEnv): OutputLine[] { - // Execute left side and feed output to right side's stdin - const leftResult = processCommand(state, left, env); - const inputText = leftResult.lines.filter((l) => l.type === 'output').map((l) => l.text).join('\n'); - - // Simulate stdin for right command - const rightParts = parseArgs(right.trim()); - const rightCmd = rightParts[0]; - const rightArgs = rightParts.slice(1); - - if (rightCmd === 'wc') { - const flags = rightArgs.filter((a) => a.startsWith('-')); - const lines = inputText.split('\n').filter(Boolean); - const words = inputText.split(/\s+/).filter(Boolean); - const bytes = inputText.length; - if (flags.some((f) => f.includes('l'))) return [{ text: String(lines.length), type: 'output' }]; - if (flags.some((f) => f.includes('w'))) return [{ text: String(words.length), type: 'output' }]; - if (flags.some((f) => f.includes('c'))) return [{ text: String(bytes), type: 'output' }]; - return [{ text: `${lines.length} ${words.length} ${bytes}`, type: 'output' }]; +// ─── Shell layer: lists, pipelines, redirections ───────────────────────────── + +/** + * Whether the command running now writes to the screen. Commands that format + * for a person (like `ls` in columns) switch to one item per line otherwise. + * Set around each command by runPipeline, restored after it. + */ +let stdoutIsTerminal = true; + +/** Where a stream goes: the screen, the next command, a file, or nowhere. */ +type Sink = { kind: 'screen' } | { kind: 'pipe' } | { kind: 'null' } | { kind: 'file'; key: string }; + +interface OpenFile { path: string[]; typed: string; append: boolean; chunks: string[] } + +interface PipelineResult { lines: OutputLine[]; newState: TerminalState; ok: boolean; clear: boolean } + +const outLines = (texts: string[]): OutputLine[] => texts.map((text) => ({ text, type: 'output' as const })); + +function openFailure(typed: string, reason: string, env: TerminalEnv): OutputLine { + return env === 'windows' + ? { text: `Out-File: Could not find a part of the path '${typed}'.`, type: 'error' } + : { text: `bash: ${typed}: ${reason}`, type: 'error' }; +} + +/** Checks a redirection target the way the shell opens it: before the command runs. */ +function checkWritable(state: TerminalState, typed: string, env: TerminalEnv): { path: string[] } | { error: OutputLine } { + const path = resolvePath(state, typed); + const parent = getNode(state.root, path.slice(0, -1)); + if (!parent || parent.type !== 'directory' || path.length === 0) { + return { error: openFailure(typed, 'No such file or directory', env) }; } + if (getNode(state.root, path)?.type === 'directory') return { error: openFailure(typed, 'Is a directory', env) }; + return { path }; +} - if (rightCmd === 'grep') { - const flags = rightArgs.filter((a) => a.startsWith('-')); - const pattern = rightArgs.find((a) => !a.startsWith('-')) || ''; - const ignoreCase = flags.some((f) => f.includes('i')); - const showLineNumbers = flags.some((f) => f.includes('n')); - const regexResult = buildGrepRegex(pattern, ignoreCase ? 'i' : ''); - if (!regexResult.ok) return [regexResult.error]; - const lines = inputText.split('\n'); - const matches = lines.map((line, i) => ({ line, i })).filter(({ line }) => regexResult.regex.test(line)); - return matches.map(({ line, i }) => ({ - text: showLineNumbers ? `${i + 1}:${line}` : line, - type: 'output' as const, - })); +/** Writes (or appends) text to a file; the target was already checked. */ +function writeFileAt(root: DirectoryNode, path: string[], content: string, append: boolean): DirectoryNode { + const newRoot = deepCloneRoot(root); + const parent = getNode(newRoot, path.slice(0, -1)) as DirectoryNode; + const name = path[path.length - 1]; + const existing = parent.children[name]; + const before = existing?.type === 'file' ? existing.content : ''; + const text = append ? before + (before && content ? '\n' : '') + content : content; + parent.children[name] = existing?.type === 'file' ? { ...existing, content: text } : makeFile(text); + return newRoot; +} + +/** `tee`, `Tee-Object`, `Out-File`… : write the piped text to each file. */ +function writeFromPipe(state: TerminalState, files: string[], text: string, append: boolean, env: TerminalEnv): { root: DirectoryNode; errors: OutputLine[] } { + let root = state.root; + const errors: OutputLine[] = []; + for (const f of files) { + const target = checkWritable({ ...state, root }, f, env); + if ('error' in target) errors.push(target.error); + else root = writeFileAt(root, target.path, text, append); } + return { root, errors }; +} + +/** Value that follows a PowerShell parameter (`-FilePath x`), case-insensitive. */ +function psParam(args: string[], ...names: string[]): string | undefined { + const i = args.findIndex((a) => names.includes(a.toLowerCase())); + return i >= 0 ? args[i + 1] : undefined; +} - if (rightCmd === 'sort') { - const lines = inputText.split('\n').filter(Boolean); - const flags = rightArgs.filter((a) => a.startsWith('-')); - const sorted = [...lines].sort(); - if (flags.some((f) => f.includes('r'))) sorted.reverse(); - return sorted.map((l) => ({ text: l, type: 'output' as const })); +/** A table printed by the simulator (`Get-Process`, `ps aux`): header line + `----` line. */ +function splitHeader(lines: string[]): { header: string[]; rows: string[] } { + return lines.length >= 2 && /^[\s-]+$/.test(lines[1]) && lines[1].includes('--') + ? { header: lines.slice(0, 2), rows: lines.slice(2) } + : { header: [], rows: lines }; +} + +function compareValues(a: string, b: string, numeric: boolean): number { + if (numeric) return (parseFloat(a) || 0) - (parseFloat(b) || 0); + return a.localeCompare(b); +} + +/** A number of lines from `-n 5`, `-n5`, `-5` (head/tail), or the default. */ +function lineCount(args: string[], fallback: number): number { + const i = args.findIndex((a) => a === '-n'); + if (i >= 0) return parseInt(args[i + 1] ?? '', 10) || fallback; + const attached = args.find((a) => /^-n?\d+$/.test(a)); + return attached ? parseInt(attached.replace(/^-n?/, ''), 10) : fallback; +} + +/** + * 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. + */ +function runFilter(state: TerminalState, text: string, stdin: string, env: TerminalEnv): CommandOutput { + const parts = parseArgs(text); + const cmd = (parts[0] ?? '').toLowerCase(); + const args = parts.slice(1); + const flags = args.filter((a) => a.startsWith('-')); + const operands = args.filter((a) => !a.startsWith('-')); + const input = stdin === '' ? [] : stdin.split('\n'); + const same = (lines: OutputLine[], s: TerminalState = state): CommandOutput => ({ lines, newState: s }); + + switch (cmd) { + case 'wc': { + const lines = stdin.split('\n').filter(Boolean); + const words = stdin.split(/\s+/).filter(Boolean); + const bytes = stdin.length; + if (flags.some((f) => f.includes('l'))) return same(outLines([String(lines.length)])); + if (flags.some((f) => f.includes('w'))) return same(outLines([String(words.length)])); + if (flags.some((f) => f.includes('c'))) return same(outLines([String(bytes)])); + return same(outLines([`${lines.length} ${words.length} ${bytes}`])); + } + + case 'grep': { + const pattern = operands[0] ?? ''; + const ignoreCase = flags.some((f) => f.includes('i')); + const regexResult = buildGrepRegex(pattern, ignoreCase ? 'i' : ''); + if (!regexResult.ok) return same([regexResult.error]); + const invert = flags.some((f) => f.includes('v')); + const matches = input.map((line, i) => ({ line, i })).filter(({ line }) => regexResult.regex.test(line) !== invert); + if (flags.some((f) => f.includes('c'))) return same(outLines([String(matches.length)])); + const numbered = flags.some((f) => f.includes('n')); + return same(outLines(matches.map(({ line, i }) => (numbered ? `${i + 1}:${line}` : line)))); + } + + case 'select-string': + case 'sls': + case 'findstr': { + // Select-String ignores case by default; findstr only with /I. + const pattern = psParam(args, '-pattern') ?? args.find((a) => !a.startsWith('-') && !a.startsWith('/')) ?? ''; + const ignoreCase = cmd !== 'findstr' || args.some((a) => a.toLowerCase() === '/i'); + const regexResult = buildGrepRegex(pattern, ignoreCase ? 'i' : ''); + if (!regexResult.ok) return same([regexResult.error]); + return same(outLines(input.filter((line) => regexResult.regex.test(line)))); + } + + 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-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 'head': + return same(outLines(input.slice(0, lineCount(args, 10)))); + + case 'tail': { + const n = lineCount(args, 10); + return same(outLines(input.slice(Math.max(0, input.length - n)))); + } + + case 'select-object': { + const { header, rows } = splitHeader(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 }); + } + const counted = flags.some((f) => f.includes('c')); + return same(outLines(groups.map((g) => (counted ? `${String(g.count).padStart(7)} ${g.line}` : g.line)))); + } + + case 'measure-object': + case 'measure': { + const items = input.filter((l) => l.trim() !== ''); + const lower = flags.map((f) => f.toLowerCase()); + if (lower.some((f) => ['-line', '-word', '-character'].includes(f))) { + const out: string[] = []; + if (lower.includes('-line')) out.push(`Lines : ${items.length}`); + if (lower.includes('-word')) out.push(`Words : ${stdin.split(/\s+/).filter(Boolean).length}`); + if (lower.includes('-character')) out.push(`Characters : ${stdin.length}`); + return same(outLines(out)); + } + return same(outLines([`Count : ${items.length}`, 'Average :', 'Sum :', 'Maximum :', 'Minimum :', 'Property :'])); + } + + case 'tee': + case 'tee-object': { + const append = flags.some((f) => ['-a', '--append', '-append'].includes(f.toLowerCase())); + const files = cmd === 'tee' + ? operands + : [psParam(args, '-filepath', '-path', '-literalpath') ?? (psParam(args, '-variable') ? undefined : operands[0])].filter((f): f is string => !!f); + const { root, errors } = writeFromPipe(state, files, stdin, append, env); + return same([...outLines(input), ...errors], { ...state, root }); + } + + case 'out-file': + case 'set-content': + case 'add-content': { + const file = psParam(args, '-filepath', '-path', '-literalpath') ?? operands[0]; + if (!file) return same([{ text: `${parts[0]}: indiquez un fichier, par exemple ${parts[0]} sortie.txt`, type: 'error' }]); + const append = cmd === 'add-content' || flags.some((f) => f.toLowerCase() === '-append'); + const { root, errors } = writeFromPipe(state, [file], stdin, append, env); + return same(errors, { ...state, root }); + } + + case 'out-null': + return same([]); + + case 'stop-process': + case 'spps': + // The processes arrive through the pipe (`Get-Process node | Stop-Process`). + return same([{ text: 'Processus arrêté.', type: 'success' }]); + + case 'cat': + case 'get-content': + case 'gc': + // With a file argument they read the file and ignore standard input. + return operands.length ? runSimple(state, text, env) : same(outLines(input)); + + case 'less': + case 'more': + case 'where-object': + case '?': + case 'format-list': + case 'fl': + case 'format-table': + case 'ft': + case 'out-host': + case 'out-string': + case 'jq': + // Displayed as received: the simulator does not filter objects or JSON. + return same(outLines(input)); + + default: + return same(outLines(input)); } +} + +/** Runs `a | b | c` with its redirections; `ok` is the status of the last command. */ +function runPipeline(state: TerminalState, stages: Stage[], env: TerminalEnv): PipelineResult { + let s = state; + let screen: OutputLine[] = []; + let piped: string | undefined; + let ok = true; + let clear = false; + + for (let i = 0; i < stages.length; i++) { + const stage = stages[i]; + const isLast = i === stages.length - 1; + const fds: Record = { 1: isLast ? { kind: 'screen' } : { kind: 'pipe' }, 2: { kind: 'screen' } }; + const files = new Map(); + let openError: OutputLine | undefined; + + for (const r of stage.redirects) { + if (r.kind === 'dup') { + fds[r.fd] = fds[r.to]; + continue; + } + let sink: Sink; + if (isNullDevice(r.target, env)) { + sink = { kind: 'null' }; + } else { + const target = checkWritable(s, r.target, env); + if ('error' in target) { openError = target.error; break; } + const key = target.path.join('/'); + if (!files.has(key)) files.set(key, { path: target.path, typed: r.target, append: r.append, chunks: [] }); + sink = { kind: 'file', key }; + } + if (r.fd === 'both') { fds[1] = sink; fds[2] = sink; } else fds[r.fd] = sink; + } + + let stdin = piped; + if (!openError && stage.stdinFile !== undefined) { + const node = getNode(s.root, resolvePath(s, stage.stdinFile)); + if (node?.type === 'file') stdin = node.content; + else openError = openFailure(stage.stdinFile, node ? 'Is a directory' : 'No such file or directory', env); + } + + piped = ''; + if (openError) { + // Like bash: a redirection that cannot be opened stops this command only. + screen.push(openError); + ok = false; + continue; + } + + const outerTerminal = stdoutIsTerminal; + stdoutIsTerminal = outerTerminal && fds[1].kind === 'screen'; + let result: CommandOutput; + try { + result = stdin !== undefined ? runFilter(s, stage.text, stdin, env) : runSimple(s, stage.text, env); + } finally { + stdoutIsTerminal = outerTerminal; + } + if (result.clear) { screen = []; clear = true; } + ok = !result.lines.some((l) => l.type === 'error'); + + const toPipe: string[] = []; + for (const line of result.lines) { + // Simulator notes are for the learner, never part of a stream. + const sink = line.type === 'info' ? { kind: 'screen' as const } : fds[line.type === 'error' ? 2 : 1]; + if (sink.kind === 'screen') screen.push(line); + else if (sink.kind === 'pipe') toPipe.push(line.text); + else if (sink.kind === 'file') files.get(sink.key)!.chunks.push(line.text); + } + piped = toPipe.join('\n'); - if (rightCmd === 'head') { - const nFlag = rightArgs.find((a) => a.startsWith('-n')); - const n = nFlag ? parseInt(nFlag.slice(2)) || 10 : 10; - return inputText.split('\n').slice(0, n).map((l) => ({ text: l, type: 'output' as const })); + s = result.newState; + for (const f of files.values()) s = { ...s, root: writeFileAt(s.root, f.path, f.chunks.join('\n'), f.append) }; } - return leftResult.lines; + // Each command of a real pipeline runs in a subshell: only what it writes to disk stays. + const newState = stages.length > 1 ? { ...state, root: s.root, git: s.git } : s; + return { lines: screen, newState, ok, clear }; +} + +function syntaxError(message: string, env: TerminalEnv): OutputLine { + return { text: env === 'windows' ? `ParserError: ${message}` : `bash: ${message}`, type: 'error' }; +} + +/** A full command line: `a && b; c | d > f`. */ +function runLine(state: TerminalState, line: string, env: TerminalEnv): CommandOutput { + const parsed = parseCommandLine(line, env); + if (!parsed.ok) return { lines: [syntaxError(parsed.error, env)], newState: state }; + // Plain command: exactly the historical path, character for character. + if (isPlainCommand(parsed.list)) return runSimple(state, line, env); + + let s = state; + let screen: OutputLine[] = []; + let lastOk = true; + let clear = false; + for (const item of parsed.list) { + if (item.op === '&&' && !lastOk) continue; + if (item.op === '||' && lastOk) continue; + const r = runPipeline(s, item.stages, env); + if (r.clear) { screen = []; clear = true; } + screen = [...screen, ...r.lines]; + s = r.newState; + lastOk = r.ok; + } + return { lines: screen, newState: s, clear: clear || undefined }; } // ─── Help & Man (env-aware) ─────────────────────────────────────────────────── @@ -1398,40 +1688,15 @@ function getCmdHelp(cmdName: string, env: TerminalEnv = 'linux'): OutputLine[] | export function processCommand(state: TerminalState, input: string, env: TerminalEnv = 'linux'): CommandOutput { const trimmed = input.trim(); const newHistory = trimmed ? [...state.commandHistory, trimmed] : state.commandHistory; - let newState: TerminalState = { ...state, commandHistory: newHistory }; + const newState: TerminalState = { ...state, commandHistory: newHistory }; if (!trimmed) return { lines: [], newState }; + return runLine(newState, trimmed, env); +} - // Handle pipes - if (trimmed.includes('|')) { - const [left, ...rest] = trimmed.split('|'); - const right = rest.join('|'); - const pipeLines = cmdPipe(state, left.trim(), right.trim(), env); - return { lines: pipeLines, newState }; - } - - // Handle output redirection - const appendMatch = trimmed.match(/^(.+?)\s*>>\s*(.+)$/); - const writeMatch = trimmed.match(/^(.+?)\s*>\s*(.+)$/); - if (appendMatch) { - const cmd = appendMatch[1].trim(); - const file = appendMatch[2].trim(); - const echoArgs = parseArgs(cmd); - if (echoArgs[0] === 'echo') { - const { lines, newRoot } = cmdEchoRedirect(state, echoArgs.slice(1).join(' '), file, true); - if (newRoot) newState = { ...newState, root: newRoot }; - return { lines, newState }; - } - } else if (writeMatch) { - const cmd = writeMatch[1].trim(); - const file = writeMatch[2].trim(); - const echoArgs = parseArgs(cmd); - if (echoArgs[0] === 'echo') { - const { lines, newRoot } = cmdEchoRedirect(state, echoArgs.slice(1).join(' '), file, false); - if (newRoot) newState = { ...newState, root: newRoot }; - return { lines, newState }; - } - } +/** One command, without list, pipe or redirection — the shell layer handles those. */ +function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): CommandOutput { + let newState = state; // Handle PowerShell $env: variable assignment ($env:VAR = "value") const psEnvSet = trimmed.match(/^\$env:([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/); diff --git a/src/test/lessonFidelity.test.ts b/src/test/lessonFidelity.test.ts index 133b839..d339d2b 100644 --- a/src/test/lessonFidelity.test.ts +++ b/src/test/lessonFidelity.test.ts @@ -21,13 +21,12 @@ import { LESSON_SOLUTIONS } from './lessonSolutions'; const ENVS: EnvId[] = ['linux', 'macos', 'windows']; -/** Cases still broken in the engine — each one names the P1 cluster that fixes it. */ -const KNOWN_DESYNCS = new Set([ - // Redirection: `2>` does not capture stderr, the error is still printed. - 'redirection/stderr [linux]', - 'redirection/stderr [macos]', - 'redirection/stderr [windows]', // + Get-Item is not simulated -]); +/** + * Cases still broken in the engine — each one names the P1 cluster that fixes it. + * Empty since the shell layer (redirections, THI-353): keep it empty. A new entry + * is only acceptable for a gap found by this test, with the fix already planned. + */ +const KNOWN_DESYNCS = new Set([]); const norm = (s: string) => s.replace(/\s+/g, ' ').trim(); diff --git a/src/test/shellLayer.test.ts b/src/test/shellLayer.test.ts new file mode 100644 index 0000000..e7147fd --- /dev/null +++ b/src/test/shellLayer.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect } from 'vitest'; +import { createInitialState, processCommand } from '../app/data/terminalEngine'; +import { parseCommandLine } from '../app/data/commands/shellSyntax'; +import type { TerminalEnv, TerminalState, OutputLine } from '../app/data/commands/types'; + +function run(env: TerminalEnv, ...cmds: string[]): { state: TerminalState; last: OutputLine[] } { + let state = createInitialState(); + let last: OutputLine[] = []; + for (const c of cmds) { + const r = processCommand(state, c, env); + state = r.newState; + last = r.lines; + } + return { state, last }; +} + +/** File content as the learner would read it back. */ +function cat(state: TerminalState, file: string, env: TerminalEnv = 'linux'): string { + return processCommand(state, `cat ${file}`, env).lines.map((l) => l.text).join('\n'); +} + +const text = (lines: OutputLine[]) => lines.map((l) => l.text).join('\n'); +const errors = (lines: OutputLine[]) => lines.filter((l) => l.type === 'error'); +/** What a real `ls` writes into a pipe or a file: one plain name per line. */ +const entries = (env: TerminalEnv) => text(run(env, env === 'windows' ? 'Get-ChildItem' : 'ls').last) + .split(/\s+/).filter(Boolean).map((n) => n.replace(/\/$/, '')); + +describe('shell syntax — parseCommandLine', () => { + it('quotes protect the pipe and redirection characters', () => { + const r = parseCommandLine('grep "a|b" f > "x > y.txt"', 'linux'); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.list).toHaveLength(1); + expect(r.list[0].stages).toHaveLength(1); + expect(r.list[0].stages[0].text).toBe('grep "a|b" f'); + expect(r.list[0].stages[0].redirects).toEqual([{ kind: 'file', fd: 1, append: false, target: 'x > y.txt' }]); + }); + + it('keeps redirections in the order they are written', () => { + const r = parseCommandLine('cmd > out.txt 2>&1', 'linux'); + expect(r.ok && r.list[0].stages[0].redirects).toEqual([ + { kind: 'file', fd: 1, append: false, target: 'out.txt' }, + { kind: 'dup', fd: 2, to: 1 }, + ]); + }); + + it('splits lists and pipelines', () => { + const r = parseCommandLine('mkdir d && cd d; ls | wc -l || echo ko', 'linux'); + expect(r.ok && r.list.map((i) => [i.op, i.stages.map((s) => s.text)])).toEqual([ + [null, ['mkdir d']], + ['&&', ['cd d']], + [';', ['ls', 'wc -l']], + ['||', ['echo ko']], + ]); + }); + + it('an escaped semicolon is part of the word (find -exec … \\;)', () => { + const r = parseCommandLine('find . -name x -exec rm {} \\;', 'linux'); + expect(r.ok && r.list).toHaveLength(1); + }); + + it('a PowerShell path keeps its backslashes', () => { + const r = parseCommandLine('.\\script.sh | grep ok', 'windows'); + expect(r.ok && r.list[0].stages.map((s) => s.text)).toEqual(['.\\script.sh', 'grep ok']); + }); + + it.each([['ls |'], ['| ls'], ['ls >'], ['ls &&'], ['echo "abc']])('rejects %s', (line) => { + expect(parseCommandLine(line, 'linux').ok).toBe(false); + }); + + it('accepts a trailing semicolon', () => { + expect(parseCommandLine('ls;', 'linux').ok).toBe(true); + }); +}); + +describe('redirections — standard output', () => { + it('> sends any command output to the file, nothing on screen', () => { + const listing = entries('linux').join('\n'); + const { state, last } = run('linux', 'ls > liste.txt'); + expect(last).toEqual([]); + expect(cat(state, 'liste.txt')).toBe(listing); + }); + + it('>> appends', () => { + // `ls d` rather than `pwd`: pwd prints `~` today, a separate THI-353 gap. + const { state } = run('linux', 'mkdir d', 'touch d/x', 'echo un > f.txt', 'echo deux >> f.txt', 'ls d >> f.txt'); + expect(cat(state, 'f.txt')).toBe('un\ndeux\nx'); + }); + + it('echo > still writes exactly what it wrote before', () => { + const { state } = run('linux', 'echo "Bonjour le monde!" > bonjour.txt'); + expect(cat(state, 'bonjour.txt')).toBe('Bonjour le monde!'); + }); + + it('a script\'s output can be redirected', () => { + const { state, last } = run('linux', 'echo "echo depuis le script" > s.sh', 'bash s.sh > out.txt'); + expect(last).toEqual([]); + expect(cat(state, 'out.txt')).toBe('depuis le script'); + }); + + it('writing into a missing directory is an error and runs nothing', () => { + const { state, last } = run('linux', 'mkdir -p x', 'echo a > dossier-absent/f.txt'); + expect(text(errors(last))).toContain('dossier-absent/f.txt: No such file or directory'); + expect(text(run('linux', 'ls dossier-absent').last)).toContain('No such file or directory'); + expect(state).toBeDefined(); + }); + + it('< reads standard input from a file', () => { + const { last } = run('linux', 'echo a > f.txt', 'echo b >> f.txt', 'wc -l < f.txt'); + expect(text(last)).toBe('2'); + }); +}); + +describe('redirections — standard error', () => { + it('2> captures the error message in the file, the screen stays clean', () => { + const { state, last } = run('linux', 'ls fichier-inexistant 2> erreurs.txt'); + expect(last).toEqual([]); + expect(cat(state, 'erreurs.txt')).toContain("ls: cannot access 'fichier-inexistant': No such file or directory"); + }); + + it('2> creates an empty file when there is no error, and output still shows', () => { + const { state, last } = run('linux', 'mkdir d', 'touch d/x', 'ls d 2> erreurs.txt'); + expect(text(last)).toBe('x'); + expect(cat(state, 'erreurs.txt')).toBe(''); + }); + + it('2>/dev/null silences the error and creates no file', () => { + const { state, last } = run('linux', 'ls absent 2>/dev/null'); + expect(last).toEqual([]); + expect(text(processCommand(state, 'ls /dev', 'linux').lines)).not.toContain('null'); + }); + + it('> file 2>&1 puts both streams in the file', () => { + const { state, last } = run('linux', 'ls absent > tout.txt 2>&1'); + expect(last).toEqual([]); + expect(cat(state, 'tout.txt')).toContain('cannot access'); + }); + + it('2>&1 > file keeps the error on screen (order matters, like bash)', () => { + const { state, last } = run('linux', 'ls absent 2>&1 > tout.txt'); + expect(text(errors(last))).toContain('cannot access'); + expect(cat(state, 'tout.txt')).toBe(''); + }); + + it('&> sends both streams to the file', () => { + const { state, last } = run('linux', 'ls absent &> tout.txt'); + expect(last).toEqual([]); + expect(cat(state, 'tout.txt')).toContain('cannot access'); + }); + + it('2>&1 lets an error travel through a pipe', () => { + const { last } = run('linux', 'ls absent 2>&1 | grep cannot'); + expect(last.map((l) => l.type)).toEqual(['output']); + expect(text(last)).toContain('cannot access'); + }); +}); + +describe('pipelines', () => { + it('ls | wc -l counts the entries', () => { + const n = entries('linux').length; + expect(text(run('linux', 'ls | wc -l').last)).toBe(String(n)); + }); + + it('ls writes columns on screen, one name per line into a pipe or a file, and with -1', () => { + expect(run('linux', 'ls').last).toHaveLength(1); + expect(text(run('linux', 'ls -1').last)).toBe(['documents/', 'downloads/', 'projets/'].join('\n')); + expect(text(run('linux', 'ls | cat').last)).toBe('documents\ndownloads\nprojets'); + }); + + it('runs more than two stages', () => { + const { last } = run('linux', 'echo "pomme" > f.txt', 'echo "poire" >> f.txt', 'echo "kiwi" >> f.txt', 'cat f.txt | grep p | wc -l'); + expect(text(last)).toBe('2'); + }); + + it('a quoted pipe is a character, not a pipe', () => { + const { last } = run('linux', 'echo "a|b" > f.txt', 'grep "a|b" f.txt'); + expect(text(last)).toBe('a|b'); + }); + + it('tee shows the output AND writes the file', () => { + const listing = entries('linux').join('\n'); + const { state, last } = run('linux', 'ls | tee ma-liste.txt'); + expect(text(last)).toBe(listing); + expect(cat(state, 'ma-liste.txt')).toBe(listing); + }); + + it('tee -a appends', () => { + const { state } = run('linux', 'echo un | tee f.txt', 'echo deux | tee -a f.txt'); + expect(cat(state, 'f.txt')).toBe('un\ndeux'); + }); + + it('errors of the left side stay on screen, the pipe only carries output', () => { + const { last } = run('linux', 'ls absent | wc -l'); + expect(text(errors(last))).toContain('cannot access'); + expect(last.filter((l) => l.type === 'output').map((l) => l.text)).toEqual(['0']); + }); + + it('sort -r, head -n, tail and uniq read the pipe', () => { + const { last } = run('linux', 'echo b > f', 'echo a >> f', 'echo b >> f', 'echo c >> f', 'cat f | sort | uniq | sort -r | head -n 2'); + expect(text(last)).toBe('c\nb'); + expect(text(run('linux', 'echo 1 > f', 'echo 2 >> f', 'echo 3 >> f', 'cat f | tail -1').last)).toBe('3'); + }); + + it('grep -v and grep -c on a pipe', () => { + const base = ['echo alpha > f', 'echo beta >> f', 'echo gamma >> f']; + expect(text(run('linux', ...base, 'cat f | grep -v beta').last)).toBe('alpha\ngamma'); + expect(text(run('linux', ...base, 'cat f | grep -c a').last)).toBe('3'); + }); + + it('records the whole line once in the history', () => { + const { state } = run('linux', 'ls | wc -l', 'echo a && echo b'); + expect(state.commandHistory.slice(-2)).toEqual(['ls | wc -l', 'echo a && echo b']); + }); +}); + +describe('command lists', () => { + it('&& runs the next command only on success', () => { + const ok = run('linux', 'mkdir projet && cd projet && pwd'); + expect(text(ok.last)).toMatch(/\/projet$/); + expect(ok.state.cwd).toEqual(['home', 'user', 'projet']); + expect(text(run('linux', 'ls absent && echo suite').last)).not.toContain('suite'); + }); + + it('|| runs the next command only on failure', () => { + expect(text(run('linux', 'ls absent || echo repli').last)).toContain('repli'); + expect(text(run('linux', 'pwd || echo repli').last)).not.toContain('repli'); + }); + + it('; always runs the next command', () => { + expect(text(run('linux', 'ls absent; echo toujours').last)).toContain('toujours'); + }); + + it('chmod +x script.sh && ./script.sh (the pattern the lessons cite)', () => { + // The simulator confirms chmod on screen (a real chmod is silent), then the script runs. + const { last } = run('linux', 'echo "echo lancé" > s.sh', 'chmod +x s.sh && ./s.sh'); + expect(errors(last)).toEqual([]); + expect(last[last.length - 1].text).toBe('lancé'); + }); + + it('a syntax error is reported, nothing runs', () => { + const { state, last } = run('linux', 'mkdir a |'); + expect(text(errors(last))).toContain('syntax error'); + expect(text(processCommand(state, 'ls a', 'linux').lines)).toContain('No such file or directory'); + }); +}); + +describe('PowerShell — redirections and pipeline cmdlets', () => { + it('Get-Item on a missing path is an error; 2> captures it', () => { + const shown = run('windows', 'Get-Item fichier-inexistant').last; + expect(text(errors(shown))).toContain("Cannot find path"); + const { state, last } = run('windows', 'Get-Item fichier-inexistant 2> erreurs.txt'); + expect(last).toEqual([]); + expect(cat(state, 'erreurs.txt', 'windows')).toContain('Cannot find path'); + }); + + it('Get-Item on an existing path shows it', () => { + const { last } = run('windows', 'Get-Item documents'); + expect(errors(last)).toEqual([]); + expect(text(last)).toContain('documents'); + }); + + it('2>$null silences the error', () => { + expect(run('windows', 'Get-Item absent 2>$null').last).toEqual([]); + }); + + it('Write-Output > file writes the text, not the operator', () => { + const { state, last } = run('windows', 'Write-Output "Bonjour le monde!" > bonjour.txt'); + expect(last).toEqual([]); + expect(cat(state, 'bonjour.txt', 'windows')).toBe('Bonjour le monde!'); + }); + + it('Tee-Object -FilePath shows and writes', () => { + const listing = entries('windows').join('\n'); + const { state, last } = run('windows', 'Get-ChildItem | Tee-Object -FilePath ma-liste.txt'); + expect(text(last)).toBe(listing); + expect(cat(state, 'ma-liste.txt', 'windows')).toBe(listing); + }); + + it('Measure-Object counts the items', () => { + const n = entries('windows').length; + expect(text(run('windows', 'Get-ChildItem | Measure-Object').last)).toContain(`Count : ${n}`); + }); + + it('Select-Object -First keeps the first lines', () => { + const { last } = run('windows', 'echo a > f.txt', 'echo b >> f.txt', 'echo c >> f.txt', 'Get-Content f.txt | Select-Object -First 2'); + expect(text(last)).toBe('a\nb'); + }); + + it('Out-File writes without printing; Out-Null prints nothing', () => { + const { state, last } = run('windows', 'Get-Location | Out-File ici.txt'); + expect(last).toEqual([]); + expect(cat(state, 'ici.txt', 'windows')).toContain('Users'); + expect(run('windows', 'Get-ChildItem | Out-Null').last).toEqual([]); + }); + + it('Get-Process node | Stop-Process stops it without an error', () => { + expect(errors(run('windows', 'Get-Process node | Stop-Process').last)).toEqual([]); + }); +}); diff --git a/src/test/terminalEngine.test.ts b/src/test/terminalEngine.test.ts index 0b4f6ea..43f78f7 100644 --- a/src/test/terminalEngine.test.ts +++ b/src/test/terminalEngine.test.ts @@ -2423,3 +2423,69 @@ describe('scripts — review follow-ups (THI-353)', () => { expect(r.lines).toEqual([{ text: 'trouvé', type: 'output' }]); }); }); + +// ─── Shell layer: new commands (full behaviour in shellLayer.test.ts) ───────── + +describe('Get-Item and pipeline cmdlets', () => { + function build(env: 'linux' | 'windows', ...cmds: string[]): TerminalState { + let s = createInitialState(); + for (const c of cmds) s = processCommand(s, c, env).newState; + return s; + } + const out = (s: TerminalState, cmd: string, env: 'linux' | 'windows' = 'windows') => + processCommand(s, cmd, env).lines.map((l) => l.text).join('\n'); + + it('Get-Item shows an existing item and names the full missing path', () => { + const s = createInitialState(); + expect(out(s, 'Get-Item documents')).toBe('documents'); + expect(out(s, 'Get-Item absent')).toBe("Get-Item: Cannot find path 'C:\\Users\\user\\absent' because it does not exist."); + expect(out(s, 'gi -Path documents')).toBe('documents'); + }); + + it('Get-Item is PowerShell only', () => { + expect(processCommand(createInitialState(), 'Get-Item documents', 'linux').lines[0].type).toBe('error'); + }); + + it('Sort-Object sorts a table by a column, header kept', () => { + const lines = out(createInitialState(), 'Get-Process | Sort-Object Id -Descending').split('\n'); + expect(lines[0]).toContain('ProcessName'); + expect(lines.slice(2).map((l) => l.trim().split(/\s+/)[6])).toEqual(['5678', '2048', '1234']); + }); + + it('Select-Object -First keeps the table header', () => { + const lines = out(createInitialState(), 'Get-Process | Select-Object -First 1').split('\n'); + expect(lines).toHaveLength(3); + expect(lines[2]).toContain('WindowsTerminal'); + }); + + it('Measure-Object -Line / -Word', () => { + const s = build('windows', 'echo "un deux" > f.txt', 'echo trois >> f.txt'); + expect(out(s, 'Get-Content f.txt | Measure-Object -Line -Word')).toBe('Lines : 2\nWords : 3'); + }); + + it('Add-Content appends, Set-Content replaces', () => { + let s = build('windows', 'echo a | Set-Content f.txt', 'echo b | Add-Content f.txt'); + expect(out(s, 'Get-Content f.txt')).toBe('a\nb'); + s = processCommand(s, 'echo c | Set-Content f.txt', 'windows').newState; + expect(out(s, 'Get-Content f.txt')).toBe('c'); + }); + + it('Select-String and findstr filter piped lines', () => { + const s = build('windows', 'echo Alpha > f.txt', 'echo beta >> f.txt'); + expect(out(s, 'Get-Content f.txt | Select-String alpha')).toBe('Alpha'); + expect(out(s, 'Get-Content f.txt | findstr alpha')).toBe(''); + expect(out(s, 'Get-Content f.txt | findstr /I alpha')).toBe('Alpha'); + }); + + it('uniq -c and sort -k3rn on a pipe (Unix)', () => { + const s = build('linux', 'echo a > f', 'echo a >> f', 'echo b >> f'); + expect(out(s, 'cat f | uniq -c', 'linux')).toBe(' 2 a\n 1 b'); + const t = build('linux', 'echo "x y 5" > g', 'echo "x y 40" >> g', 'echo "x y 9" >> g'); + expect(out(t, 'cat g | sort -k3rn', 'linux')).toBe('x y 40\nx y 9\nx y 5'); + }); + + it('git log output goes through a pipe (coloured lines are standard output)', () => { + const s = build('linux', 'mkdir p', 'cd p', 'git init', 'touch a', 'git add a', 'git commit -m "premier"'); + expect(out(s, 'git log | grep -c commit', 'linux')).toBe('1'); + }); +}); From c263b5065f0ce48288cb3cb312ca9f324e8e4849 Mon Sep 17 00:00:00 2001 From: "Thierry V." <46031203+thierryvm@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:35:16 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(terminal):=20review=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20commands=20after=20a=20pipe,=20grep=20status,=20pip?= =?UTF-8?q?eline=20subshells?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A command that does not read stdin runs normally after a pipe (`echo x | mkdir d` created nothing, `echo hi | pwd` printed "hi") - `claude` after a pipe explains that Claude Code is not simulated (info, not a red line); sed / awk / cut / tr / xargs say they are not simulated yet and let the text through - CommandOutput.status: grep, Select-String and findstr exit 1 without a match, so `grep x f || echo repli` works - Every command of a pipeline starts from the caller's cwd and variables; only files are shared (`cd documents | tee out.txt` wrote into documents/) Co-Authored-By: Claude Opus 5.5 --- src/app/data/commands/types.ts | 6 +++++ src/app/data/terminalEngine.ts | 41 +++++++++++++++++++++++++--------- src/test/shellLayer.test.ts | 38 +++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/app/data/commands/types.ts b/src/app/data/commands/types.ts index 3b72777..27fafef 100644 --- a/src/app/data/commands/types.ts +++ b/src/app/data/commands/types.ts @@ -59,6 +59,12 @@ export interface CommandOutput { lines: OutputLine[]; clear?: boolean; newState: TerminalState; + /** + * Exit code, for commands that can fail without printing an error + * (`grep` without a match exits 1). When absent, a command fails if it + * printed an error line. + */ + status?: number; } export interface OutputLine { diff --git a/src/app/data/terminalEngine.ts b/src/app/data/terminalEngine.ts index 3a6ea8e..7f9a5bd 100644 --- a/src/app/data/terminalEngine.ts +++ b/src/app/data/terminalEngine.ts @@ -966,9 +966,10 @@ function runFilter(state: TerminalState, text: string, stdin: string, env: Termi if (!regexResult.ok) return same([regexResult.error]); const invert = flags.some((f) => f.includes('v')); const matches = input.map((line, i) => ({ line, i })).filter(({ line }) => regexResult.regex.test(line) !== invert); - if (flags.some((f) => f.includes('c'))) return same(outLines([String(matches.length)])); + const status = matches.length ? 0 : 1; + if (flags.some((f) => f.includes('c'))) return { ...same(outLines([String(matches.length)])), status }; const numbered = flags.some((f) => f.includes('n')); - return same(outLines(matches.map(({ line, i }) => (numbered ? `${i + 1}:${line}` : line)))); + return { ...same(outLines(matches.map(({ line, i }) => (numbered ? `${i + 1}:${line}` : line)))), status }; } case 'select-string': @@ -979,7 +980,8 @@ function runFilter(state: TerminalState, text: string, stdin: string, env: Termi const ignoreCase = cmd !== 'findstr' || args.some((a) => a.toLowerCase() === '/i'); const regexResult = buildGrepRegex(pattern, ignoreCase ? 'i' : ''); if (!regexResult.ok) return same([regexResult.error]); - return same(outLines(input.filter((line) => regexResult.regex.test(line)))); + const matches = input.filter((line) => regexResult.regex.test(line)); + return { ...same(outLines(matches)), status: matches.length ? 0 : 1 }; } case 'sort': { @@ -1098,8 +1100,21 @@ function runFilter(state: TerminalState, text: string, stdin: string, env: Termi // Displayed as received: the simulator does not filter objects or JSON. return same(outLines(input)); + case 'claude': + // `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 'sed': + case 'awk': + case 'cut': + 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' }]); + default: - return same(outLines(input)); + // A command that does not read standard input runs as usual (`echo x | mkdir d`). + return runSimple(state, text, env); } } @@ -1114,6 +1129,9 @@ function runPipeline(state: TerminalState, stages: Stage[], env: TerminalEnv): P for (let i = 0; i < stages.length; i++) { const stage = stages[i]; const isLast = i === stages.length - 1; + // Every command of a pipeline starts in the caller's directory and variables; + // only the files written by earlier commands are shared. + const base: TerminalState = i === 0 ? state : { ...state, root: s.root, git: s.git }; const fds: Record = { 1: isLast ? { kind: 'screen' } : { kind: 'pipe' }, 2: { kind: 'screen' } }; const files = new Map(); let openError: OutputLine | undefined; @@ -1127,7 +1145,7 @@ function runPipeline(state: TerminalState, stages: Stage[], env: TerminalEnv): P if (isNullDevice(r.target, env)) { sink = { kind: 'null' }; } else { - const target = checkWritable(s, r.target, env); + const target = checkWritable(base, r.target, env); if ('error' in target) { openError = target.error; break; } const key = target.path.join('/'); if (!files.has(key)) files.set(key, { path: target.path, typed: r.target, append: r.append, chunks: [] }); @@ -1138,7 +1156,7 @@ function runPipeline(state: TerminalState, stages: Stage[], env: TerminalEnv): P let stdin = piped; if (!openError && stage.stdinFile !== undefined) { - const node = getNode(s.root, resolvePath(s, stage.stdinFile)); + const node = getNode(base.root, resolvePath(base, stage.stdinFile)); if (node?.type === 'file') stdin = node.content; else openError = openFailure(stage.stdinFile, node ? 'Is a directory' : 'No such file or directory', env); } @@ -1155,12 +1173,12 @@ function runPipeline(state: TerminalState, stages: Stage[], env: TerminalEnv): P stdoutIsTerminal = outerTerminal && fds[1].kind === 'screen'; let result: CommandOutput; try { - result = stdin !== undefined ? runFilter(s, stage.text, stdin, env) : runSimple(s, stage.text, env); + result = stdin !== undefined ? runFilter(base, stage.text, stdin, env) : runSimple(base, stage.text, env); } finally { stdoutIsTerminal = outerTerminal; } if (result.clear) { screen = []; clear = true; } - ok = !result.lines.some((l) => l.type === 'error'); + ok = result.status !== undefined ? result.status === 0 : !result.lines.some((l) => l.type === 'error'); const toPipe: string[] = []; for (const line of result.lines) { @@ -1869,8 +1887,11 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com return { lines, newState }; } - case 'grep': - return { lines: cmdGrep(newState, args), newState }; + 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 }; + } case 'head': return { lines: cmdHead(newState, args), newState }; diff --git a/src/test/shellLayer.test.ts b/src/test/shellLayer.test.ts index e7147fd..00ab657 100644 --- a/src/test/shellLayer.test.ts +++ b/src/test/shellLayer.test.ts @@ -207,6 +207,35 @@ describe('pipelines', () => { expect(text(run('linux', ...base, 'cat f | grep -c a').last)).toBe('3'); }); + it('a command that does not read stdin still runs after a pipe', () => { + const { state, last } = run('linux', 'echo hi | mkdir test'); + expect(last).toEqual([]); + expect(text(processCommand(state, 'ls -1', 'linux').lines)).toContain('test/'); + expect(text(run('linux', 'echo hi | whoami').last)).toBe('user'); + }); + + it('claude after a pipe: an explanation, not a red line', () => { + // (not `git diff`: outside a repository its error is legitimate) + const { last } = run('linux', 'echo "une modification" | claude "explique"'); + expect(errors(last)).toEqual([]); + expect(last.map((l) => l.type)).toEqual(['info']); + expect(text(last)).toContain('pas simulé'); + }); + + it('a text tool the simulator lacks says so and lets the text through', () => { + const { last } = run('linux', 'echo abc | sed s/a/b/'); + expect(errors(last)).toEqual([]); + expect(last[0]).toEqual({ text: 'abc', type: 'output' }); + expect(last[1].type).toBe('info'); + }); + + it('a cd inside a pipeline does not move the next command (subshell)', () => { + const { state } = run('linux', 'cd documents | tee out.txt'); + expect(state.cwd).toEqual(['home', 'user']); + expect(cat(state, 'out.txt')).toBe(''); + expect(text(processCommand(state, 'ls documents', 'linux').lines)).not.toContain('out.txt'); + }); + it('records the whole line once in the history', () => { const { state } = run('linux', 'ls | wc -l', 'echo a && echo b'); expect(state.commandHistory.slice(-2)).toEqual(['ls | wc -l', 'echo a && echo b']); @@ -226,6 +255,15 @@ describe('command lists', () => { expect(text(run('linux', 'pwd || echo repli').last)).not.toContain('repli'); }); + it('grep without a match fails, like its exit code 1', () => { + const base = ['echo alpha > f.txt']; + expect(text(run('linux', ...base, 'grep zzz f.txt || echo repli').last)).toBe('repli'); + expect(text(run('linux', ...base, 'cat f.txt | grep zzz || echo repli').last)).toBe('repli'); + expect(text(run('linux', ...base, 'grep zzz f.txt && echo trouvé').last)).toBe(''); + expect(text(run('linux', ...base, 'grep alpha f.txt && echo trouvé').last)).toBe('alpha\ntrouvé'); + expect(text(run('windows', ...base, 'Get-Content f.txt | Select-String zzz || echo repli').last)).toBe('repli'); + }); + it('; always runs the next command', () => { expect(text(run('linux', 'ls absent; echo toujours').last)).toContain('toujours'); });