From 4a484dd546045b43021d88f3e5367cddfdcbc5c5 Mon Sep 17 00:00:00 2001 From: binekrasik Date: Tue, 19 May 2026 18:22:43 +0200 Subject: [PATCH] feat: improve bash-like command parsing --- src/fs/Item.ts | 14 ++ src/program/Cd.ts | 35 +++ src/program/Echo.ts | 36 +-- src/program/Ls.ts | 7 +- src/program/Printf.ts | 15 ++ src/program/Pwd.ts | 15 ++ src/shell/wush/ShellEnvironment.ts | 21 ++ src/shell/wush/Wush.ts | 361 ++++++++++++++++++++++------- 8 files changed, 399 insertions(+), 105 deletions(-) create mode 100644 src/program/Cd.ts create mode 100644 src/program/Printf.ts create mode 100644 src/program/Pwd.ts create mode 100644 src/shell/wush/ShellEnvironment.ts diff --git a/src/fs/Item.ts b/src/fs/Item.ts index 629494c..362bd19 100644 --- a/src/fs/Item.ts +++ b/src/fs/Item.ts @@ -126,6 +126,18 @@ export class Item { return this.isDirectory } + IsReadable(): boolean { + return true + } + + IsWritable(): boolean { + return true + } + + IsExecutable(): boolean { + return this.executable + } + async Append(data: string): Promise { if (!(await this.Exists())) throw new Error(`Cannot append to a file that has not been created: ${this.path}`) if (this.isDirectory) throw new Error("Cannot append data to a directory") @@ -298,6 +310,8 @@ export class Item { .get(this.storageKey) request.onsuccess = () => { + console.log(request.result) + const parsed = request.result as ItemPayload | undefined if (parsed) { this.isDirectory = parsed.isDirectory diff --git a/src/program/Cd.ts b/src/program/Cd.ts new file mode 100644 index 0000000..26facb6 --- /dev/null +++ b/src/program/Cd.ts @@ -0,0 +1,35 @@ +import { Item } from '../fs/Item' +import type { Shell } from '../shell/Shell' +import type { SimpleStream } from '../utils/SimpleStream' +import { Program } from './Program' + +export class Cd extends Program { + private shell: Shell + + constructor(shell: Shell) { + super() + this.shell = shell + } + + async Exec(_: SimpleStream, stdout: SimpleStream, workdir: Item, args: string[]): Promise { + const item = args[1] + ? await Item.openDir(args[1].startsWith('/') + ? args[1] + : `${workdir.GetPath()}${workdir.GetPath().endsWith('/') ? '' : '/'}${args[1]}`) + : workdir + + if (args[1] && !item.IsDirectory()) { + stdout.emit("cd: error: the provided path is not a directory\n") + return 1 + } + + if (!(await item.Exists())) { + stdout.emit(`cd: error: path ${item.GetPath()} doesn't exist\n`) + return 2 + } + + await this.shell.SetWorkingDirectory(item) + + return 0 + } +} diff --git a/src/program/Echo.ts b/src/program/Echo.ts index 57df71e..754ff7f 100644 --- a/src/program/Echo.ts +++ b/src/program/Echo.ts @@ -7,27 +7,29 @@ export class Echo extends Program { super() } - async Exec(_: SimpleStream, stdout: SimpleStream, workdir: Item, args: string[]): Promise { - if (args.length < 2) { - stdout.emit("echo: error: missing path argument\n") - return 1 - } + async Exec(_: SimpleStream, stdout: SimpleStream, __: Item, args: string[]): Promise { + // if (args.length < 2) { + // stdout.emit("echo: error: missing path argument\n") + // return 1 + // } - const item = await Item.open(args[1].startsWith('/') - ? args[1] - : `${workdir.GetPath()}${workdir.GetPath().endsWith('/') ? '' : '/'}${args[1]}`) + // const item = await Item.open(args[1].startsWith('/') + // ? args[1] + // : `${workdir.GetPath()}${workdir.GetPath().endsWith('/') ? '' : '/'}${args[1]}`) - if (!(await item.Exists())) { - stdout.emit(`echo: error: item ${item.GetPath()} doesn't exist.\n`) - return 2 - } + // if (!(await item.Exists())) { + // stdout.emit(`echo: error: item ${item.GetPath()} doesn't exist.\n`) + // return 2 + // } - if (item.IsDirectory()) { - stdout.emit(`echo: error: can't write data to a directory; item ${item.GetPath()} is a directory.\n`) - return 3 - } + // if (item.IsDirectory()) { + // stdout.emit(`echo: error: can't write data to a directory; item ${item.GetPath()} is a directory.\n`) + // return 3 + // } - await item.Write(args.slice(2).join(' ')) + // await item.Write(args.slice(2).join(' ')) + + stdout.emit(args.slice(1).join(' ') + '\n') return 0 } diff --git a/src/program/Ls.ts b/src/program/Ls.ts index f85c317..a1476a3 100644 --- a/src/program/Ls.ts +++ b/src/program/Ls.ts @@ -24,14 +24,11 @@ export class Ls extends Program { return 2 } - console.log(item) - stdout.emit(`-> Listing contents of item: '${item.GetPath()}'\n`) - stdout.emit(` [ drwx name ]\n`) + stdout.emit(` [ drwx name ]\n`) const items = await item.List() items.forEach(entry => { - stdout.emit(item.IsDirectory().toString()) - stdout.emit(` | ${(item.IsDirectory() ? 'd' : '').padEnd(4, '-').padEnd(8, ' ')} ${entry.GetName()}\n`) + stdout.emit(` | ${`${(entry.IsDirectory() ? 'd' : '-')}${(entry.IsReadable() ? 'r' : '-')}${(entry.IsWritable() ? 'w' : '-')}${(entry.IsExecutable() ? 'x' : '-')}`.padEnd(8, ' ')} '${entry.GetName()}'\n`) }) return 0 diff --git a/src/program/Printf.ts b/src/program/Printf.ts new file mode 100644 index 0000000..5922016 --- /dev/null +++ b/src/program/Printf.ts @@ -0,0 +1,15 @@ +import { Item } from '../fs/Item' +import type { SimpleStream } from '../utils/SimpleStream' +import { Program } from './Program' + +export class Printf extends Program { + constructor() { + super() + } + + async Exec(_: SimpleStream, stdout: SimpleStream, __: Item, args: string[]): Promise { + stdout.emit(args.slice(1).join(' ')) + + return 0 + } +} diff --git a/src/program/Pwd.ts b/src/program/Pwd.ts new file mode 100644 index 0000000..829b61f --- /dev/null +++ b/src/program/Pwd.ts @@ -0,0 +1,15 @@ +import { Item } from '../fs/Item' +import type { SimpleStream } from '../utils/SimpleStream' +import { Program } from './Program' + +export class Pwd extends Program { + constructor() { + super() + } + + async Exec(_: SimpleStream, stdout: SimpleStream, workdir: Item, __: string[]): Promise { + stdout.emit(`${workdir.GetPath()}\n`) + + return 0 + } +} diff --git a/src/shell/wush/ShellEnvironment.ts b/src/shell/wush/ShellEnvironment.ts new file mode 100644 index 0000000..6f93d23 --- /dev/null +++ b/src/shell/wush/ShellEnvironment.ts @@ -0,0 +1,21 @@ +/** + * Wush environment manager + * Manages environment variables, aliases etc. + */ +export class ShellEnvironment { + private variables: Record = {} + + SetVariable(name: string, value: string | null): void { + // remove the variable if value is null + if (value === null) { + delete this.variables[name] + return + } + + this.variables[name] = value + } + + GetVariable(name: string): string | undefined { + return this.variables[name] + } +} diff --git a/src/shell/wush/Wush.ts b/src/shell/wush/Wush.ts index 87f608f..b32b20f 100644 --- a/src/shell/wush/Wush.ts +++ b/src/shell/wush/Wush.ts @@ -22,6 +22,8 @@ import { Cat } from '../../program/Cat' import { Echo } from '../../program/Echo' import { Mkdir } from '../../program/Mkdir' import { Cd } from '../../program/Cd' +import { Printf } from '../../program/Printf' +import { Pwd } from '../../program/Pwd' // import { Pwd } from '../program/Pwd' // import { Cd } from '../program/Cd' @@ -83,8 +85,9 @@ export class Wush extends Shell { this.programs['cat'] = new Cat() this.programs['echo'] = new Echo() this.programs['mkdir'] = new Mkdir() - // this.programs['pwd'] = new Pwd() + this.programs['pwd'] = new Pwd() this.programs['cd'] = new Cd(this) + this.programs['printf'] = new Printf() this.stdout.on(data => this.WriteEscapedString(data)) this.Prompt() @@ -102,28 +105,32 @@ export class Wush extends Shell { delete this.programs[name] } - ExecuteProgram(name: string, args: string[]) { - this.programs[name].Exec(this.stdin, this.stdout, this.workingDirectory, args) - .then(code => { - this.execExitCode = code != -1 ? code : -2 - }) - .catch((e) => { - this.WriteEscapedString(`wush: command ${name} exited with the following exception\n`) - this.WriteEscapedString(` | ${String(e)}\n`) - }) - .finally(() => { - // 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 + async ExecuteProgram(name: string, args: string[]): Promise { + return new Promise(resolve => { + this.programs[name].Exec(this.stdin, this.stdout, this.workingDirectory, args) + .then(code => { + this.execExitCode = code != -1 ? code : -2 + resolve() + }) + .catch((e) => { + this.WriteEscapedString(`wush: command ${name} exited with the following exception\n`) + this.WriteEscapedString(` | ${String(e)}\n`) + resolve() + }) + .finally(() => { + // 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.terminal.Write(`The program exited with exit code ${this.execExitCode}.`) - this.Prompt() - }) + // this.terminal.Write(`The program exited with exit code ${this.execExitCode}.`) + resolve() + }) + }) } Prompt() { - this.terminal.Write(`boykisser in ${this.workingDirectory.GetPath()} -> `) + this.terminal.Write(`user in ${this.workingDirectory.GetPath()} -> `) } /** @@ -175,79 +182,267 @@ export class Wush extends Shell { this.bufferPos = 0 } - ExecuteBuffer() { - // parse the buffer - const state: { - inString: boolean - inHardString: boolean - inAndOperator: boolean - } = { - inString: false, - inHardString: false, - inAndOperator: false, - } - - const commands: { + async ExecuteBuffer() { + type OperatorToken = '&&' | '||' | '|' | ';' + type Token = { type: 'word'; value: string } | { type: 'operator'; value: OperatorToken } + type ParsedCommand = { args: string[] stdin: null | SimpleStream stdout: null | SimpleStream - }[] = [] + } + type ParsedPipeline = { commands: ParsedCommand[] } + type ParsedListItem = { pipeline: ParsedPipeline; operator: '&&' | '||' | ';' | null } - const textBuf = - this.buffer - .join('') - .split('') + const tokenize = (text: string): { tokens: Token[]; error: string | null } => { + const tokens: Token[] = [] + let current = '' + let wordStarted = false + let inSingleQuote = false + let inDoubleQuote = false + let escapeNext = false - textBuf.forEach((char, i) => { - // spacing - if (char === ' ' && !(state.inString || state.inHardString )) { - if (state.args[state.args.length - 1] !== '') - state.args.push("") - // string ("") handling - } else if (char === '"' && !state.inHardString) { - state.inString = !state.inString - // hard string ('') handling - } else if (char === "'" && !state.inString) { - state.inHardString = !state.inHardString - // && operator - } else if (char === '&' || char === '|' && !(state.inString || state.inHardString )) { - if (state.inAndOperator) { - if (state.args[state.args.length - 1] === '') - state.args[state.args.length - 1] = `${char}${char}` - else state.args.push(`${char}${char}`) + const pushWord = () => { + if (!wordStarted) + return - state.args.push("") - state.inAndOperator = false - } else if (this.buffer[i + 1] !== char) { - state.args[state.args.length - 1] += char - } else state.inAndOperator = true - // adding the char to the current argument - } else { - state.args[state.args.length - 1] += char - } - }) - - this.FlushBuffer() - - console.log(`Executing ${state.args[0]} with args '${state.args}'`) - - this.execExitCode = -1 - - if (this.programs[state.args[0]] instanceof Program) { - this.ExecuteProgram(state.args[0], state.args) - } else { - this.execExitCode = 0 - - // don't print anything if there are no arguments - if (state.args[0].length !== 0) { - this.terminal.Write(`wush: unknown command: ${state.args[0]}.`) - this.terminal.MoveCursor(0, 1, { x: true, y: false }) - - this.execExitCode = 127 + tokens.push({ type: 'word', value: current }) + current = '' + wordStarted = false } - this.Prompt() + for (let i = 0; i < text.length; i++) { + const char = text[i] + + if (escapeNext) { + current += char + escapeNext = false + wordStarted = true + continue + } + + if (inSingleQuote) { + if (char === "'") { + inSingleQuote = false + wordStarted = true + } else { + current += char + wordStarted = true + } + + continue + } + + if (inDoubleQuote) { + if (char === '"') { + inDoubleQuote = false + wordStarted = true + } else if (char === '\\') { + if (i + 1 >= text.length) + return { tokens: [], error: 'unterminated escape sequence' } + + current += text[i + 1] + i++ + wordStarted = true + } else { + current += char + wordStarted = true + } + + continue + } + + if (char === '\\') { + escapeNext = true + wordStarted = true + continue + } + + if (char === "'") { + inSingleQuote = true + wordStarted = true + continue + } + + if (char === '"') { + inDoubleQuote = true + wordStarted = true + continue + } + + if (char === ' ' || char === '\t') { + pushWord() + continue + } + + if (char === '#') { + if (!wordStarted) + break + + current += char + wordStarted = true + continue + } + + if (char === ';') { + pushWord() + tokens.push({ type: 'operator', value: ';' }) + continue + } + + if (char === '|') { + pushWord() + if (text[i + 1] === '|') { + tokens.push({ type: 'operator', value: '||' }) + i++ + } else { + tokens.push({ type: 'operator', value: '|' }) + } + continue + } + + if (char === '&') { + if (text[i + 1] === '&') { + pushWord() + tokens.push({ type: 'operator', value: '&&' }) + i++ + } else { + current += char + wordStarted = true + } + continue + } + + current += char + wordStarted = true + } + + if (escapeNext) + return { tokens: [], error: 'unterminated escape sequence' } + if (inSingleQuote) + return { tokens: [], error: 'unterminated single quote' } + if (inDoubleQuote) + return { tokens: [], error: 'unterminated double quote' } + + pushWord() + return { tokens, error: null } } + + const parseTokens = (tokens: Token[]): { list: ParsedListItem[]; error: string | null } => { + const list: ParsedListItem[] = [] + let currentArgs: string[] = [] + let currentPipeline: ParsedCommand[] = [] + let expectCommand = false + + const pushCommand = () => { + if (currentArgs.length === 0) + return false + + currentPipeline.push({ + args: currentArgs, + stdin: null, + stdout: null, + }) + currentArgs = [] + return true + } + + const pushPipeline = (operator: '&&' | '||' | ';' | null) => { + if (currentPipeline.length === 0) + return false + + list.push({ pipeline: { commands: currentPipeline }, operator }) + currentPipeline = [] + return true + } + + for (const token of tokens) { + if (token.type === 'word') { + currentArgs.push(token.value) + expectCommand = false + continue + } + + if (token.value === '|') { + if (!pushCommand()) + return { list: [], error: 'unexpected "|" operator' } + + expectCommand = true + continue + } + + if (!pushCommand() || !pushPipeline(token.value)) + return { list: [], error: `unexpected "${token.value}" operator` } + + expectCommand = true + } + + if (expectCommand) + return { list: [], error: 'unexpected end of input' } + + if (currentArgs.length > 0) + pushCommand() + + if (currentPipeline.length > 0) + pushPipeline(null) + + return { list, error: null } + } + + const line = this.buffer.join('') + this.FlushBuffer() + + const { tokens, error } = tokenize(line) + if (error) { + this.terminal.Write(`wush: error: ${error}`) + this.terminal.MoveCursor(0, 1, { x: true, y: false }) + + this.Prompt() + return + } + + const { list, error: parseError } = parseTokens(tokens) + if (parseError) { + this.terminal.Write(`wush: error: ${parseError}`) + this.terminal.MoveCursor(0, 1, { x: true, y: false }) + + this.Prompt() + return + } + + if (list.length === 0) { + this.Prompt() + 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 }) + + this.Prompt() + return + } + + 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.Prompt() } ProcessSimpleControlCode(code: string): boolean {