mirror of
https://github.com/NeoFlock/neonucleus.git
synced 2025-09-24 09:03:32 +02:00
605 lines
18 KiB
Lua
605 lines
18 KiB
Lua
local fs = require("filesystem")
|
|
local process = require("process")
|
|
local shell = require("shell")
|
|
local text = require("text")
|
|
local tx = require("transforms")
|
|
local unicode = require("unicode")
|
|
|
|
local sh = require("sh")
|
|
|
|
local isWordOf = sh.internal.isWordOf
|
|
|
|
-------------------------------------------------------------------------------
|
|
|
|
function sh.internal.command_passed(ec)
|
|
return sh.internal.command_result_as_code(ec) == 0
|
|
end
|
|
|
|
-- takes ewords and searches for redirections (may not have any)
|
|
-- removes the redirects and their arguments from the ewords
|
|
-- returns a redirection table that is used during process load
|
|
-- returns false if no redirections are defined
|
|
-- to open the redirect handles, see openCommandRedirects
|
|
function sh.internal.buildCommandRedirects(words)
|
|
local redirects = {}
|
|
local index = 1 -- we move index manually to allow removals from ewords
|
|
local from_io, to_io, mode
|
|
local syn_err_msg = "syntax error near unexpected token "
|
|
|
|
-- hasValidPiping has been modified, it does not verify redirects now
|
|
-- we could have bad redirects such as "echo hi > > foo"
|
|
-- we must validate the input here
|
|
|
|
while true do
|
|
local word = words[index]
|
|
if not word then break end
|
|
|
|
-- redirections are
|
|
-- 1. single part
|
|
-- 2. not quoted
|
|
local part = word[1]
|
|
local token = not word[2] and not part.qr and part.txt or ""
|
|
local _, _, from_io_txt, mode_txt, to_io_txt = token:find("(%d*)([<>]>?)%&?(.*)")
|
|
if mode_txt then
|
|
if mode then
|
|
return nil, syn_err_msg .. token
|
|
end
|
|
mode = assert(({["<"]="r",[">"]="w",[">>"]="a",})[mode_txt], "redirect failed to detect mode")
|
|
from_io = from_io_txt ~= "" and tonumber(from_io_txt) or mode == "r" and 0 or 1
|
|
to_io = to_io_txt ~= "" and tonumber(to_io_txt)
|
|
elseif mode then
|
|
token = sh.internal.evaluate({word})
|
|
if #token > 1 then
|
|
return nil, string.format("%s: ambiguous redirect", part.txt)
|
|
end
|
|
to_io = token[1]
|
|
else
|
|
index = index + 1
|
|
end
|
|
|
|
if mode then
|
|
table.remove(words, index)
|
|
end
|
|
|
|
if to_io then
|
|
table.insert(redirects, {from_io, to_io, mode})
|
|
mode = nil
|
|
to_io = nil
|
|
end
|
|
end
|
|
|
|
if mode then
|
|
return nil, syn_err_msg .. "newline"
|
|
end
|
|
|
|
return redirects
|
|
end
|
|
|
|
-- redirects as built by buildCommentRedirects
|
|
function sh.internal.openCommandRedirects(redirects)
|
|
local data = process.info().data
|
|
local ios = data.io
|
|
|
|
for _,rjob in ipairs(redirects) do
|
|
local from_io, to_io, mode = table.unpack(rjob)
|
|
|
|
if type(to_io) == "number" then -- io to io
|
|
-- from_io and to_io should be numbers
|
|
ios[from_io] = io.dup(ios[to_io])
|
|
else
|
|
-- to_io should be a string
|
|
local file, reason = io.open(shell.resolve(to_io), mode)
|
|
if not file then
|
|
io.stderr:write("could not open '" .. to_io .. "': " .. reason .. "\n")
|
|
os.exit(1)
|
|
end
|
|
ios[from_io] = file
|
|
end
|
|
end
|
|
end
|
|
|
|
-- takes an eword, returns a list of glob hits or {word} if no globs exist
|
|
function sh.internal.glob(eword)
|
|
-- words are parts, parts are txt and qr
|
|
-- eword.txt is a convenience field of the parts
|
|
-- turn word into regex based on globits
|
|
local globbers = {{"*",".*"},{"?","."}}
|
|
local glob_pattern = ""
|
|
local has_globits
|
|
for _,part in ipairs(eword) do
|
|
local next = part.txt
|
|
-- globs only exist outside quotes
|
|
if not part.qr then
|
|
local escaped = text.escapeMagic(next)
|
|
next = escaped
|
|
|
|
for _,glob_rule in ipairs(globbers) do
|
|
--remove duplicates
|
|
while true do
|
|
local prev = next
|
|
next = next:gsub(text.escapeMagic(glob_rule[1]):rep(2), glob_rule[1])
|
|
if prev == next then
|
|
break
|
|
end
|
|
end
|
|
--revert globit
|
|
next = next:gsub("%%%"..glob_rule[1], glob_rule[2])
|
|
end
|
|
|
|
-- if next is still equal to escaped that means no globits were detected in this word part
|
|
-- this word may not contain a globit, the prior search did a cheap search for globits
|
|
has_globits = has_globits or next ~= escaped
|
|
end
|
|
glob_pattern = glob_pattern .. next
|
|
end
|
|
|
|
if not has_globits then
|
|
return {eword.txt}
|
|
end
|
|
|
|
local segments = text.split(glob_pattern, {"/"}, true)
|
|
local hiddens = tx.foreach(segments,function(e)return e:match("^%%%.")==nil end)
|
|
local function is_visible(s,i)
|
|
return not hiddens[i] or s:match("^%.") == nil
|
|
end
|
|
|
|
local function magical(s)
|
|
for _,glob_rule in ipairs(globbers) do
|
|
if (" "..s):match("[^%%]"..text.escapeMagic(glob_rule[2])) then
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
|
|
local is_abs = glob_pattern:sub(1, 1) == "/"
|
|
local root = is_abs and '' or shell.getWorkingDirectory():gsub("([^/])$","%1/")
|
|
local paths = {is_abs and "/" or ''}
|
|
local relative_separator = ''
|
|
for i,segment in ipairs(segments) do
|
|
local enclosed_pattern = string.format("^(%s)/?$", segment)
|
|
local next_paths = {}
|
|
for _,path in ipairs(paths) do
|
|
if fs.isDirectory(root..path) then
|
|
if magical(segment) then
|
|
for file in fs.list(root..path) do
|
|
if file:match(enclosed_pattern) and is_visible(file, i) then
|
|
table.insert(next_paths, path..relative_separator..file:gsub("/+$",''))
|
|
end
|
|
end
|
|
else -- not a globbing segment, just use it raw
|
|
local plain = text.removeEscapes(segment)
|
|
local fpath = root..path..relative_separator..plain
|
|
local hit = path..relative_separator..plain:gsub("/+$",'')
|
|
if fs.exists(fpath) then
|
|
table.insert(next_paths, hit)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
paths = next_paths
|
|
if not next(paths) then
|
|
-- if no next_paths were hit here, the ENTIRE glob value is not a path
|
|
return {eword.txt}
|
|
end
|
|
relative_separator = "/"
|
|
end
|
|
return paths
|
|
end
|
|
|
|
function sh.getMatchingPrograms(baseName)
|
|
if not baseName or baseName == "" then return {} end
|
|
local result = {}
|
|
local result_keys = {} -- cache for fast value lookup
|
|
local function check(key)
|
|
if key:find(baseName, 1, true) == 1 and not result_keys[key] then
|
|
table.insert(result, key)
|
|
result_keys[key] = true
|
|
end
|
|
end
|
|
for alias in shell.aliases() do
|
|
check(alias)
|
|
end
|
|
for basePath in string.gmatch(os.getenv("PATH"), "[^:]+") do
|
|
for file in fs.list(shell.resolve(basePath)) do
|
|
check(file:gsub("%.lua$", ""))
|
|
end
|
|
end
|
|
return result
|
|
end
|
|
|
|
function sh.getMatchingFiles(partial_path)
|
|
-- name: text of the partial file name being expanded
|
|
local name = partial_path:gsub("^.*/", "")
|
|
-- here we remove the name text from the partialPrefix
|
|
local basePath = unicode.sub(partial_path, 1, -unicode.len(name) - 1)
|
|
|
|
local resolvedPath = shell.resolve(basePath)
|
|
local result, baseName = {}
|
|
|
|
-- note: we strip the trailing / to make it easier to navigate through
|
|
-- directories using tab completion (since entering the / will then serve
|
|
-- as the intention to go into the currently hinted one).
|
|
-- if we have a directory but no trailing slash there may be alternatives
|
|
-- on the same level, so don't look inside that directory... (cont.)
|
|
if fs.isDirectory(resolvedPath) and name == "" then
|
|
baseName = "^(.-)/?$"
|
|
else
|
|
baseName = "^(" .. text.escapeMagic(name) .. ".-)/?$"
|
|
end
|
|
|
|
for file in fs.list(resolvedPath) do
|
|
local match = file:match(baseName)
|
|
if match then
|
|
table.insert(result, basePath .. match:gsub("(%s)", "\\%1"))
|
|
end
|
|
end
|
|
-- (cont.) but if there's only one match and it's a directory, *then* we
|
|
-- do want to add the trailing slash here.
|
|
if #result == 1 and fs.isDirectory(shell.resolve(result[1])) then
|
|
result[1] = result[1] .. "/"
|
|
end
|
|
return result
|
|
end
|
|
|
|
function sh.internal.hintHandlerSplit(line)
|
|
-- I do not plan on having text tokenizer parse error on
|
|
-- trailiing \ in case of future support for multiple line
|
|
-- input. But, there are also no hints for it
|
|
if line:match("\\$") then return nil end
|
|
|
|
local splits = text.internal.tokenize(line,{show_escapes=true})
|
|
if not splits then -- parse error, e.g. unclosed quotes
|
|
return nil -- no split, no hints
|
|
end
|
|
|
|
local num_splits = #splits
|
|
|
|
-- search for last statement delimiters
|
|
local last_close = 0
|
|
for index = num_splits, 1, -1 do
|
|
local word = splits[index]
|
|
if isWordOf(word, {";","&&","||","|"}) then
|
|
last_close = index
|
|
break
|
|
end
|
|
end
|
|
|
|
-- if the very last word of the line is a delimiter
|
|
-- consider this a fresh new, empty line
|
|
-- this captures edge cases with empty input as well (i.e. no splits)
|
|
if last_close == num_splits then
|
|
return nil -- no hints on empty command
|
|
end
|
|
|
|
local last_word = splits[num_splits]
|
|
local normal = text.internal.normalize({last_word})[1]
|
|
|
|
-- if there is white space following the words
|
|
-- and we have at least one word following the last delimiter
|
|
-- then in all cases we are looking for ANY arg
|
|
if unicode.sub(line, -unicode.len(normal)) ~= normal then
|
|
return line, nil, ""
|
|
end
|
|
|
|
local prefix = unicode.sub(line, 1, -unicode.len(normal) - 1)
|
|
|
|
-- renormlizing the string will create 'printed' quality text
|
|
normal = text.internal.normalize(text.internal.tokenize(normal), true)[1]
|
|
|
|
-- one word: cmd
|
|
-- many: arg
|
|
if last_close == num_splits - 1 then
|
|
return prefix, normal, nil
|
|
else
|
|
return prefix, nil, normal
|
|
end
|
|
end
|
|
|
|
function sh.internal.hintHandlerImpl(full_line, cursor)
|
|
-- line: text preceding the cursor: we want to hint this part (expand it)
|
|
local line = unicode.sub(full_line, 1, cursor - 1)
|
|
-- suffix: text following the cursor (if any, else empty string) to append to the hints
|
|
local suffix = unicode.sub(full_line, cursor)
|
|
|
|
-- hintHandlerSplit helps make the hints work even after delimiters such as ;
|
|
-- it also catches parse errors such as unclosed quotes
|
|
-- prev: not needed for this hint
|
|
-- cmd: the command needing hint
|
|
-- arg: the argument needing hint
|
|
local prev, cmd, arg = sh.internal.hintHandlerSplit(line)
|
|
|
|
-- also, if there is no text to hint, there are no hints
|
|
if not prev then -- no hints e.g. unclosed quote, e.g. no text
|
|
return {}
|
|
end
|
|
local result
|
|
|
|
local searchInPath = cmd and not cmd:find("/")
|
|
if searchInPath then
|
|
result = sh.getMatchingPrograms(cmd)
|
|
else
|
|
-- special arg issue, after equal sign
|
|
if arg then
|
|
local equal_index = arg:find("=[^=]*$")
|
|
if equal_index then
|
|
prev = prev .. unicode.sub(arg, 1, equal_index)
|
|
arg = unicode.sub(arg, equal_index + 1)
|
|
end
|
|
end
|
|
result = sh.getMatchingFiles(cmd or arg)
|
|
end
|
|
|
|
-- in very special cases, the suffix should include a blank space to indicate to the user that the hint is discrete
|
|
local resultSuffix = suffix
|
|
if #result > 0 and unicode.sub(result[1], -1) ~= "/" and
|
|
not suffix:sub(1,1):find('%s') and
|
|
#result == 1 or searchInPath then
|
|
resultSuffix = " " .. resultSuffix
|
|
end
|
|
|
|
table.sort(result)
|
|
for i = 1, #result do
|
|
-- the hints define the whole line of text
|
|
result[i] = prev .. result[i] .. resultSuffix
|
|
end
|
|
return result
|
|
end
|
|
|
|
-- verifies that no pipes are doubled up nor at the start nor end of words
|
|
function sh.internal.hasValidPiping(words, pipes)
|
|
checkArg(1, words, "table")
|
|
checkArg(2, pipes, "table", "nil")
|
|
|
|
if #words == 0 then
|
|
return true
|
|
end
|
|
|
|
local semi_split = tx.first(text.syntax, {{";"}}) -- symbols before ; are redirects and follow slightly different rules, see buildCommandRedirects
|
|
pipes = pipes or tx.sub(text.syntax, semi_split + 1)
|
|
|
|
local state = "" -- cannot start on a pipe
|
|
|
|
for w=1,#words do
|
|
local word = words[w]
|
|
for p=1,#word do
|
|
local part = word[p]
|
|
if part.qr then
|
|
state = nil
|
|
elseif part.txt == "" then
|
|
state = nil -- not sure how this is possible (empty part without quotes?)
|
|
elseif #text.split(part.txt, pipes, true) == 0 then
|
|
local prev = state
|
|
state = part.txt
|
|
if prev then -- cannot have two pipes in a row
|
|
word = nil
|
|
break
|
|
end
|
|
else
|
|
state = nil
|
|
end
|
|
end
|
|
if not word then -- bad pipe
|
|
break
|
|
end
|
|
end
|
|
|
|
if state then
|
|
return false, "syntax error near unexpected token " .. state
|
|
else
|
|
return true
|
|
end
|
|
end
|
|
|
|
function sh.internal.boolean_executor(chains, predicator)
|
|
local function not_gate(result, reason)
|
|
return sh.internal.command_passed(result) and 1 or 0, reason
|
|
end
|
|
|
|
local last = true
|
|
local last_reason
|
|
local boolean_stage = 1
|
|
local negation_stage = 2
|
|
local command_stage = 0
|
|
local stage = negation_stage
|
|
local skip = false
|
|
|
|
for ci=1,#chains do
|
|
local next = chains[ci]
|
|
local single = #next == 1 and #next[1] == 1 and not next[1][1].qr and next[1][1].txt
|
|
|
|
if single == "||" then
|
|
if stage ~= command_stage or #chains == 0 then
|
|
return nil, "syntax error near unexpected token '"..single.."'"
|
|
end
|
|
if sh.internal.command_passed(last) then
|
|
skip = true
|
|
end
|
|
stage = boolean_stage
|
|
elseif single == "&&" then
|
|
if stage ~= command_stage or #chains == 0 then
|
|
return nil, "syntax error near unexpected token '"..single.."'"
|
|
end
|
|
if not sh.internal.command_passed(last) then
|
|
skip = true
|
|
end
|
|
stage = boolean_stage
|
|
elseif not skip then
|
|
local chomped = #next
|
|
local negate = sh.internal.remove_negation(next)
|
|
chomped = chomped ~= #next
|
|
if negate then
|
|
local prev = predicator
|
|
predicator = function(n,i)
|
|
local result, reason = not_gate(prev(n,i))
|
|
predicator = prev
|
|
return result, reason
|
|
end
|
|
end
|
|
if chomped then
|
|
stage = negation_stage
|
|
end
|
|
if #next > 0 then
|
|
last, last_reason = predicator(next,ci)
|
|
stage = command_stage
|
|
end
|
|
else
|
|
skip = false
|
|
stage = command_stage
|
|
end
|
|
end
|
|
|
|
if stage == negation_stage then
|
|
last = not_gate(last)
|
|
end
|
|
|
|
return last, last_reason
|
|
end
|
|
|
|
function sh.internal.splitStatements(words, semicolon)
|
|
checkArg(1, words, "table")
|
|
checkArg(2, semicolon, "string", "nil")
|
|
semicolon = semicolon or ";"
|
|
|
|
return tx.partition(words, function(g, i)
|
|
if isWordOf(g, {semicolon}) then
|
|
return i, i
|
|
end
|
|
end, true)
|
|
end
|
|
|
|
function sh.internal.splitChains(s,pc)
|
|
checkArg(1, s, "table")
|
|
checkArg(2, pc, "string", "nil")
|
|
pc = pc or "|"
|
|
return tx.partition(s, function(w)
|
|
-- each word has multiple parts due to quotes
|
|
if isWordOf(w, {pc}) then
|
|
return true
|
|
end
|
|
end, true) -- drop |s
|
|
end
|
|
|
|
function sh.internal.groupChains(s)
|
|
checkArg(1,s,"table")
|
|
return tx.partition(s,function(w)return isWordOf(w,{"&&","||"})end)
|
|
end
|
|
|
|
function sh.internal.remove_negation(chain)
|
|
if isWordOf(chain[1], {"!"}) then
|
|
table.remove(chain, 1)
|
|
return not sh.internal.remove_negation(chain)
|
|
end
|
|
return false
|
|
end
|
|
|
|
function sh.internal.execute_complex(words, eargs, env)
|
|
-- we shall validate pipes before any statement execution
|
|
local statements = sh.internal.splitStatements(words)
|
|
for i=1,#statements do
|
|
local ok, why = sh.internal.hasValidPiping(statements[i])
|
|
if not ok then return nil,why end
|
|
end
|
|
|
|
for si=1,#statements do local s = statements[si]
|
|
local chains = sh.internal.groupChains(s)
|
|
local last_code, reason = sh.internal.boolean_executor(chains, function(chain, chain_index)
|
|
local pipe_parts = sh.internal.splitChains(chain)
|
|
local next_args = chain_index == #chains and si == #statements and eargs or {}
|
|
return sh.internal.executePipes(pipe_parts, next_args, env)
|
|
end)
|
|
sh.internal.ec.last = sh.internal.command_result_as_code(last_code, reason)
|
|
end
|
|
return sh.internal.ec.last == 0
|
|
end
|
|
|
|
-- params: words[tokenized word list]
|
|
-- return: command args, redirects
|
|
function sh.internal.evaluate(words)
|
|
local redirects, why = sh.internal.buildCommandRedirects(words)
|
|
if not redirects then
|
|
return nil, why
|
|
end
|
|
|
|
do
|
|
local normalized = text.internal.normalize(words)
|
|
local command_text = table.concat(normalized, " ")
|
|
local subbed = sh.internal.parse_sub(command_text)
|
|
if subbed ~= command_text then
|
|
words = text.internal.tokenize(subbed)
|
|
end
|
|
end
|
|
|
|
local repack = false
|
|
for _, word in ipairs(words) do
|
|
for _, part in pairs(word) do
|
|
if not (part.qr or {})[3] then
|
|
local expanded = sh.expand(part.txt)
|
|
if expanded ~= part.txt then
|
|
part.txt = expanded
|
|
repack = true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if repack then
|
|
local normalized = text.internal.normalize(words)
|
|
local command_text = table.concat(normalized, " ")
|
|
words = text.internal.tokenize(command_text)
|
|
end
|
|
|
|
local args = {}
|
|
for _, word in ipairs(words) do
|
|
local eword = { txt = "" }
|
|
for _, part in ipairs(word) do
|
|
eword.txt = eword.txt .. part.txt
|
|
eword[#eword + 1] = { qr = part.qr, txt = part.txt }
|
|
end
|
|
for _, arg in ipairs(sh.internal.glob(eword)) do
|
|
args[#args + 1] = arg
|
|
end
|
|
end
|
|
|
|
return args, redirects
|
|
end
|
|
|
|
function sh.internal.parse_sub(input, quotes)
|
|
-- unquoted command substituted text is parsed as individual parameters
|
|
-- there is not a concept of "keeping whitespace" as previously thought
|
|
-- we see removal of whitespace only because they are separate arguments
|
|
-- e.g. /echo `echo a b`/ becomes /echo a b/ quite literally, and the a and b are separate inputs
|
|
-- e.g. /echo a"`echo b c`"d/ becomes /echo a"b c"d/ which is a single input
|
|
|
|
if quotes and quotes[1] == '`' then
|
|
input = string.format("`%s`", input)
|
|
quotes[1], quotes[2] = "", "" -- substitution removes the quotes
|
|
end
|
|
|
|
-- cannot use gsub here becuase it is a [C] call, and io.popen needs to yield at times
|
|
local packed = {}
|
|
-- not using for i... because i can skip ahead
|
|
local i, len = 1, #input
|
|
|
|
while i <= len do
|
|
local fi, si, capture = input:find("`([^`]*)`", i)
|
|
|
|
if not fi then
|
|
table.insert(packed, input:sub(i))
|
|
break
|
|
end
|
|
table.insert(packed, input:sub(i, fi - 1))
|
|
|
|
local sub = io.popen(capture)
|
|
local result = sub:read("*a")
|
|
sub:close()
|
|
|
|
-- command substitution cuts trailing newlines
|
|
table.insert(packed, (result:gsub("\n+$","")))
|
|
i = si+1
|
|
end
|
|
|
|
return table.concat(packed)
|
|
end
|
|
|
|
|