opus/sys/apps/shell.lua

679 lines
15 KiB
Lua

local parentShell = _ENV.shell
_ENV.shell = { }
local trace = require('opus.trace')
local Util = require('opus.util')
local fs = _G.fs
local settings = _G.settings
local shell = _ENV.shell
local DIR = (parentShell and parentShell.dir()) or ""
local PATH = (parentShell and parentShell.path()) or ".:/rom/programs"
local tAliases = (parentShell and parentShell.aliases()) or {}
local tCompletionInfo = (parentShell and parentShell.getCompletionInfo()) or {}
local bExit = false
local tProgramStack = {}
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
local defaultHandlers = {
function(env, command, args)
return command:match("^(https?:)") and {
title = fs.getName(command),
path = command,
args = args,
load = Util.loadUrl,
env = env,
}
end,
function(env, command, args)
command = env.shell.resolveProgram(command)
or error('No such program')
_G.requireInjector(env, fs.getDir(command))
return {
title = fs.getName(command):match('([^%.]+)'),
path = command,
args = args,
load = loadfile,
env = env,
}
end,
}
function shell.getHandlers()
if parentShell and parentShell.getHandlers then
return parentShell.getHandlers()
end
return defaultHandlers
end
local handlers = shell.getHandlers()
function shell.registerHandler(fn)
table.insert(handlers, 1, fn)
end
local function handleCommand(env, command, args)
for _,v in pairs(handlers) do
local pi = v(env, command, args)
if pi then
return pi
end
end
end
local function run(...)
local args = tokenise(...)
if #args == 0 then
error('No such program')
end
local pi = handleCommand(shell.makeEnv(_ENV), table.remove(args, 1), args)
local O_v_O, err = pi.load(pi.path, pi.env)
if not O_v_O then
error(err, -1)
end
if _ENV.multishell then
_ENV.multishell.setTitle(_ENV.multishell.getCurrent(), pi.title)
end
tProgramStack[#tProgramStack + 1] = pi
pi.env[ "arg" ] = { [0] = pi.path, table.unpack(pi.args) }
local r = { O_v_O(table.unpack(pi.args)) }
tProgramStack[#tProgramStack] = nil
return table.unpack(r)
end
-- Install shell API
function shell.run(...)
local oldTitle
if _ENV.multishell then
oldTitle = _ENV.multishell.getTitle(_ENV.multishell.getCurrent())
end
local r = { trace(run, ...) }
if _ENV.multishell then
_ENV.multishell.setTitle(_ENV.multishell.getCurrent(), oldTitle or 'shell')
end
return table.unpack(r)
end
function shell.exit()
bExit = true
end
function shell.dir() return DIR end
function shell.setDir(d)
d = fs.combine(d, '')
if not fs.isDir(d) then
error("Not a directory", 2)
end
DIR = d
end
function shell.path() return PATH end
function shell.setPath(p) PATH = p end
function shell.resolve( _sPath )
local sStartChar = string.sub( _sPath, 1, 1 )
if sStartChar == "/" or sStartChar == "\\" then
return fs.combine( "", _sPath )
else
return fs.combine(DIR, _sPath )
end
end
function shell.resolveProgram(_sCommand)
if tAliases[_sCommand] ~= nil then
_sCommand = tAliases[_sCommand]
end
local function check(f)
return fs.exists(f) and not fs.isDir(f) and f
end
local function inPath()
-- Otherwise, look on the path variable
for sPath in string.gmatch(PATH or '', "[^:]+") do
sPath = fs.combine(sPath, _sCommand )
if check(sPath) then
return sPath
end
if check(sPath .. '.lua') then
return sPath .. '.lua'
end
end
end
if not _sCommand:find('/') then
return inPath()
end
-- so... even if you are in the rom directory and you run:
-- 'packages/common/edit.lua', allow this even though it
-- does not use a leading slash. Ideally, fs.combine would
-- provide the leading slash... but it does not.
return check(shell.resolve(_sCommand))
or check(shell.resolve(_sCommand) .. '.lua')
or check(_sCommand)
or check(_sCommand .. '.lua')
end
function shell.programs(_bIncludeHidden)
local tItems = { }
-- Add programs from the path
for sPath in string.gmatch(PATH, "[^:]+") do
sPath = shell.resolve(sPath)
if fs.isDir( sPath ) then
local tList = fs.list( sPath )
for _,sFile in pairs( tList ) do
if not fs.isDir( fs.combine( sPath, sFile ) ) and
(_bIncludeHidden or string.sub( sFile, 1, 1 ) ~= ".") then
tItems[ sFile ] = true
end
end
end
end
-- Sort and return
local tItemList = { }
for sItem in pairs(tItems) do
table.insert(tItemList, sItem)
end
table.sort(tItemList)
return tItemList
end
function shell.completeProgram(sLine)
if #sLine > 0 and string.sub(sLine, 1, 1) == '/' then
-- Add programs from the root
return fs.complete(sLine, '', true, false)
end
local tResults = { }
local tSeen = { }
-- Add aliases
for sAlias in pairs( tAliases ) do
if #sAlias > #sLine and string.sub(sAlias, 1, #sLine) == sLine then
local sResult = string.sub(sAlias, #sLine + 1)
if not tSeen[sResult] then
table.insert(tResults, sResult .. ' ')
tSeen[sResult] = true
end
end
end
-- Add programs from the path
local tPrograms = shell.programs()
for n=1,#tPrograms do
local sProgram = tPrograms[n]
if #sProgram >= #sLine and string.sub(sProgram, 1, #sLine) == sLine then
local sResult = string.sub(sProgram, #sLine + 1)
if not tSeen[sResult] then
table.insert(tResults, sResult .. ' ')
tSeen[sResult] = true
end
end
end
-- Sort and return
table.sort(tResults)
return tResults
end
function shell.complete(sLine)
local tWords = tokenise(sLine)
local nIndex = #tWords
if string.sub(sLine, #sLine, #sLine) == ' ' and #Util.trim(sLine) > 0 then
nIndex = nIndex + 1
end
if nIndex == 0 then
return fs.complete('', shell.dir(), true, false)
elseif nIndex == 1 then
local results = shell.completeProgram(tWords[1] or '')
for _, v in pairs(fs.complete(table.concat(tWords, ' '), shell.dir(), true, false)) do
table.insert(results, v)
end
return results
else
local sPath = shell.resolveProgram(tWords[1])
local sPart = tWords[nIndex] or ''
local tPreviousParts = tWords
tPreviousParts[nIndex] = nil
local results
local tInfo = tCompletionInfo[sPath]
if tInfo then
results = tInfo.fnComplete(shell, nIndex - 1, sPart, tPreviousParts)
end
return results and #results > 0 and results
or fs.complete(sPart, shell.dir(), true, false)
end
end
function shell.setCompletionFunction(sProgram, fnComplete)
tCompletionInfo[sProgram] = { fnComplete = fnComplete }
end
function shell.getCompletionInfo()
return tCompletionInfo
end
function shell.getRunningProgram()
return tProgramStack[#tProgramStack] and tProgramStack[#tProgramStack].path
end
function shell.getRunningInfo()
return tProgramStack[#tProgramStack]
end
-- convenience function for making a runnable env
function shell.makeEnv(env, dir)
env = setmetatable(Util.shallowCopy(env), { __index = _G })
_G.requireInjector(env, dir)
return env
end
function shell.setAlias(_sCommand, _sProgram)
tAliases[_sCommand] = _sProgram
end
function shell.clearAlias(_sCommand)
tAliases[_sCommand] = nil
end
function shell.aliases()
local tCopy = {}
for sAlias, sCommand in pairs(tAliases) do
tCopy[sAlias] = sCommand
end
return tCopy
end
function shell.newTab(tabInfo, ...)
local args = tokenise(...)
local path = table.remove(args, 1)
path = shell.resolveProgram(path)
if path then
tabInfo.path = path
tabInfo.args = args
tabInfo.title = fs.getName(path):match('([^%.]+)')
if path ~= 'sys/apps/shell.lua' then
table.insert(tabInfo.args, 1, tabInfo.path)
tabInfo.path = 'sys/apps/shell.lua'
end
return _ENV.multishell.openTab(_ENV, tabInfo)
end
return nil, 'No such program'
end
if not _ENV.multishell then
function shell.newTab()
error('Multishell is not available')
end
end
function shell.openTab(...)
return shell.newTab({ }, ...)
end
function shell.openForegroundTab( ... )
return shell.newTab({ focused = true }, ...)
end
function shell.openHiddenTab( ... )
return shell.newTab({ hidden = true }, ...)
end
function shell.switchTab(tabId)
_ENV.multishell.setFocus(tabId)
end
local tArgs = { ... }
if #tArgs > 0 then
return run(...)
end
local Config = require('opus.config')
local Entry = require('opus.entry')
local History = require('opus.history')
local Input = require('opus.input')
local Sound = require('opus.sound')
local Terminal = require('opus.terminal')
local colors = _G.colors
local os = _G.os
local term = _G.term
local textutils = _G.textutils
local oldTerm
local terminal = term.current()
local _len = string.len
local _rep = string.rep
local _sub = string.sub
local config = {
color = {
textColor = colors.white,
commandTextColor = colors.yellow,
directoryTextColor = colors.orange,
promptTextColor = colors.blue,
directoryColor = colors.green,
fileColor = colors.white,
backgroundColor = colors.black,
},
displayDirectory = true,
}
Config.load('shellprompt', config)
local _colors = config.color
-- temp
if not _colors.backgroundColor then
_colors.backgroundColor = colors.black
_colors.fileColor = colors.white
end
if not terminal.scrollUp then
terminal = Terminal.window(term.current())
terminal.setMaxScroll(200)
oldTerm = term.redirect(terminal)
term.setBackgroundColor(_colors.backgroundColor)
term.clear()
end
local palette = terminal.canvas.palette
local function autocomplete(line)
local words = { }
for word in line:gmatch("%S+") do
table.insert(words, word)
end
if line:match(' $') then
table.insert(words, '')
end
if #words == 0 then
words = { '' }
end
local results = shell.complete(line) or { }
Util.filterInplace(results, function(f)
return not Util.key(results, f .. '/')
end)
local w = words[#words] or ''
for k,arg in pairs(results) do
results[k] = w .. arg
end
if #results == 1 then
words[#words] = results[1]
return table.concat(words, ' ')
elseif #results > 1 then
local function someComplete()
-- ugly (complete as much as possible)
local word = words[#words] or ''
local i = #word + 1
while true do
local ch
for _,f in ipairs(results) do
if #f < i then
words[#words] = _sub(f, 1, i - 1)
return table.concat(words, ' ')
end
if not ch then
ch = _sub(f, i, i)
elseif _sub(f, i, i) ~= ch then
if i == #word + 1 then
return
end
words[#words] = _sub(f, 1, i - 1)
return table.concat(words, ' ')
end
end
i = i + 1
end
end
local t = someComplete()
if t then
return t
end
print()
local word = words[#words] or ''
local prefix = word:match("(.*/)") or ''
if #prefix > 0 then
for _,f in ipairs(results) do
if f:match("^" .. prefix) ~= prefix then
prefix = ''
break
end
end
end
local tDirs, tFiles = { }, { }
for _,f in ipairs(results) do
if fs.isDir(shell.resolve(f)) then
f = f:gsub(prefix, '', 1)
table.insert(tDirs, f)
else
f = f:gsub(prefix, '', 1)
table.insert(tFiles, f)
end
end
table.sort(tDirs)
table.sort(tFiles)
if #tDirs > 0 and #tDirs < #tFiles then
local tw = term.getSize()
local nMaxLen = tw / 8
for _,sItem in pairs(results) do
nMaxLen = math.max(_len(sItem) + 1, nMaxLen)
end
local nCols = math.floor(tw / nMaxLen)
if #tDirs < nCols then
for _ = #tDirs + 1, nCols do
table.insert(tDirs, '')
end
end
end
if #tDirs > 0 then
textutils.tabulate(_colors.directoryColor, tDirs, _colors.fileColor, tFiles)
else
textutils.tabulate(_colors.fileColor, tFiles)
end
term.setTextColour(_colors.promptTextColor)
term.write("$ " )
term.setTextColour(_colors.commandTextColor)
return line
end
end
local function shellRead(history)
local lastLen = 0
local entry = Entry({
width = term.getSize() - 3,
offset = 3,
})
history:reset()
term.setCursorBlink(true)
local function updateCursor()
term.setCursorPos(3 + entry.pos - entry.scroll, select(2, term.getCursorPos()))
end
local function redraw()
if terminal.scrollBottom then
terminal.scrollBottom()
end
local _,cy = term.getCursorPos()
term.setCursorPos(3, cy)
entry.value = entry.value or ''
local filler = #entry.value < lastLen
and _rep(' ', lastLen - #entry.value)
or ''
local str = _sub(entry.value, entry.scroll + 1, entry.width + entry.scroll) .. filler
local fg = _rep(palette[_colors.commandTextColor], #str)
local bg = _rep(palette[_colors.backgroundColor], #str)
if entry.mark.active then
bg = _rep('f', entry.mark.x) ..
_rep('7', entry.mark.ex - entry.mark.x) ..
_rep('f', #entry.value - entry.mark.ex + #filler + 1)
bg = _sub(bg, entry.scroll + 1, entry.scroll + #str)
end
term.blit(str, fg, bg)
updateCursor()
lastLen = #entry.value
end
while true do
local event, p1, p2, p3 = os.pullEventRaw()
local ie = Input:translate(event, p1, p2, p3)
if ie then
if ie.code == 'scroll_up' and terminal.scrollUp then
terminal.scrollUp()
elseif ie.code == 'scroll_down' and terminal.scrollDown then
terminal.scrollDown()
elseif ie.code == 'terminate' then
bExit = true
break
elseif ie.code == 'enter' then
break
elseif ie.code == 'up' or ie.code == 'control-p' or
ie.code == 'down' or ie.code == 'control-n' then
entry:reset()
if ie.code == 'up' or ie.code == 'control-p' then
entry.value = history:back() or ''
else
entry.value = history:forward() or ''
end
entry.pos = #entry.value
entry:updateScroll()
redraw()
elseif ie.code == 'tab' then
entry.value = entry.value or ''
if entry.pos == #entry.value then
local cline = autocomplete(entry.value)
if cline then
entry.value = cline
entry.pos = #entry.value
entry:unmark()
entry:updateScroll()
redraw()
else
Sound.play('entity.villager.no')
end
end
else
entry:process(ie)
entry.value = entry.value or ''
if entry.textChanged then
redraw()
elseif entry.posChanged then
updateCursor()
end
end
elseif event == "term_resize" then
terminal.reposition(1, 1, oldTerm.getSize())
entry.width = term.getSize() - 3
entry:updateScroll()
redraw()
end
end
print()
term.setCursorBlink(false)
return entry.value or ''
end
local history = History.load('usr/.shell_history', 100)
term.setBackgroundColor(_colors.backgroundColor)
if settings.get("motd.enable") then
shell.run("motd")
end
while not bExit do
if config.displayDirectory then
term.setTextColour(_colors.directoryTextColor)
print('==' .. os.getComputerLabel() .. ':/' .. DIR)
end
term.setTextColour(_colors.promptTextColor)
term.write("$ " )
term.setTextColour(_colors.commandTextColor)
local sLine = shellRead(history)
if bExit then -- terminated
break
end
sLine = Util.trim(sLine)
if #sLine > 0 and sLine ~= 'exit' then
history:add(sLine)
end
term.setTextColour(_colors.textColor)
if #sLine > 0 then
local result, err = shell.run(sLine)
local cx = term.getCursorPos()
if cx ~= 1 then
print()
end
term.setBackgroundColor(_colors.backgroundColor)
if not result and err then
_G.printError(err)
end
end
end
if oldTerm then
term.redirect(oldTerm)
end