diff --git a/package.json b/package.json index 6e7b5e7..ebbc449 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "webshell", "private": true, - "version": "0.0.0", + "version": "0.2.1", "type": "module", "scripts": { "dev": "vite", diff --git a/src/fs/Item.ts b/src/fs/Item.ts index 9e59fd0..80dfc2b 100644 --- a/src/fs/Item.ts +++ b/src/fs/Item.ts @@ -193,6 +193,49 @@ export class Item { return items.filter((item): item is Item => item !== null) } + /** + * Copies this item to the destination, replacing any existing destination. + */ + async Copy(destination: Item): Promise { + if (destination.path === this.path) { + throw new Error(`Cannot copy item onto itself: ${this.path}`) + } + + if (!(await this.Exists())) { + throw new Error(`Cannot copy item that has not been created: ${this.path}`) + } + + await this.load() + + if (await destination.Exists()) { + await destination.Delete() + } + + const childrenToCopy = this.isDirectory ? await this.List() : [] + + destination.isDirectory = this.isDirectory + destination.executable = this.executable + destination.data = this.isDirectory ? null : this.data + destination.children = [] + + await destination.Create() + + if (this.isDirectory) { + await Promise.all(childrenToCopy.map(async child => { + const childDestinationPath = Item.NormalizePath( + `${destination.GetPath()}/${child.GetName()}` + ) + const destChild = child.IsDirectory() + ? await Item.openDir(childDestinationPath) + : await Item.open(childDestinationPath) + + await child.Copy(destChild) + })) + + await destination.load() + } + } + async Delete(): Promise { if (!(await this.Exists())) return diff --git a/src/program/Edit.ts b/src/program/Edit.ts new file mode 100644 index 0000000..221070f --- /dev/null +++ b/src/program/Edit.ts @@ -0,0 +1,390 @@ +import { GetCurrentTerminal } from '../app' +import { Item } from '../fs/Item' +import type { SimpleStream } from '../utils/SimpleStream' +import { Program } from './Program' + +type Viewport = { + cols: number + rows: number + headerRows: number + footerRows: number + contentHeight: number +} + +type PromptState = + | { active: false } + | { active: true; label: string; value: string; handler: (value: string) => Promise } + +export class Edit extends Program { + constructor() { + super() + } + + async Exec(stdin: SimpleStream, stdout: SimpleStream, workdir: Item, args: string[]): Promise { + if (args.length < 2) { + stdout.emit('edit: error: missing path argument\n') + return 1 + } + + const terminal = GetCurrentTerminal() + const resolvePath = (path: string) => Item.NormalizePath(path.startsWith('/') ? path : `${workdir.GetPath()}/${path}`) + + let filePath = resolvePath(args[1]) + let file = await Item.open(filePath, '') + let fileExists = await file.Exists() + let lines = this.normalizeLines(file.ReadData()) + let dirty = false + + let cursorLine = 0 + let cursorCol = 0 + let preferredCol = 0 + let scrollLine = 0 + let scrollCol = 0 + + let statusMessage = '' + let exitArmed = false + + let promptState: PromptState = { active: false } + + const getViewport = (): Viewport => { + const cols = Math.max(1, Math.floor(terminal.GetWidthCells())) + const rows = Math.max(1, Math.floor(terminal.GetHeightCells())) + const headerRows = rows >= 3 ? 1 : 0 + const footerRows = rows >= 2 ? 1 : 0 + const contentHeight = Math.max(1, rows - headerRows - footerRows) + return { cols, rows, headerRows, footerRows, contentHeight } + } + + const formatLine = (text: string, width: number) => { + if (text.length > width) + return text.slice(0, width) + return text.padEnd(width, ' ') + } + + const clampCursor = () => { + cursorLine = Math.max(0, Math.min(cursorLine, lines.length - 1)) + const lineLength = lines[cursorLine].length + cursorCol = Math.max(0, Math.min(cursorCol, lineLength)) + preferredCol = cursorCol + } + + const ensureCursorVisible = (viewport: Viewport) => { + if (cursorLine < scrollLine) + scrollLine = cursorLine + if (cursorLine >= scrollLine + viewport.contentHeight) + scrollLine = cursorLine - viewport.contentHeight + 1 + + if (cursorCol < scrollCol) + scrollCol = cursorCol + if (cursorCol >= scrollCol + viewport.cols) + scrollCol = cursorCol - viewport.cols + 1 + + const maxScrollLine = Math.max(0, lines.length - viewport.contentHeight) + scrollLine = Math.min(scrollLine, maxScrollLine) + scrollCol = Math.max(0, scrollCol) + } + + const setStatus = (message: string) => { + statusMessage = message + } + + const openFile = async (path: string) => { + try { + const resolved = resolvePath(path) + const nextFile = await Item.open(resolved, '') + const exists = await nextFile.Exists() + const content = nextFile.ReadData() + + filePath = resolved + file = nextFile + fileExists = exists + lines = this.normalizeLines(content) + dirty = false + + cursorLine = 0 + cursorCol = 0 + preferredCol = 0 + scrollLine = 0 + scrollCol = 0 + + setStatus(exists ? `Opened ${filePath}` : `New file ${filePath}`) + } catch (e) { + setStatus(`Open failed: ${e instanceof Error ? e.message : String(e)}`) + } + } + + const saveFile = async () => { + const data = lines.join('\n') + + try { + if (fileExists) { + await file.Write(data) + } else { + const created = await Item.open(filePath, data) + await created.Create() + file = created + fileExists = true + } + + dirty = false + exitArmed = false + setStatus(`Saved ${filePath}`) + } catch (e) { + setStatus(`Save failed: ${e instanceof Error ? e.message : String(e)}`) + } + } + + const startPrompt = (label: string, handler: (value: string) => Promise) => { + promptState = { active: true, label, value: '', handler } + } + + const render = () => { + const viewport = getViewport() + const footerRow = viewport.headerRows + viewport.contentHeight + (viewport.footerRows ? 1 : 0) + ensureCursorVisible(viewport) + + let output = '\x1b[2J' + + if (viewport.headerRows) { + const headerText = `EDIT - ${filePath}${dirty ? ' *' : ''}` + output += `\x1b[1;1H${formatLine(headerText, viewport.cols)}` + } + + const contentStartRow = viewport.headerRows + 1 + for (let i = 0; i < viewport.contentHeight; i++) { + const lineIndex = scrollLine + i + const lineText = lines[lineIndex] ?? '' + const visible = lineText.slice(scrollCol, scrollCol + viewport.cols) + output += `\x1b[${contentStartRow + i};1H${formatLine(visible, viewport.cols)}` + } + + if (viewport.footerRows) { + const positionText = `Ln ${cursorLine + 1}, Col ${cursorCol + 1}` + const shortcuts = 'F2 Save F3 Open F10 Exit' + const footerText = promptState.active + ? `${promptState.label}${promptState.value}` + : `${shortcuts} ${positionText}${statusMessage ? ` ${statusMessage}` : ''}` + output += `\x1b[${footerRow};1H${formatLine(footerText, viewport.cols)}` + } + + if (promptState.active) { + const cursorRow = footerRow + const cursorColPos = Math.min(viewport.cols, promptState.label.length + promptState.value.length + 1) + output += `\x1b[${cursorRow};${cursorColPos}H` + } else { + const cursorRow = contentStartRow + (cursorLine - scrollLine) + const cursorColPos = 1 + (cursorCol - scrollCol) + output += `\x1b[${cursorRow};${cursorColPos}H` + } + + stdout.emit(output) + } + + render() + + while (true) { + const key = await stdin.wait() + + if (promptState.active) { + if (key === 'Escape') { + promptState = { active: false } + setStatus('Canceled') + render() + continue + } + + if (key === 'Enter') { + const { handler } = promptState + const value = promptState.value.trim() + promptState = { active: false } + + if (value.length > 0) { + await handler(value) + } else { + setStatus('Canceled') + } + + render() + continue + } + + if (key === 'Backspace') { + const nextValue: string = promptState.value.slice(0, -1) + promptState = { + active: true, + label: promptState.label, + value: nextValue, + handler: promptState.handler, + } + render() + continue + } + + if (key.length === 1) { + const nextValue: string = promptState.value + key + promptState = { + active: true, + label: promptState.label, + value: nextValue, + handler: promptState.handler, + } + render() + } + + continue + } + + if (exitArmed && key !== 'F10') + exitArmed = false + + switch (key) { + case 'F2': + await saveFile() + render() + continue + case 'F3': + startPrompt('Open: ', openFile) + render() + continue + case 'F10': + if (dirty && !exitArmed) { + exitArmed = true + setStatus('Unsaved changes. Press F10 again to exit.') + render() + continue + } + stdout.emit('\x1b[2J\x1b[H') + return 0 + case 'ArrowLeft': + if (cursorCol > 0) { + cursorCol -= 1 + } else if (cursorLine > 0) { + cursorLine -= 1 + cursorCol = lines[cursorLine].length + } + preferredCol = cursorCol + render() + continue + case 'ArrowRight': { + const lineLength = lines[cursorLine].length + if (cursorCol < lineLength) { + cursorCol += 1 + } else if (cursorLine < lines.length - 1) { + cursorLine += 1 + cursorCol = 0 + } + preferredCol = cursorCol + render() + continue + } + case 'ArrowUp': + cursorLine = Math.max(0, cursorLine - 1) + cursorCol = Math.min(preferredCol, lines[cursorLine].length) + render() + continue + case 'ArrowDown': + cursorLine = Math.min(lines.length - 1, cursorLine + 1) + cursorCol = Math.min(preferredCol, lines[cursorLine].length) + render() + continue + case 'Home': + cursorCol = 0 + preferredCol = cursorCol + render() + continue + case 'End': + cursorCol = lines[cursorLine].length + preferredCol = cursorCol + render() + continue + case 'PageUp': { + const viewport = getViewport() + cursorLine = Math.max(0, cursorLine - viewport.contentHeight) + cursorCol = Math.min(preferredCol, lines[cursorLine].length) + render() + continue + } + case 'PageDown': { + const viewport = getViewport() + cursorLine = Math.min(lines.length - 1, cursorLine + viewport.contentHeight) + cursorCol = Math.min(preferredCol, lines[cursorLine].length) + render() + continue + } + case 'Backspace': + if (cursorCol > 0) { + const line = lines[cursorLine] + lines[cursorLine] = line.slice(0, cursorCol - 1) + line.slice(cursorCol) + cursorCol -= 1 + preferredCol = cursorCol + dirty = true + } else if (cursorLine > 0) { + const current = lines[cursorLine] + cursorLine -= 1 + cursorCol = lines[cursorLine].length + lines[cursorLine] += current + lines.splice(cursorLine + 1, 1) + preferredCol = cursorCol + dirty = true + } + render() + continue + case 'Delete': { + const line = lines[cursorLine] + if (cursorCol < line.length) { + lines[cursorLine] = line.slice(0, cursorCol) + line.slice(cursorCol + 1) + dirty = true + } else if (cursorLine < lines.length - 1) { + lines[cursorLine] = line + lines[cursorLine + 1] + lines.splice(cursorLine + 1, 1) + dirty = true + } + render() + continue + } + case 'Enter': { + const line = lines[cursorLine] + const before = line.slice(0, cursorCol) + const after = line.slice(cursorCol) + lines[cursorLine] = before + lines.splice(cursorLine + 1, 0, after) + cursorLine += 1 + cursorCol = 0 + preferredCol = 0 + dirty = true + render() + continue + } + case 'Tab': + lines[cursorLine] = this.insertText(lines[cursorLine], cursorCol, ' ') + cursorCol += 4 + preferredCol = cursorCol + dirty = true + render() + continue + } + + if (key.length === 1) { + lines[cursorLine] = this.insertText(lines[cursorLine], cursorCol, key) + cursorCol += 1 + preferredCol = cursorCol + dirty = true + render() + continue + } + + clampCursor() + render() + } + } + + private normalizeLines(content: string | null): string[] { + const normalized = (content ?? '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') + const lines = normalized.split('\n') + return lines.length === 0 ? [''] : lines + } + + private insertText(line: string, index: number, text: string): string { + return line.slice(0, index) + text + line.slice(index) + } +} diff --git a/src/program/Mv.ts b/src/program/Mv.ts new file mode 100644 index 0000000..0b56ec2 --- /dev/null +++ b/src/program/Mv.ts @@ -0,0 +1,45 @@ +import { Item } from '../fs/Item' +import type { SimpleStream } from '../utils/SimpleStream' +import { Program } from './Program' + +export class Mv extends Program { + constructor() { + super() + } + + async Exec(_: SimpleStream, stdout: SimpleStream, workdir: Item, args: string[]): Promise { + if (args.length < 3) { + stdout.emit("mv: error: missing the first and/or second path arguments\n") + return 1 + } + + let item1: Item + let item2: Item + + try { + item1 = await Item.openDir(Item.NormalizePath(args[1].startsWith('/') ? args[1] : `${workdir.GetPath()}/${args[1]}`)) + item2 = await Item.openDir(Item.NormalizePath(args[2].startsWith('/') ? args[2] : `${workdir.GetPath()}/${args[2]}`)) + } catch { + item1 = await Item.open(Item.NormalizePath(args[1].startsWith('/') ? args[1] : `${workdir.GetPath()}/${args[1]}`)) + item2 = await Item.open(Item.NormalizePath(args[2].startsWith('/') ? args[2] : `${workdir.GetPath()}/${args[2]}`)) + } + + if (await item1.Exists()) { + stdout.emit(`mv: error: source directory ${item1.GetPath()} does not exist.\n`) + return 2 + } + + if (await item2.Exists()) { + stdout.emit(`mv: error: destination directory ${item2.GetPath()} already exists.\n`) + return 2 + } + + await item2.Create() + await item1.Copy(item2) + await item1.Delete() + + stdout.emit(`-> moved ${item1.GetPath()} -> ${item2.GetPath()}\n`) + + return 0 + } +} diff --git a/src/shell/wush/Environment.ts b/src/shell/wush/Environment.ts index 8f1175d..668b111 100644 --- a/src/shell/wush/Environment.ts +++ b/src/shell/wush/Environment.ts @@ -6,11 +6,11 @@ export class Environment { private variables: Record = {} private aliases: Record = {} - SetVariable(name: string, value: string | null): void { + SetVariable(name: string, value: any | null): void { if (value === null) { // remove the variable if value is null delete this.variables[name] - } else this.variables[name] = value + } else this.variables[name] = String(value) } GetVariable(name: string): string | null { diff --git a/src/shell/wush/InputParser.ts b/src/shell/wush/InputParser.ts new file mode 100644 index 0000000..e5fbcfe --- /dev/null +++ b/src/shell/wush/InputParser.ts @@ -0,0 +1,13 @@ +/** + * Wush input parser + * Provides necessary parsing functions + */ +export class InputParser { + static Tokenize(input: string) { + + } + + static Parse(tokens: string[]) { + + } +} diff --git a/src/shell/wush/Wush.ts b/src/shell/wush/Wush.ts index 5532ac5..fab3e62 100644 --- a/src/shell/wush/Wush.ts +++ b/src/shell/wush/Wush.ts @@ -27,6 +27,8 @@ import { Printf } from '../../program/Printf' import { Pwd } from '../../program/Pwd' import { Environment } from './Environment' import { InputManager } from './InputManager' +import { Edit } from '../../program/Edit' +import { Mv } from '../../program/Mv' export class Wush extends Shell { public readonly Version = "0.3.2" @@ -38,7 +40,6 @@ export class Wush extends Shell { _promptStart: CursorPosition = { col: 0, row: 0 } private inputManager: InputManager - // @ts-ignore unused for now private environment: Environment // exec stuff @@ -93,11 +94,21 @@ export class Wush extends Shell { this.programs['pwd'] = new Pwd() this.programs['cd'] = new Cd(this) this.programs['printf'] = new Printf() + this.programs['edit'] = new Edit() + this.programs['mv'] = new Mv() + + // reset exit code + this.SetExitCode(0) // initial prompt this.Prompt() } + SetExitCode(code: number): void { + this.execExitCode = code + this.environment.SetVariable("status", code) + } + HasRunningProgram(): boolean { return this.execExitCode === -1 } @@ -122,7 +133,7 @@ export class Wush extends Shell { return new Promise(resolve => { this.programs[name].Exec(this.stdin, this.stdout, this.workingDirectory, args) .then(code => { - this.execExitCode = code != -1 ? code : -2 + this.SetExitCode(code != -1 ? code : -2) resolve() }) .catch((e) => { @@ -134,7 +145,7 @@ export class Wush extends Shell { // check if the exec actually exited with an exit code // and if not, set it to -2 to indicate that it didn't exit with a valid code if (this.execExitCode === -1) - this.execExitCode = -2 + this.SetExitCode(-2) // this.terminal.Write(`The program exited with exit code ${this.execExitCode}.`) resolve() @@ -234,19 +245,33 @@ export class Wush extends Shell { this._bufferPos = 0 } - // takes the prompt buffer, parses it and executes the contents + /** + * Takes the prompt buffer, parses it and executes the contents + */ async ExecuteLineBuffer(data?: string) { - type OperatorToken = '&&' | '||' | '|' | ';' + type OperatorToken = '&&' | '||' | '|' | ';' | '>' | '>>' | '<' type Token = { type: 'word'; value: string } | { type: 'operator'; value: OperatorToken } type ParsedCommand = { args: string[] stdin: null | SimpleStream stdout: null | SimpleStream + stdinPath: string | null + stdoutPath: string | null + stdoutAppend: boolean } type ParsedPipeline = { commands: ParsedCommand[] } type ParsedListItem = { pipeline: ParsedPipeline; operator: '&&' | '||' | ';' | null } - // splits the buffer into processable pieces (tokens) + // environment variable helpers + /** + * @returns value of the variable, empty string otherwise + */ + const resolveVariable = (name: string): string => this.environment.GetVariable(name) ?? '' + const isValidVarNameChar = (char: string) => /[A-Za-z0-9_]/.test(char) + + /** + * splits the buffer into smaller processable pieces (tokens) + */ const tokenize = (text: string): { tokens: Token[]; error: string | null } => { const tokens: Token[] = [] @@ -258,7 +283,7 @@ export class Wush extends Shell { let escapeNext = false /** - * adds a new word to the token buffer + * adds a new plain string to the token buffer */ const pushWord = () => { if (!wordStarted) @@ -271,6 +296,69 @@ export class Wush extends Shell { wordStarted = false } + // reads environment variable tokens + const readVariable = (index: number): { + value: string + nextIndex: number + error: string | null + expanded: boolean + } => { + if (index + 1 >= text.length) + return { value: '$', nextIndex: index, error: null, expanded: false } + + const next = text[index + 1] + if (next === '{') { + let end = index + 2 + while (end < text.length && text[end] !== '}') + end++ + + if (end >= text.length) + return { value: '', nextIndex: text.length - 1, error: 'unterminated variable expansion', expanded: true } + + const name = text.slice(index + 2, end) + if (name.length === 0) + return { value: '', nextIndex: end, error: 'empty variable name', expanded: true } + + return { value: resolveVariable(name), nextIndex: end, error: null, expanded: true } + } + + if (isValidVarNameChar(next)) { + let end = index + 1 + while (end + 1 < text.length && isValidVarNameChar(text[end + 1])) + end++ + + const name = text.slice(index + 1, end + 1) + return { value: resolveVariable(name), nextIndex: end, error: null, expanded: true } + } + + return { value: '$', nextIndex: index, error: null, expanded: false } + } + + const appendExpandedValue = (value: string, split: boolean, preserveEmpty: boolean) => { + if (value.length === 0) { + if (preserveEmpty) + wordStarted = true + + return + } + + if (!split) { + current += value + wordStarted = true + return + } + + for (const char of value) { + if (char === ' ' || char === '\t') { + pushWord() + continue + } + + current += char + wordStarted = true + } + } + // iterates over the provided text buffer for (let i = 0; i < text.length; i++) { const char = text[i] @@ -307,6 +395,18 @@ export class Wush extends Shell { current += text[i + 1] i++ wordStarted = true + } else if (char === '$') { + const { value, nextIndex, error, expanded } = readVariable(i) + if (error) + return { tokens: [], error } + + if (expanded) { + appendExpandedValue(value, false, true) + i = nextIndex + } else { + current += char + wordStarted = true + } } else { current += char wordStarted = true @@ -338,6 +438,22 @@ export class Wush extends Shell { continue } + if (char === '$') { + const { value, nextIndex, error, expanded } = readVariable(i) + if (error) + return { tokens: [], error } + + if (expanded) { + appendExpandedValue(value, true, false) + i = nextIndex + } else { + current += char + wordStarted = true + } + + continue + } + if (char === ' ' || char === '\t') { pushWord() continue @@ -358,6 +474,23 @@ export class Wush extends Shell { continue } + if (char === '>') { + pushWord() + if (text[i + 1] === '>') { + tokens.push({ type: 'operator', value: '>>' }) + i++ + } else { + tokens.push({ type: 'operator', value: '>' }) + } + continue + } + + if (char === '<') { + pushWord() + tokens.push({ type: 'operator', value: '<' }) + continue + } + if (char === '|') { pushWord() if (text[i + 1] === '|') { @@ -402,6 +535,10 @@ export class Wush extends Shell { let currentArgs: string[] = [] let currentPipeline: ParsedCommand[] = [] let expectCommand = false + let expectRedirect: 'stdin' | 'stdout' | 'append' | null = null + let currentStdinPath: string | null = null + let currentStdoutPath: string | null = null + let currentStdoutAppend = false const pushCommand = () => { if (currentArgs.length === 0) @@ -411,8 +548,14 @@ export class Wush extends Shell { args: currentArgs, stdin: null, stdout: null, + stdinPath: currentStdinPath, + stdoutPath: currentStdoutPath, + stdoutAppend: currentStdoutAppend, }) currentArgs = [] + currentStdinPath = null + currentStdoutPath = null + currentStdoutAppend = false return true } @@ -427,12 +570,43 @@ export class Wush extends Shell { for (const token of tokens) { if (token.type === 'word') { + if (expectRedirect) { + if (expectRedirect === 'stdin') { + currentStdinPath = token.value + } else { + currentStdoutPath = token.value + currentStdoutAppend = expectRedirect === 'append' + } + + expectRedirect = null + expectCommand = false + continue + } + currentArgs.push(token.value) expectCommand = false continue } + if (expectRedirect) + return { list: [], error: 'expected redirection target' } + + if (token.value === '>' || token.value === '>>' || token.value === '<') { + if (currentArgs.length === 0) + return { list: [], error: `unexpected "${token.value}" operator` } + + expectRedirect = token.value === '<' + ? 'stdin' + : token.value === '>>' + ? 'append' + : 'stdout' + continue + } + if (token.value === '|') { + if (currentStdoutPath) + return { list: [], error: 'unexpected "|" operator after redirection' } + if (!pushCommand()) return { list: [], error: 'unexpected "|" operator' } @@ -449,6 +623,9 @@ export class Wush extends Shell { if (expectCommand) return { list: [], error: 'unexpected end of input' } + if (expectRedirect) + return { list: [], error: 'expected redirection target' } + if (currentArgs.length > 0) pushCommand() @@ -494,32 +671,170 @@ export class Wush extends Shell { return } - if (list.some(item => item.pipeline.commands.length > 1)) { - this.terminal.Write(`wush: error: pipes are not supported yet`) - this.terminal.MoveCursor(0, 1, { x: true, y: false }) + const resolvePath = (path: string) => Item.NormalizePath( + path.startsWith('/') ? path : `${this.workingDirectory.GetPath()}/${path}` + ) - this.Prompt() - return + const openInputRedirect = async (path: string) => { + const resolved = resolvePath(path) + const item = await Item.open(resolved) + + if (!(await item.Exists())) + throw new Error(`input file ${resolved} doesn't exist`) + + if (item.IsDirectory()) + throw new Error(`input file ${resolved} is a directory`) + + return { stream: new SimpleStream(), data: item.ReadData() ?? '' } + } + + const openOutputRedirect = async (path: string, append: boolean) => { + const resolved = resolvePath(path) + const item = await Item.open(resolved) + + if (!(await item.Exists())) + await item.Create() + + if (item.IsDirectory()) + throw new Error(`output path ${resolved} is a directory`) + + if (!append) + await item.Write('') + + const stream = new SimpleStream() + let writeQueue = Promise.resolve() + const listener = (data: string) => { + writeQueue = writeQueue.then(() => item.Append(data)) + } + stream.on(listener) + + return { + stream, + finish: () => writeQueue, + teardown: () => stream.off(listener), + } + } + + const setupPipelineStreams = async (pipeline: ParsedPipeline) => { + const teardown: Array<() => void> = [] + const stdins: SimpleStream[] = [] + const stdouts: SimpleStream[] = [] + const finishers: Array<() => Promise> = [] + const inputFeeds: Array<{ stream: SimpleStream; data: string }> = [] + + try { + for (const [index, command] of pipeline.commands.entries()) { + let stdin: SimpleStream + let stdout: SimpleStream + + if (command.stdinPath) { + if (index !== 0) + throw new Error('input redirection is only supported on the first command in a pipeline') + + const { stream, data } = await openInputRedirect(command.stdinPath) + stdin = stream + inputFeeds.push({ stream, data }) + } else { + stdin = index === 0 ? this.stdin : new SimpleStream() + } + + if (command.stdoutPath) { + if (index !== pipeline.commands.length - 1) + throw new Error('output redirection is only supported on the last command in a pipeline') + + const { stream, finish, teardown: remove } = await openOutputRedirect( + command.stdoutPath, + command.stdoutAppend + ) + stdout = stream + finishers.push(finish) + teardown.push(remove) + } else { + stdout = index === pipeline.commands.length - 1 ? this.stdout : new SimpleStream() + } + + command.stdin = stdin + command.stdout = stdout + + stdins.push(stdin) + stdouts.push(stdout) + } + + for (let i = 0; i < pipeline.commands.length - 1; i++) { + if (pipeline.commands[i].stdoutPath) + throw new Error('cannot pipe a command with output redirection') + + if (pipeline.commands[i + 1].stdinPath) + throw new Error('cannot pipe into a command with input redirection') + + const listener = (data: string) => stdins[i + 1].emit(data) + stdouts[i].on(listener) + teardown.push(() => stdouts[i].off(listener)) + } + + return { stdins, stdouts, teardown, finishers, inputFeeds } + } catch (e) { + teardown.forEach(remove => remove()) + throw e + } + } + + const executePipeline = async (pipeline: ParsedPipeline): Promise => { + const missing = pipeline.commands.find(command => !(this.programs[command.args[0]] instanceof Program)) + if (missing) { + const name = missing.args[0] + + if (name.length !== 0) { + this.terminal.Write(`wush: error: unknown command: ${name}.`) + this.terminal.MoveCursor(0, 1, { x: true, y: false }) + return 127 + } + + return 0 + } + + let setup: Awaited> + try { + setup = await setupPipelineStreams(pipeline) + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + this.terminal.Write(`wush: error: ${message}`) + this.terminal.MoveCursor(0, 1, { x: true, y: false }) + return 1 + } + + try { + const exitCodes = await Promise.all(pipeline.commands.map(async (command, index) => { + try { + return await this.programs[command.args[0]].Exec( + setup.stdins[index], + setup.stdouts[index], + this.workingDirectory, + command.args + ) + } catch (e) { + this.WriteEscapedString(`wush: command ${command.args[0]} exited with the following exception\n`) + this.WriteEscapedString(` | ${String(e)}\n`) + return -2 + } + })) + + setup.inputFeeds.forEach(feed => { + queueMicrotask(() => feed.stream.emit(feed.data)) + }) + + await Promise.all(setup.finishers.map(finish => finish())) + + const lastExitCode = exitCodes[exitCodes.length - 1] + return lastExitCode !== -1 ? lastExitCode : -2 + } finally { + setup.teardown.forEach(remove => remove()) + } } for (const item of list) { - const command = item.pipeline.commands[0] - - this.execExitCode = -1 - - if (this.programs[command.args[0]] instanceof Program) { - await this.ExecuteProgram(command.args[0], command.args) - } else { - this.execExitCode = 0 - - // don't print anything if there are no arguments - if (command.args[0].length !== 0) { - this.terminal.Write(`wush: error: unknown command: ${command.args[0]}.`) - this.terminal.MoveCursor(0, 1, { x: true, y: false }) - - this.execExitCode = 127 - } - } + this.SetExitCode(-1) + this.SetExitCode(await executePipeline(item.pipeline)) } this.Prompt()