diff --git a/src/io/file_output.ts b/src/io/file_output.ts index e970a07..890253b 100644 --- a/src/io/file_output.ts +++ b/src/io/file_output.ts @@ -1,28 +1,25 @@ import { BufferedOutput } from "./buffered_output" -//import { IFileSystem } from "../file_system" +import { IFileSystem } from "../file_system" export class FileOutput extends BufferedOutput { - //constructor(fs: IFileSystem, path: string, append: boolean) { - constructor(path: string, append: boolean) { + constructor(readonly fileSystem: IFileSystem, readonly path: string, readonly append: boolean) { super() - //this.fs = fs - this.path = path - this.append = append + } - console.log(this.path) + override async flush(): Promise { + const { FS } = this.fileSystem + let content = this.data.join("") if (this.append) { - throw Error("FileOutput in append mode not implemented") + try { + const prevContent = FS.readFile(this.path, { "encoding": "utf8" }) + content = prevContent + content + } catch (e) { + // If file does not exist, fallback to write (non-append) behaviour. + } } - } - override async flush(): Promise { - //const all_data = this.data.join() - //this.fs.write(this.path, all_data) + FS.writeFile(this.path, content) this.clear() } - - //private readonly fs: IFileSystem - private readonly path: string - private readonly append: boolean // or replace } diff --git a/src/parse.ts b/src/parse.ts index cff09aa..e09d264 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -78,12 +78,12 @@ function _createCommandNode(tokens: Token[]) { let args = tokens.slice(1) // Handle redirects. - const index = args.findIndex((token) => token.value == ">") + const index = args.findIndex((token) => token.value.startsWith(">")) if (index >= 0) { // Must support multiple redirects for a single command. if (args.length != index + 2) { // Need better error handling here. - throw Error("Redirect should be followed by file to redirect to") + throw new Error("Redirect should be followed by file to redirect to") } const redirect = new RedirectNode(args[index], args[index+1]) args = args.slice(0, index) diff --git a/src/shell.ts b/src/shell.ts index 0d5cf58..8ff4d2f 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -1,7 +1,7 @@ import { CommandRegistry } from "./command_registry" import { Context } from "./context" import { IFileSystem } from "./file_system" -import { TerminalOutput } from "./io" +import { FileOutput, Output, TerminalOutput } from "./io" import { IOutputCallback } from "./output_callback" import { CommandNode, parse } from "./parse" import * as FsModule from './wasm/fs' @@ -93,23 +93,40 @@ export class Shell { // Keeping this public for tests. async _runCommands(cmdText: string): Promise { - const cmdNodes = parse(cmdText) - const ncmds = cmdNodes.length const stdout = new TerminalOutput(this._outputCallback) try { + const cmdNodes = parse(cmdText) + const ncmds = cmdNodes.length + for (let i = 0; i < ncmds; ++i) { const command = cmdNodes[i] as CommandNode const cmdName = command.name.value - const commands = CommandRegistry.instance().get(cmdName) - if (commands === null) { + const runner = CommandRegistry.instance().get(cmdName) + if (runner === null) { // Give location of command in input? throw new Error(`Unknown command: '${cmdName}'`) } + let output: Output = stdout + if (command.redirects) { + // Support single redirect only, write or append to file. + if (command.redirects.length > 1) { + throw new Error("Only implemented a single redirect per command") + } + const redirect = command.redirects[0] + const redirectChars = redirect.token.value + if (!(redirectChars == ">" || redirectChars == ">>")) { + throw new Error("Only implemented redirect write to file, not " + redirectChars) + } + + const path = redirect.target.value + output = new FileOutput(this._fileSystem!, path, redirectChars == ">>") + } + const args = command.suffix.map((token) => token.value) - const context = new Context(args, this._fileSystem!, this._mountpoint, stdout) - await commands.run(cmdName, context) + const context = new Context(args, this._fileSystem!, this._mountpoint, output) + await runner.run(cmdName, context) await context.flush() } diff --git a/src/tokenize.ts b/src/tokenize.ts index db02972..60313ee 100644 --- a/src/tokenize.ts +++ b/src/tokenize.ts @@ -13,28 +13,29 @@ export function tokenize(source: string): Token[] { let offset: number = -1 // Offset of start of current token, -1 if not in token. const n = source.length + let prevChar: string = "" + let prevCharType: CharType = CharType.None for (let i = 0; i < n; i++) { const char = source[i] + const charType = _getCharType(char) if (offset >= 0) { // In token. - if (whitespace.includes(char)) { + if (charType == CharType.Whitespace) { // Finish current token. tokens.push({offset, value: source.slice(offset, i)}) offset = -1 - } else if (delimiters.includes(char)) { - // Finish current token and create new one for delimiter. + } else if (charType != prevCharType || (charType == CharType.Delimiter && char != prevChar)) { + // Finish current token and start new one. tokens.push({offset, value: source.slice(offset, i)}) - tokens.push({offset: i, value: source.slice(i, i+1)}) - offset = -1 + offset = i } } else { // Not in token. - if (delimiters.includes(char)) { - // Single character delimiter. - tokens.push({offset: i, value: source.slice(i, i+1)}) - } else if (!whitespace.includes(char)) { + if (charType != CharType.Whitespace) { // Start new token. offset = i } } + prevChar = char + prevCharType = charType } if (offset >= 0) { @@ -44,3 +45,20 @@ export function tokenize(source: string): Token[] { return tokens } + +enum CharType { + None, + Delimiter, + Whitespace, + Other, +} + +function _getCharType(char: string): CharType { + if (whitespace.includes(char)) { + return CharType.Whitespace + } else if (delimiters.includes(char)) { + return CharType.Delimiter + } else { + return CharType.Other + } +} diff --git a/tests/commands/cat.test.ts b/tests/commands/cat.test.ts index 00e7d02..8135db8 100644 --- a/tests/commands/cat.test.ts +++ b/tests/commands/cat.test.ts @@ -1,6 +1,6 @@ import { shell_setup_simple } from "../shell_setup" -describe("echo command", () => { +describe("cat command", () => { it("should write to stdout", async () => { const { shell, output } = await shell_setup_simple() await shell._runCommands("cat file1") diff --git a/tests/commands/grep.test.ts b/tests/commands/grep.test.ts new file mode 100644 index 0000000..152b605 --- /dev/null +++ b/tests/commands/grep.test.ts @@ -0,0 +1,27 @@ +import { shell_setup_simple } from "../shell_setup" + +describe("grep command", () => { + it("should write to stdout", async () => { + const { shell, output } = await shell_setup_simple() + await shell._runCommands("grep cond file2") + expect(output.text).toEqual("Second line\r\n") + }) + + it("should support ^ and $", async () => { + const { shell, output, FS } = await shell_setup_simple() + const line0 = " hello" + const line1 = "hello " + FS.writeFile("file3", line0 + "\n" + line1) + + await shell._runCommands("grep hello file3") + expect(output.text).toEqual(line0 + "\r\n" + line1 + "\r\n") + output.clear() + + await shell._runCommands("grep ^hello file3") + expect(output.text).toEqual(line1 + "\r\n") + output.clear() + + await shell._runCommands("grep hello$ file3") + expect(output.text).toEqual(line0 + "\r\n") + }) +}) diff --git a/tests/file_system_setup.ts b/tests/file_system_setup.ts deleted file mode 100644 index 5ddffba..0000000 --- a/tests/file_system_setup.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ContentsManagerMock } from "@jupyterlab/services/lib/testutils" -import { IFileSystem } from "../src" -import { JupyterFileSystem } from "../src/jupyter_file_system" - -export async function file_system_setup(name: string): Promise { - if (name == "jupyter") { - const cm = new ContentsManagerMock() - await cm.save("file1", {content: "Contents of file1"}) - await cm.save("file2") - await cm.save("dirA", { type: "directory" }) - return new JupyterFileSystem(cm) - } else { - throw Error("No other IFileSystem-derived classes supported") - } -} diff --git a/tests/parse.test.ts b/tests/parse.test.ts index f7b5412..85dfa16 100644 --- a/tests/parse.test.ts +++ b/tests/parse.test.ts @@ -69,4 +69,9 @@ describe("parse", () => { [new RedirectNode({offset: 5, value: ">"}, {offset: 6, value: "file"})]) ]) }) + + it("should raise on redirect without target file", () => { + expect(() => parse("ls >")).toThrow("file to redirect to") + expect(() => parse("ls >>")).toThrow("file to redirect to") + }) }) diff --git a/tests/shell.test.ts b/tests/shell.test.ts index 018f1da..8e825e6 100644 --- a/tests/shell.test.ts +++ b/tests/shell.test.ts @@ -13,6 +13,17 @@ describe("Shell", () => { await shell._runCommands(" ls") expect(output.text).toEqual("dirA file1 file2\r\n") }) + + it("should output to file", async () => { + const { shell, output, FS } = await shell_setup_simple() + await shell._runCommands("echo Hello > out") + expect(output.text).toEqual("") + expect(FS.readFile("out", { "encoding": "utf8" })).toEqual("Hello\n") + + await shell._runCommands("echo Goodbye >> out") + expect(output.text).toEqual("") + expect(FS.readFile("out", { "encoding": "utf8" })).toEqual("Hello\nGoodbye\n") + }) }) describe("input", () => { diff --git a/tests/tokenize.test.ts b/tests/tokenize.test.ts index 6064d42..84cb87d 100644 --- a/tests/tokenize.test.ts +++ b/tests/tokenize.test.ts @@ -30,25 +30,22 @@ describe("Tokenize", () => { expect(tokenize("ls;")).toEqual([{offset: 0, value: "ls"}, {offset: 2, value: ";"}]) expect(tokenize(";ls")).toEqual([{offset: 0, value: ";"}, {offset: 1, value: "ls"}]) expect(tokenize(";ls;;")).toEqual([ - {offset: 0, value: ";"}, {offset: 1, value: "ls"}, {offset: 3, value: ";"}, - {offset: 4, value: ";"}, + {offset: 0, value: ";"}, {offset: 1, value: "ls"}, {offset: 3, value: ";;"}, ]) expect(tokenize("ls ; ; pwd")).toEqual([ {offset: 0, value: "ls"}, {offset: 3, value: ";"}, {offset: 5, value: ";"}, {offset: 7, value: "pwd"}, ]) expect(tokenize("ls ;; pwd")).toEqual([ - {offset: 0, value: "ls"}, {offset: 3, value: ";"}, {offset: 4, value: ";"}, - {offset: 6, value: "pwd"}, + {offset: 0, value: "ls"}, {offset: 3, value: ";;"}, {offset: 6, value: "pwd"}, ]) expect(tokenize("ls;pwd")).toEqual([ {offset: 0, value: "ls"}, {offset: 2, value: ";"}, {offset: 3, value: "pwd"}, ]) expect(tokenize("ls;;pwd")).toEqual([ - {offset: 0, value: "ls"}, {offset: 2, value: ";"}, {offset: 3, value: ";"}, - {offset: 4, value: "pwd"}, + {offset: 0, value: "ls"}, {offset: 2, value: ";;"}, {offset: 4, value: "pwd"}, ]) - expect(tokenize(" ;; ")).toEqual([{offset: 1, value: ";"}, {offset: 2, value: ";"}]) + expect(tokenize(" ;; ")).toEqual([{offset: 1, value: ";;"}]) expect(tokenize(" ; ; ")).toEqual([{offset: 1, value: ";"}, {offset: 3, value: ";"}]) }) @@ -72,5 +69,17 @@ describe("Tokenize", () => { {offset: 0, value: "ls"}, {offset: 3, value: "-l"}, {offset: 5, value: ">"}, {offset: 6, value: "somefile"}, ]) + expect(tokenize("ls >> somefile")).toEqual([ + {offset: 0, value: "ls"}, {offset: 3, value: ">>"}, {offset: 6, value: "somefile"}, + ]) + expect(tokenize("ls>>somefile")).toEqual([ + {offset: 0, value: "ls"}, {offset: 2, value: ">>"}, {offset: 4, value: "somefile"}, + ]) + expect(tokenize("ls >>somefile")).toEqual([ + {offset: 0, value: "ls"}, {offset: 3, value: ">>"}, {offset: 5, value: "somefile"}, + ]) + expect(tokenize("ls>> somefile")).toEqual([ + {offset: 0, value: "ls"}, {offset: 2, value: ">>"}, {offset: 5, value: "somefile"}, + ]) }) })