diff --git a/projects/core/src/main/resources/data/computercraft/lua/rom/programs/shell.lua b/projects/core/src/main/resources/data/computercraft/lua/rom/programs/shell.lua index c4a4104d5..cb44b5e5a 100644 --- a/projects/core/src/main/resources/data/computercraft/lua/rom/programs/shell.lua +++ b/projects/core/src/main/resources/data/computercraft/lua/rom/programs/shell.lua @@ -1,14 +1,39 @@ ---- The shell API provides access to CraftOS's command line interface. --- --- It allows you to @{run|start programs}, @{setCompletionFunction|add --- completion for a program}, and much more. --- --- @{shell} is not a "true" API. Instead, it is a standard program, which injects its --- API into the programs that it launches. This allows for multiple shells to --- run at the same time, but means that the API is not available in the global --- environment, and so is unavailable to other @{os.loadAPI|APIs}. --- --- @module[module] shell +--[[- The shell API provides access to CraftOS's command line interface. + +It allows you to @{run|start programs}, @{setCompletionFunction|add completion +for a program}, and much more. + +@{shell} is not a "true" API. Instead, it is a standard program, which injects +its API into the programs that it launches. This allows for multiple shells to +run at the same time, but means that the API is not available in the global +environment, and so is unavailable to other @{os.loadAPI|APIs}. + +## Programs and the program path +When you run a command with the shell, either from the prompt or +@{shell.run|from Lua code}, the shell API performs several steps to work out +which program to run: + + 1. Firstly, the shell attempts to resolve @{shell.aliases|aliases}. This allows + us to use multiple names for a single command. For example, the `list` + program has two aliases: `ls` and `dir`. When you write `ls /rom`, that's + expanded to `list /rom`. + + 2. Next, the shell attempts to find where the program actually is. For this, it + uses the @{shell.path|program path}. This is a colon separated list of + directories, each of which is checked to see if it contains the program. + + `list` or `list.lua` doesn't exist in `.` (the current directory), so she + shell now looks in `/rom/programs`, where `list.lua` can be found! + + 3. Finally, the shell reads the file and checks if the file starts with a + `#!`. This is a [hashbang][], which says that this file shouldn't be treated + as Lua, but instead passed to _another_ program, the name of which should + follow the `#!`. + +[hashbang]: https://en.wikipedia.org/wiki/Shebang_(Unix) + +@module[module] shell +]] local make_package = dofile("rom/modules/main/cc/require.lua").make @@ -54,6 +79,71 @@ else bgColour = colours.black end +local function tokenise(...) + local sLine = table.concat({ ... }, " ") + local tWords = {} + local bQuoted = false + for match in string.gmatch(sLine .. "\"", "(.-)\"") do + if bQuoted then + table.insert(tWords, match) + else + for m in string.gmatch(match, "[^ \t]+") do + table.insert(tWords, m) + end + end + bQuoted = not bQuoted + end + return tWords +end + +-- Execute a program using os.run, unless a shebang is present. +-- In that case, execute the program using the interpreter specified in the hashbang. +-- This may occur recursively, up to the maximum number of times specified by remainingRecursion +-- Returns the same type as os.run, which is a boolean indicating whether the program exited successfully. +local function executeProgram(remainingRecursion, path, args) + local file, err = fs.open(path, "r") + if not file then + printError(err) + return false + end + + -- First check if the file begins with a #! + local contents = file.readLine() + file.close() + + if contents and contents:sub(1, 2) == "#!" then + remainingRecursion = remainingRecursion - 1 + if remainingRecursion == 0 then + printError("Hashbang recursion depth limit reached when loading file: " .. path) + return false + end + + -- Load the specified hashbang program instead + local hashbangArgs = tokenise(contents:sub(3)) + local originalHashbangPath = table.remove(hashbangArgs, 1) + local resolvedHashbangProgram = shell.resolveProgram(originalHashbangPath) + if not resolvedHashbangProgram then + printError("Hashbang program not found: " .. originalHashbangPath) + return false + end + + -- Add the path and any arguments to the interpreter's arguments + table.insert(hashbangArgs, path) + for _, v in ipairs(args) do + table.insert(hashbangArgs, v) + end + + hashbangArgs[0] = originalHashbangPath + return executeProgram(remainingRecursion, resolvedHashbangProgram, hashbangArgs) + end + + local dir = fs.getDir(path) + local env = createShellEnv(dir) + env.arg = args + + return os.run(env, path, table.unpack(args)) +end + --- Run a program with the supplied arguments. -- -- Unlike @{shell.run}, each argument is passed to the program verbatim. While @@ -84,10 +174,7 @@ function shell.execute(command, ...) multishell.setTitle(multishell.getCurrent(), sTitle) end - local sDir = fs.getDir(sPath) - local env = createShellEnv(sDir) - env.arg = { [0] = command, ... } - local result = os.run(env, sPath, ...) + local result = executeProgram(100, sPath, { [0] = command, ... }) tProgramStack[#tProgramStack] = nil if multishell then @@ -108,23 +195,6 @@ function shell.execute(command, ...) end end -local function tokenise(...) - local sLine = table.concat({ ... }, " ") - local tWords = {} - local bQuoted = false - for match in string.gmatch(sLine .. "\"", "(.-)\"") do - if bQuoted then - table.insert(tWords, match) - else - for m in string.gmatch(match, "[^ \t]+") do - table.insert(tWords, m) - end - end - bQuoted = not bQuoted - end - return tWords -end - -- Install shell API --- Run a program with the supplied arguments. @@ -541,8 +611,8 @@ end --- Get the current aliases for this shell. -- -- Aliases are used to allow multiple commands to refer to a single program. For --- instance, the `list` program is aliased `dir` or `ls`. Running `ls`, `dir` or --- `list` in the shell will all run the `list` program. +-- instance, the `list` program is aliased to `dir` or `ls`. Running `ls`, `dir` +-- or `list` in the shell will all run the `list` program. -- -- @treturn { [string] = string } A table, where the keys are the names of -- aliases, and the values are the path to the program. diff --git a/projects/core/src/test/resources/test-rom/spec/programs/shell_spec.lua b/projects/core/src/test/resources/test-rom/spec/programs/shell_spec.lua index 5b1645d46..e0dd40dfd 100644 --- a/projects/core/src/test/resources/test-rom/spec/programs/shell_spec.lua +++ b/projects/core/src/test/resources/test-rom/spec/programs/shell_spec.lua @@ -17,6 +17,67 @@ describe("The shell", function() expect(args):same { [0] = "/test-rom/data/dump-args", "arg1", "arg 2" } end) + + local function make_hashbang_file(target, filename) + local tmp = fs.open(filename or "test-files/out.lua", "w") + tmp.write("#!" .. target) + tmp.close() + end + + it("supports hashbangs", function() + make_hashbang_file("/test-rom/data/dump-args") + shell.execute("test-files/out.lua", "arg1", "arg2") + + local args = _G.__arg + _G.__arg = nil + + expect(args):same { + [0] = "/test-rom/data/dump-args", + "test-files/out.lua", + "arg1", + "arg2", + } + end) + + it("supports arguments", function() + make_hashbang_file("/test-rom/data/dump-args \"iArg1 iArg1-2\" iArg2") + shell.execute("test-files/out.lua", "arg1", "arg2") + + local args = _G.__arg + _G.__arg = nil + + expect(args):same { + [0] = "/test-rom/data/dump-args", + "iArg1 iArg1-2", + "iArg2", + "test-files/out.lua", + "arg1", + "arg2", + } + end) + + it("supports recursion", function() + make_hashbang_file("/test-rom/data/dump-args") + make_hashbang_file("test-files/out.lua", "test-files/out2.lua") + + shell.execute("test-files/out2.lua", "arg1", "arg2") + + local args = _G.__arg + _G.__arg = nil + + expect(args):same { + [0] = "/test-rom/data/dump-args", + "test-files/out.lua", + "test-files/out2.lua", + "arg1", + "arg2", + } + end) + + it("returns error for infinite recursion", function() + make_hashbang_file("test-files/out.lua") + expect(shell.execute("test-files/out.lua")):eq(false) + end) end) describe("shell.run", function()