diff --git a/luash b/luash index 6a61184..77f6b3d 100755 --- a/luash +++ b/luash @@ -1,21 +1,33 @@ #!/usr/bin/env lua -- luash: Minimal Lua shell scripting preprocessor --- Load injected library into global scope for REPL +-- Module path for local src/ +package.path = (debug.getinfo(1, "S").source:match("@(.*/)") or "./") .. "src/?.lua;" .. package.path + +-- Load injected library via module local function load_injected_lib() - local injected_lib_path = debug.getinfo(1, "S").source:match("@(.*/)") or "./" - local lib_file = io.open(injected_lib_path .. "__injected_lib.lua", "r") - if lib_file then - local lib_code = lib_file:read("*a") - lib_file:close() - local lib_func = load(lib_code) - if lib_func then lib_func() end - end + local injected = require('injected') + injected.load() end -- REPL (Interactive Shell) Implementation function start_repl() load_injected_lib() + local repl_mod = require('repl') + + -- Preprocessing functions (simplified versions for REPL) + local function process_env_vars_repl(code) + local pre = require('preprocess') + return pre.process_env_vars(code) + end + local function process_shell_commands_repl(code) + local pre = require('preprocess') + return pre.process_shell_commands(code) + end + local function process_interactive_commands_repl(code) + local pre = require('preprocess') + return pre.process_interactive_commands(code) + end -- Preprocessing functions (simplified versions for REPL) local function process_env_vars_repl(code) @@ -133,12 +145,8 @@ function start_repl() -- Luash preprocessing function for REPL local function preprocess_luash_repl(code) - -- Remove shebang line if present and preserve line numbers - local has_shebang = code:match("^#!") - code = code:gsub("^#![^\r\n]*[\r\n]?", "") - if has_shebang then - code = "\n" .. code - end + local pre = require('preprocess') + code = pre.remove_shebang_preserve_lines(code) code = process_env_vars_repl(code) code = process_interactive_commands_repl(code) code = process_shell_commands_repl(code) @@ -210,218 +218,8 @@ function start_repl() return s:match("^%s*(.-)%s*$") end - -- Raw mode (optional) for immediate key handling like Ctrl+L - local function detect_tty() - local r = os.execute("test -t 0 >/dev/null 2>&1") - if type(r) == "number" then return r == 0 end - return r == true - end - local env_raw = os.getenv("LUASH_RAW") - local raw_mode_enabled = (env_raw == "1") or (env_raw == nil and detect_tty()) - - local function set_raw_mode(enable) - if enable then - os.execute("stty -echo -icanon -isig min 1 time 0") - else - os.execute("stty sane") - end - end - - local function raw_read_line(prompt) - io.write(prompt) - io.flush() - local buf = {} - while true do - local ch = io.read(1) - if not ch then - return nil - end - local b = string.byte(ch) - if b == 10 or b == 13 then -- Enter - io.write("\n") - return table.concat(buf) - elseif b == 12 then -- Ctrl+L - -- Clear screen and reprint banner + prompt + current buffer - repl.buffer = table.concat(buf) - io.write("\27[2J\27[H") - io.flush() - print("🚀 Luash Interactive Shell") - print("Type Lua expressions, use $VAR for env vars, `commands` for shell") - print("Special commands: .help .exit .clear .history (Ctrl+L to clear, Ctrl+D to exit)") - print("") - io.write(prompt) - io.write(repl.buffer) - io.flush() - -- Keep current input for usability, but allow immediate Ctrl+D by not forcing buf non-empty - -- buf remains as-is - elseif b == 3 then -- Ctrl+C - return "__CTRL_C__" - elseif b == 4 then -- Ctrl+D - -- Exit immediately (treat as EOF) regardless of current buffer state - return nil - elseif b == 127 or b == 8 then -- Backspace/Delete - if #buf > 0 then - table.remove(buf) - io.write("\b \b") - io.flush() - end - else - table.insert(buf, ch) - io.write(ch) - io.flush() - end - end - end - - -- Main REPL loop - print("🚀 Luash Interactive Shell") - print("Type Lua expressions, use $VAR for env vars, `commands` for shell") - print("Special commands: .help .exit .clear .history (Ctrl+L to clear, Ctrl+D to exit)") - print("") - - if raw_mode_enabled then set_raw_mode(true) end - local ok_loop, loop_err = pcall(function() - while true do - local prompt = repl.buffer == "" and "luash> " or " >> " - local line - if raw_mode_enabled then - line = raw_read_line(prompt) - if line == "__CTRL_C__" then - print("\nInterrupted (Ctrl+C)") - break - end - else - io.write(prompt) - io.flush() - -- Safely read a line; handle Ctrl+C gracefully - local ok, l = pcall(io.read, "*line") - if not ok then - print("\nInterrupted (Ctrl+C)") - break - end - line = l - end - - if not line then -- EOF (Ctrl+D) - print("\nGoodbye!") - break - end - - -- Handle Ctrl+L (form feed) to clear screen in non-raw mode - if (not raw_mode_enabled) and line:find(string.char(12), 1, true) then - repl.buffer = "" - io.write("\27[2J\27[H") -- Clear screen and move cursor to top - io.flush() - print("🚀 Luash Interactive Shell") - print("Type Lua expressions, use $VAR for env vars, `commands` for shell") - print("Special commands: .help .exit .clear .history (Ctrl+L to clear, Ctrl+D to exit)") - print("") - goto continue - end - - line = trim(line) - - -- Handle special dot commands - if line == ".exit" or line == ".quit" then - break - elseif line == ".clear" then - repl.buffer = "" - print("Buffer cleared") - elseif line == ".history" then - for i, cmd in ipairs(repl.history) do - print(string.format("%3d: %s", i, cmd)) - end - elseif line == ".help" then - print([[ -Luash Interactive Shell Help: - -Luash Features: - $VAR - Access environment variables - $VAR = "value" - Set environment variables - `command` - Execute shell commands - #{var} - Variable interpolation in commands - !command - Interactive shell commands - -Special Commands: - .help - Show this help - .exit, .quit - Exit the shell - .clear - Clear input buffer - .history - Show command history - -Examples: - print("Hello " .. $USER) - files = `ls -la` - $GREETING = "Hello World" - result = `echo #{$GREETING}` - !ps aux | head -5 - -Multi-line input supported for functions, loops, etc.]]) - else - -- Add to buffer - if repl.buffer ~= "" then - repl.buffer = repl.buffer .. "\n" .. line - else - repl.buffer = line - end - - -- Try to compile to determine if we need continuation (parser-driven) - if line ~= "" then - local processed = preprocess_luash_repl(repl.buffer) - - -- First, try as an expression (so results print automatically) - local func, err = load("return " .. processed, "@repl") - if not func then - if err and err:match("") then - -- Incomplete chunk; wait for more input - -- keep buffer as-is - else - -- Try as a statement block - func, err = load(processed, "@repl") - if not func then - if err and err:match("") then - -- Incomplete; wait for more input - else - -- Definitive syntax error; report and clear buffer - print("Syntax error: " .. err) - repl.buffer = "" - end - else - -- Executable statement block - table.insert(repl.history, repl.buffer) - local success, result = pcall(func) - if success then - if result ~= nil then - print(tostring(result)) - end - else - print("Error: " .. tostring(result)) - end - repl.buffer = "" - end - end - else - -- Executable expression - table.insert(repl.history, repl.buffer) - local success, result = pcall(func) - if success then - if result ~= nil then - print(tostring(result)) - end - else - print("Error: " .. tostring(result)) - end - repl.buffer = "" - end - end - end - ::continue:: - end - end) - if raw_mode_enabled then set_raw_mode(false) end - if not ok_loop then - if raw_mode_enabled then set_raw_mode(false) end - print("\nREPL error: " .. tostring(loop_err)) - end + -- Delegate to module REPL + repl_mod.start(preprocess_luash_repl, load_injected_lib) end -- 1. Check for an input file or REPL mode @@ -436,201 +234,7 @@ elseif not filename or filename == "-i" or filename == "--interactive" then start_repl() return end - -local file = io.open(filename, "r") -if not file then - print("Error: Cannot open file '" .. filename .. "'") - return -end -local source_code = file:read("*a") -file:close() - --- Remove shebang line if present and preserve line numbers -local has_shebang = source_code:match("^#!") -source_code = source_code:gsub("^#![^\r\n]*[\r\n]?", "") - --- If we removed a shebang, add an empty line to preserve line numbers -if has_shebang then - source_code = "\n" .. source_code -end - - - --- Preprocessing functions -local function process_env_vars(code) -local lines = {} - for line in code:gmatch("([^\r\n]*)") do - if not line:match("^%s*%-%-") then -- Skip comments - - -- Handle environment variable assignment: $VAR=value - if line:match("^%$([%w_]+)%s*=") then - line = line:gsub("^%$([%w_]+)%s*=%s*(.+)", function(var, value) - return "env_set('" .. var .. "', " .. value .. ")" - end) - else - -- Handle $VAR substitution, but not inside strings - local in_string = false - local quote_char = nil - local result = "" - local i = 1 - - while i <= #line do - local char = line:sub(i, i) - - -- Track string boundaries - if (char == '"' or char == "'") and (i == 1 or line:sub(i-1, i-1) ~= "\\") then - if not in_string then - in_string = true - quote_char = char - elseif char == quote_char then - in_string = false - quote_char = nil - end - result = result .. char - i = i + 1 - elseif char == "$" and not in_string then - -- Found $ outside of string, check for variable pattern - local var_match = line:sub(i):match("^%$([%w_]+)") - if var_match then - result = result .. "env_get('" .. var_match .. "')" - i = i + #var_match + 1 - else - result = result .. char - i = i + 1 - end - else - result = result .. char - i = i + 1 - end - end - - line = result - end - end - table.insert(lines, line) - end - - return table.concat(lines, "\n") -end - -local function process_shell_commands(code) - local lines = {} - for line in code:gmatch("([^\r\n]*)") do - -- Skip processing backticks inside strings - local in_string = false - local quote_char = nil - local result = "" - local i = 1 - - while i <= #line do - local char = line:sub(i, i) - - -- Track string boundaries - if (char == '"' or char == "'") and (i == 1 or line:sub(i-1, i-1) ~= "\\") then - if not in_string then - in_string = true - quote_char = char - elseif char == quote_char then - in_string = false - quote_char = nil - end - result = result .. char - i = i + 1 - elseif char == "`" and not in_string then - -- Found backtick outside of string, find the closing backtick - local end_pos = line:find("`", i + 1) - if end_pos then - local command = line:sub(i + 1, end_pos - 1) - - -- Handle variable interpolation with #{var} syntax - if command:find("#{") then - -- Escape existing quotes first, then do interpolation - local escaped_cmd = command:gsub('"', '\\"') - local interpolated_cmd = escaped_cmd:gsub("#{([%w_.]+)}", '" .. tostring(%1) .. "') - result = result .. 'shell("' .. interpolated_cmd .. '")' - else - result = result .. 'shell("' .. command:gsub('"', '\\"') .. '")' - end - i = end_pos + 1 - else - result = result .. char - i = i + 1 - end - else - result = result .. char - i = i + 1 - end - end - - table.insert(lines, result) - end - - return table.concat(lines, "\n") -end - -local function process_interactive_commands(code) - local lines = {} - for line in code:gmatch("([^\r\n]*)") do - -- Only match ! at the beginning of the line (ignoring whitespace) - local command = line:match("^%s*!(.*)") - if command then - local indent = line:match("^(%s*)") - table.insert(lines, indent .. 'run("' .. command:gsub('"', '\\"') .. '")') - else - table.insert(lines, line) - end - end - return table.concat(lines, "\n") -end - --- Load injected helper functions (minimal) -local injected_lib_path = debug.getinfo(1, "S").source:match("@(.*/)") or "./" -local lib_file = io.open(injected_lib_path .. "__injected_lib.lua", "r") -local injected_lib = "" -if lib_file then - injected_lib = lib_file:read("*a") - lib_file:close() -end - --- Apply preprocessing -source_code = process_env_vars(source_code) -source_code = process_interactive_commands(source_code) -source_code = process_shell_commands(source_code) - --- Debug output -if os.getenv("LUASH_DEBUG") then - print("-- Generated Lua code:") - print(source_code) - print("-- End generated code") -end - --- Pre-load injected library into global environment -if injected_lib ~= "" then - local lib_func, lib_err = load(injected_lib, "@__injected_lib.lua") - if lib_func then - local success, result = pcall(lib_func) - if not success then - print("--- ERROR loading injected library ---") - print(result) - return - end - else - print("--- SYNTAX ERROR in __injected_lib.lua ---") - print(lib_err) - return - end -end - --- Execute the user's source code (with correct line numbers) -local func, err = load(source_code, "@" .. filename) -if not func then - print("--- SYNTAX ERROR in " .. filename .. " ---") - print(err) -else - local success, result = pcall(func) - if not success then - print("--- RUNTIME ERROR in " .. filename .. " ---") - print(result) - end -end +-- Delegate to runner for file execution +local runner = require('runner') +runner.run_file(filename) diff --git a/src/injected.lua b/src/injected.lua new file mode 100644 index 0000000..1604965 --- /dev/null +++ b/src/injected.lua @@ -0,0 +1,24 @@ +local M = {} + +function M.load() + local base = debug.getinfo(1, "S").source:match("@(.*/)") or "./" + base = base:gsub("/src/?$", "/") + local lib_file = io.open(base .. "src/lib/injected_lib.lua", "r") + if lib_file then + local lib_code = lib_file:read("*a") + lib_file:close() + local lib_func, err = load(lib_code, "@__injected_lib.lua") + if lib_func then + local ok, res = pcall(lib_func) + if not ok then + error("Error loading injected lib: " .. tostring(res)) + end + else + error("Syntax error in injected lib: " .. tostring(err)) + end + end +end + +return M + + diff --git a/__injected_lib.lua b/src/lib/injected_lib.lua similarity index 100% rename from __injected_lib.lua rename to src/lib/injected_lib.lua diff --git a/src/main.lua b/src/main.lua new file mode 100644 index 0000000..64d92d0 --- /dev/null +++ b/src/main.lua @@ -0,0 +1,35 @@ +local runner = require('runner') + +local M = {} + +function M.run(argv) + local filename = argv[1] + if filename == "-h" or filename == "--help" then + print("Usage: luash ") + print(" luash -i # Interactive mode") + print(" luash -h # Show this help") + return + elseif not filename or filename == "-i" or filename == "--interactive" then + local entry = require('repl') + local preprocess = require('preprocess') + local injected = require('injected') + local function preprocess_luash_repl(code) + code = preprocess.remove_shebang_preserve_lines(code) + code = preprocess.process_env_vars(code) + code = preprocess.process_interactive_commands(code) + code = preprocess.process_shell_commands(code) + return code + end + local function load_injected_lib() + injected.load() + end + entry.start(preprocess_luash_repl, load_injected_lib) + return + else + runner.run_file(filename) + end +end + +return M + + diff --git a/src/preprocess.lua b/src/preprocess.lua new file mode 100644 index 0000000..e98521a --- /dev/null +++ b/src/preprocess.lua @@ -0,0 +1,120 @@ +local M = {} + +function M.remove_shebang_preserve_lines(source_code) + local has_shebang = source_code:match("^#!") + source_code = source_code:gsub("^#![^\r\n]*[\r\n]?", "") + if has_shebang then + source_code = "\n" .. source_code + end + return source_code +end + +function M.process_env_vars(code) + local lines = {} + for line in code:gmatch("([^\r\n]*)") do + if not line:match("^%s*%-%-") then + if line:match("^%$([%w_]+)%s*=") then + line = line:gsub("^%$([%w_]+)%s*=%s*(.+)", function(var, value) + return "env_set('" .. var .. "', " .. value .. ")" + end) + else + local in_string = false + local quote_char = nil + local result = "" + local i = 1 + while i <= #line do + local char = line:sub(i, i) + if (char == '"' or char == "'") and (i == 1 or line:sub(i-1, i-1) ~= "\\") then + if not in_string then + in_string = true + quote_char = char + elseif char == quote_char then + in_string = false + quote_char = nil + end + result = result .. char + i = i + 1 + elseif char == "$" and not in_string then + local var_match = line:sub(i):match("^%$([%w_]+)") + if var_match then + result = result .. "env_get('" .. var_match .. "')" + i = i + #var_match + 1 + else + result = result .. char + i = i + 1 + end + else + result = result .. char + i = i + 1 + end + end + line = result + end + end + table.insert(lines, line) + end + return table.concat(lines, "\n") +end + +function M.process_interactive_commands(code) + local lines = {} + for line in code:gmatch("([^\r\n]*)") do + local command = line:match("^%s*!(.*)") + if command then + local indent = line:match("^(%s*)") + table.insert(lines, indent .. 'run("' .. command:gsub('"', '\\"') .. '")') + else + table.insert(lines, line) + end + end + return table.concat(lines, "\n") +end + +function M.process_shell_commands(code) + local lines = {} + for line in code:gmatch("([^\r\n]*)") do + local in_string = false + local quote_char = nil + local result = "" + local i = 1 + while i <= #line do + local char = line:sub(i, i) + if (char == '"' or char == "'") and (i == 1 or line:sub(i-1, i-1) ~= "\\") then + if not in_string then + in_string = true + quote_char = char + elseif char == quote_char then + in_string = false + quote_char = nil + end + result = result .. char + i = i + 1 + elseif char == "`" and not in_string then + local end_pos = line:find("`", i + 1) + if end_pos then + local command = line:sub(i + 1, end_pos - 1) + if command:find("#{") then + local escaped_cmd = command:gsub('"', '\\"') + local interpolated_cmd = escaped_cmd:gsub("#{([%w_.]+)}", '" .. tostring(%1) .. "') + result = result .. 'shell("' .. interpolated_cmd .. '")' + else + result = result .. 'shell("' .. command:gsub('"', '\\"') .. '")' + end + i = end_pos + 1 + else + result = result .. char + i = i + 1 + end + else + result = result .. char + i = i + 1 + end + end + table.insert(lines, result) + end + return table.concat(lines, "\n") +end + +return M + + diff --git a/src/repl.lua b/src/repl.lua new file mode 100644 index 0000000..410a977 --- /dev/null +++ b/src/repl.lua @@ -0,0 +1,130 @@ +local M = {} + +function M.start(preprocess_fn, load_injected_lib) + load_injected_lib() + +local util = require('util') +local function detect_tty() return util.is_tty() end + local env_raw = os.getenv("LUASH_RAW") + local raw_mode_enabled = (env_raw == "1") or (env_raw == nil and detect_tty()) + + local repl = { buffer = "", history = {}, history_index = 0 } + + local function set_raw_mode(enable) util.set_raw_mode(enable) end + + local function trim(s) return util.trim(s) end + + local function raw_read_line(prompt) + io.write(prompt); io.flush() + local buf = {} + while true do + local ch = io.read(1) + if not ch then return nil end + local b = string.byte(ch) + if b == 10 or b == 13 then io.write("\n"); return table.concat(buf) + elseif b == 12 then -- Ctrl+L + repl.buffer = table.concat(buf) + io.write("\27[2J\27[H"); io.flush() + print("🚀 Luash Interactive Shell") + print("Type Lua expressions, use $VAR for env vars, `commands` for shell") + print("Special commands: .help .exit .clear .history (Ctrl+L to clear, Ctrl+D to exit)") + print("") + io.write(prompt); io.write(repl.buffer); io.flush() + elseif b == 3 then return "__CTRL_C__" + elseif b == 4 then return nil -- Ctrl+D as EOF + elseif b == 127 or b == 8 then if #buf > 0 then table.remove(buf); io.write("\b \b"); io.flush() end + else table.insert(buf, ch); io.write(ch); io.flush() end + end + end + + print("🚀 Luash Interactive Shell") + print("Type Lua expressions, use $VAR for env vars, `commands` for shell") + print("Special commands: .help .exit .clear .history (Ctrl+L to clear, Ctrl+D to exit)") + print("") + + if raw_mode_enabled then set_raw_mode(true) end + local ok_loop, loop_err = pcall(function() + while true do + local prompt = repl.buffer == "" and "luash> " or " >> " + local line + if raw_mode_enabled then + line = raw_read_line(prompt) + if line == "__CTRL_C__" then print("\nInterrupted (Ctrl+C)"); break end + else + io.write(prompt); io.flush() + local ok, l = pcall(io.read, "*line") + if not ok then print("\nInterrupted (Ctrl+C)"); break end + line = l + end + if not line then print("\nGoodbye!"); break end + + if (not raw_mode_enabled) and line:find(string.char(12), 1, true) then + repl.buffer = ""; io.write("\27[2J\27[H"); io.flush() + print("🚀 Luash Interactive Shell") + print("Type Lua expressions, use $VAR for env vars, `commands` for shell") + print("Special commands: .help .exit .clear .history (Ctrl+L to clear, Ctrl+D to exit)") + print(""); goto continue + end + + line = trim(line) + if line == ".exit" or line == ".quit" then break + elseif line == ".clear" then repl.buffer = ""; print("Buffer cleared") + elseif line == ".history" then for i, cmd in ipairs(repl.history) do print(string.format("%3d: %s", i, cmd)) end + elseif line == ".help" then print([[Luash Interactive Shell Help: + +Luash Features: + $VAR - Access environment variables + $VAR = "value" - Set environment variables + `command` - Execute shell commands + #{var} - Variable interpolation in commands + !command - Interactive shell commands + +Special Commands: + .help - Show this help + .exit, .quit - Exit the shell + .clear - Clear input buffer + .history - Show command history + +Multi-line input supported for functions, loops, etc.]]) + else + if repl.buffer ~= "" then repl.buffer = repl.buffer .. "\n" .. line else repl.buffer = line end + + if line ~= "" then + local processed = preprocess_fn(repl.buffer) + local func, err = load("return " .. processed, "@repl") + if not func then + if err and err:match("") then + -- incomplete + else + func, err = load(processed, "@repl") + if not func then + if err and err:match("") then + -- incomplete + else + print("Syntax error: " .. err); repl.buffer = "" + end + else + table.insert(repl.history, repl.buffer) + local success, result = pcall(func) + if success then if result ~= nil then print(tostring(result)) end else print("Error: " .. tostring(result)) end + repl.buffer = "" + end + end + else + table.insert(repl.history, repl.buffer) + local success, result = pcall(func) + if success then if result ~= nil then print(tostring(result)) end else print("Error: " .. tostring(result)) end + repl.buffer = "" + end + end + end + ::continue:: + end + end) + if raw_mode_enabled then set_raw_mode(false) end + if not ok_loop then if raw_mode_enabled then set_raw_mode(false) end; print("\nREPL error: " .. tostring(loop_err)) end +end + +return M + + diff --git a/src/runner.lua b/src/runner.lua new file mode 100644 index 0000000..7c5bfa9 --- /dev/null +++ b/src/runner.lua @@ -0,0 +1,39 @@ +local preprocess = require('preprocess') +local injected = require('injected') + +local M = {} + +function M.run_file(filename) + local file = io.open(filename, "r") + if not file then + print("Error: Cannot open file '" .. filename .. "'") + return + end + local source_code = file:read("*a") + file:close() + + source_code = preprocess.remove_shebang_preserve_lines(source_code) + source_code = preprocess.process_env_vars(source_code) + source_code = preprocess.process_interactive_commands(source_code) + source_code = preprocess.process_shell_commands(source_code) + + -- Load injected helpers into global scope (not prepended to source) + injected.load() + + -- Execute + local func, err = load(source_code, "@" .. filename) + if not func then + print("--- SYNTAX ERROR in " .. filename .. " ---") + print(err) + return + end + local success, result = pcall(func) + if not success then + print("--- RUNTIME ERROR in " .. filename .. " ---") + print(result) + end +end + +return M + + diff --git a/src/util.lua b/src/util.lua new file mode 100644 index 0000000..8edceb4 --- /dev/null +++ b/src/util.lua @@ -0,0 +1,27 @@ +local M = {} + +function M.trim(s) + return s:match("^%s*(.-)%s*$") +end + +function M.is_tty() + local r = os.execute("test -t 0 >/dev/null 2>&1") + if type(r) == "number" then return r == 0 end + return r == true +end + +function M.set_raw_mode(enable) + if enable then + os.execute("stty -echo -icanon -isig min 1 time 0") + else + os.execute("stty sane") + end +end + +function M.script_dir() + return (debug.getinfo(2, "S").source:match("@(.*/)") or "./") +end + +return M + +