feat: improve bash-like command parsing
This commit is contained in:
@@ -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<void> {
|
||||
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
|
||||
|
||||
35
src/program/Cd.ts
Normal file
35
src/program/Cd.ts
Normal file
@@ -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<string>, stdout: SimpleStream<string>, workdir: Item, args: string[]): Promise<number> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -7,27 +7,29 @@ export class Echo extends Program {
|
||||
super()
|
||||
}
|
||||
|
||||
async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, workdir: Item, args: string[]): Promise<number> {
|
||||
if (args.length < 2) {
|
||||
stdout.emit("echo: error: missing path argument\n")
|
||||
return 1
|
||||
}
|
||||
async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, __: Item, args: string[]): Promise<number> {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
15
src/program/Printf.ts
Normal file
15
src/program/Printf.ts
Normal file
@@ -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<string>, stdout: SimpleStream<string>, __: Item, args: string[]): Promise<number> {
|
||||
stdout.emit(args.slice(1).join(' '))
|
||||
|
||||
return 0
|
||||
}
|
||||
}
|
||||
15
src/program/Pwd.ts
Normal file
15
src/program/Pwd.ts
Normal file
@@ -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<string>, stdout: SimpleStream<string>, workdir: Item, __: string[]): Promise<number> {
|
||||
stdout.emit(`${workdir.GetPath()}\n`)
|
||||
|
||||
return 0
|
||||
}
|
||||
}
|
||||
21
src/shell/wush/ShellEnvironment.ts
Normal file
21
src/shell/wush/ShellEnvironment.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Wush environment manager
|
||||
* Manages environment variables, aliases etc.
|
||||
*/
|
||||
export class ShellEnvironment {
|
||||
private variables: Record<string, string> = {}
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
return new Promise<void>(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<string>
|
||||
stdout: null | SimpleStream<string>
|
||||
}[] = []
|
||||
}
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user