From aefc44a058834c9a00a0442e4a1934eb13ac3127 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 26 Sep 2026 21:29:08 +0200 Subject: [PATCH 1/4] Shell parser improved Improve memory usage and parsing strategy. Fixes stack overflow. --- Documentation/ideas.md | 1 - Modules/app-module/source/stdio_wrap.cpp | 39 +- Tactility/Private/Tactility/app/shell/Shell.h | 5 +- .../Private/Tactility/app/shell/shell/sh.h | 17 +- .../Tactility/app/shell/shell/sh_arith.h | 8 + .../Tactility/app/shell/shell/sh_builtins.h | 9 + .../Tactility/app/shell/shell/sh_glob.h | 8 + .../Tactility/app/shell/shell/sh_lex.h | 8 + .../Tactility/app/shell/shell/sh_machine.h | 474 ++++++++ .../Tactility/app/shell/shell/sh_parse.h | 8 + .../Tactility/app/shell/shell/sh_port.h | 8 + Tactility/Source/app/shell/Run.cpp | 57 +- Tactility/Source/app/shell/Shell.cpp | 80 +- Tactility/Source/app/shell/main.cpp | 24 +- Tactility/Source/app/shell/shell/sh_arith.c | 309 ----- Tactility/Source/app/shell/shell/sh_arith.cpp | 404 +++++++ .../Source/app/shell/shell/sh_builtins.c | 203 ++-- Tactility/Source/app/shell/shell/sh_exec.c | 559 --------- Tactility/Source/app/shell/shell/sh_exec.cpp | 1066 +++++++++++++++++ Tactility/Source/app/shell/shell/sh_expand.c | 691 ----------- .../Source/app/shell/shell/sh_expand.cpp | 682 +++++++++++ Tactility/Source/app/shell/shell/sh_glob.c | 60 +- Tactility/Source/app/shell/shell/sh_lex.c | 62 +- Tactility/Source/app/shell/shell/sh_parse.c | 504 -------- Tactility/Source/app/shell/shell/sh_parse.cpp | 656 ++++++++++ Tactility/Tests/CMakeLists.txt | 5 +- Tactility/Tests/Scripts/shell_test.sh | 256 ++++ .../Tests/Source/ShellCompletionTest.cpp | 37 + .../Tests/Source/ShellInterpreterTest.cpp | 582 +++++++++ 29 files changed, 4518 insertions(+), 2304 deletions(-) create mode 100644 Tactility/Private/Tactility/app/shell/shell/sh_machine.h delete mode 100644 Tactility/Source/app/shell/shell/sh_arith.c create mode 100644 Tactility/Source/app/shell/shell/sh_arith.cpp delete mode 100644 Tactility/Source/app/shell/shell/sh_exec.c create mode 100644 Tactility/Source/app/shell/shell/sh_exec.cpp delete mode 100644 Tactility/Source/app/shell/shell/sh_expand.c create mode 100644 Tactility/Source/app/shell/shell/sh_expand.cpp delete mode 100644 Tactility/Source/app/shell/shell/sh_parse.c create mode 100644 Tactility/Source/app/shell/shell/sh_parse.cpp create mode 100644 Tactility/Tests/Scripts/shell_test.sh create mode 100644 Tactility/Tests/Source/ShellCompletionTest.cpp create mode 100644 Tactility/Tests/Source/ShellInterpreterTest.cpp diff --git a/Documentation/ideas.md b/Documentation/ideas.md index b052f38ec..9add85f69 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -27,7 +27,6 @@ - Improve Setup: Show "Step done" screen - Make it more clear to end-users that an SD card is required to run Tactility - Warn if file operations are done from prohibited tasks (e.g. lvgl task) -- Shell interpreter works recursive-descent algorithm, which requires a big stack size. Can we optimize this? - stopAppFromToolbar() in Tactility.cpp stops the top-most app. Change it so the toolbar knows for which app id it is created, so it can rely on that. - Consider not unpacking `.app` files and executing them directly. Might want to cache file offsets. Cache file must be pinned to app version. - lvgl-module's spinner relies on hard-coded spinner asset from Tactility main project. diff --git a/Modules/app-module/source/stdio_wrap.cpp b/Modules/app-module/source/stdio_wrap.cpp index 9be71b210..3be2ca77b 100644 --- a/Modules/app-module/source/stdio_wrap.cpp +++ b/Modules/app-module/source/stdio_wrap.cpp @@ -425,9 +425,16 @@ int readOneFromStdin(char& out) { return static_cast(app_io_read(STDIN_FILENO, &out, 1)); } +// The process' own streams, captured before anything can reassign stdin/stdout/stderr. A caller +// that points stdout at a file (e.g. the shell's redirection) must get real file I/O, so only these +// original streams are routed to the app's fds. +FILE* const originalStdin = stdin; +FILE* const originalStdout = stdout; +FILE* const originalStderr = stderr; + int targetFdOf(FILE* stream) { - if (stream == stdout) return STDOUT_FILENO; - if (stream == stderr) return STDERR_FILENO; + if (stream == originalStdout) return STDOUT_FILENO; + if (stream == originalStderr) return STDERR_FILENO; return -1; } @@ -435,14 +442,17 @@ int targetFdOf(FILE* stream) { extern "C" { +int __wrap_vfprintf(FILE* stream, const char* format, va_list args); +int __wrap_fputc(int c, FILE* stream); + int __wrap_vprintf(const char* format, va_list args) { - return formatTo(STDOUT_FILENO, format, args); + return __wrap_vfprintf(stdout, format, args); } int __wrap_printf(const char* format, ...) { va_list args; va_start(args, format); - int result = formatTo(STDOUT_FILENO, format, args); + int result = __wrap_vfprintf(stdout, format, args); va_end(args); return result; } @@ -465,6 +475,9 @@ int __wrap_fprintf(FILE* stream, const char* format, ...) { } int __wrap_puts(const char* s) { + if (targetFdOf(stdout) < 0) { + return __real_fputs(s, stdout) < 0 ? EOF : __real_fputc('\n', stdout); + } writeAllTo(STDOUT_FILENO, s, strlen(s)); writeAllTo(STDOUT_FILENO, "\n", 1); return 0; @@ -480,9 +493,7 @@ int __wrap_fputs(const char* s, FILE* stream) { } int __wrap_putchar(int c) { - auto ch = static_cast(c); - writeAllTo(STDOUT_FILENO, &ch, 1); - return c; + return __wrap_fputc(c, stdout); } int __wrap_fputc(int c, FILE* stream) { @@ -495,20 +506,20 @@ int __wrap_fputc(int c, FILE* stream) { return __real_fputc(c, stream); } -int __wrap_getchar() { +int __wrap_fgetc(FILE* stream) { + if (stream != originalStdin) { + return __real_fgetc(stream); + } char c; return readOneFromStdin(c) == 1 ? static_cast(c) : EOF; } -int __wrap_fgetc(FILE* stream) { - if (stream == stdin) { - return __wrap_getchar(); - } - return __real_fgetc(stream); +int __wrap_getchar() { + return __wrap_fgetc(stdin); } char* __wrap_fgets(char* buffer, int size, FILE* stream) { - if (stream != stdin) { + if (stream != originalStdin) { return __real_fgets(buffer, size, stream); } if (size <= 0) { diff --git a/Tactility/Private/Tactility/app/shell/Shell.h b/Tactility/Private/Tactility/app/shell/Shell.h index 6f982cc13..3a2015909 100644 --- a/Tactility/Private/Tactility/app/shell/Shell.h +++ b/Tactility/Private/Tactility/app/shell/Shell.h @@ -58,8 +58,9 @@ void forEachCommand(void* context, void (*callback)(const Command& command, void /** * Completes the final word of `line`. * - * The first word completes against command names, any later word against filesystem paths — which - * is why this lives here rather than in LineEditor: it needs both the command table and ShellFs. + * Every word completes against filesystem paths, and a first word without a '/' against command + * names as well. That is why this lives here rather than in LineEditor: it needs both the command + * table and ShellFs. * * On a unique match, `outSuffix` receives the text to append. On several, the common prefix shared * by all of them is returned instead (which may be empty), and the candidates are printed. diff --git a/Tactility/Private/Tactility/app/shell/shell/sh.h b/Tactility/Private/Tactility/app/shell/shell/sh.h index 396160518..18158876f 100644 --- a/Tactility/Private/Tactility/app/shell/shell/sh.h +++ b/Tactility/Private/Tactility/app/shell/shell/sh.h @@ -6,6 +6,10 @@ #include +#ifdef __cplusplus +extern "C" { +#endif + // ---- shell state ----------------------------------------------------------- struct node; // forward (AST body of a function) @@ -118,13 +122,6 @@ void sh_fields_init(sh_fields *f); void sh_fields_push(sh_fields *f, const char *s); void sh_fields_free(sh_fields *f); -// Expand one raw word token into zero or more fields (word splitting applies to -// unquoted expansions). Appends to `out`. -void sh_expand_word(sh_state *st, const char *raw, sh_fields *out); -// Expand a raw word to a single string (no splitting) - for redirect targets -// and assignment values. Caller frees the returned string. -char *sh_expand_single(sh_state *st, const char *raw); -// Expand a here-doc body: parameter/command expansion and backslash escaping of -// $ ` \ (double-quote semantics), preserving newlines and literal quotes. -// Caller frees the returned string. -char *sh_expand_heredoc(sh_state *st, const char *raw); +#ifdef __cplusplus +} +#endif diff --git a/Tactility/Private/Tactility/app/shell/shell/sh_arith.h b/Tactility/Private/Tactility/app/shell/shell/sh_arith.h index 923d4d771..cdacf3260 100644 --- a/Tactility/Private/Tactility/app/shell/shell/sh_arith.h +++ b/Tactility/Private/Tactility/app/shell/shell/sh_arith.h @@ -3,9 +3,17 @@ #include "sh.h" +#ifdef __cplusplus +extern "C" { +#endif + // Evaluate arithmetic expression `expr` (signed long, C/POSIX operator set). // Returns 0 on success and writes *out; nonzero on error (division by zero, // bad token, etc.). `st` is used to read/write shell variables. On error, an // English message is left in *errmsg (points at a static buffer; may be NULL // on success). Pass errmsg=NULL to ignore. int sh_arith_eval(sh_state *st, const char *expr, long *out, const char **errmsg); + +#ifdef __cplusplus +} +#endif diff --git a/Tactility/Private/Tactility/app/shell/shell/sh_builtins.h b/Tactility/Private/Tactility/app/shell/shell/sh_builtins.h index 6774542d3..cfd121419 100644 --- a/Tactility/Private/Tactility/app/shell/shell/sh_builtins.h +++ b/Tactility/Private/Tactility/app/shell/shell/sh_builtins.h @@ -1,6 +1,15 @@ #pragma once #include "sh.h" +#ifdef __cplusplus +extern "C" { +#endif + // If argv[0] names a shell builtin, run it, store its status in *status, and // return 1. Otherwise return 0 (caller should try an external command). +// `eval` and `source`/`.` run nested programs, so the executor handles those itself. int sh_run_builtin(sh_state *st, int argc, char **argv, int *status); + +#ifdef __cplusplus +} +#endif diff --git a/Tactility/Private/Tactility/app/shell/shell/sh_glob.h b/Tactility/Private/Tactility/app/shell/shell/sh_glob.h index 6d450cde2..8cfc9b1dd 100644 --- a/Tactility/Private/Tactility/app/shell/shell/sh_glob.h +++ b/Tactility/Private/Tactility/app/shell/shell/sh_glob.h @@ -4,6 +4,10 @@ // ${v#..}/${v%..} strip operators and, later, by `case` and pathname globbing. #pragma once +#ifdef __cplusplus +extern "C" { +#endif + // Match string `s` against shell pattern `p` using '*' (any run, incl. empty) // and '?' (exactly one char). Backslash escapes the next metacharacter. // Returns 1 on match, 0 otherwise. @@ -22,3 +26,7 @@ char *sh_strip_suffix(const char *s, const char *p, int longest); // the word literal, dash-style. #include "sh.h" int sh_glob_pathnames(const char *pat, sh_fields *out); + +#ifdef __cplusplus +} +#endif diff --git a/Tactility/Private/Tactility/app/shell/shell/sh_lex.h b/Tactility/Private/Tactility/app/shell/shell/sh_lex.h index d967d987d..d4e0c9b01 100644 --- a/Tactility/Private/Tactility/app/shell/shell/sh_lex.h +++ b/Tactility/Private/Tactility/app/shell/shell/sh_lex.h @@ -1,5 +1,9 @@ #pragma once +#ifdef __cplusplus +extern "C" { +#endif + typedef enum { T_WORD, // a raw word, quotes preserved (resolved later by sh_expand) T_SEMI, // ; @@ -38,3 +42,7 @@ typedef struct sh_toklist { // Tokenize src into tl. Returns 0 on success, -1 on lex error. int sh_lex(const char *src, sh_toklist *tl); void sh_toklist_free(sh_toklist *tl); + +#ifdef __cplusplus +} +#endif diff --git a/Tactility/Private/Tactility/app/shell/shell/sh_machine.h b/Tactility/Private/Tactility/app/shell/shell/sh_machine.h new file mode 100644 index 000000000..12e4eb8d9 --- /dev/null +++ b/Tactility/Private/Tactility/app/shell/shell/sh_machine.h @@ -0,0 +1,474 @@ +// Interpreter execution machine. +// +// Execution and expansion run on an explicit stack of frames instead of the +// native call stack. Each frame is the state of one unfinished construct (a +// loop, a command, a word being expanded, ...). The machine repeatedly steps +// the top frame: a frame either pushes a child frame and waits for its result, +// or finishes and hands its result to the frame below. The native stack stays +// the same size regardless of how deeply a script nests. +// +// Frames are stored in chunks that never move, so a frame may hand a child a +// pointer to one of its own members. A frame must have no side effects before +// its first step(): frames are moved into place, and a moved-from frame is +// destroyed without ever having run. +#pragma once + +#include "sh.h" +#include "sh_parse.h" +#include "sh_port.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +// Maximum number of frames. Exceeding it (or running out of memory for frames) aborts the run with +// "nesting too deep" instead of exhausting memory. +#define SH_MAX_FRAMES 1024 + +namespace sh { + +using Fields = std::vector; + +/** What a finished frame hands to the frame that pushed it. */ +struct Result { + int status = 0; + std::string text; // single-string expansions and command substitution output + Fields fields; // word expansion +}; + +/** The field currently being built during expansion. */ +struct FieldBuilder { + std::string text; + bool started = false; + + void put(char c) { + text.push_back(c); + started = true; + } + /** Ends the field if it has begun. */ + void emit(Fields& out); + /** Ends the field even when empty (an IFS delimiter boundary). */ + void emitForced(Fields& out); +}; + +/** Control-flow flags that command substitution and pipeline stages keep local. */ +struct FlowFlags { + int exiting, exitCode, brk, cont, returning, returnCode; + + static FlowFlags save(const sh_state& st) { + return { st.exiting, st.exit_code, st.brk, st.cont, st.returning, st.return_code }; + } + void restore(sh_state& st) const { + st.exiting = exiting; st.exit_code = exitCode; + st.brk = brk; st.cont = cont; + st.returning = returning; st.return_code = returnCode; + } +}; + +/** Redirections applied around a command. Restores the streams and removes here-doc files when destroyed. */ +struct Redirections { + sh_redir_saved io {}; + bool active = false; + std::vector paths; // expanded filenames + std::vector tempFiles; // here-doc bodies + + Redirections() = default; + Redirections(Redirections&&) = default; + ~Redirections(); + void restore(); +}; + +class Machine; + +enum class Step { Call, Return }; + +// region Frames + +/** Lex, parse and execute a script. Also implements `eval` and `source`/`.`. */ +struct RunProgram { + enum class Mode { Plain, Eval, Source }; + + std::string source; + Mode mode = Mode::Plain; + std::optional positional; // Source: replaces $@ for the duration + node* root = nullptr; + bool started = false; + char** savedPositional = nullptr; + int savedPositionalCount = 0; + bool positionalSwapped = false; + sh_state* state = nullptr; + + RunProgram(std::string source, Mode mode) : source(std::move(source)), mode(mode) {} + RunProgram(RunProgram&&) = default; + ~RunProgram(); + Step step(Machine& m); + +private: + Step finish(Machine& m, int status); + void restorePositional(sh_state& st); +}; + +/** Any AST node: compound-command redirects, negation and errexit around the node's own frame. */ +struct ExecNode { + node* n; + int exempt; + enum class Phase { Start, Redirected, Body } phase = Phase::Start; + std::unique_ptr redirections; // only for a compound command with redirects + + ExecNode(node* n, int exempt) : n(n), exempt(exempt) {} + Step step(Machine& m); + +private: + Step body(Machine& m); + Step finish(Machine& m, int status); +}; + +struct ExecList { + node* n; + int exempt; + int index = 0; + int status = 0; + bool started = false; + Step step(Machine& m); +}; + +struct ExecAndOr { + node* n; + int exempt; + enum class Phase { Start, Left, Right } phase = Phase::Start; + Step step(Machine& m); +}; + +struct ExecIf { + node* n; + int exempt; + int clause = 0; + enum class Phase { Start, Condition, Tail } phase = Phase::Start; + Step step(Machine& m); +}; + +struct ExecWhile { + node* n; + int exempt; + int status = 0; + sh_state* state = nullptr; // set once loop_depth has been incremented + enum class Phase { Start, Condition, Body } phase = Phase::Start; + + ExecWhile(node* n, int exempt) : n(n), exempt(exempt) {} + ExecWhile(ExecWhile&&) = default; + ~ExecWhile(); + Step step(Machine& m); +}; + +struct ExecFor { + node* n; + int exempt; + Fields items; + int word = 0; + size_t item = 0; + int status = 0; + sh_state* state = nullptr; // set once loop_depth has been incremented + enum class Phase { Start, Expanding, Body } phase = Phase::Start; + + ExecFor(node* n, int exempt) : n(n), exempt(exempt) {} + ExecFor(ExecFor&&) = default; + ~ExecFor(); + Step step(Machine& m); + +private: + Step next(Machine& m); +}; + +struct ExecCase { + node* n; + int exempt; + std::string subject; + int clause = 0; + int pattern = 0; + enum class Phase { Start, Subject, Pattern, Tail } phase = Phase::Start; + Step step(Machine& m); + +private: + Step nextPattern(Machine& m); +}; + +/** `{ list; }` or a `( list )` subshell, which snapshots and restores the shell state. */ +struct ExecGroup { + node* n; + int exempt; + bool started = false; + sh_state* state = nullptr; // set once a subshell snapshot has been taken + sh_var* savedVars = nullptr; + char** savedPositional = nullptr; + int savedPositionalCount = 0; + char* savedArg0 = nullptr; + std::string savedCwd; + FlowFlags savedFlow {}; + int savedErrexit = 0; + int savedNounset = 0; + + ExecGroup(node* n, int exempt) : n(n), exempt(exempt) {} + ExecGroup(ExecGroup&&) = default; + ~ExecGroup(); + Step step(Machine& m); +}; + +/** A pipeline, emulated with temp files between stages. */ +struct ExecPipe { + node* n; + int exempt; + std::string tempA; + std::string tempB; + int stage = 0; + int status = 0; + const char* previousOutput = nullptr; + std::unique_ptr stageIo; + bool stageRedirected = false; + FlowFlags savedFlow {}; + bool started = false; + + ExecPipe(node* n, int exempt) : n(n), exempt(exempt) {} + ExecPipe(ExecPipe&&) = default; + ~ExecPipe(); + Step step(Machine& m); + +private: + Step startStage(Machine& m); +}; + +/** A simple command: assignments, word expansion, redirects, then a function, builtin or external command. */ +struct ExecSimple { + node* n; + int exempt; + enum class Phase { + Start, PureAssign, PureRedirected, Words, EmptyAssign, PrefixAssign, Redirected, Ran + } phase = Phase::Start; + int index = 0; + int emptyStatus = 0; + Fields argv; + std::unique_ptr redirections; // only for a command with redirects + + struct Binding { + std::string name; + std::optional value; + std::optional environment; + }; + std::vector bindings; // prefix assignments to undo afterwards + sh_state* state = nullptr; // set while bindings are applied + + ExecSimple(node* n, int exempt) : n(n), exempt(exempt) {} + ExecSimple(ExecSimple&&) = default; + ~ExecSimple(); + Step step(Machine& m); + +private: + Step expandAssignment(Machine& m); + void applyAssignment(Machine& m, const std::string& value); + Step redirect(Machine& m, Phase next); + Step run(Machine& m); + Step finish(Machine& m, int status); + void restoreBindings(); +}; + +/** A shell function call with its own positional parameters and `local` scope. */ +struct CallFunction { + sh_func* function; + Fields argv; + int exempt; + char** savedPositional = nullptr; + int savedPositionalCount = 0; + int previousReturning = 0; + sh_state* state = nullptr; // set once the call has been entered + + CallFunction(sh_func* function, Fields argv, int exempt) : function(function), argv(std::move(argv)), exempt(exempt) {} + CallFunction(CallFunction&&) = default; + ~CallFunction(); + Step step(Machine& m); +}; + +/** Expands a node's redirect words and applies them into the owner's Redirections. Status 0 on success, 1 on failure. */ +struct ApplyRedirects { + node* n; + Redirections* target; + int index = 0; + bool waiting = false; + + struct Item { + sh_rd_op op; + int fd; + int dupfd; + int path; // index into target->paths or target->tempFiles, -1 for none + bool temp; + }; + std::vector items; + + Step step(Machine& m); + +private: + void writeHeredoc(const std::string& body); +}; + +/** `$(...)`: runs a command with stdout captured to a temp file, then reads the output back. */ +struct CommandSubst { + std::string command; + std::string tempFile; + std::unique_ptr io; + bool redirected = false; + bool started = false; + FlowFlags savedFlow {}; + + explicit CommandSubst(std::string command) : command(std::move(command)) {} + CommandSubst(CommandSubst&&) = default; + ~CommandSubst(); + Step step(Machine& m); + +private: + Step finish(Machine& m, std::string output); +}; + +/** Expands one raw word: into fields (Word), a single string (Single) or a here-doc body (Heredoc). */ +struct Expand { + enum class Mode { Word, Single, Heredoc }; + + std::string raw; + Mode mode; + bool allowSplit = false; + bool allowTilde = false; + bool quotedContext = false; + + FieldBuilder field; + Fields out; + size_t i = 0; + bool started = false; + bool inDoubleQuote = false; + + enum class Pending { None, Arithmetic, CommandSubstitution, Brace, Backtick } pending = Pending::None; + bool pendingSplit = false; + + static Expand word(std::string raw); + static Expand single(std::string raw, bool allowTilde = true, bool quotedContext = false); + static Expand heredoc(std::string raw); + + Step step(Machine& m); + +private: + char at(size_t k) const { return k < raw.size() ? raw[k] : '\0'; } + void resume(Machine& m); + bool scanWord(Machine& m); + bool scanHeredoc(Machine& m); + bool dollar(Machine& m, bool split, bool quoted); + Step finish(Machine& m); +}; + +/** The interior of a `${...}` expression, appending into the owning Expand's field. */ +struct ExpandBrace { + std::string content; + bool quoted; + bool split; + FieldBuilder* field; + Fields* out; + + enum class Phase { Start, Strip, Plus, Minus, Assign, Error } phase = Phase::Start; + std::string name; + bool longest = false; + char stripKind = 0; + std::string value; // the parameter's value when the operator doesn't need its word + + Step step(Machine& m); + +private: + Step start(Machine& m); +}; + +// endregion + +using Frame = std::variant; + +/** A stack of frames in fixed chunks, so frames never move once pushed. */ +class FrameStack { + static constexpr size_t CHUNK_SIZE = 8; + + struct Chunk { + Chunk* previous; + size_t count; + alignas(Frame) unsigned char storage[CHUNK_SIZE * sizeof(Frame)]; + + Frame* at(size_t index) { return std::launder(reinterpret_cast(storage) + index); } + }; + + Chunk* top = nullptr; + Chunk* spare = nullptr; // one emptied chunk is kept, so a loop at a chunk boundary doesn't allocate every iteration + size_t count = 0; + +public: + FrameStack() = default; + FrameStack(const FrameStack&) = delete; + FrameStack& operator=(const FrameStack&) = delete; + ~FrameStack(); + + /** @return false when no memory is available for the frame */ + bool push(Frame&& frame); + void pop(); + Frame& back() { return *top->at(top->count - 1); } + size_t size() const { return count; } + bool empty() const { return count == 0; } +}; + +class Machine { +public: + explicit Machine(sh_state& st) : st(st) {} + + sh_state& st; + /** The result of the frame that finished most recently. */ + Result result; + + /** Runs the root frame to completion and returns its status. */ + int run(Frame root); + + /** Pushes a child frame, which runs before the calling frame is stepped again. */ + Step call(Frame frame) { + pending.emplace(std::move(frame)); + return Step::Call; + } + + Step done(int status) { + result = Result { .status = status, .text = {}, .fields = {} }; + return Step::Return; + } + + Step done(std::string text) { + result = Result { .status = 0, .text = std::move(text), .fields = {} }; + return Step::Return; + } + + Step done(Fields fields) { + result = Result { .status = 0, .text = {}, .fields = std::move(fields) }; + return Step::Return; + } + +private: + FrameStack frames; + std::optional pending; +}; + +// region Expansion helpers (sh_expand.cpp) + +const char* get_ifs(sh_state* st); +void append_val(FieldBuilder& b, Fields& out, const char* v, bool split, const char* ifs); +const char* param_raw(sh_state* st, const char* name, char** owned); +bool nounset_fire(sh_state* st, const char* name, const char* val); +void expand_at_star(sh_state* st, bool star, bool quoted, FieldBuilder& b, Fields& out, bool split, const char* ifs); +bool is_var_name(const char* name); + +// endregion + +} // namespace sh diff --git a/Tactility/Private/Tactility/app/shell/shell/sh_parse.h b/Tactility/Private/Tactility/app/shell/shell/sh_parse.h index bd2a9470a..19d771979 100644 --- a/Tactility/Private/Tactility/app/shell/shell/sh_parse.h +++ b/Tactility/Private/Tactility/app/shell/shell/sh_parse.h @@ -1,6 +1,10 @@ #pragma once #include "sh_lex.h" +#ifdef __cplusplus +extern "C" { +#endif + typedef enum { N_LIST, // sequence of and_or nodes (children), run in order N_ANDOR, // binary: left right, op in { &&, || } @@ -85,3 +89,7 @@ typedef struct node { // sets *errmsg to a static description. node *sh_parse(sh_toklist *tl, const char **errmsg); void sh_free_node(node *n); + +#ifdef __cplusplus +} +#endif diff --git a/Tactility/Private/Tactility/app/shell/shell/sh_port.h b/Tactility/Private/Tactility/app/shell/shell/sh_port.h index 9f5f7e31f..9f950991e 100644 --- a/Tactility/Private/Tactility/app/shell/shell/sh_port.h +++ b/Tactility/Private/Tactility/app/shell/shell/sh_port.h @@ -5,6 +5,10 @@ #include +#ifdef __cplusplus +extern "C" { +#endif + // Run an external (non-builtin) command with a fully-expanded argv. // Returns the command's exit status. Sets *found to 1 if the command existed, // 0 if it could not be located (so the core can report "not found"). @@ -52,3 +56,7 @@ void sh_port_tmpfile(int which, char *buf, int bufsz); // Working directory (cd / pwd builtins). int sh_port_chdir(const char *path); // 0 on success void sh_port_getcwd(char *buf, int bufsz); + +#ifdef __cplusplus +} +#endif diff --git a/Tactility/Source/app/shell/Run.cpp b/Tactility/Source/app/shell/Run.cpp index 7353eecc6..468ec4b64 100644 --- a/Tactility/Source/app/shell/Run.cpp +++ b/Tactility/Source/app/shell/Run.cpp @@ -130,20 +130,34 @@ bool lastByteWasNewline = true; * * @return the child's exit status, or RUN_APP_START_FAILED if it never started */ +/** Copies what a child wrote to one of its output streams to `target`, which may be redirected. */ +void drainOutput(AppStream& stream, FILE* target, uint8_t* buffer, size_t size) { + size_t n; + while ((n = app_stream_read(&stream, buffer, size)) > 0) { + fprintf(target, "%.*s", static_cast(n), reinterpret_cast(buffer)); + lastByteWasNewline = buffer[n - 1] == '\n'; + } +} + int runApp(AppStartContext& context) { constexpr size_t STDIN_BUFFER_SIZE = 256; constexpr size_t STDOUT_BUFFER_SIZE = 1024; + constexpr size_t STDERR_BUFFER_SIZE = 256; constexpr MemoryPolicy policy = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 }; auto* stdinBuffer = static_cast(memory_alloc_with_policy(STDIN_BUFFER_SIZE, &policy)); auto* stdoutBuffer = static_cast(memory_alloc_with_policy(STDOUT_BUFFER_SIZE, &policy)); - if (stdinBuffer == nullptr || stdoutBuffer == nullptr) { + auto* stderrBuffer = static_cast(memory_alloc_with_policy(STDERR_BUFFER_SIZE, &policy)); + if (stdinBuffer == nullptr || stdoutBuffer == nullptr || stderrBuffer == nullptr) { memory_free(stdinBuffer); memory_free(stdoutBuffer); + memory_free(stderrBuffer); return RUN_APP_START_FAILED; } AppStream stdinStream {}; AppStream stdoutStream {}; + // Kept apart from stdout, so `$(...)` captures only a child's stdout, like any shell + AppStream stderrStream {}; TaskEventGroup eventGroup {}; task_event_group_construct(&eventGroup); @@ -158,7 +172,8 @@ int runApp(AppStartContext& context) { AppStreamBinding bindings[] = { { STDIN_FILENO, &stdinStream, stdinBuffer, STDIN_BUFFER_SIZE, &eventGroup, {}, -1 }, - { STDOUT_FILENO, &stdoutStream, stdoutBuffer, STDOUT_BUFFER_SIZE, &eventGroup, windowSize, STDERR_FILENO }, + { STDOUT_FILENO, &stdoutStream, stdoutBuffer, STDOUT_BUFFER_SIZE, &eventGroup, windowSize, -1 }, + { STDERR_FILENO, &stderrStream, stderrBuffer, STDERR_BUFFER_SIZE, &eventGroup, windowSize, -1 }, }; AppEventSubscription eventSub {}; @@ -172,8 +187,9 @@ int runApp(AppStartContext& context) { if (result != ERROR_NONE) { app_event_unsubscribe(&eventSub); task_event_group_destruct(&eventGroup); - free(stdinBuffer); - free(stdoutBuffer); + memory_free(stdinBuffer); + memory_free(stdoutBuffer); + memory_free(stderrBuffer); return RUN_APP_START_FAILED; } @@ -207,11 +223,8 @@ int runApp(AppStartContext& context) { vTaskDelay(pdMS_TO_TICKS(APP_PUMP_INTERVAL_MS)); } - size_t n; - while ((n = app_stream_read(&stdoutStream, drain, sizeof(drain))) > 0) { - printf("%.*s", static_cast(n), reinterpret_cast(drain)); - lastByteWasNewline = drain[n - 1] == '\n'; - } + drainOutput(stdoutStream, stdout, drain, sizeof(drain)); + drainOutput(stderrStream, stderr, drain, sizeof(drain)); AppEvent event {}; while (app_event_poll(&eventSub, &event) == ERROR_NONE) { @@ -223,17 +236,15 @@ int runApp(AppStartContext& context) { } // The child may have exited right after its last write, before the loop above's last read. - size_t n; - while ((n = app_stream_read(&stdoutStream, drain, sizeof(drain))) > 0) { - printf("%.*s", static_cast(n), reinterpret_cast(drain)); - lastByteWasNewline = drain[n - 1] == '\n'; - } + drainOutput(stdoutStream, stdout, drain, sizeof(drain)); + drainOutput(stderrStream, stderr, drain, sizeof(drain)); // Must run before app_manager_stop() reaps the child: only app_stream_unsubscribe() guarantees // the fd binding is gone and no AppFileOps call is still in flight, which is what makes these // stack-local AppStreams safe to let go out of scope below. app_stream_unsubscribe(&stdinStream); app_stream_unsubscribe(&stdoutStream); + app_stream_unsubscribe(&stderrStream); app_manager_stop(childId); @@ -242,6 +253,7 @@ int runApp(AppStartContext& context) { memory_free(stdinBuffer); memory_free(stdoutBuffer); + memory_free(stderrBuffer); return childResult; } @@ -260,7 +272,14 @@ int runElf(const char* resolvedPath, int argc, char** argv) { // per-process cwd for fopen() to resolve against (no chdir() at all), and the shell's own cwd // lives in ShellFs, meaning nothing to libc. shouldMakeAbsolute() decides which arguments qualify. char* rewritten[32]; - char storage[8][FILE_MAX_PATH_STRING_LENGTH]; + constexpr int STORAGE_COUNT = 8; + // On the heap: the loader runs on this task's stack right after, and needs the room + constexpr MemoryPolicy policy = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 }; + auto* storage = static_cast(memory_alloc_with_policy(STORAGE_COUNT * FILE_MAX_PATH_STRING_LENGTH, &policy)); + if (storage == nullptr) { + printf("%s: out of memory\n", argv[0]); + return 126; + } int stored = 0; const int passedArgc = (argc < static_cast(sizeof(rewritten) / sizeof(rewritten[0]))) @@ -271,12 +290,13 @@ int runElf(const char* resolvedPath, int argc, char** argv) { rewritten[i] = argv[i]; // argv[0] is the binary itself, already resolved by the caller. - if (i == 0 || stored >= 8) { + if (i == 0 || stored >= STORAGE_COUNT) { continue; } - if (shouldMakeAbsolute(argv[i], storage[stored], FILE_MAX_PATH_STRING_LENGTH)) { - rewritten[i] = storage[stored]; + char* slot = storage + stored * FILE_MAX_PATH_STRING_LENGTH; + if (shouldMakeAbsolute(argv[i], slot, FILE_MAX_PATH_STRING_LENGTH)) { + rewritten[i] = slot; stored++; } } @@ -298,6 +318,7 @@ int runElf(const char* resolvedPath, int argc, char** argv) { app_start_context_set_arguments_ext(&context, passedArgc, rewritten); const int result = runApp(context); + memory_free(storage); int exitCode; if (result == RUN_APP_START_FAILED) { diff --git a/Tactility/Source/app/shell/Shell.cpp b/Tactility/Source/app/shell/Shell.cpp index b9fb3c354..15eb836e0 100644 --- a/Tactility/Source/app/shell/Shell.cpp +++ b/Tactility/Source/app/shell/Shell.cpp @@ -148,43 +148,41 @@ bool complete(const char* line, char* outSuffix, size_t suffixSize, bool* outLis const bool isFirstWord = (wordStart == nullptr); wordStart = isFirstWord ? line : wordStart + 1; + // A first word names a command or, like "./tool" or "app/tool", a file to run. + const bool includeCommands = isFirstWord && strchr(wordStart, '/') == nullptr; + Candidates candidates {}; - candidates.word = wordStart; - candidates.wordLength = strlen(wordStart); // Paths complete against a directory, which may be named in the word itself ("ls /data/fo"). char directory[FILE_MAX_PATH_STRING_LENGTH] = {}; const char* namePart = wordStart; - if (!isFirstWord) { - const char* slash = strrchr(wordStart, '/'); - if (slash != nullptr) { - char prefix[FILE_MAX_PATH_STRING_LENGTH]; - const size_t prefixLength = static_cast(slash - wordStart); - if (prefixLength >= sizeof(prefix)) { - return false; - } - memcpy(prefix, wordStart, prefixLength); - prefix[prefixLength] = '\0'; - - // A leading "/foo" leaves an empty prefix, which means the root itself. - if (!ShellFs::resolvePath(prefixLength == 0 ? "/" : prefix, directory, sizeof(directory))) { - return false; - } - namePart = slash + 1; - } else { - snprintf(directory, sizeof(directory), "%s", ShellFs::cwd()); + const char* slash = strrchr(wordStart, '/'); + if (slash != nullptr) { + char prefix[FILE_MAX_PATH_STRING_LENGTH]; + const size_t prefixLength = static_cast(slash - wordStart); + if (prefixLength >= sizeof(prefix)) { + return false; } + memcpy(prefix, wordStart, prefixLength); + prefix[prefixLength] = '\0'; - candidates.word = namePart; - candidates.wordLength = strlen(namePart); + // A leading "/foo" leaves an empty prefix, which means the root itself. + if (!ShellFs::resolvePath(prefixLength == 0 ? "/" : prefix, directory, sizeof(directory))) { + return false; + } + namePart = slash + 1; + } else { + snprintf(directory, sizeof(directory), "%s", ShellFs::cwd()); } - if (isFirstWord) { + candidates.word = namePart; + candidates.wordLength = strlen(namePart); + + if (includeCommands) { forEachCommand(&candidates, offerCommand); - } else { - directory_list(directory, &candidates, offerEntry); } + directory_list(directory, &candidates, offerEntry); if (candidates.count == 0) { return false; @@ -200,11 +198,9 @@ bool complete(const char* line, char* outSuffix, size_t suffixSize, bool* outLis if (candidates.count == 1) { const size_t used = strlen(outSuffix); if (used + 1 < suffixSize) { - const bool directoryMatch = !isFirstWord && [&] { - char full[FILE_MAX_PATH_STRING_LENGTH]; - const int written = snprintf(full, sizeof(full), "%s/%s", directory, candidates.first); - return written > 0 && static_cast(written) < sizeof(full) && directory_exists(full); - }(); + char full[FILE_MAX_PATH_STRING_LENGTH]; + const int written = snprintf(full, sizeof(full), "%s/%s", directory, candidates.first); + const bool directoryMatch = written > 0 && static_cast(written) < sizeof(full) && directory_exists(full); outSuffix[used] = directoryMatch ? '/' : ' '; outSuffix[used + 1] = '\0'; } @@ -214,11 +210,10 @@ bool complete(const char* line, char* outSuffix, size_t suffixSize, bool* outLis // Several matches and nothing more to add: show what they are. if (outSuffix[0] == '\0') { printf("\n"); - if (isFirstWord) { + if (includeCommands) { forEachCommand(&candidates, printCandidateCommand); - } else { - directory_list(directory, &candidates, printCandidateEntry); } + directory_list(directory, &candidates, printCandidateEntry); printf("\n"); *outListed = true; } @@ -269,9 +264,17 @@ int runCommand(int argc, char** argv, int* found) { *found = 1; // ELF binaries and shell scripts are told apart by content rather than by extension, // since the filesystem is FAT and carries no execute bit to consult. - return app_is_executable_path(resolved) - ? runElf(resolved, argc, argv) - : runScript(resolved, argc, argv); + if (app_is_executable_path(resolved)) { + return runElf(resolved, argc, argv); + } + // A script runs in its own `sh` app instance, so it gets its own task and stack rather + // than nesting another interpreter on this one's. + std::vector shArgv; + shArgv.push_back(const_cast("sh")); + for (int i = 0; i < argc; i++) { + shArgv.push_back(argv[i]); + } + return runFromMemory("sh", static_cast(shArgv.size()), shArgv.data()); } *found = 0; @@ -279,9 +282,8 @@ int runCommand(int argc, char** argv, int* found) { } int runScriptSource(const char* source, int argc, char** argv) { - // Scripts run on their own interpreter state rather than the session's. A script is reached - // from inside sh_run_string(): the interpreter calls runCommand() for the `sh foo` line while - // the outer parse is still in progress, and a fresh state keeps the two runs independent. + // Scripts run on their own interpreter state rather than the session's: each runs in its own + // `sh` app instance, and a fresh state keeps it independent of the interactive session. // // The consequence is that a script cannot see or modify the session's variables. `.` and // `source` remain the way to run something in the current shell, which is what they are for. diff --git a/Tactility/Source/app/shell/main.cpp b/Tactility/Source/app/shell/main.cpp index b7d811f65..fa79b9e83 100644 --- a/Tactility/Source/app/shell/main.cpp +++ b/Tactility/Source/app/shell/main.cpp @@ -12,6 +12,7 @@ #include #include +#include #include namespace tt::app::shell { @@ -49,8 +50,16 @@ int main(int, char*[]) { puts("Type 'exit' to quit shell."); - LineEditor editor; - editor.begin(PROMPT); + // Heap-allocated: its history buffer is several KB, which the interpreter needs on the stack + constexpr MemoryPolicy policy = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 }; + void* editorMemory = memory_alloc_with_policy(sizeof(LineEditor), &policy); + if (editorMemory == nullptr) { + puts("shell: out of memory"); + Shell::shutdown(); + return 1; + } + auto* editor = new (editorMemory) LineEditor(); + editor->begin(PROMPT); // Blocks until a byte arrives or the terminal app running this one closes its end (touch to // exit), which read() reports the same way any closed pipe does: 0, ending this loop. Typing @@ -59,17 +68,20 @@ int main(int, char*[]) { char c; while (read(STDIN_FILENO, &c, 1) == 1) { const char* line = nullptr; - if (editor.feed(c, &line)) { + if (editor->feed(c, &line)) { Shell::execute(line); // Print \n if output didn't end with it, to make the shell more readable. endLineIfNeeded(); if (Shell::shouldExit()) { break; } - editor.begin(PROMPT); + editor->begin(PROMPT); } } + editor->~LineEditor(); + memory_free(editorMemory); + const int exitCode = Shell::exitCode(); Shell::shutdown(); return exitCode; @@ -81,7 +93,7 @@ extern const ::AppManifest manifest = { .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(main) }, .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, - .stack = { .depth = 11 * 1024, .desired_memory_capability = 0 }, + .stack = { .depth = 6144, .desired_memory_capability = 0 }, }; static int32_t shMain(int argc, char* argv[]) { @@ -111,7 +123,7 @@ extern const ::AppManifest sh_manifest = { .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(shMain) }, .flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS, - .stack = { .depth = 0, .desired_memory_capability = 0 }, + .stack = { .depth = 8192, .desired_memory_capability = 0 }, }; } diff --git a/Tactility/Source/app/shell/shell/sh_arith.c b/Tactility/Source/app/shell/shell/sh_arith.c deleted file mode 100644 index a7fedbad8..000000000 --- a/Tactility/Source/app/shell/shell/sh_arith.c +++ /dev/null @@ -1,309 +0,0 @@ -// POSIX arithmetic expansion `$(( ))` evaluator. -// -// Recursive-descent / precedence-climbing parser over a `const char *`, -// operating on signed `long` only. No floats, no arrays, no `**`/`++`/`--` -// (dash rejects those; so do we), and -- by scope decision -- no ternary, -// bitwise, or shift operators. Bare variable names auto-deref; -// unset/empty is 0; assignment operators write back to shell variables. -#include - -#include -#include -#include -#include -#include - -#define ARITH_MAX_DEPTH 32 // recursion guard for variable-value re-eval - -typedef struct { - sh_state *st; - const char *p; // current position - int err; // nonzero once an error occurs - const char *msg; // error message (static string) - int depth; // variable-deref recursion depth -} actx; - -static long parse_comma(actx *c); -static long parse_assign(actx *c); - -static void fail(actx *c, const char *m) -{ - if (!c->err) { c->err = 1; c->msg = m; } -} - -static void skip_ws(actx *c) -{ - while (*c->p == ' ' || *c->p == '\t' || *c->p == '\n') c->p++; -} - -// ---- token peeking ---------------------------------------------------------- -// We tokenize on the fly. `peek` matches a fixed operator string at the current -// position (after skipping whitespace) without consuming. -static int peek(actx *c, const char *op) -{ - skip_ws(c); - return strncmp(c->p, op, strlen(op)) == 0; -} - -// Consume operator `op` if present; returns 1 if consumed. -static int eat(actx *c, const char *op) -{ - if (peek(c, op)) { c->p += strlen(op); return 1; } - return 0; -} - -// ---- variable deref --------------------------------------------------------- -// Evaluate the arithmetic value of a variable's string value (POSIX: variable -// values are themselves arithmetic expressions). Empty/unset -> 0. -static long deref(actx *c, const char *name) -{ - const char *v = sh_get(c->st, name); - if (!v) return 0; - while (*v == ' ' || *v == '\t' || *v == '\n') v++; // empty/blank value -> 0 - if (!*v) return 0; - if (c->depth >= ARITH_MAX_DEPTH) { fail(c, "arithmetic recursion too deep"); return 0; } - // Parse the value as a fresh sub-expression. - actx sub = *c; - sub.p = v; - sub.depth = c->depth + 1; - long r = parse_comma(&sub); - skip_ws(&sub); - if (!sub.err && *sub.p) fail(&sub, "invalid arithmetic value"); - if (sub.err) { fail(c, sub.msg); return 0; } - return r; -} - -// Read a bare identifier at c->p into `name` (caller-sized buf). Returns len. -static int read_ident(actx *c, char *name, int cap) -{ - int n = 0; - while ((isalnum((unsigned char)*c->p) || *c->p == '_') && n < cap - 1) - name[n++] = *c->p++; - name[n] = 0; - return n; -} - -// ---- integer literal -------------------------------------------------------- -static long parse_number(actx *c) -{ - const char *s = c->p; - long val = 0; - if (s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) { - s += 2; - if (!isxdigit((unsigned char)*s)) { fail(c, "bad hex constant"); return 0; } - while (isxdigit((unsigned char)*s)) { - int d = isdigit((unsigned char)*s) ? *s - '0' - : (tolower((unsigned char)*s) - 'a' + 10); - val = val * 16 + d; - s++; - } - } else if (s[0] == '0' && isdigit((unsigned char)s[1])) { - s++; // octal - while (*s >= '0' && *s <= '7') { val = val * 8 + (*s - '0'); s++; } - if (*s == '8' || *s == '9') { fail(c, "bad octal constant"); return 0; } - } else { - while (isdigit((unsigned char)*s)) { val = val * 10 + (*s - '0'); s++; } - } - c->p = s; - return val; -} - -// ---- primary ---------------------------------------------------------------- -static long parse_primary(actx *c) -{ - skip_ws(c); - if (*c->p == '(') { - c->p++; - long r = parse_comma(c); - skip_ws(c); - if (*c->p == ')') c->p++; - else fail(c, "missing ) in arithmetic"); - return r; - } - // A leading '$' before an identifier: normal expansion would have handled - // $var already, but if it reaches here, skip the '$' and deref the name. - if (*c->p == '$') c->p++; - - if (isdigit((unsigned char)*c->p)) return parse_number(c); - - if (isalpha((unsigned char)*c->p) || *c->p == '_') { - char name[128]; - read_ident(c, name, sizeof(name)); - skip_ws(c); - // Assignment forms are handled at parse_assign level; here just deref. - return deref(c, name); - } - fail(c, "unexpected token in arithmetic"); - return 0; -} - -// ---- unary ------------------------------------------------------------------ -static long parse_unary(actx *c) -{ - skip_ws(c); - if (eat(c, "+")) return parse_unary(c); - if (eat(c, "-")) return -parse_unary(c); - if (eat(c, "!")) return !parse_unary(c); - return parse_primary(c); -} - -// ---- binary precedence levels ---------------------------------------------- -static long parse_mul(actx *c) -{ - long l = parse_unary(c); - for (;;) { - skip_ws(c); - // Reject `**` explicitly (not POSIX; dash errors). - if (c->p[0] == '*' && c->p[1] == '*') { fail(c, "** not supported"); return 0; } - if (eat(c, "*")) { l = l * parse_unary(c); } - else if (eat(c, "/")) { - long r = parse_unary(c); - if (r == 0) { fail(c, "division by zero"); return 0; } - l = l / r; - } else if (eat(c, "%")) { - long r = parse_unary(c); - if (r == 0) { fail(c, "division by zero"); return 0; } - l = l % r; - } else break; - } - return l; -} - -static long parse_add(actx *c) -{ - long l = parse_mul(c); - for (;;) { - skip_ws(c); - // Guard against ++/-- (not POSIX). - if ((c->p[0] == '+' && c->p[1] == '+') || (c->p[0] == '-' && c->p[1] == '-')) { - fail(c, "++/-- not supported"); return 0; - } - if (eat(c, "+")) l = l + parse_mul(c); - else if (eat(c, "-")) l = l - parse_mul(c); - else break; - } - return l; -} - -static long parse_rel(actx *c) -{ - long l = parse_add(c); - for (;;) { - skip_ws(c); - if (eat(c, "<=")) l = (l <= parse_add(c)); - else if (eat(c, ">=")) l = (l >= parse_add(c)); - else if (eat(c, "<")) l = (l < parse_add(c)); - else if (eat(c, ">")) l = (l > parse_add(c)); - else break; - } - return l; -} - -static long parse_eq(actx *c) -{ - long l = parse_rel(c); - for (;;) { - skip_ws(c); - if (eat(c, "==")) l = (l == parse_rel(c)); - else if (eat(c, "!=")) l = (l != parse_rel(c)); - else break; - } - return l; -} - -static long parse_land(actx *c) -{ - long l = parse_eq(c); - while (eat(c, "&&")) { long r = parse_eq(c); l = (l && r); } - return l; -} - -static long parse_lor(actx *c) -{ - long l = parse_land(c); - while (eat(c, "||")) { long r = parse_land(c); l = (l || r); } - return l; -} - -// ---- assignment (right-assoc) ---------------------------------------------- -// Detect ` ` at the current position. On match, records the -// name and the compound operator char (0 for plain `=`), consumes both, and -// returns 1. Otherwise leaves position untouched and returns 0. -static int try_assign_lhs(actx *c, char *name, int cap, char *op) -{ - const char *save = c->p; - skip_ws(c); - if (*c->p == '$') c->p++; - if (!(isalpha((unsigned char)*c->p) || *c->p == '_')) { c->p = save; return 0; } - read_ident(c, name, cap); - skip_ws(c); - const char *ops[] = { "+=", "-=", "*=", "/=", "%=", NULL }; - for (int i = 0; ops[i]; i++) { - size_t len = strlen(ops[i]); - if (strncmp(c->p, ops[i], len) == 0) { - *op = ops[i][len - 2]; // the operation char before '=' - c->p += len; - return 1; - } - } - // plain '=' but not '==' - if (c->p[0] == '=' && c->p[1] != '=') { *op = 0; c->p++; return 1; } - c->p = save; - return 0; -} - -static long apply_compound(actx *c, char op, long cur, long rhs) -{ - switch (op) { - case 0: return rhs; - case '+': return cur + rhs; - case '-': return cur - rhs; - case '*': return cur * rhs; - case '/': if (rhs == 0) { fail(c, "division by zero"); return 0; } return cur / rhs; - case '%': if (rhs == 0) { fail(c, "division by zero"); return 0; } return cur % rhs; - default: return rhs; - } -} - -static long parse_assign(actx *c) -{ - char name[128], op; - if (try_assign_lhs(c, name, sizeof(name), &op)) { - long rhs = parse_assign(c); // right-associative - if (c->err) return 0; - long cur = 0; - if (op) cur = deref(c, name); - long res = apply_compound(c, op, cur, rhs); - if (c->err) return 0; - char buf[32]; - snprintf(buf, sizeof(buf), "%ld", res); - sh_set(c->st, name, buf); - return res; - } - return parse_lor(c); -} - -static long parse_comma(actx *c) -{ - long l = parse_assign(c); - while (eat(c, ",")) l = parse_assign(c); - return l; -} - -int sh_arith_eval(sh_state *st, const char *expr, long *out, const char **errmsg) -{ - actx c = { .st = st, .p = expr, .err = 0, .msg = NULL, .depth = 0 }; - // An empty or all-whitespace expression evaluates to 0 (dash: `$(( ))`). - skip_ws(&c); - if (!*c.p) { if (errmsg) *errmsg = NULL; *out = 0; return 0; } - long r = parse_comma(&c); - skip_ws(&c); - if (!c.err && *c.p) fail(&c, "unexpected trailing characters in arithmetic"); - if (c.err) { - if (errmsg) *errmsg = c.msg; - return 1; - } - if (errmsg) *errmsg = NULL; - *out = r; - return 0; -} diff --git a/Tactility/Source/app/shell/shell/sh_arith.cpp b/Tactility/Source/app/shell/shell/sh_arith.cpp new file mode 100644 index 000000000..1193fe20e --- /dev/null +++ b/Tactility/Source/app/shell/shell/sh_arith.cpp @@ -0,0 +1,404 @@ +// POSIX arithmetic expansion `$(( ))` evaluator. +// +// Operator-precedence evaluation over explicit operator/value/input stacks, +// operating on signed `long` only. No floats, no arrays, no `**`/`++`/`--` +// (dash rejects those; so do we), and -- by scope decision -- no ternary, +// bitwise, or shift operators. Bare variable names auto-deref; +// unset/empty is 0; assignment operators write back to shell variables. +// +// A variable's value is itself an arithmetic expression. It is evaluated by +// pushing it as a nested input rather than by recursing, so neither deeply +// parenthesized expressions nor chains of variables grow the native stack. +#include + +#include +#include +#include +#include + +namespace { + +constexpr int ARITH_MAX_DEPTH = 32; // guard for variable-value re-evaluation + +enum class Op { + Comma, Assign, Or, And, Equal, NotEqual, Less, LessEqual, Greater, GreaterEqual, + Add, Subtract, Multiply, Divide, Modulo, Plus, Negate, Not, + // Markers: not operators, never reduced + LeftParen, // '(' + Deref, // boundary of a variable value being evaluated + AssignDone // boundary of a compound assignment's current value being evaluated +}; + +struct Operator { + Op op; + char assign = 0; // Assign/AssignDone: compound operation char, 0 for plain `=` + std::string name; // Assign/AssignDone: target variable + long rhs = 0; // AssignDone: the already-evaluated right-hand side +}; + +struct Input { + std::string text; + size_t pos = 0; + int depth = 0; // 0 for the expression itself, >0 for variable values + + char at(size_t offset = 0) const { return pos + offset < text.size() ? text[pos + offset] : '\0'; } + bool startsWith(const char* token) const { return text.compare(pos, std::char_traits::length(token), token) == 0; } + void skipWhitespace() { while (at() == ' ' || at() == '\t' || at() == '\n') pos++; } +}; + +bool isMarker(Op op) { + return op == Op::LeftParen || op == Op::Deref || op == Op::AssignDone; +} + +bool isUnary(Op op) { + return op == Op::Plus || op == Op::Negate || op == Op::Not; +} + +int precedence(Op op) { + switch (op) { + case Op::Comma: return 1; + case Op::Assign: return 2; + case Op::Or: return 3; + case Op::And: return 4; + case Op::Equal: case Op::NotEqual: return 5; + case Op::Less: case Op::LessEqual: case Op::Greater: case Op::GreaterEqual: return 6; + case Op::Add: case Op::Subtract: return 7; + case Op::Multiply: case Op::Divide: case Op::Modulo: return 8; + default: return 9; // unary + } +} + +class Evaluator { +public: + Evaluator(sh_state* state, const char* expr) : st(state) { + inputs.push_back(Input { expr, 0, 0 }); + } + + int run(long* out, const char** errmsg) { + // An empty or all-whitespace expression evaluates to 0 (dash: `$(( ))`). + inputs.back().skipWhitespace(); + if (inputs.back().at() == '\0') { + if (errmsg) *errmsg = nullptr; + *out = 0; + return 0; + } + + while (error == nullptr && step()) {} + + if (error != nullptr) { + if (errmsg) *errmsg = error; + return 1; + } + if (errmsg) *errmsg = nullptr; + *out = values.back(); + return 0; + } + +private: + sh_state* st; + std::vector inputs; + std::vector ops; + std::vector values; + const char* error = nullptr; + bool expectOperand = true; + // An assignment may only start an assignment-expression: at the very start, or after '(', ',' or another assignment. + bool assignAllowed = true; + + void fail(const char* message) { + if (error == nullptr) error = message; + } + + long popValue() { + long value = values.back(); + values.pop_back(); + return value; + } + + /** Performs one unit of work. Returns false when evaluation is complete. */ + bool step() { + Input& in = inputs.back(); + in.skipWhitespace(); + if (expectOperand) { + readOperand(in); + return true; + } + if (in.at() == '\0') { + return endOfInput(); + } + readOperator(in); + return true; + } + + void readOperand(Input& in) { + char c = in.at(); + if (c == '(') { in.pos++; ops.push_back({ Op::LeftParen }); assignAllowed = true; return; } + if (c == '+') { in.pos++; ops.push_back({ Op::Plus }); assignAllowed = false; return; } + if (c == '-') { in.pos++; ops.push_back({ Op::Negate }); assignAllowed = false; return; } + if (c == '!') { in.pos++; ops.push_back({ Op::Not }); assignAllowed = false; return; } + + if (assignAllowed && tryAssignment(in)) { + return; + } + + // A leading '$' before an identifier: normal expansion would have handled + // $var already, but if it reaches here, skip the '$' and deref the name. + if (in.at() == '$') in.pos++; + + if (isdigit(static_cast(in.at()))) { + long value = readNumber(in); + if (error == nullptr) pushOperand(value); + return; + } + + if (isalpha(static_cast(in.at())) || in.at() == '_') { + std::string name = readIdentifier(in); + deref(name, [this](long value) { pushOperand(value); }, Operator { Op::Deref }); + return; + } + + fail("unexpected token in arithmetic"); + } + + void pushOperand(long value) { + values.push_back(value); + expectOperand = false; + assignAllowed = false; + } + + /** + * Resolves a variable's arithmetic value. An unset or blank value is available immediately and + * passed to onImmediate. Otherwise its text is pushed as a nested input behind `marker`, and the + * result arrives through endOfInput(). + */ + template + void deref(const std::string& name, OnImmediate&& onImmediate, Operator marker) { + const char* value = sh_get(st, name.c_str()); + if (value != nullptr) { + while (*value == ' ' || *value == '\t' || *value == '\n') value++; // empty/blank value -> 0 + } + if (value == nullptr || *value == '\0') { + onImmediate(0); + return; + } + int depth = inputs.back().depth + 1; + if (depth > ARITH_MAX_DEPTH) { + fail("arithmetic recursion too deep"); + return; + } + ops.push_back(std::move(marker)); + // Copied: evaluating the value may reassign the variable it came from. + inputs.push_back(Input { value, 0, depth }); + expectOperand = true; + assignAllowed = true; + } + + // Detect ` ` at the current position. On match, pushes the + // assignment and returns true. Otherwise leaves the position untouched. + bool tryAssignment(Input& in) { + size_t save = in.pos; + if (in.at() == '$') in.pos++; + if (!(isalpha(static_cast(in.at())) || in.at() == '_')) { in.pos = save; return false; } + std::string name = readIdentifier(in); + in.skipWhitespace(); + static const char* compound[] = { "+=", "-=", "*=", "/=", "%=" }; + for (const char* op : compound) { + if (in.startsWith(op)) { + in.pos += 2; + ops.push_back({ Op::Assign, op[0], std::move(name) }); + return true; + } + } + // plain '=' but not '==' + if (in.at() == '=' && in.at(1) != '=') { + in.pos++; + ops.push_back({ Op::Assign, 0, std::move(name) }); + return true; + } + in.pos = save; + return false; + } + + static std::string readIdentifier(Input& in) { + size_t start = in.pos; + while (isalnum(static_cast(in.at())) || in.at() == '_') in.pos++; + return in.text.substr(start, in.pos - start); + } + + long readNumber(Input& in) { + long value = 0; + if (in.at() == '0' && (in.at(1) == 'x' || in.at(1) == 'X')) { + in.pos += 2; + if (!isxdigit(static_cast(in.at()))) { fail("bad hex constant"); return 0; } + while (isxdigit(static_cast(in.at()))) { + char c = in.at(); + int digit = isdigit(static_cast(c)) ? c - '0' : (tolower(static_cast(c)) - 'a' + 10); + value = value * 16 + digit; + in.pos++; + } + } else if (in.at() == '0' && isdigit(static_cast(in.at(1)))) { + in.pos++; // octal + while (in.at() >= '0' && in.at() <= '7') { value = value * 8 + (in.at() - '0'); in.pos++; } + if (in.at() == '8' || in.at() == '9') { fail("bad octal constant"); return 0; } + } else { + while (isdigit(static_cast(in.at()))) { value = value * 10 + (in.at() - '0'); in.pos++; } + } + return value; + } + + const char* trailingError() const { + return inputs.back().depth > 0 ? "invalid arithmetic value" : "unexpected trailing characters in arithmetic"; + } + + void readOperator(Input& in) { + Op op; + size_t length = 1; + char c = in.at(); + if (c == ')') { + if (!ops.empty() && !isMarker(ops.back().op)) { reduce(); return; } + if (ops.empty() || ops.back().op != Op::LeftParen) { fail(trailingError()); return; } + ops.pop_back(); + in.pos++; + return; + } + if (c == ',') op = Op::Comma; + else if (in.startsWith("||")) { op = Op::Or; length = 2; } + else if (in.startsWith("&&")) { op = Op::And; length = 2; } + else if (in.startsWith("==")) { op = Op::Equal; length = 2; } + else if (in.startsWith("!=")) { op = Op::NotEqual; length = 2; } + else if (in.startsWith("<=")) { op = Op::LessEqual; length = 2; } + else if (in.startsWith(">=")) { op = Op::GreaterEqual; length = 2; } + else if (c == '<') op = Op::Less; + else if (c == '>') op = Op::Greater; + else if (in.startsWith("**")) { fail("** not supported"); return; } + else if (c == '*') op = Op::Multiply; + else if (c == '/') op = Op::Divide; + else if (c == '%') op = Op::Modulo; + else if (in.startsWith("++") || in.startsWith("--")) { fail("++/-- not supported"); return; } + else if (c == '+') op = Op::Add; + else if (c == '-') op = Op::Subtract; + else { fail(trailingError()); return; } + + // Left-associative: finish everything of equal or higher precedence first. + if (!ops.empty() && !isMarker(ops.back().op) && precedence(ops.back().op) >= precedence(op)) { + reduce(); + return; + } + in.pos += length; + ops.push_back({ op }); + expectOperand = true; + assignAllowed = op == Op::Comma; + } + + /** Called at the end of the current input. Returns false once the whole expression is done. */ + bool endOfInput() { + if (!ops.empty() && !isMarker(ops.back().op)) { + reduce(); + return true; + } + if (!ops.empty() && ops.back().op == Op::LeftParen) { + fail("missing ) in arithmetic"); + return true; + } + if (inputs.size() == 1) { + return false; + } + + Operator marker = std::move(ops.back()); + ops.pop_back(); + inputs.pop_back(); + if (marker.op == Op::AssignDone) { + long current = popValue(); + finishAssignment(marker.name, marker.assign, current, marker.rhs); + } else { + // Op::Deref: the value is already on the value stack + expectOperand = false; + assignAllowed = false; + } + return true; + } + + long apply(char op, long current, long rhs) { + switch (op) { + case 0: return rhs; + case '+': return current + rhs; + case '-': return current - rhs; + case '*': return current * rhs; + case '/': if (rhs == 0) { fail("division by zero"); return 0; } return current / rhs; + case '%': if (rhs == 0) { fail("division by zero"); return 0; } return current % rhs; + default: return rhs; + } + } + + void finishAssignment(const std::string& name, char op, long current, long rhs) { + long result = apply(op, current, rhs); + if (error != nullptr) return; + char buffer[32]; + snprintf(buffer, sizeof(buffer), "%ld", result); + sh_set(st, name.c_str(), buffer); + pushOperand(result); + } + + /** Applies the operator on top of the stack. */ + void reduce() { + Operator top = std::move(ops.back()); + ops.pop_back(); + + if (isUnary(top.op)) { + long value = popValue(); + values.push_back(top.op == Op::Negate ? -value : top.op == Op::Not ? !value : value); + return; + } + + if (top.op == Op::Assign) { + long rhs = popValue(); + if (top.assign == 0) { + finishAssignment(top.name, 0, 0, rhs); + return; + } + // Compound: the variable's current value is read after the right-hand side, like dash. + char assign = top.assign; + std::string name = top.name; + deref( + name, + [&](long current) { finishAssignment(name, assign, current, rhs); }, + Operator { Op::AssignDone, assign, name, rhs } + ); + return; + } + + long rhs = popValue(); + long lhs = popValue(); + long result = 0; + switch (top.op) { + case Op::Comma: result = rhs; break; + case Op::Or: result = (lhs || rhs); break; + case Op::And: result = (lhs && rhs); break; + case Op::Equal: result = (lhs == rhs); break; + case Op::NotEqual: result = (lhs != rhs); break; + case Op::Less: result = (lhs < rhs); break; + case Op::LessEqual: result = (lhs <= rhs); break; + case Op::Greater: result = (lhs > rhs); break; + case Op::GreaterEqual: result = (lhs >= rhs); break; + case Op::Add: result = lhs + rhs; break; + case Op::Subtract: result = lhs - rhs; break; + case Op::Multiply: result = lhs * rhs; break; + case Op::Divide: + if (rhs == 0) { fail("division by zero"); return; } + result = lhs / rhs; + break; + case Op::Modulo: + if (rhs == 0) { fail("division by zero"); return; } + result = lhs % rhs; + break; + default: break; + } + values.push_back(result); + } +}; + +} // namespace + +extern "C" int sh_arith_eval(sh_state* st, const char* expr, long* out, const char** errmsg) +{ + return Evaluator(st, expr).run(out, errmsg); +} diff --git a/Tactility/Source/app/shell/shell/sh_builtins.c b/Tactility/Source/app/shell/shell/sh_builtins.c index c9fef848d..dbef296c0 100644 --- a/Tactility/Source/app/shell/shell/sh_builtins.c +++ b/Tactility/Source/app/shell/shell/sh_builtins.c @@ -112,66 +112,98 @@ static int t_binop(const char *a, const char *op, const char *b, int *err) return 0; } -typedef struct { char **a; int n; int i; int err; } tparse; - -static int t_oexpr(tparse *t); - -static int t_primary(tparse *t) +// Operator-precedence evaluation with explicit value/operator stacks, so a +// long run of `!` or `(` arguments can't exhaust the native stack. Operators: +// '!' (prefix, binds to the following primary or group), 'a' (-a) above 'o' +// (-o), and '(' as a group marker. Both sides of -a/-o are always evaluated, +// matching dash. +typedef struct { + char *ops; + int *vals; + int nops, nvals, cap; +} tstack; + +static void t_push_op(tstack *t, char op) { t->ops[t->nops++] = op; } +static void t_push_val(tstack *t, int v) { t->vals[t->nvals++] = v; } + +// Apply the binary operator on top of the op stack. +static void t_reduce(tstack *t) { - if (t->i >= t->n) return 0; // missing expression -> false - char *s = t->a[t->i]; - if (strcmp(s, "(") == 0) { - t->i++; - int r = t_oexpr(t); - if (t->i >= t->n || strcmp(t->a[t->i], ")") != 0) { t->err = 1; return r; } - t->i++; - return r; - } - if (t_is_unop(s) && t->i + 1 < t->n) { - int r = t_unop(s[1], t->a[t->i + 1]); - t->i += 2; - return r; - } - if (t->i + 1 < t->n && t_is_binop(t->a[t->i + 1])) { - if (t->i + 2 >= t->n) { t->err = 1; return 0; } - int r = t_binop(t->a[t->i], t->a[t->i + 1], t->a[t->i + 2], &t->err); - t->i += 3; - return r; - } - int r = s[0] != 0; - t->i++; - return r; + char op = t->ops[--t->nops]; + int rhs = t->vals[--t->nvals]; + int lhs = t->vals[--t->nvals]; + t_push_val(t, op == 'a' ? (rhs && lhs) : (rhs || lhs)); } -static int t_nexpr(tparse *t) +// Push a finished operand, applying any '!' waiting directly in front of it. +static void t_push_operand(tstack *t, int v) { - if (t->i < t->n && strcmp(t->a[t->i], "!") == 0) { - t->i++; - return !t_nexpr(t); - } - return t_primary(t); + while (t->nops > 0 && t->ops[t->nops - 1] == '!') { t->nops--; v = !v; } + t_push_val(t, v); } -static int t_aexpr(tparse *t) +// Evaluate argv[0..n). Returns 1/0 (true/false); sets *err on a syntax error; +// *consumed receives how many arguments were used. +static int t_evaluate(char **a, int n, int *err, int *consumed) { - int r = t_nexpr(t); - while (t->i < t->n && strcmp(t->a[t->i], "-a") == 0) { - t->i++; - int rhs = t_nexpr(t); - r = rhs && r; - } - return r; -} - -static int t_oexpr(tparse *t) -{ - int r = t_aexpr(t); - while (t->i < t->n && strcmp(t->a[t->i], "-o") == 0) { - t->i++; - int rhs = t_aexpr(t); - r = rhs || r; - } - return r; + tstack t; + t.cap = n + 1; + t.ops = malloc(t.cap); + t.vals = malloc(t.cap * sizeof(int)); + t.nops = t.nvals = 0; + + int i = 0; + int expect_operand = 1; + for (;;) { + if (expect_operand) { + if (i >= n) { t_push_operand(&t, 0); expect_operand = 0; break; } // missing expression -> false + char *s = a[i]; + if (strcmp(s, "!") == 0) { t_push_op(&t, '!'); i++; continue; } + if (strcmp(s, "(") == 0) { t_push_op(&t, '('); i++; continue; } + int r; + if (t_is_unop(s) && i + 1 < n) { + r = t_unop(s[1], a[i + 1]); + i += 2; + } else if (i + 1 < n && t_is_binop(a[i + 1])) { + if (i + 2 >= n) { *err = 1; i = n; r = 0; } + else { r = t_binop(a[i], a[i + 1], a[i + 2], err); i += 3; } + } else { + r = s[0] != 0; + i++; + } + t_push_operand(&t, r); + expect_operand = 0; + continue; + } + if (i < n && (strcmp(a[i], "-a") == 0 || strcmp(a[i], "-o") == 0)) { + char op = a[i][1]; + while (t.nops > 0 && t.ops[t.nops - 1] != '(' && (op == 'o' || t.ops[t.nops - 1] == 'a')) t_reduce(&t); + t_push_op(&t, op); + i++; + expect_operand = 1; + continue; + } + if (i < n && strcmp(a[i], ")") == 0) { + while (t.nops > 0 && t.ops[t.nops - 1] != '(') t_reduce(&t); + if (t.nops == 0) break; // unmatched ')': left for the caller to reject + t.nops--; // the '(' + i++; + int v = t.vals[--t.nvals]; + t_push_operand(&t, v); + continue; + } + break; + } + while (t.nops > 0) { + if (t.ops[t.nops - 1] == '(') { *err = 1; t.nops--; continue; } // missing ')' + if (t.ops[t.nops - 1] == '!') { t.nops--; continue; } + t_reduce(&t); + } + int result = t.nvals > 0 ? t.vals[t.nvals - 1] : 0; + free(t.ops); + free(t.vals); + *consumed = i; + return result; } // Evaluate a test expression (argv/argc already stripped of the program name @@ -189,9 +221,9 @@ static int eval_test(int argc, char **argv) return err ? 2 : (r ? 0 : 1); } - tparse t = { argv, argc, 0, 0 }; - int r = t_oexpr(&t); - if (t.err || t.i != t.n) return 2; + int err = 0, consumed = 0; + int r = t_evaluate(argv, argc, &err, &consumed); + if (err || consumed != argc) return 2; return r ? 0 : 1; } @@ -249,60 +281,6 @@ static int builtin_echo(int argc, char **argv) return 0; } -// ---- eval / source --------------------------------------------------------- - -static int builtin_eval(sh_state *st, int argc, char **argv) -{ - if (argc < 2) return 0; - int len = 0; - for (int i = 1; i < argc; i++) len += (int)strlen(argv[i]) + 1; - char *joined = malloc(len + 1); - joined[0] = 0; - for (int i = 1; i < argc; i++) { - if (i > 1) strcat(joined, " "); - strcat(joined, argv[i]); - } - int rc = sh_run_string(st, joined); - free(joined); - // A syntax error in eval'd text is fatal in a non-interactive shell - // (dash aborts with status 2); a runtime failure inside is not. - if (st->parse_error) { st->exiting = 1; st->exit_code = 2; } - return rc; -} - -static int builtin_source(sh_state *st, int argc, char **argv) -{ - if (argc < 2) { fprintf(stderr, "%s: filename argument required\n", argv[0]); return 2; } - /* Tactility: read through the bridge rather than fopen() directly. The shell's working - * directory is tracked in ShellFs and is unrelated to the C library's, so a relative path - * would otherwise resolve against the wrong place - and file access has to take the mount - * lock, since the display and SD card can share a bus. */ - size_t len = 0; - char *src = shell_bridge_read_file(argv[1], &len); - if (!src) { fprintf(stderr, "%s: %s: cannot open\n", argv[0], argv[1]); return 1; } - - // Extra args set the positional params for the duration (dash behavior). - char **sp = NULL; int snp = 0, replaced = 0; - if (argc > 2) { - sp = st->pos; snp = st->npos; - st->pos = NULL; st->npos = 0; - sh_set_positional(st, NULL, argv + 2, argc - 2); - replaced = 1; - } - int rc = sh_run_string(st, src); - if (replaced) { - for (int i = 0; i < st->npos; i++) free(st->pos[i]); - free(st->pos); - st->pos = sp; st->npos = snp; - } - free(src); - // `return` inside a sourced file stops the file, not the whole shell. - if (st->returning) { rc = st->return_code; st->returning = 0; } - // A syntax error in the sourced file is fatal (dash aborts with status 2). - if (st->parse_error) { st->exiting = 1; st->exit_code = 2; } - return rc; -} - // ---- read ------------------------------------------------------------------ // Field-split the raw line (buf[0..len), with a parallel `esc` flag marking @@ -514,11 +492,6 @@ int sh_run_builtin(sh_state *st, int argc, char **argv, int *status) return 1; } - if (strcmp(cmd, "eval") == 0) { *status = builtin_eval(st, argc, argv); return 1; } - if (strcmp(cmd, ".") == 0 || strcmp(cmd, "source") == 0) { - *status = builtin_source(st, argc, argv); - return 1; - } if (strcmp(cmd, "read") == 0) { *status = builtin_read(st, argc, argv); return 1; } if (strcmp(cmd, "break") == 0 || strcmp(cmd, "continue") == 0) { diff --git a/Tactility/Source/app/shell/shell/sh_exec.c b/Tactility/Source/app/shell/shell/sh_exec.c deleted file mode 100644 index 2189be3e9..000000000 --- a/Tactility/Source/app/shell/shell/sh_exec.c +++ /dev/null @@ -1,559 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -static int exec_node(sh_state *st, node *n, int exempt); -static int call_function(sh_state *st, sh_func *fn, int argc, char **argv, int exempt); - -// Run one already-expanded argv (function, builtin, or external), no redirection. -// `exempt` marks an errexit-suppressed context (propagated into functions). -static int run_argv(sh_state *st, int argc, char **argv, int exempt) -{ - int status = 0; - // Functions override non-special builtins and externals (dash order). - sh_func *fn = sh_func_find(st, argv[0]); - if (fn) return call_function(st, fn, argc, argv, exempt); - if (sh_run_builtin(st, argc, argv, &status)) return status; - int found = 0; - status = sh_port_run_external(argc, argv, &found); - if (!found) { - fprintf(stderr, "\x1B[91m%s: not found\x1B[0m\n", argv[0]); - return 127; - } - return status; -} - -// Apply a "NAME=raw" assignment (value is expanded). A leading `~` in the value -// tilde-expands via the normal word-start rule (so a=~/src works); the full -// POSIX colon rule (PATH=~/bin:~/sbin) is deliberately left out. -static void apply_assign(sh_state *st, const char *raw) -{ - const char *eq = strchr(raw, '='); - if (!eq) return; - int nlen = (int)(eq - raw); - char name[128]; - if (nlen >= (int)sizeof(name)) nlen = sizeof(name) - 1; - memcpy(name, raw, nlen); - name[nlen] = 0; - char *val = sh_expand_single(st, eq + 1); - sh_set(st, name, val); - free(val); -} - -// Build argv by expanding all words. Returns argc; argv is malloc'd and -// NULL-terminated (caller frees each item and the array). -static int build_argv(sh_state *st, node *n, char ***out_argv) -{ - sh_fields f; - sh_fields_init(&f); - for (int i = 0; i < n->nword; i++) sh_expand_word(st, n->words[i], &f); - char **argv = malloc((f.count + 1) * sizeof(char *)); - for (int i = 0; i < f.count; i++) argv[i] = f.items[i]; // take ownership - argv[f.count] = NULL; - free(f.items); // items themselves transferred - *out_argv = argv; - return f.count; -} - -static void free_argv(char **argv, int argc) -{ - for (int i = 0; i < argc; i++) free(argv[i]); - free(argv); -} - -// Applied redirection context: the saved streams plus resources to release when -// the command finishes (expanded target strings and here-doc temp files). -typedef struct { - sh_redir_saved io; - int active; - char *paths[16]; int npath; // expanded filename strings to free - char *tmps[8]; int ntmp; // here-doc temp files to remove + free -} redir_state; - -// Build the ordered runtime redirect list from a node and apply it. Returns 0 -// on success (rs holds state for redir_end), -1 on failure (rs already cleaned; -// do not call redir_end). -static int redir_begin(sh_state *st, node *n, redir_state *rs) -{ - rs->active = 0; rs->npath = 0; rs->ntmp = 0; - sh_redir_rt items[16]; - int ni = 0; - for (int i = 0; i < n->nredir && ni < 16; i++) { - sh_redir *r = &n->redirs[i]; - sh_redir_rt *it = &items[ni]; - it->fd = r->fd; it->dupfd = r->dupfd; it->path = NULL; - if (r->kind == R_HEREDOC) { - char *body = r->heredoc_quoted ? strdup(r->word) - : sh_expand_heredoc(st, r->word); - char buf[512]; - sh_port_tmpfile(2, buf, sizeof(buf)); - FILE *hf = fopen(buf, "w"); - if (hf) { fwrite(body, 1, strlen(body), hf); fclose(hf); } - free(body); - char *tmp = strdup(buf); - if (rs->ntmp < 8) rs->tmps[rs->ntmp++] = tmp; - it->op = SH_RD_IN; it->fd = 0; it->path = tmp; - } else if (r->kind == R_DUP) { - it->op = SH_RD_DUP; - } else if (r->kind == R_CLOSE) { - it->op = SH_RD_CLOSE; - } else { - char *path = sh_expand_single(st, r->word); - if (rs->npath < 16) rs->paths[rs->npath++] = path; - it->op = (r->kind == R_IN) ? SH_RD_IN - : (r->kind == R_APPEND) ? SH_RD_APPEND : SH_RD_OUT; - it->path = path; - } - ni++; - } - if (sh_redir_apply(items, ni, &rs->io) != 0) { - for (int i = 0; i < rs->npath; i++) free(rs->paths[i]); - for (int i = 0; i < rs->ntmp; i++) { remove(rs->tmps[i]); free(rs->tmps[i]); } - return -1; - } - rs->active = 1; - return 0; -} - -static void redir_end(redir_state *rs) -{ - if (rs->active) sh_redir_restore(&rs->io); - for (int i = 0; i < rs->npath; i++) free(rs->paths[i]); - for (int i = 0; i < rs->ntmp; i++) { remove(rs->tmps[i]); free(rs->tmps[i]); } -} - -static int exec_simple(sh_state *st, node *n, int exempt) -{ - st->cmdsub_ran = 0; - - // Pure assignment (no command words): persist to shell state. If a value - // contained command substitution, $? is that command sub's status; else 0. - if (n->nword == 0) { - for (int i = 0; i < n->nassign; i++) apply_assign(st, n->assigns[i]); - if (st->exiting) return st->exit_code; // ${a?msg} in a value is fatal - if (n->nredir > 0) { - // Bare redirect (`>file`): open/close the targets, run nothing. - redir_state rs; - if (redir_begin(st, n, &rs) != 0) return 2; - redir_end(&rs); - } - return st->cmdsub_ran ? st->cmdsub_status : 0; - } - - char **argv; - int argc = build_argv(st, n, &argv); - // A ${v:?} expansion may have aborted mid-word; don't run the command. - if (st->exiting) { - free_argv(argv, argc); - return st->exit_code; - } - if (argc == 0) { - // Command expanded to nothing (e.g. `$(exit 42)`); still apply pure - // assignments. $? is the last command sub's status, or 0 if none ran. - int cs = st->cmdsub_ran ? st->cmdsub_status : 0; - for (int i = 0; i < n->nassign; i++) apply_assign(st, n->assigns[i]); - free_argv(argv, argc); - return cs; - } - - // Temporary bindings: apply assignments, remember old values to restore. - int nsaved = n->nassign; - char *saved_names[32]; - char *saved_vals[32]; - char *saved_env[32]; - if (nsaved > 32) nsaved = 32; - for (int i = 0; i < nsaved; i++) { - const char *eq = strchr(n->assigns[i], '='); - int nlen = (int)(eq - n->assigns[i]); - char *nm = malloc(nlen + 1); - memcpy(nm, n->assigns[i], nlen); nm[nlen] = 0; - saved_names[i] = nm; - const char *ov = sh_get(st, nm); - saved_vals[i] = ov ? strdup(ov) : NULL; - const char *oe = getenv(nm); - saved_env[i] = oe ? strdup(oe) : NULL; - apply_assign(st, n->assigns[i]); - // A prefix binding is exported to the command's environment (POSIX); - // setenv so the forked child inherits it. Restored below. - const char *nv = sh_get(st, nm); - setenv(nm, nv ? nv : "", 1); - } - - // Redirections (ordered, fd-level). - int status; - redir_state rs; - if (st->exiting) { // fatal ${a?msg} while expanding a prefix binding - free_argv(argv, argc); - for (int i = 0; i < nsaved; i++) { free(saved_names[i]); free(saved_vals[i]); free(saved_env[i]); } - return st->exit_code; - } - if (redir_begin(st, n, &rs) != 0) { - fprintf(stderr, "%s: redirection failed\n", argv[0]); - status = 2; // dash exits a failed redirection with status 2 - } else { - status = run_argv(st, argc, argv, exempt); - redir_end(&rs); - } - - // Restore temporary bindings. - for (int i = 0; i < nsaved; i++) { - if (saved_vals[i]) sh_set(st, saved_names[i], saved_vals[i]); - else sh_unset(st, saved_names[i]); - if (saved_env[i]) setenv(saved_names[i], saved_env[i], 1); - else unsetenv(saved_names[i]); - free(saved_names[i]); - free(saved_vals[i]); - free(saved_env[i]); - } - - free_argv(argv, argc); - return status; -} - -static int exec_pipe(sh_state *st, node *n, int exempt) -{ - if (n->nchild == 1) return exec_node(st, n->children[0], exempt); - - // Pipelines are emulated with temp files. A stage may itself be a compound - // command containing another pipeline (`{ a | b; } | c`, a pipe inside a - // loop body, `eval` of a pipeline, ...), so each nesting level must use its - // own temp files -- otherwise the inner pipeline clobbers and removes the - // files backing the outer stage's redirection. Track depth and derive - // distinct file ids from it. - static int pipe_depth = 0; - int d = pipe_depth++; - - char tmpa[512], tmpb[512]; - sh_port_tmpfile(2 * d, tmpa, sizeof(tmpa)); - sh_port_tmpfile(2 * d + 1, tmpb, sizeof(tmpb)); - - int status = 0; - const char *prev_in = NULL; - for (int i = 0; i < n->nchild; i++) { - const char *out = (i < n->nchild - 1) ? ((i % 2) ? tmpa : tmpb) : NULL; - node *c = n->children[i]; - - sh_redir_rt items[2]; - int ni = 0; - if (prev_in) items[ni++] = (sh_redir_rt){ 0, SH_RD_IN, prev_in, 0 }; - if (out) items[ni++] = (sh_redir_rt){ 1, SH_RD_OUT, out, 0 }; - sh_redir_saved io; - if (sh_redir_apply(items, ni, &io) != 0) { - status = 1; - break; - } - // Each stage runs like a subshell for control flow: an inner `exit`, - // `return`, or errexit abort stays local to the stage (real shells fork - // each stage). Only the final stage's status becomes the pipeline's, and - // errexit is decided at the pipeline level (see exec_node), so the stage - // itself runs with errexit active (exempt inherited from the pipeline). - int s_exit = st->exiting, s_code = st->exit_code; - int s_brk = st->brk, s_cont = st->cont; - int s_ret = st->returning, s_rcode = st->return_code; - status = exec_node(st, c, exempt); - st->exiting = s_exit; st->exit_code = s_code; - st->brk = s_brk; st->cont = s_cont; - st->returning = s_ret; st->return_code = s_rcode; - sh_redir_restore(&io); - - prev_in = out; - } - remove(tmpa); - remove(tmpb); - pipe_depth--; - return status; -} - -static int exec_list(sh_state *st, node *n, int exempt) -{ - int status = 0; - for (int i = 0; i < n->nchild; i++) { - status = exec_node(st, n->children[i], exempt); - if (st->exiting || st->returning || st->brk || st->cont) break; - } - return status; -} - -static int exec_andor(sh_state *st, node *n, int exempt) -{ - // The left operand is always errexit-exempt (its failure is tested by the - // &&/||). The right (tail) operand inherits the surrounding context. - int status = exec_node(st, n->left, 1); - if (st->exiting || st->returning) return status; - if (n->andor_op == T_AMPAMP) { - if (status == 0) status = exec_node(st, n->right, exempt); - } else { // T_BARBAR - if (status != 0) status = exec_node(st, n->right, exempt); - } - return status; -} - -static int exec_if(sh_state *st, node *n, int exempt) -{ - for (int i = 0; i < n->nclause; i++) { - int c = exec_node(st, n->conds[i], 1); // condition: errexit-exempt - if (st->exiting || st->returning) return c; - if (c == 0) return exec_node(st, n->bodies[i], exempt); - } - if (n->else_body) return exec_node(st, n->else_body, exempt); - return 0; -} - -// Handle break/continue after running a loop body. Returns 1 if the loop -// should stop, 0 if it should continue to the next iteration. -static int loop_control(sh_state *st) -{ - if (st->exiting || st->returning) return 1; - if (st->brk) { st->brk--; return 1; } // break; outer loops see remaining - if (st->cont) { st->cont--; return st->cont ? 1 : 0; } - return 0; -} - -static int exec_while(sh_state *st, node *n, int exempt) -{ - int status = 0; - st->loop_depth++; - while (!st->exiting) { - int c = exec_node(st, n->cond, 1); // condition: errexit-exempt - // A break/continue evaluated inside the condition applies to this loop. - if (st->brk || st->cont) { loop_control(st); break; } - if (n->until) c = (c == 0); // `until`: loop while cond fails - if (st->exiting || st->returning || c != 0) break; - status = exec_node(st, n->body, exempt); - if (loop_control(st)) break; - } - st->loop_depth--; - return status; -} - -static int exec_for(sh_state *st, node *n, int exempt) -{ - int status = 0; - sh_fields f; - sh_fields_init(&f); - if (n->for_implicit) { - // `for x; do` iterates over "$@" (each positional param, unsplit). - for (int i = 0; i < st->npos; i++) sh_fields_push(&f, st->pos[i]); - } else { - for (int i = 0; i < n->for_nword; i++) sh_expand_word(st, n->for_words[i], &f); - } - st->loop_depth++; - for (int i = 0; i < f.count && !st->exiting; i++) { - sh_set(st, n->for_name, f.items[i]); - status = exec_node(st, n->body, exempt); - if (loop_control(st)) break; - } - st->loop_depth--; - sh_fields_free(&f); - return status; -} - -static int exec_case(sh_state *st, node *n, int exempt) -{ - char *subj = sh_expand_single(st, n->case_word); - int status = 0; - for (int i = 0; i < n->nclause_case; i++) { - sh_case_clause *cl = &n->clauses[i]; - for (int j = 0; j < cl->npat; j++) { - char *pat = sh_expand_single(st, cl->pats[j]); - int hit = sh_pattern_match(pat, subj); - free(pat); - if (hit) { - status = exec_node(st, cl->body, exempt); - free(subj); - return status; // first match wins, no fallthrough - } - } - } - free(subj); - return status; -} - -// Deep-copy / free the variable list for subshell isolation. -static sh_var *clone_vars(sh_var *v) -{ - sh_var *head = NULL, **tail = &head; - for (; v; v = v->next) { - sh_var *c = malloc(sizeof(*c)); - c->name = strdup(v->name); - c->value = strdup(v->value); - c->exported = v->exported; - c->next = NULL; - *tail = c; - tail = &c->next; - } - return head; -} - -static void free_vars(sh_var *v) -{ - while (v) { sh_var *nx = v->next; free(v->name); free(v->value); free(v); v = nx; } -} - -static int exec_group(sh_state *st, node *n, int exempt) -{ - if (!n->subshell) return exec_node(st, n->body, exempt); - - // Subshell ( list ): snapshot vars, positional params, cwd, and control - // flags; run isolated; restore everything but the resulting $?. - sh_var *saved_vars = clone_vars(st->vars); - char **saved_pos = st->pos; - int saved_npos = st->npos; - char *saved_arg0 = st->arg0 ? strdup(st->arg0) : NULL; - st->pos = NULL; st->npos = 0; - if (saved_pos) { - st->pos = malloc(saved_npos * sizeof(char *)); - for (int i = 0; i < saved_npos; i++) st->pos[i] = strdup(saved_pos[i]); - st->npos = saved_npos; - } - char cwd[512]; - sh_port_getcwd(cwd, sizeof(cwd)); - int s_exit = st->exiting, s_code = st->exit_code; - int s_brk = st->brk, s_cont = st->cont; - int s_ret = st->returning, s_rcode = st->return_code; - int s_ee = st->opt_errexit, s_nu = st->opt_nounset; - - int status = exec_node(st, n->body, exempt); - - // Restore. Option changes (set -e/-u) inside a subshell must not leak out. - free_vars(st->vars); - st->vars = saved_vars; - for (int i = 0; i < st->npos; i++) free(st->pos[i]); - free(st->pos); - st->pos = saved_pos; st->npos = saved_npos; - free(st->arg0); st->arg0 = saved_arg0; - sh_port_chdir(cwd); - st->exiting = s_exit; st->exit_code = s_code; - st->brk = s_brk; st->cont = s_cont; - st->returning = s_ret; st->return_code = s_rcode; - st->opt_errexit = s_ee; st->opt_nounset = s_nu; - return status; -} - -static int exec_funcdef(sh_state *st, node *n) -{ - sh_func_define(st, n->func_name, n->body); - n->body = NULL; // ownership transferred; keep sh_free_node(root) from freeing it - return 0; -} - -// Call a function: swap in the call args as $1..$#, run the body under a fresh -// `local` scope, honor `return`, and restore the caller's positional params. -static int call_function(sh_state *st, sh_func *fn, int argc, char **argv, int exempt) -{ - if (st->call_depth >= SH_MAX_CALL_DEPTH) { - fprintf(stderr, "%s: recursion too deep\n", fn->name); - return 1; - } - char **saved_pos = st->pos; - int saved_npos = st->npos; - st->pos = NULL; st->npos = 0; - sh_set_positional(st, NULL, argv + 1, argc - 1); // $0 unchanged (dash) - - sh_scope_push(st); - st->call_depth++; - int prev_ret = st->returning; - st->returning = 0; - - exec_node(st, fn->body, exempt); - - int code = st->returning ? st->return_code : st->last_status; - st->returning = prev_ret; - st->call_depth--; - sh_scope_pop(st); - - for (int i = 0; i < st->npos; i++) free(st->pos[i]); - free(st->pos); - st->pos = saved_pos; st->npos = saved_npos; - return code; -} - -static int exec_node(sh_state *st, node *n, int exempt) -{ - // Compound commands may carry a trailing redirect list; apply it around the - // whole command. Simple commands handle their own redirects internally. - redir_state rs; - int has_redir = (n->kind != N_SIMPLE) && n->nredir > 0; - if (has_redir && redir_begin(st, n, &rs) != 0) { - st->last_status = 1; - return 1; - } - - // A negated pipeline (`! cmd`) is an errexit-exempt context throughout, and - // its own non-zero result never triggers errexit. - int inner_exempt = exempt || n->negated; - - int status = 0; - switch (n->kind) { - case N_LIST: status = exec_list(st, n, inner_exempt); break; - case N_ANDOR: status = exec_andor(st, n, inner_exempt); break; - case N_PIPE: status = exec_pipe(st, n, inner_exempt); break; - case N_SIMPLE: status = exec_simple(st, n, inner_exempt); break; - case N_IF: status = exec_if(st, n, inner_exempt); break; - case N_WHILE: status = exec_while(st, n, inner_exempt); break; - case N_FOR: status = exec_for(st, n, inner_exempt); break; - case N_CASE: status = exec_case(st, n, inner_exempt); break; - case N_GROUP: status = exec_group(st, n, inner_exempt); break; - case N_FUNCDEF: status = exec_funcdef(st, n); break; - } - if (has_redir) redir_end(&rs); - - if (n->negated) status = (status == 0); - st->last_status = status; - - // errexit: a command that returns non-zero in a non-exempt context aborts. - // Structural nodes (list / and-or / brace group) don't trigger — their inner - // commands already decide — but a subshell `( )` is a command, so it does. - // Setting `exiting` stops the current command list; a subshell/$() confines - // it, the top level exits. - int structural = (n->kind == N_LIST || n->kind == N_ANDOR || - (n->kind == N_GROUP && !n->subshell)); - if (st->opt_errexit && !exempt && !n->negated && status != 0 && !structural && - !st->exiting && !st->returning && !st->brk && !st->cont) { - st->exiting = 1; - st->exit_code = status; - } - return status; -} - -int sh_run_string_args(sh_state *st, const char *src, int argc, char **argv) -{ - if (argv) { - const char *arg0 = argc > 0 ? argv[0] : NULL; - sh_set_positional(st, arg0, argv + 1, argc > 1 ? argc - 1 : 0); - } - return sh_run_string(st, src); -} - -int sh_run_string(sh_state *st, const char *src) -{ - st->parse_error = 0; - sh_toklist tl; - if (sh_lex(src, &tl) != 0) { - fprintf(stderr, "sh: syntax error (unterminated quote)\n"); - st->parse_error = 1; - st->last_status = 2; - return 2; - } - const char *err = NULL; - node *root = sh_parse(&tl, &err); - if (!root) { - fprintf(stderr, "sh: syntax error: %s\n", err ? err : "parse error"); - st->parse_error = 1; - sh_toklist_free(&tl); - st->last_status = 2; - return 2; - } - int status = exec_node(st, root, 0); - sh_free_node(root); - sh_toklist_free(&tl); - if (st->exiting) return st->exit_code; - return status; -} diff --git a/Tactility/Source/app/shell/shell/sh_exec.cpp b/Tactility/Source/app/shell/shell/sh_exec.cpp new file mode 100644 index 000000000..276cfe681 --- /dev/null +++ b/Tactility/Source/app/shell/shell/sh_exec.cpp @@ -0,0 +1,1066 @@ +// Execution: the frame machine plus the frames for programs, commands and +// control flow. See sh_machine.h for how frames interact. +// +// Frames that change shell state for their duration (loop depth, a subshell +// snapshot, a function's positional params, prefix assignments, redirected +// streams) undo it in their destructor. That covers both normal completion +// and the machine unwinding everything after "nesting too deep". +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace sh { + +namespace { + +constexpr MemoryPolicy FRAME_MEMORY_POLICY = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 }; + +// Prefix assignments (`A=1 B=2 cmd`) beyond this many are not applied. +constexpr int MAX_PREFIX_ASSIGNMENTS = 32; + +// Deep-copy / free the variable list for subshell isolation. +sh_var* clone_vars(sh_var* v) +{ + sh_var* head = nullptr; + sh_var** tail = &head; + for (; v; v = v->next) { + auto* c = static_cast(malloc(sizeof(sh_var))); + c->name = strdup(v->name); + c->value = strdup(v->value); + c->exported = v->exported; + c->next = nullptr; + *tail = c; + tail = &c->next; + } + return head; +} + +void free_vars(sh_var* v) +{ + while (v) { + sh_var* next = v->next; + free(v->name); + free(v->value); + free(v); + v = next; + } +} + +void free_positional(char** pos, int npos) +{ + for (int i = 0; i < npos; i++) free(pos[i]); + free(pos); +} + +/** NULL-terminated argv pointing into `fields`, for the C APIs. */ +std::vector to_argv(Fields& fields) +{ + std::vector argv; + argv.reserve(fields.size() + 1); + for (auto& field : fields) argv.push_back(field.data()); + argv.push_back(nullptr); + return argv; +} + +std::string temp_path(int which) +{ + char buffer[512]; + sh_port_tmpfile(which, buffer, sizeof(buffer)); + return buffer; +} + +// Handle break/continue after running a loop body. Returns true if the loop +// should stop, false if it should continue to the next iteration. +bool loop_control(sh_state& st) +{ + if (st.exiting || st.returning) return true; + if (st.brk) { st.brk--; return true; } // break; outer loops see remaining + if (st.cont) { st.cont--; return st.cont != 0; } + return false; +} + +/** The variable name of a "NAME=raw" assignment word. */ +std::string assignment_name(const char* raw) +{ + const char* eq = strchr(raw, '='); + size_t length = eq ? static_cast(eq - raw) : strlen(raw); + return std::string(raw, std::min(length, 127)); +} + +const char* assignment_value(const char* raw) +{ + const char* eq = strchr(raw, '='); + return eq ? eq + 1 : ""; +} + +} // namespace + +// region Support types + +void FieldBuilder::emit(Fields& out) +{ + if (started) out.push_back(text); + text.clear(); + started = false; +} + +void FieldBuilder::emitForced(Fields& out) +{ + out.push_back(text); + text.clear(); + started = false; +} + +Redirections::~Redirections() +{ + restore(); + for (auto& path : tempFiles) remove(path.c_str()); +} + +void Redirections::restore() +{ + if (active) { + sh_redir_restore(&io); + active = false; + } +} + +FrameStack::~FrameStack() +{ + while (!empty()) pop(); + if (spare != nullptr) memory_free(spare); +} + +bool FrameStack::push(Frame&& frame) +{ + if (top == nullptr || top->count == CHUNK_SIZE) { + Chunk* chunk = spare; + spare = nullptr; + if (chunk == nullptr) { + chunk = static_cast(memory_alloc_with_policy(sizeof(Chunk), &FRAME_MEMORY_POLICY)); + if (chunk == nullptr) return false; + } + chunk->previous = top; + chunk->count = 0; + top = chunk; + } + new (top->storage + top->count * sizeof(Frame)) Frame(std::move(frame)); + top->count++; + count++; + return true; +} + +void FrameStack::pop() +{ + back().~Frame(); + top->count--; + count--; + if (top->count == 0) { + Chunk* emptied = top; + top = emptied->previous; + if (spare != nullptr) memory_free(spare); + spare = emptied; + } +} + +int Machine::run(Frame root) +{ + if (!frames.push(std::move(root))) { + fprintf(stderr, "sh: out of memory\n"); + st.last_status = 2; + return 2; + } + while (!frames.empty()) { + Step step = std::visit([this](auto& frame) { return frame.step(*this); }, frames.back()); + if (step == Step::Return) { + frames.pop(); + continue; + } + bool pushed = frames.size() < SH_MAX_FRAMES && frames.push(std::move(*pending)); + pending.reset(); + if (!pushed) { + fprintf(stderr, "sh: nesting too deep\n"); + // Popping runs every frame's cleanup: streams, temp files, scopes and loop depth are restored. + while (!frames.empty()) frames.pop(); + st.brk = 0; + st.cont = 0; + st.returning = 0; + st.last_status = 2; + return 2; + } + } + return result.status; +} + +// endregion + +// region RunProgram + +RunProgram::~RunProgram() +{ + sh_free_node(root); + if (state != nullptr) restorePositional(*state); +} + +void RunProgram::restorePositional(sh_state& st) +{ + if (!positionalSwapped) return; + positionalSwapped = false; + free_positional(st.pos, st.npos); + st.pos = savedPositional; + st.npos = savedPositionalCount; +} + +Step RunProgram::step(Machine& m) +{ + sh_state& st = m.st; + if (started) { + int status = m.result.status; + sh_free_node(root); + root = nullptr; + return finish(m, st.exiting ? st.exit_code : status); + } + started = true; + state = &st; + + if (positional.has_value()) { + // Extra `source` arguments set the positional params for the duration (dash behavior). + savedPositional = st.pos; + savedPositionalCount = st.npos; + st.pos = nullptr; + st.npos = 0; + auto argv = to_argv(*positional); + sh_set_positional(&st, nullptr, argv.data(), static_cast(positional->size())); + positionalSwapped = true; + } + + st.parse_error = 0; + sh_toklist tl; + if (sh_lex(source.c_str(), &tl) != 0) { + fprintf(stderr, "sh: syntax error (unterminated quote)\n"); + st.parse_error = 1; + st.last_status = 2; + return finish(m, 2); + } + const char* err = nullptr; + root = sh_parse(&tl, &err); + sh_toklist_free(&tl); + if (!root) { + fprintf(stderr, "sh: syntax error: %s\n", err ? err : "parse error"); + st.parse_error = 1; + st.last_status = 2; + return finish(m, 2); + } + return m.call(ExecNode(root, 0)); +} + +Step RunProgram::finish(Machine& m, int status) +{ + sh_state& st = m.st; + if (mode == Mode::Source) { + restorePositional(st); + // `return` inside a sourced file stops the file, not the whole shell. + if (st.returning) { status = st.return_code; st.returning = 0; } + } + // A syntax error in eval'd or sourced text is fatal in a non-interactive shell + // (dash aborts with status 2); a runtime failure inside is not. + if (mode != Mode::Plain && st.parse_error) { + st.exiting = 1; + st.exit_code = 2; + } + return m.done(status); +} + +// endregion + +// region ExecNode + +Step ExecNode::step(Machine& m) +{ + switch (phase) { + case Phase::Start: + // Compound commands may carry a trailing redirect list; apply it around the + // whole command. Simple commands handle their own redirects internally. + if (n->kind != N_SIMPLE && n->nredir > 0) { + phase = Phase::Redirected; + redirections = std::make_unique(); + return m.call(ApplyRedirects { .n = n, .target = redirections.get() }); + } + return body(m); + case Phase::Redirected: + if (m.result.status != 0) { + m.st.last_status = 1; + return m.done(1); + } + return body(m); + case Phase::Body: + break; + } + int status = m.result.status; + if (redirections) redirections->restore(); + return finish(m, status); +} + +Step ExecNode::body(Machine& m) +{ + // A negated pipeline (`! cmd`) is an errexit-exempt context throughout, and + // its own non-zero result never triggers errexit. + int inner = exempt || n->negated; + phase = Phase::Body; + switch (n->kind) { + case N_LIST: return m.call(ExecList { .n = n, .exempt = inner }); + case N_ANDOR: return m.call(ExecAndOr { .n = n, .exempt = inner }); + case N_PIPE: return m.call(ExecPipe(n, inner)); + case N_SIMPLE: return m.call(ExecSimple(n, inner)); + case N_IF: return m.call(ExecIf { .n = n, .exempt = inner }); + case N_WHILE: return m.call(ExecWhile(n, inner)); + case N_FOR: return m.call(ExecFor(n, inner)); + case N_CASE: return m.call(ExecCase { .n = n, .exempt = inner }); + case N_GROUP: return m.call(ExecGroup(n, inner)); + case N_FUNCDEF: + sh_func_define(&m.st, n->func_name, n->body); + n->body = nullptr; // ownership transferred; keep sh_free_node(root) from freeing it + break; + } + return finish(m, 0); +} + +Step ExecNode::finish(Machine& m, int status) +{ + sh_state& st = m.st; + if (n->negated) status = (status == 0); + st.last_status = status; + + // errexit: a command that returns non-zero in a non-exempt context aborts. + // Structural nodes (list / and-or / brace group) don't trigger — their inner + // commands already decide — but a subshell `( )` is a command, so it does. + // Setting `exiting` stops the current command list; a subshell/$() confines + // it, the top level exits. + bool structural = n->kind == N_LIST || n->kind == N_ANDOR || (n->kind == N_GROUP && !n->subshell); + if (st.opt_errexit && !exempt && !n->negated && status != 0 && !structural && + !st.exiting && !st.returning && !st.brk && !st.cont) { + st.exiting = 1; + st.exit_code = status; + } + return m.done(status); +} + +// endregion + +// region Lists, and-or, if, loops, case + +Step ExecList::step(Machine& m) +{ + sh_state& st = m.st; + if (started) { + status = m.result.status; + if (st.exiting || st.returning || st.brk || st.cont) return m.done(status); + index++; + } + started = true; + if (index < n->nchild) return m.call(ExecNode(n->children[index], exempt)); + return m.done(status); +} + +Step ExecAndOr::step(Machine& m) +{ + sh_state& st = m.st; + switch (phase) { + case Phase::Start: + // The left operand is always errexit-exempt (its failure is tested by the + // &&/||). The right (tail) operand inherits the surrounding context. + phase = Phase::Left; + return m.call(ExecNode(n->left, 1)); + case Phase::Left: { + int status = m.result.status; + if (st.exiting || st.returning) return m.done(status); + bool runRight = (n->andor_op == T_AMPAMP) ? status == 0 : status != 0; + if (!runRight) return m.done(status); + phase = Phase::Right; + return m.call(ExecNode(n->right, exempt)); + } + case Phase::Right: + break; + } + return m.done(m.result.status); +} + +Step ExecIf::step(Machine& m) +{ + sh_state& st = m.st; + switch (phase) { + case Phase::Start: + break; + case Phase::Condition: { + int c = m.result.status; + if (st.exiting || st.returning) return m.done(c); + if (c == 0) { + phase = Phase::Tail; + return m.call(ExecNode(n->bodies[clause], exempt)); + } + clause++; + break; + } + case Phase::Tail: + return m.done(m.result.status); + } + if (clause < n->nclause) { + phase = Phase::Condition; + return m.call(ExecNode(n->conds[clause], 1)); // condition: errexit-exempt + } + if (n->else_body) { + phase = Phase::Tail; + return m.call(ExecNode(n->else_body, exempt)); + } + return m.done(0); +} + +ExecWhile::~ExecWhile() +{ + if (state != nullptr) state->loop_depth--; +} + +Step ExecWhile::step(Machine& m) +{ + sh_state& st = m.st; + switch (phase) { + case Phase::Start: + st.loop_depth++; + state = &st; + break; + case Phase::Condition: { + int c = m.result.status; + // A break/continue evaluated inside the condition applies to this loop. + if (st.brk || st.cont) { + loop_control(st); + return m.done(status); + } + if (n->until) c = (c == 0); // `until`: loop while cond fails + if (st.exiting || st.returning || c != 0) return m.done(status); + phase = Phase::Body; + return m.call(ExecNode(n->body, exempt)); + } + case Phase::Body: + status = m.result.status; + if (loop_control(st)) return m.done(status); + break; + } + if (st.exiting) return m.done(status); + phase = Phase::Condition; + return m.call(ExecNode(n->cond, 1)); // condition: errexit-exempt +} + +ExecFor::~ExecFor() +{ + if (state != nullptr) state->loop_depth--; +} + +Step ExecFor::step(Machine& m) +{ + sh_state& st = m.st; + switch (phase) { + case Phase::Start: + if (n->for_implicit) { + // `for x; do` iterates over "$@" (each positional param, unsplit). + for (int i = 0; i < st.npos; i++) items.emplace_back(st.pos[i]); + return next(m); + } + if (n->for_nword == 0) return next(m); + phase = Phase::Expanding; + return m.call(Expand::word(n->for_words[word])); + case Phase::Expanding: + for (auto& field : m.result.fields) items.push_back(std::move(field)); + word++; + if (word < n->for_nword) return m.call(Expand::word(n->for_words[word])); + return next(m); + case Phase::Body: + status = m.result.status; + if (loop_control(st)) return m.done(status); + item++; + break; + } + if (item < items.size() && !st.exiting) { + sh_set(&st, n->for_name, items[item].c_str()); + return m.call(ExecNode(n->body, exempt)); + } + return m.done(status); +} + +/** Enters the loop once the words are expanded. */ +Step ExecFor::next(Machine& m) +{ + sh_state& st = m.st; + st.loop_depth++; + state = &st; + phase = Phase::Body; + if (item < items.size() && !st.exiting) { + sh_set(&st, n->for_name, items[item].c_str()); + return m.call(ExecNode(n->body, exempt)); + } + return m.done(status); +} + +Step ExecCase::step(Machine& m) +{ + switch (phase) { + case Phase::Start: + phase = Phase::Subject; + return m.call(Expand::single(n->case_word)); + case Phase::Subject: + subject = std::move(m.result.text); + return nextPattern(m); + case Phase::Pattern: + if (sh_pattern_match(m.result.text.c_str(), subject.c_str())) { + phase = Phase::Tail; + return m.call(ExecNode(n->clauses[clause].body, exempt)); // first match wins, no fallthrough + } + pattern++; + return nextPattern(m); + case Phase::Tail: + break; + } + return m.done(m.result.status); +} + +/** Expands the next pattern to try, moving on to the next clause when this one is exhausted. */ +Step ExecCase::nextPattern(Machine& m) +{ + while (clause < n->nclause_case && pattern >= n->clauses[clause].npat) { + clause++; + pattern = 0; + } + if (clause >= n->nclause_case) return m.done(0); + phase = Phase::Pattern; + return m.call(Expand::single(n->clauses[clause].pats[pattern])); +} + +// endregion + +// region Groups and subshells + +ExecGroup::~ExecGroup() +{ + if (state == nullptr) return; + sh_state& st = *state; + // Restore. Option changes (set -e/-u) inside a subshell must not leak out. + free_vars(st.vars); + st.vars = savedVars; + free_positional(st.pos, st.npos); + st.pos = savedPositional; + st.npos = savedPositionalCount; + free(st.arg0); + st.arg0 = savedArg0; + sh_port_chdir(savedCwd.c_str()); + savedFlow.restore(st); + st.opt_errexit = savedErrexit; + st.opt_nounset = savedNounset; +} + +Step ExecGroup::step(Machine& m) +{ + sh_state& st = m.st; + if (started) { + return m.done(m.result.status); + } + started = true; + if (!n->subshell) return m.call(ExecNode(n->body, exempt)); + + // Subshell ( list ): snapshot vars, positional params, cwd, and control + // flags; run isolated; the destructor restores everything but the resulting $?. + savedVars = clone_vars(st.vars); + savedPositional = st.pos; + savedPositionalCount = st.npos; + savedArg0 = st.arg0 ? strdup(st.arg0) : nullptr; + st.pos = nullptr; + st.npos = 0; + if (savedPositional) { + st.pos = static_cast(malloc(savedPositionalCount * sizeof(char*))); + for (int i = 0; i < savedPositionalCount; i++) st.pos[i] = strdup(savedPositional[i]); + st.npos = savedPositionalCount; + } + char cwd[512]; + sh_port_getcwd(cwd, sizeof(cwd)); + savedCwd = cwd; + savedFlow = FlowFlags::save(st); + savedErrexit = st.opt_errexit; + savedNounset = st.opt_nounset; + state = &st; + return m.call(ExecNode(n->body, exempt)); +} + +// endregion + +// region Pipelines + +ExecPipe::~ExecPipe() +{ + if (stageRedirected) sh_redir_restore(stageIo.get()); + if (!tempA.empty()) remove(tempA.c_str()); + if (!tempB.empty()) remove(tempB.c_str()); +} + +Step ExecPipe::step(Machine& m) +{ + if (n->nchild == 1) { + if (started) return m.done(m.result.status); + started = true; + return m.call(ExecNode(n->children[0], exempt)); + } + + if (!started) { + // Pipelines are emulated with temp files. A stage may itself be a compound + // command containing another pipeline, so each pipeline gets its own files. + started = true; + stageIo = std::make_unique(); + tempA = temp_path(0); + tempB = temp_path(1); + return startStage(m); + } + + // A stage finished. Each stage runs like a subshell for control flow: an inner + // `exit`, `return`, or errexit abort stays local to the stage (real shells fork + // each stage). Only the final stage's status becomes the pipeline's, and + // errexit is decided at the pipeline level (see ExecNode). + status = m.result.status; + savedFlow.restore(m.st); + sh_redir_restore(stageIo.get()); + stageRedirected = false; + previousOutput = (stage < n->nchild - 1) ? ((stage % 2) ? tempA.c_str() : tempB.c_str()) : nullptr; + stage++; + return startStage(m); +} + +Step ExecPipe::startStage(Machine& m) +{ + if (stage >= n->nchild) return m.done(status); + + const char* output = (stage < n->nchild - 1) ? ((stage % 2) ? tempA.c_str() : tempB.c_str()) : nullptr; + sh_redir_rt items[2]; + int count = 0; + if (previousOutput) items[count++] = sh_redir_rt { 0, SH_RD_IN, previousOutput, 0 }; + if (output) items[count++] = sh_redir_rt { 1, SH_RD_OUT, output, 0 }; + if (sh_redir_apply(items, count, stageIo.get()) != 0) { + return m.done(1); + } + stageRedirected = true; + savedFlow = FlowFlags::save(m.st); + return m.call(ExecNode(n->children[stage], exempt)); +} + +// endregion + +// region Simple commands + +ExecSimple::~ExecSimple() +{ + restoreBindings(); +} + +Step ExecSimple::step(Machine& m) +{ + sh_state& st = m.st; + switch (phase) { + case Phase::Start: + st.cmdsub_ran = 0; + index = 0; + if (n->nword == 0) { + // Pure assignment (no command words): persist to shell state. + phase = Phase::PureAssign; + break; + } + phase = Phase::Words; + return m.call(Expand::word(n->words[0])); + + case Phase::PureAssign: + case Phase::EmptyAssign: + applyAssignment(m, m.result.text); + index++; + break; + + case Phase::PureRedirected: + if (m.result.status != 0) return m.done(2); + redirections->restore(); + return m.done(st.cmdsub_ran ? st.cmdsub_status : 0); + + case Phase::Words: + for (auto& field : m.result.fields) argv.push_back(std::move(field)); + index++; + if (index < n->nword) return m.call(Expand::word(n->words[index])); + // A ${v:?} expansion may have aborted mid-word; don't run the command. + if (st.exiting) return m.done(st.exit_code); + index = 0; + if (argv.empty()) { + // Command expanded to nothing (e.g. `$(exit 42)`); still apply pure + // assignments. $? is the last command sub's status, or 0 if none ran. + emptyStatus = st.cmdsub_ran ? st.cmdsub_status : 0; + phase = Phase::EmptyAssign; + } else { + // Temporary bindings: apply assignments, remember old values to restore. + phase = Phase::PrefixAssign; + } + break; + + case Phase::PrefixAssign: { + applyAssignment(m, m.result.text); + // A prefix binding is exported to the command's environment (POSIX); + // setenv so the child inherits it. Restored afterwards. + const char* value = sh_get(&st, bindings.back().name.c_str()); + setenv(bindings.back().name.c_str(), value ? value : "", 1); + index++; + break; + } + + case Phase::Redirected: + if (m.result.status != 0) { + fprintf(stderr, "%s: redirection failed\n", argv[0].c_str()); + return finish(m, 2); // dash exits a failed redirection with status 2 + } + return run(m); + + case Phase::Ran: + return finish(m, m.result.status); + } + + switch (phase) { + case Phase::PureAssign: + if (index < n->nassign) return expandAssignment(m); + if (st.exiting) return m.done(st.exit_code); // ${a?msg} in a value is fatal + if (n->nredir > 0) { + // Bare redirect (`>file`): open/close the targets, run nothing. + return redirect(m, Phase::PureRedirected); + } + return m.done(st.cmdsub_ran ? st.cmdsub_status : 0); + + case Phase::EmptyAssign: + if (index < n->nassign) return expandAssignment(m); + return m.done(emptyStatus); + + case Phase::PrefixAssign: + if (index < std::min(n->nassign, MAX_PREFIX_ASSIGNMENTS)) { + const char* raw = n->assigns[index]; + Binding binding { .name = assignment_name(raw), .value = {}, .environment = {} }; + if (const char* old = sh_get(&st, binding.name.c_str())) binding.value = old; + if (const char* old = getenv(binding.name.c_str())) binding.environment = old; + bindings.push_back(std::move(binding)); + state = &st; + return expandAssignment(m); + } + if (st.exiting) { + // fatal ${a?msg} while expanding a prefix binding: the bindings stay applied + bindings.clear(); + state = nullptr; + return m.done(st.exit_code); + } + if (n->nredir > 0) return redirect(m, Phase::Redirected); + return run(m); + + default: + return m.done(0); + } +} + +Step ExecSimple::redirect(Machine& m, Phase next) +{ + phase = next; + redirections = std::make_unique(); + return m.call(ApplyRedirects { .n = n, .target = redirections.get() }); +} + +Step ExecSimple::expandAssignment(Machine& m) +{ + return m.call(Expand::single(assignment_value(n->assigns[index]))); +} + +// Apply a "NAME=raw" assignment with its expanded value. A leading `~` in the value +// tilde-expands via the normal word-start rule (so a=~/src works); the full +// POSIX colon rule (PATH=~/bin:~/sbin) is deliberately left out. +void ExecSimple::applyAssignment(Machine& m, const std::string& value) +{ + const char* raw = n->assigns[index]; + if (!strchr(raw, '=')) return; + sh_set(&m.st, assignment_name(raw).c_str(), value.c_str()); +} + +// Run the expanded argv (function, builtin, or external), with redirects applied. +Step ExecSimple::run(Machine& m) +{ + sh_state& st = m.st; + const std::string& command = argv[0]; + phase = Phase::Ran; + + // Functions override non-special builtins and externals (dash order). + if (sh_func* function = sh_func_find(&st, command.c_str())) { + return m.call(CallFunction(function, argv, exempt)); + } + + if (command == "eval") { + if (argv.size() < 2) return finish(m, 0); + std::string joined; + for (size_t i = 1; i < argv.size(); i++) { + if (i > 1) joined.push_back(' '); + joined += argv[i]; + } + return m.call(RunProgram(std::move(joined), RunProgram::Mode::Eval)); + } + + if (command == "." || command == "source") { + if (argv.size() < 2) { + fprintf(stderr, "%s: filename argument required\n", command.c_str()); + return finish(m, 2); + } + /* Tactility: read through the bridge rather than fopen() directly. The shell's working + * directory is tracked in ShellFs and is unrelated to the C library's, so a relative path + * would otherwise resolve against the wrong place - and file access has to take the mount + * lock, since the display and SD card can share a bus. */ + size_t length = 0; + char* source = shell_bridge_read_file(argv[1].c_str(), &length); + if (!source) { + fprintf(stderr, "%s: %s: cannot open\n", command.c_str(), argv[1].c_str()); + return finish(m, 1); + } + RunProgram program(std::string(source, length), RunProgram::Mode::Source); + free(source); + if (argv.size() > 2) program.positional = Fields(argv.begin() + 2, argv.end()); + return m.call(std::move(program)); + } + + auto cargv = to_argv(argv); + int argc = static_cast(argv.size()); + int status = 0; + if (sh_run_builtin(&st, argc, cargv.data(), &status)) return finish(m, status); + + int found = 0; + status = sh_port_run_external(argc, cargv.data(), &found); + if (!found) { + fprintf(stderr, "\x1B[91m%s: not found\x1B[0m\n", command.c_str()); + status = 127; + } + return finish(m, status); +} + +Step ExecSimple::finish(Machine& m, int status) +{ + if (redirections) redirections->restore(); + restoreBindings(); + return m.done(status); +} + +// Restore temporary bindings. +void ExecSimple::restoreBindings() +{ + if (state == nullptr) return; + for (auto& binding : bindings) { + if (binding.value) sh_set(state, binding.name.c_str(), binding.value->c_str()); + else sh_unset(state, binding.name.c_str()); + if (binding.environment) setenv(binding.name.c_str(), binding.environment->c_str(), 1); + else unsetenv(binding.name.c_str()); + } + bindings.clear(); + state = nullptr; +} + +// endregion + +// region Functions + +CallFunction::~CallFunction() +{ + if (state == nullptr) return; + sh_state& st = *state; + st.returning = previousReturning; + st.call_depth--; + sh_scope_pop(&st); + free_positional(st.pos, st.npos); + st.pos = savedPositional; + st.npos = savedPositionalCount; +} + +// Call a function: swap in the call args as $1..$#, run the body under a fresh +// `local` scope, honor `return`; the destructor restores the caller's positional params. +Step CallFunction::step(Machine& m) +{ + sh_state& st = m.st; + if (state != nullptr) { + return m.done(st.returning ? st.return_code : st.last_status); + } + if (st.call_depth >= SH_MAX_CALL_DEPTH) { + fprintf(stderr, "%s: recursion too deep\n", function->name); + return m.done(1); + } + savedPositional = st.pos; + savedPositionalCount = st.npos; + st.pos = nullptr; + st.npos = 0; + auto cargv = to_argv(argv); + sh_set_positional(&st, nullptr, cargv.data() + 1, static_cast(argv.size()) - 1); // $0 unchanged (dash) + + sh_scope_push(&st); + st.call_depth++; + previousReturning = st.returning; + st.returning = 0; + state = &st; + return m.call(ExecNode(function->body, exempt)); +} + +// endregion + +// region Redirections + +// Builds the ordered runtime redirect list from a node and applies it. Here-doc +// bodies go to temp files that the target removes when it is destroyed. +Step ApplyRedirects::step(Machine& m) +{ + if (waiting) { + waiting = false; + sh_redir* r = &n->redirs[index]; + if (r->kind == R_HEREDOC) { + writeHeredoc(m.result.text); + } else { + target->paths.push_back(std::move(m.result.text)); + sh_rd_op op = (r->kind == R_IN) ? SH_RD_IN : (r->kind == R_APPEND) ? SH_RD_APPEND : SH_RD_OUT; + items.push_back(Item { op, r->fd, 0, static_cast(target->paths.size()) - 1, false }); + } + index++; + } + + while (index < n->nredir && items.size() < 16) { + sh_redir* r = &n->redirs[index]; + if (r->kind == R_HEREDOC) { + if (r->heredoc_quoted) { + writeHeredoc(r->word); + index++; + continue; + } + waiting = true; + return m.call(Expand::heredoc(r->word)); + } + if (r->kind == R_DUP) { + items.push_back(Item { SH_RD_DUP, r->fd, r->dupfd, -1, false }); + index++; + continue; + } + if (r->kind == R_CLOSE) { + items.push_back(Item { SH_RD_CLOSE, r->fd, 0, -1, false }); + index++; + continue; + } + waiting = true; + return m.call(Expand::single(r->word)); + } + + // Paths are only referenced once every word is expanded, so the vectors no longer grow. + std::vector runtime; + runtime.reserve(items.size()); + for (const auto& item : items) { + const char* path = nullptr; + if (item.path >= 0) { + path = item.temp ? target->tempFiles[item.path].c_str() : target->paths[item.path].c_str(); + } + runtime.push_back(sh_redir_rt { item.fd, item.op, path, item.dupfd }); + } + if (sh_redir_apply(runtime.data(), static_cast(runtime.size()), &target->io) != 0) { + for (auto& path : target->tempFiles) remove(path.c_str()); + target->tempFiles.clear(); + return m.done(1); + } + target->active = true; + return m.done(0); +} + +void ApplyRedirects::writeHeredoc(const std::string& body) +{ + std::string path = temp_path(2); + if (FILE* file = fopen(path.c_str(), "w")) { + fwrite(body.data(), 1, body.size(), file); + fclose(file); + } + target->tempFiles.push_back(std::move(path)); + items.push_back(Item { SH_RD_IN, 0, 0, static_cast(target->tempFiles.size()) - 1, true }); +} + +// endregion + +// region Command substitution + +CommandSubst::~CommandSubst() +{ + if (redirected) sh_redir_restore(io.get()); + if (!tempFile.empty()) remove(tempFile.c_str()); +} + +// Run `command` as command substitution: execute it with stdout captured to a temp +// file, read the output back, and strip trailing newlines (POSIX). Control-flow +// flags are saved/restored so an inner `exit`/`break`/`continue` stays local to +// the substitution. +Step CommandSubst::step(Machine& m) +{ + if (!started) { + started = true; + tempFile = temp_path(2); + savedFlow = FlowFlags::save(m.st); + sh_redir_rt item = { 1, SH_RD_OUT, tempFile.c_str(), 0 }; + io = std::make_unique(); + if (sh_redir_apply(&item, 1, io.get()) != 0) { + tempFile.clear(); + return finish(m, {}); + } + redirected = true; + return m.call(RunProgram(command, RunProgram::Mode::Plain)); + } + + sh_redir_restore(io.get()); + redirected = false; + std::string output; + if (FILE* file = fopen(tempFile.c_str(), "rb")) { + int c; + while ((c = fgetc(file)) != EOF) output.push_back(static_cast(c)); + fclose(file); + } + remove(tempFile.c_str()); + tempFile.clear(); + return finish(m, std::move(output)); +} + +Step CommandSubst::finish(Machine& m, std::string output) +{ + sh_state& st = m.st; + // Remember the substitution's own exit status: a command or assignment + // whose only "command" is command substitution(s) reports the status of + // the last one (POSIX). The program left it in last_status. + st.cmdsub_ran = 1; + st.cmdsub_status = st.last_status; + savedFlow.restore(st); + while (!output.empty() && output.back() == '\n') output.pop_back(); + return m.done(std::move(output)); +} + +// endregion + +} // namespace sh + +extern "C" { + +int sh_run_string(sh_state* st, const char* src) +{ + sh::Machine machine(*st); + return machine.run(sh::RunProgram(src, sh::RunProgram::Mode::Plain)); +} + +int sh_run_string_args(sh_state* st, const char* src, int argc, char** argv) +{ + if (argv) { + const char* arg0 = argc > 0 ? argv[0] : nullptr; + sh_set_positional(st, arg0, argv + 1, argc > 1 ? argc - 1 : 0); + } + return sh_run_string(st, src); +} + +} diff --git a/Tactility/Source/app/shell/shell/sh_expand.c b/Tactility/Source/app/shell/shell/sh_expand.c deleted file mode 100644 index 21f18c83f..000000000 --- a/Tactility/Source/app/shell/shell/sh_expand.c +++ /dev/null @@ -1,691 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include - -void sh_fields_init(sh_fields *f) { f->items = NULL; f->count = 0; f->cap = 0; } - -void sh_fields_push(sh_fields *f, const char *s) -{ - if (f->count == f->cap) { - f->cap = f->cap ? f->cap * 2 : 8; - f->items = realloc(f->items, f->cap * sizeof(char *)); - } - f->items[f->count++] = strdup(s); -} - -void sh_fields_free(sh_fields *f) -{ - for (int i = 0; i < f->count; i++) free(f->items[i]); - free(f->items); - f->items = NULL; - f->count = f->cap = 0; -} - -// A growable string used to build the current field. -typedef struct { char *buf; int len; int cap; int started; } fld; - -static void fld_init(fld *b) { b->buf = malloc(16); b->cap = 16; b->len = 0; b->started = 0; b->buf[0] = 0; } -static void fld_put(fld *b, char c) -{ - if (b->len + 1 >= b->cap) { b->cap *= 2; b->buf = realloc(b->buf, b->cap); } - b->buf[b->len++] = c; - b->buf[b->len] = 0; - b->started = 1; -} -static void fld_emit(fld *b, sh_fields *out) -{ - if (b->started) { sh_fields_push(out, b->buf); } - b->len = 0; b->started = 0; b->buf[0] = 0; -} -// Like fld_emit, but always pushes a field even when empty/unstarted. Used at an -// IFS delimiter boundary, where a field ends regardless of whether it had any -// content (so `a__b` -> a,'',b rather than a,b). -static void fld_emit_force(fld *b, sh_fields *out) -{ - sh_fields_push(out, b->buf); - b->len = 0; b->started = 0; b->buf[0] = 0; -} - -// Run `cmd` as command substitution: execute it with stdout captured to a temp -// file, read the output back, and strip trailing newlines (POSIX). Control-flow -// flags are saved/restored so an inner `exit`/`break`/`continue` stays local to -// the substitution. Returns an owned string (never NULL). -static char *command_subst(sh_state *st, const char *cmd) -{ - static int depth = 0; // nested $() get distinct temp names (avoid pipe 0/1) - char tmp[512]; - sh_port_tmpfile(2 + depth, tmp, sizeof(tmp)); - depth++; - - int s_exit = st->exiting, s_code = st->exit_code; - int s_brk = st->brk, s_cont = st->cont; - int s_ret = st->returning, s_rcode = st->return_code; - - char *buf = NULL; - int len = 0; - sh_redir_rt item = { 1, SH_RD_OUT, tmp, 0 }; - sh_redir_saved io; - if (sh_redir_apply(&item, 1, &io) == 0) { - sh_run_string(st, cmd); - sh_redir_restore(&io); - FILE *f = fopen(tmp, "rb"); - if (f) { - int cap = 0, c; - while ((c = fgetc(f)) != EOF) { - if (len + 1 >= cap) { cap = cap ? cap * 2 : 128; buf = realloc(buf, cap); } - buf[len++] = (char)c; - } - fclose(f); - } - remove(tmp); - } - - depth--; - // Remember the substitution's own exit status: a command or assignment - // whose only "command" is command substitution(s) reports the status of - // the last one (POSIX). sh_run_string left it in last_status. - st->cmdsub_ran = 1; - st->cmdsub_status = st->last_status; - st->exiting = s_exit; st->exit_code = s_code; - st->brk = s_brk; st->cont = s_cont; - st->returning = s_ret; st->return_code = s_rcode; - - if (!buf) return strdup(""); - while (len > 0 && buf[len - 1] == '\n') len--; - char *res = realloc(buf, len + 1); - if (res) buf = res; - buf[len] = 0; - return buf; -} - -// ---- parameter helpers ----------------------------------------------------- - -static const char *get_ifs(sh_state *st) -{ - const char *v = sh_get(st, "IFS"); - return v ? v : " \t\n"; -} -static int is_ifs(char c, const char *ifs) { return c && strchr(ifs, c) != NULL; } -static int is_ifs_ws(char c) { return c == ' ' || c == '\t' || c == '\n'; } - -// Append `v` to the field `b`. If `split`, break fields on $IFS following POSIX -// 2.6.5: leading IFS whitespace at the start of a field is elided; a run of IFS -// whitespace is one delimiter; each non-whitespace IFS char (with adjacent IFS -// whitespace) is also a delimiter and so can create empty fields between/at the -// front, but a lone trailing delimiter does not create a trailing empty field. -// The final (possibly partial or empty) field is left in `b` so it can continue -// across adjacent word parts; the caller flushes it with fld_emit. -static void append_val(fld *b, sh_fields *out, const char *v, int split, const char *ifs) -{ - if (!split) { for (const char *q = v; *q; q++) fld_put(b, *q); return; } - const char *q = v; - // Strip leading IFS whitespace only when no earlier part has begun this field. - if (!b->started) - while (*q && is_ifs(*q, ifs) && is_ifs_ws(*q)) q++; - for (;;) { - // Accumulate the field's content up to the next IFS char. - while (*q && !is_ifs(*q, ifs)) { fld_put(b, *q); q++; } - if (!*q) return; // field continues into next part / flush - fld_emit_force(b, out); // delimiter -> field ends (even if empty) - // Consume one delimiter: IFS whitespace, at most one non-ws IFS char, - // then trailing IFS whitespace. - while (*q && is_ifs(*q, ifs) && is_ifs_ws(*q)) q++; - if (*q && is_ifs(*q, ifs) && !is_ifs_ws(*q)) { - q++; - while (*q && is_ifs(*q, ifs) && is_ifs_ws(*q)) q++; - } - if (!*q) return; // trailing delimiter: no empty field - } -} - -// Is `name` a plain variable name (assignable, not a positional/special param)? -static int is_var_name(const char *name) -{ - if (!(isalpha((unsigned char)name[0]) || name[0] == '_')) return 0; - for (const char *q = name + 1; *q; q++) - if (!(isalnum((unsigned char)*q) || *q == '_')) return 0; - return 1; -} - -// Resolve a scalar parameter's raw value. Returns NULL if the parameter is -// unset (needed to distinguish unset from set-but-empty). *owned receives a -// malloc'd buffer the caller must free (may stay NULL). -static const char *param_raw(sh_state *st, const char *name, char **owned) -{ - *owned = NULL; - if (!name[0]) return NULL; - if (name[1] == 0) { - char c = name[0]; - if (c == '?') { char *b = malloc(16); snprintf(b, 16, "%d", st->last_status); *owned = b; return b; } - if (c == '#') { char *b = malloc(16); snprintf(b, 16, "%d", st->npos); *owned = b; return b; } - if (c == '$') return "1"; // stub: fake constant pid - if (c == '!') return ""; // stub: no last bg pid, but "set" - if (c == '-') return ""; // stub: shell flags - if (c == '0') return st->arg0 ? st->arg0 : ""; - } - int alldig = 1; - for (const char *q = name; *q; q++) - if (!isdigit((unsigned char)*q)) { alldig = 0; break; } - if (alldig) { - long idx = strtol(name, NULL, 10); - if (idx == 0) return st->arg0 ? st->arg0 : ""; - if (idx >= 1 && idx <= st->npos) return st->pos[idx - 1]; - return NULL; // out of range -> unset - } - return sh_get(st, name); -} - -// nounset (set -u): if `val` is unset (NULL) and nounset is on, report the error -// and abort the shell (dash exits 2). Returns 1 if it fired (caller emits nothing). -// Specials ($?, $#, $0, ...) resolve non-NULL, so only genuine unset vars and -// out-of-range positionals reach here; a bare $@/$* is handled separately. -static int nounset_fire(sh_state *st, const char *name, const char *val) -{ - if (val || !st->opt_nounset) return 0; - fprintf(stderr, "%s: parameter not set\n", name); - st->exiting = 1; st->exit_code = 2; st->last_status = 2; - return 1; -} - -// Expand $@ / $* into fields. `star` selects '*'. `quoted` is set inside "...". -static void expand_at_star(sh_state *st, int star, int quoted, fld *b, - sh_fields *out, int split, const char *ifs) -{ - int n = st->npos; - char sep = ' '; - const char *ifsv = sh_get(st, "IFS"); - if (ifsv) sep = ifsv[0]; // may be 0 if IFS="" - - if (star && quoted) { - // "$*": join all params by the first char of $IFS into a single field. - int total = 1; - for (int i = 0; i < n; i++) total += (int)strlen(st->pos[i]) + 1; - char *tmp = malloc(total); - tmp[0] = 0; - int len = 0; - for (int i = 0; i < n; i++) { - int l = (int)strlen(st->pos[i]); - memcpy(tmp + len, st->pos[i], l); len += l; - if (i < n - 1 && sep) tmp[len++] = sep; - } - tmp[len] = 0; - append_val(b, out, tmp, quoted ? 0 : split, ifs); - free(tmp); - return; - } - - // $@ : one field per positional param. - if (n == 0) return; // "$@" with no params vanishes (prefix field kept) - for (int i = 0; i < n; i++) { - if (i > 0) fld_emit(b, out); - if (quoted) { - for (char *q = st->pos[i]; *q; q++) fld_put(b, *q); - b->started = 1; // keep even empty params as fields - } else { - append_val(b, out, st->pos[i], split, ifs); - } - } -} - -static void do_brace(sh_state *st, const char *content, int quoted, fld *b, - sh_fields *out, int split, const char *ifs); -static char *sh_expand_single_t(sh_state *st, const char *raw, int allow_tilde, - int quoted_ctx); - -// Parse a $-expansion at raw[*i] (raw[*i]=='$'). Append the value(s). -// `split` enables IFS word-splitting; `quoted` marks double-quote context. -static void do_dollar(sh_state *st, const char *raw, int *i, fld *b, - sh_fields *out, int split, int quoted) -{ - int p = *i + 1; // skip '$' - char name[128]; - int n = 0; - const char *ifs = get_ifs(st); - - if (raw[p] == '(' && raw[p + 1] == '(') { - // $((expr)) arithmetic expansion. Scan to the matching `))`, tracking - // nested `((`/`))` (so `$(( $((a)) + 1 ))` works). - p += 2; - int start = p, depth = 1; - while (raw[p] && depth > 0) { - if (raw[p] == '(') depth++; - else if (raw[p] == ')') depth--; - if (depth == 0) break; - p++; - } - int inner_len = p - start; - char *inner = malloc(inner_len + 1); - memcpy(inner, raw + start, inner_len); - inner[inner_len] = 0; - // Consume the closing `))`. - if (raw[p] == ')') p++; - if (raw[p] == ')') p++; - // Command substitutions and `$var` forms inside the expression are - // expanded first (parameter/command expansion only, no glob/split); - // bare identifiers are left for the evaluator to dereference. - char *expr = sh_expand_heredoc(st, inner); - free(inner); - inner = expr; - long result = 0; - const char *aerr = NULL; - char num[32]; - if (sh_arith_eval(st, inner, &result, &aerr) == 0) { - snprintf(num, sizeof(num), "%ld", result); - append_val(b, out, num, split, ifs); - } else { - fprintf(stderr, "sh: arithmetic: %s\n", aerr ? aerr : "error"); - // dash treats an arithmetic evaluation error as a fatal error in a - // non-interactive shell: abort the current command and exit 2. - st->exiting = 1; st->exit_code = 2; st->last_status = 2; - } - free(inner); - *i = p; - return; - } - - if (raw[p] == '(') { - // $(...) command substitution. - p++; - int start = p, depth = 1; - while (raw[p] && depth > 0) { - char d = raw[p]; - if (d == '\'') { p++; while (raw[p] && raw[p] != '\'') p++; if (raw[p]) p++; continue; } - if (d == '"') { - p++; - while (raw[p] && raw[p] != '"') { if (raw[p] == '\\' && raw[p + 1]) p += 2; else p++; } - if (raw[p]) p++; - continue; - } - if (d == '\\' && raw[p + 1]) { p += 2; continue; } - if (d == '(') depth++; - else if (d == ')') { if (--depth == 0) break; } - p++; - } - int inner_len = p - start; - char *inner = malloc(inner_len + 1); - memcpy(inner, raw + start, inner_len); - inner[inner_len] = 0; - if (raw[p] == ')') p++; - char *val = command_subst(st, inner); - free(inner); - append_val(b, out, val, quoted ? 0 : split, ifs); - free(val); - *i = p; - return; - } - - if (raw[p] == '{') { - // ${...} : find the matching '}' (respect nesting, $(), quotes). - p++; - int start = p, depth = 1; - while (raw[p] && depth > 0) { - char d = raw[p]; - if (d == '\\' && raw[p + 1]) { p += 2; continue; } - if (d == '\'') { p++; while (raw[p] && raw[p] != '\'') p++; if (raw[p]) p++; continue; } - if (d == '"') { - p++; - while (raw[p] && raw[p] != '"') { if (raw[p] == '\\' && raw[p + 1]) p += 2; else p++; } - if (raw[p]) p++; - continue; - } - if (d == '$' && raw[p + 1] == '{') { depth++; p += 2; continue; } - if (d == '$' && raw[p + 1] == '(') { - p += 2; int pd = 1; - while (raw[p] && pd) { if (raw[p] == '(') pd++; else if (raw[p] == ')') { if (--pd == 0) { p++; break; } } p++; } - continue; - } - if (d == '{') depth++; - else if (d == '}') { if (--depth == 0) break; } - p++; - } - int clen = p - start; - char *content = malloc(clen + 1); - memcpy(content, raw + start, clen); - content[clen] = 0; - if (raw[p] == '}') p++; - do_brace(st, content, quoted, b, out, split, ifs); - free(content); - *i = p; - return; - } - - if (raw[p] == '@' || raw[p] == '*') { - expand_at_star(st, raw[p] == '*', quoted, b, out, split, ifs); - *i = p + 1; - return; - } - - if (raw[p] == '?' || raw[p] == '#' || raw[p] == '$' || raw[p] == '!' || raw[p] == '-') { - name[0] = raw[p]; name[1] = 0; p++; - } else if (isdigit((unsigned char)raw[p])) { - name[0] = raw[p]; name[1] = 0; p++; // bare $N is a single digit - } else if (isalpha((unsigned char)raw[p]) || raw[p] == '_') { - while ((isalnum((unsigned char)raw[p]) || raw[p] == '_') && n < (int)sizeof(name) - 1) - name[n++] = raw[p++]; - name[n] = 0; - } else { - fld_put(b, '$'); // lone '$' - literal - *i = *i + 1; - return; - } - - char *owned = NULL; - const char *val = param_raw(st, name, &owned); - if (nounset_fire(st, name, val)) { free(owned); *i = p; return; } - append_val(b, out, val ? val : "", split, ifs); - free(owned); - *i = p; -} - -// Handle the interior of a ${...} expression (operators, length, plain). -static void do_brace(sh_state *st, const char *content, int quoted, fld *b, - sh_fields *out, int split, const char *ifs) -{ - // Length form: ${#name} - if (content[0] == '#') { - char d = content[1]; - if (isalnum((unsigned char)d) || d == '_' || d == '@' || d == '*' || - (d == '#' && content[2] == 0)) { // ${##}: length of "$#" - const char *nm = content + 1; - char num[16]; - if (strcmp(nm, "@") == 0 || strcmp(nm, "*") == 0) { - snprintf(num, sizeof(num), "%d", st->npos); - } else { - char *ow = NULL; - const char *v = param_raw(st, nm, &ow); - if (nounset_fire(st, nm, v)) { free(ow); return; } - snprintf(num, sizeof(num), "%d", v ? (int)strlen(v) : 0); - free(ow); - } - append_val(b, out, num, split, ifs); - return; - } - } - - // Parse the parameter name. - char name[128]; - int nl = 0; - const char *r = content; - if (*r == '@' || *r == '*' || *r == '?' || *r == '!' || *r == '$' || - *r == '-' || *r == '#') { - name[0] = *r; nl = 1; r++; - } else if (isdigit((unsigned char)*r)) { - while (isdigit((unsigned char)*r) && nl < 127) name[nl++] = *r++; - } else if (isalpha((unsigned char)*r) || *r == '_') { - while ((isalnum((unsigned char)*r) || *r == '_') && nl < 127) name[nl++] = *r++; - } - name[nl] = 0; - - // No operator: plain ${name}. - if (*r == 0) { - if (strcmp(name, "@") == 0 || strcmp(name, "*") == 0) { - expand_at_star(st, name[0] == '*', quoted, b, out, split, ifs); - } else { - char *ow = NULL; - const char *v = param_raw(st, name, &ow); - if (nounset_fire(st, name, v)) { free(ow); return; } - append_val(b, out, v ? v : "", split, ifs); - free(ow); - } - return; - } - - // Prefix/suffix strip: ${v#p} ${v##p} ${v%p} ${v%%p} - if (*r == '#' || *r == '%') { - char kind = *r; r++; - int longest = 0; - if (*r == kind) { longest = 1; r++; } - char *pat = sh_expand_single(st, r); - char *ow = NULL; - const char *v = param_raw(st, name, &ow); - const char *sv = v ? v : ""; - char *res = (kind == '#') ? sh_strip_prefix(sv, pat, longest) - : sh_strip_suffix(sv, pat, longest); - append_val(b, out, res, split, ifs); - free(res); free(pat); free(ow); - return; - } - - // Value operators: :- - :+ + := = :? ? - int colon = 0; - if (*r == ':' && (r[1] == '-' || r[1] == '+' || r[1] == '=' || r[1] == '?')) { - colon = 1; r++; - } - char op = *r; - if (op == '-' || op == '+' || op == '=' || op == '?') { - const char *word = r + 1; - char *ow = NULL; - const char *val = param_raw(st, name, &ow); - int set = (val != NULL); - int active = colon ? (!set || val[0] == 0) : !set; // "empty or unset" test - - if (op == '+') { - // Use word if the parameter IS set (non-empty for colon form). - if (!active) { - char *w = sh_expand_single_t(st, word, !quoted, quoted); - append_val(b, out, w, quoted ? 0 : split, ifs); - free(w); - } - } else if (op == '-') { - if (active) { - char *w = sh_expand_single_t(st, word, !quoted, quoted); - append_val(b, out, w, quoted ? 0 : split, ifs); - free(w); - } else { - append_val(b, out, val, quoted ? 0 : split, ifs); - } - } else if (op == '=') { - if (active) { - char *w = sh_expand_single_t(st, word, !quoted, quoted); - if (is_var_name(name)) sh_set(st, name, w); - else fprintf(stderr, "%s: cannot assign in this way\n", name); - append_val(b, out, w, quoted ? 0 : split, ifs); - free(w); - } else { - append_val(b, out, val, quoted ? 0 : split, ifs); - } - } else { // '?' - if (active) { - char *w = sh_expand_single_t(st, word, !quoted, quoted); - if (w[0]) fprintf(stderr, "%s: %s\n", name, w); - else fprintf(stderr, "%s: parameter not set\n", name); - free(w); - st->exiting = 1; - st->exit_code = 2; - st->last_status = 2; - } else { - append_val(b, out, val, quoted ? 0 : split, ifs); - } - } - free(ow); - return; - } - - // Unknown operator: fall back to plain value. - char *ow = NULL; - const char *v = param_raw(st, name, &ow); - append_val(b, out, v ? v : "", split, ifs); - free(ow); -} - -// Core expansion of a raw word. Emits one or more fields into `out`. -static void expand(sh_state *st, const char *raw, sh_fields *out, - int allow_split, int allow_tilde, int quoted_ctx) -{ - fld b; - fld_init(&b); - int i = 0; - - // Tilde expansion: only unquoted and only at word start. Since the tokenizer - // keeps quotes in the raw word ("~" arrives as "\"~\""), a literal raw[0]=='~' - // check gives the "unquoted, word-start" rule for free. - if (raw[0] == '~') { - int j = 1; - while (raw[j] && raw[j] != '/') j++; - if (allow_tilde && j == 1) { // bare "~" or "~/..." -> $HOME - const char *home = sh_get(st, "HOME"); - if (home) { - for (const char *p = home; *p; p++) fld_put(&b, *p); - b.started = 1; - i = j; // resume at '/' or end; leaves ~name literal - } - } - // ~user: no passwd db here, leave literal (handled by normal loop). - } - - while (raw[i]) { - char c = raw[i]; - if (c == '\'') { - b.started = 1; - i++; - while (raw[i] && raw[i] != '\'') fld_put(&b, raw[i++]); - if (raw[i] == '\'') i++; - } else if (c == '"') { - b.started = 1; - i++; - while (raw[i] && raw[i] != '"') { - if (raw[i] == '\\' && (raw[i+1] == '$' || raw[i+1] == '"' || - raw[i+1] == '\\' || raw[i+1] == '`')) { - fld_put(&b, raw[i+1]); i += 2; - } else if (raw[i] == '$') { - do_dollar(st, raw, &i, &b, out, 0, 1); // quoted: no split - } else { - fld_put(&b, raw[i++]); - } - } - if (raw[i] == '"') i++; - } else if (c == '\\') { - // In a double-quoted context (e.g. the word of `${x-...}` inside - // "..."), a backslash only escapes $ " ` and itself; before any - // other char it stays literal. Unquoted, it escapes the next char. - if (quoted_ctx) { - char nx = raw[i+1]; - if (nx == '$' || nx == '"' || nx == '`' || nx == '\\') { - fld_put(&b, nx); i += 2; - } else { - fld_put(&b, '\\'); i++; - } - } else { - i++; - if (raw[i]) fld_put(&b, raw[i++]); - } - } else if (c == '$') { - do_dollar(st, raw, &i, &b, out, allow_split, 0); - } else { - fld_put(&b, c); - i++; - } - } - fld_emit(&b, out); - free(b.buf); -} - -// Does the raw (still-quoted) word contain a glob metachar outside quotes and -// not backslash-escaped? Only such words are candidates for pathname expansion: -// quoted metachars never glob, and (scope decision) metachars -// that arrive via $var/$() expansion don't either -- so the expanded fields can -// be used as patterns directly, with no quoted-mask tracking. -static int raw_has_glob(const char *raw) -{ - for (int i = 0; raw[i]; i++) { - char c = raw[i]; - if (c == '\'') { i++; while (raw[i] && raw[i] != '\'') i++; if (!raw[i]) break; } - else if (c == '"') { i++; while (raw[i] && raw[i] != '"') { if (raw[i] == '\\' && raw[i+1]) i++; i++; } if (!raw[i]) break; } - else if (c == '\\') { if (raw[i+1]) i++; } - else if (c == '*' || c == '?' || c == '[') return 1; - } - return 0; -} - -void sh_expand_word(sh_state *st, const char *raw, sh_fields *out) -{ - if (!raw_has_glob(raw)) { - expand(st, raw, out, 1, 1, 0); - return; - } - sh_fields tmp; - sh_fields_init(&tmp); - expand(st, raw, &tmp, 1, 1, 0); - for (int i = 0; i < tmp.count; i++) { - if (sh_glob_pathnames(tmp.items[i], out) == 0) - sh_fields_push(out, tmp.items[i]); // no match: keep literal - } - sh_fields_free(&tmp); -} - -char *sh_expand_heredoc(sh_state *st, const char *raw) -{ - sh_fields f; - sh_fields_init(&f); // do_dollar needs an out sink; splitting is disabled - fld b; - fld_init(&b); - b.started = 1; // an empty body is still a (zero-length) result - int i = 0; - while (raw[i]) { - char c = raw[i]; - if (c == '\\' && (raw[i+1] == '$' || raw[i+1] == '`' || - raw[i+1] == '\\' || raw[i+1] == '\n')) { - if (raw[i+1] == '\n') i += 2; // line continuation - else { fld_put(&b, raw[i+1]); i += 2; } - } else if (c == '$') { - do_dollar(st, raw, &i, &b, &f, 0, 1); // quoted style, no split - } else if (c == '`') { - // Backtick command substitution reached without the lexer's - // `->$() rewrite (e.g. inside a $((...)) operand). Capture to the - // matching backtick and run it, honoring \` \\ \$ escapes. - int j = i + 1; - fld cmd; fld_init(&cmd); cmd.started = 1; - while (raw[j] && raw[j] != '`') { - if (raw[j] == '\\' && (raw[j+1] == '`' || raw[j+1] == '\\' || - raw[j+1] == '$')) { - fld_put(&cmd, raw[j+1]); j += 2; - } else { - fld_put(&cmd, raw[j]); j++; - } - } - if (raw[j] == '`') j++; - char *val = command_subst(st, cmd.buf); - for (const char *p = val; *p; p++) fld_put(&b, *p); - free(val); - free(cmd.buf); - i = j; - } else { - fld_put(&b, c); i++; - } - } - char *res = strdup(b.buf); - free(b.buf); - sh_fields_free(&f); - return res; -} - -char *sh_expand_single(sh_state *st, const char *raw) -{ - return sh_expand_single_t(st, raw, 1, 0); -} - -static char *sh_expand_single_t(sh_state *st, const char *raw, int allow_tilde, - int quoted_ctx) -{ - sh_fields f; - sh_fields_init(&f); - expand(st, raw, &f, 0, allow_tilde, quoted_ctx); - char *res; - if (f.count == 0) res = strdup(""); - else if (f.count == 1) res = strdup(f.items[0]); - else { - // join with spaces (shouldn't happen with split disabled) - int len = 0; - for (int i = 0; i < f.count; i++) len += strlen(f.items[i]) + 1; - res = malloc(len + 1); - res[0] = 0; - for (int i = 0; i < f.count; i++) { if (i) strcat(res, " "); strcat(res, f.items[i]); } - } - sh_fields_free(&f); - return res; -} diff --git a/Tactility/Source/app/shell/shell/sh_expand.cpp b/Tactility/Source/app/shell/shell/sh_expand.cpp new file mode 100644 index 000000000..10c1713d4 --- /dev/null +++ b/Tactility/Source/app/shell/shell/sh_expand.cpp @@ -0,0 +1,682 @@ +// Word expansion: tilde, parameter, arithmetic and command substitution, +// quote removal, IFS field splitting and pathname globbing. +// +// Expansion is a pair of frames (see sh_machine.h). A word is scanned left to +// right by Expand, which suspends at `$(...)`, `${...}` and `$((...))` to let a +// child frame produce the value, then resumes scanning where it left off. +#include + +#include +#include + +#include +#include +#include +#include + +extern "C" { + +void sh_fields_init(sh_fields* f) { f->items = nullptr; f->count = 0; f->cap = 0; } + +void sh_fields_push(sh_fields* f, const char* s) +{ + if (f->count == f->cap) { + f->cap = f->cap ? f->cap * 2 : 8; + f->items = static_cast(realloc(f->items, f->cap * sizeof(char*))); + } + f->items[f->count++] = strdup(s); +} + +void sh_fields_free(sh_fields* f) +{ + for (int i = 0; i < f->count; i++) free(f->items[i]); + free(f->items); + f->items = nullptr; + f->count = f->cap = 0; +} + +} + +namespace sh { + +namespace { + +bool is_ifs(char c, const char* ifs) { return c && strchr(ifs, c) != nullptr; } +bool is_ifs_ws(char c) { return c == ' ' || c == '\t' || c == '\n'; } + +// Does the raw (still-quoted) word contain a glob metachar outside quotes and +// not backslash-escaped? Only such words are candidates for pathname expansion: +// quoted metachars never glob, and (scope decision) metachars +// that arrive via $var/$() expansion don't either -- so the expanded fields can +// be used as patterns directly, with no quoted-mask tracking. +bool raw_has_glob(const std::string& raw) +{ + size_t n = raw.size(); + for (size_t i = 0; i < n; i++) { + char c = raw[i]; + if (c == '\'') { i++; while (i < n && raw[i] != '\'') i++; if (i >= n) break; } + else if (c == '"') { i++; while (i < n && raw[i] != '"') { if (raw[i] == '\\' && i + 1 < n) i++; i++; } if (i >= n) break; } + else if (c == '\\') { if (i + 1 < n) i++; } + else if (c == '*' || c == '?' || c == '[') return true; + } + return false; +} + +} // namespace + +// region Parameter helpers + +const char* get_ifs(sh_state* st) +{ + const char* v = sh_get(st, "IFS"); + return v ? v : " \t\n"; +} + +// Append `v` to the field `b`. If `split`, break fields on $IFS following POSIX +// 2.6.5: leading IFS whitespace at the start of a field is elided; a run of IFS +// whitespace is one delimiter; each non-whitespace IFS char (with adjacent IFS +// whitespace) is also a delimiter and so can create empty fields between/at the +// front, but a lone trailing delimiter does not create a trailing empty field. +// The final (possibly partial or empty) field is left in `b` so it can continue +// across adjacent word parts; the caller flushes it with emit(). +void append_val(FieldBuilder& b, Fields& out, const char* v, bool split, const char* ifs) +{ + if (!split) { for (const char* q = v; *q; q++) b.put(*q); return; } + const char* q = v; + // Strip leading IFS whitespace only when no earlier part has begun this field. + if (!b.started) + while (*q && is_ifs(*q, ifs) && is_ifs_ws(*q)) q++; + for (;;) { + // Accumulate the field's content up to the next IFS char. + while (*q && !is_ifs(*q, ifs)) { b.put(*q); q++; } + if (!*q) return; // field continues into next part / flush + b.emitForced(out); // delimiter -> field ends (even if empty) + // Consume one delimiter: IFS whitespace, at most one non-ws IFS char, + // then trailing IFS whitespace. + while (*q && is_ifs(*q, ifs) && is_ifs_ws(*q)) q++; + if (*q && is_ifs(*q, ifs) && !is_ifs_ws(*q)) { + q++; + while (*q && is_ifs(*q, ifs) && is_ifs_ws(*q)) q++; + } + if (!*q) return; // trailing delimiter: no empty field + } +} + +// Is `name` a plain variable name (assignable, not a positional/special param)? +bool is_var_name(const char* name) +{ + if (!(isalpha(static_cast(name[0])) || name[0] == '_')) return false; + for (const char* q = name + 1; *q; q++) + if (!(isalnum(static_cast(*q)) || *q == '_')) return false; + return true; +} + +// Resolve a scalar parameter's raw value. Returns NULL if the parameter is +// unset (needed to distinguish unset from set-but-empty). *owned receives a +// malloc'd buffer the caller must free (may stay NULL). +const char* param_raw(sh_state* st, const char* name, char** owned) +{ + *owned = nullptr; + if (!name[0]) return nullptr; + if (name[1] == 0) { + char c = name[0]; + if (c == '?') { auto* b = static_cast(malloc(16)); snprintf(b, 16, "%d", st->last_status); *owned = b; return b; } + if (c == '#') { auto* b = static_cast(malloc(16)); snprintf(b, 16, "%d", st->npos); *owned = b; return b; } + if (c == '$') return "1"; // stub: fake constant pid + if (c == '!') return ""; // stub: no last bg pid, but "set" + if (c == '-') return ""; // stub: shell flags + if (c == '0') return st->arg0 ? st->arg0 : ""; + } + bool alldig = true; + for (const char* q = name; *q; q++) + if (!isdigit(static_cast(*q))) { alldig = false; break; } + if (alldig) { + long idx = strtol(name, nullptr, 10); + if (idx == 0) return st->arg0 ? st->arg0 : ""; + if (idx >= 1 && idx <= st->npos) return st->pos[idx - 1]; + return nullptr; // out of range -> unset + } + return sh_get(st, name); +} + +// nounset (set -u): if `val` is unset (NULL) and nounset is on, report the error +// and abort the shell (dash exits 2). Returns true if it fired (caller emits nothing). +// Specials ($?, $#, $0, ...) resolve non-NULL, so only genuine unset vars and +// out-of-range positionals reach here; a bare $@/$* is handled separately. +bool nounset_fire(sh_state* st, const char* name, const char* val) +{ + if (val || !st->opt_nounset) return false; + fprintf(stderr, "%s: parameter not set\n", name); + st->exiting = 1; st->exit_code = 2; st->last_status = 2; + return true; +} + +// Expand $@ / $* into fields. `star` selects '*'. `quoted` is set inside "...". +void expand_at_star(sh_state* st, bool star, bool quoted, FieldBuilder& b, Fields& out, bool split, const char* ifs) +{ + int n = st->npos; + char sep = ' '; + const char* ifsv = sh_get(st, "IFS"); + if (ifsv) sep = ifsv[0]; // may be 0 if IFS="" + + if (star && quoted) { + // "$*": join all params by the first char of $IFS into a single field. + std::string joined; + for (int i = 0; i < n; i++) { + joined += st->pos[i]; + if (i < n - 1 && sep) joined.push_back(sep); + } + append_val(b, out, joined.c_str(), false, ifs); + return; + } + + // $@ : one field per positional param. + if (n == 0) return; // "$@" with no params vanishes (prefix field kept) + for (int i = 0; i < n; i++) { + if (i > 0) b.emit(out); + if (quoted) { + for (char* q = st->pos[i]; *q; q++) b.put(*q); + b.started = true; // keep even empty params as fields + } else { + append_val(b, out, st->pos[i], split, ifs); + } + } +} + +// endregion + +// region Expand + +Expand Expand::word(std::string raw) +{ + Expand e {}; + e.raw = std::move(raw); + e.mode = Mode::Word; + e.allowSplit = true; + e.allowTilde = true; + return e; +} + +Expand Expand::single(std::string raw, bool allowTilde, bool quotedContext) +{ + Expand e {}; + e.raw = std::move(raw); + e.mode = Mode::Single; + e.allowTilde = allowTilde; + e.quotedContext = quotedContext; + return e; +} + +// A here-doc body: parameter/command expansion and backslash escaping of +// $ ` \ (double-quote semantics), preserving newlines and literal quotes. +Expand Expand::heredoc(std::string raw) +{ + Expand e {}; + e.raw = std::move(raw); + e.mode = Mode::Heredoc; + return e; +} + +Step Expand::step(Machine& m) +{ + if (pending != Pending::None) resume(m); + + if (!started) { + started = true; + if (mode == Mode::Heredoc) { + field.started = true; // an empty body is still a (zero-length) result + } else if (at(0) == '~') { + // Tilde expansion: only unquoted and only at word start. Since the tokenizer + // keeps quotes in the raw word ("~" arrives as "\"~\""), a literal raw[0]=='~' + // check gives the "unquoted, word-start" rule for free. + size_t j = 1; + while (at(j) && at(j) != '/') j++; + if (allowTilde && j == 1) { // bare "~" or "~/..." -> $HOME + if (const char* home = sh_get(&m.st, "HOME")) { + for (const char* p = home; *p; p++) field.put(*p); + field.started = true; + i = j; // resume at '/' or end; leaves ~name literal + } + } + // ~user: no passwd db here, leave literal (handled by normal loop). + } + } + + bool suspended = (mode == Mode::Heredoc) ? scanHeredoc(m) : scanWord(m); + return suspended ? Step::Call : finish(m); +} + +// Handles the value a child frame produced for the expansion that suspended the scan. +void Expand::resume(Machine& m) +{ + Pending was = pending; + pending = Pending::None; + switch (was) { + case Pending::Arithmetic: { + // Command substitutions and `$var` forms inside the expression were + // expanded first (parameter/command expansion only, no glob/split); + // bare identifiers are left for the evaluator to dereference. + long result = 0; + const char* error = nullptr; + if (sh_arith_eval(&m.st, m.result.text.c_str(), &result, &error) == 0) { + char num[32]; + snprintf(num, sizeof(num), "%ld", result); + append_val(field, out, num, pendingSplit, get_ifs(&m.st)); + } else { + fprintf(stderr, "sh: arithmetic: %s\n", error ? error : "error"); + // dash treats an arithmetic evaluation error as a fatal error in a + // non-interactive shell: abort the current command and exit 2. + m.st.exiting = 1; m.st.exit_code = 2; m.st.last_status = 2; + } + break; + } + case Pending::CommandSubstitution: + append_val(field, out, m.result.text.c_str(), pendingSplit, get_ifs(&m.st)); + break; + case Pending::Backtick: + for (char c : m.result.text) field.put(c); + break; + case Pending::Brace: + case Pending::None: + break; + } +} + +// Scans an ordinary word. Returns true when it suspended for a child frame. +bool Expand::scanWord(Machine& m) +{ + for (;;) { + char c = at(i); + if (inDoubleQuote) { + if (c == '\0') { inDoubleQuote = false; continue; } + if (c == '"') { i++; inDoubleQuote = false; continue; } + char next = at(i + 1); + if (c == '\\' && (next == '$' || next == '"' || next == '\\' || next == '`')) { + field.put(next); + i += 2; + } else if (c == '$') { + if (dollar(m, false, true)) return true; // quoted: no split + } else { + field.put(c); + i++; + } + continue; + } + + if (c == '\0') return false; + if (c == '\'') { + field.started = true; + i++; + while (at(i) && at(i) != '\'') field.put(raw[i++]); + if (at(i) == '\'') i++; + } else if (c == '"') { + field.started = true; + i++; + inDoubleQuote = true; + } else if (c == '\\') { + // In a double-quoted context (e.g. the word of `${x-...}` inside + // "..."), a backslash only escapes $ " ` and itself; before any + // other char it stays literal. Unquoted, it escapes the next char. + if (quotedContext) { + char next = at(i + 1); + if (next == '$' || next == '"' || next == '`' || next == '\\') { + field.put(next); + i += 2; + } else { + field.put('\\'); + i++; + } + } else { + i++; + if (at(i)) field.put(raw[i++]); + } + } else if (c == '$') { + if (dollar(m, allowSplit, false)) return true; + } else { + field.put(c); + i++; + } + } +} + +// Scans a here-doc body. Returns true when it suspended for a child frame. +bool Expand::scanHeredoc(Machine& m) +{ + while (at(i)) { + char c = at(i); + char next = at(i + 1); + if (c == '\\' && (next == '$' || next == '`' || next == '\\' || next == '\n')) { + if (next != '\n') field.put(next); // a backslash-newline is a line continuation + i += 2; + } else if (c == '$') { + if (dollar(m, false, true)) return true; // quoted style, no split + } else if (c == '`') { + // Backtick command substitution reached without the lexer's + // `->$() rewrite (e.g. inside a $((...)) operand). Capture to the + // matching backtick and run it, honoring \` \\ \$ escapes. + size_t j = i + 1; + std::string command; + while (at(j) && at(j) != '`') { + if (at(j) == '\\' && (at(j + 1) == '`' || at(j + 1) == '\\' || at(j + 1) == '$')) { + command.push_back(at(j + 1)); + j += 2; + } else { + command.push_back(at(j)); + j++; + } + } + if (at(j) == '`') j++; + i = j; + pending = Pending::Backtick; + m.call(CommandSubst(std::move(command))); + return true; + } else { + field.put(c); + i++; + } + } + return false; +} + +// Parse a $-expansion at raw[i] (raw[i]=='$') and append the value(s). `split` +// enables IFS word-splitting; `quoted` marks double-quote context. Returns true +// when the value comes from a child frame, which resume() then appends. +bool Expand::dollar(Machine& m, bool split, bool quoted) +{ + sh_state* st = &m.st; + size_t p = i + 1; // skip '$' + + if (at(p) == '(' && at(p + 1) == '(') { + // $((expr)) arithmetic expansion. Scan to the matching `))`, tracking + // nested `((`/`))` (so `$(( $((a)) + 1 ))` works). + p += 2; + size_t start = p; + int depth = 1; + while (at(p) && depth > 0) { + if (at(p) == '(') depth++; + else if (at(p) == ')') depth--; + if (depth == 0) break; + p++; + } + std::string inner = raw.substr(start, p - start); + // Consume the closing `))`. + if (at(p) == ')') p++; + if (at(p) == ')') p++; + i = p; + pending = Pending::Arithmetic; + pendingSplit = split; + m.call(Expand::heredoc(std::move(inner))); + return true; + } + + if (at(p) == '(') { + // $(...) command substitution. + p++; + size_t start = p; + int depth = 1; + while (at(p) && depth > 0) { + char d = at(p); + if (d == '\'') { p++; while (at(p) && at(p) != '\'') p++; if (at(p)) p++; continue; } + if (d == '"') { + p++; + while (at(p) && at(p) != '"') { if (at(p) == '\\' && at(p + 1)) p += 2; else p++; } + if (at(p)) p++; + continue; + } + if (d == '\\' && at(p + 1)) { p += 2; continue; } + if (d == '(') depth++; + else if (d == ')') { if (--depth == 0) break; } + p++; + } + std::string inner = raw.substr(start, p - start); + if (at(p) == ')') p++; + i = p; + pending = Pending::CommandSubstitution; + pendingSplit = quoted ? false : split; + m.call(CommandSubst(std::move(inner))); + return true; + } + + if (at(p) == '{') { + // ${...} : find the matching '}' (respect nesting, $(), quotes). + p++; + size_t start = p; + int depth = 1; + while (at(p) && depth > 0) { + char d = at(p); + if (d == '\\' && at(p + 1)) { p += 2; continue; } + if (d == '\'') { p++; while (at(p) && at(p) != '\'') p++; if (at(p)) p++; continue; } + if (d == '"') { + p++; + while (at(p) && at(p) != '"') { if (at(p) == '\\' && at(p + 1)) p += 2; else p++; } + if (at(p)) p++; + continue; + } + if (d == '$' && at(p + 1) == '{') { depth++; p += 2; continue; } + if (d == '$' && at(p + 1) == '(') { + p += 2; + int pd = 1; + while (at(p) && pd) { if (at(p) == '(') pd++; else if (at(p) == ')') { if (--pd == 0) { p++; break; } } p++; } + continue; + } + if (d == '{') depth++; + else if (d == '}') { if (--depth == 0) break; } + p++; + } + std::string content = raw.substr(start, p - start); + if (at(p) == '}') p++; + i = p; + pending = Pending::Brace; + m.call(ExpandBrace { .content = std::move(content), .quoted = quoted, .split = split, .field = &field, .out = &out }); + return true; + } + + const char* ifs = get_ifs(st); + + if (at(p) == '@' || at(p) == '*') { + expand_at_star(st, at(p) == '*', quoted, field, out, split, ifs); + i = p + 1; + return false; + } + + std::string name; + char c = at(p); + if (c == '?' || c == '#' || c == '$' || c == '!' || c == '-') { + name.push_back(c); + p++; + } else if (isdigit(static_cast(c))) { + name.push_back(c); // bare $N is a single digit + p++; + } else if (isalpha(static_cast(c)) || c == '_') { + while ((isalnum(static_cast(at(p))) || at(p) == '_') && name.size() < 127) name.push_back(at(p++)); + } else { + field.put('$'); // lone '$' - literal + i++; + return false; + } + + char* owned = nullptr; + const char* val = param_raw(st, name.c_str(), &owned); + if (!nounset_fire(st, name.c_str(), val)) { + append_val(field, out, val ? val : "", split, ifs); + } + free(owned); + i = p; + return false; +} + +Step Expand::finish(Machine& m) +{ + if (mode == Mode::Heredoc) return m.done(std::move(field.text)); + + field.emit(out); + + if (mode == Mode::Single) { + if (out.empty()) return m.done(std::string()); + if (out.size() == 1) return m.done(std::move(out[0])); + // join with spaces (shouldn't happen with split disabled) + std::string joined; + for (size_t k = 0; k < out.size(); k++) { + if (k) joined.push_back(' '); + joined += out[k]; + } + return m.done(std::move(joined)); + } + + if (!raw_has_glob(raw)) return m.done(std::move(out)); + Fields globbed; + for (auto& item : out) { + sh_fields matches; + sh_fields_init(&matches); + if (sh_glob_pathnames(item.c_str(), &matches) == 0) { + globbed.push_back(std::move(item)); // no match: keep literal + } else { + for (int k = 0; k < matches.count; k++) globbed.emplace_back(matches.items[k]); + } + sh_fields_free(&matches); + } + return m.done(std::move(globbed)); +} + +// endregion + +// region ExpandBrace + +Step ExpandBrace::step(Machine& m) +{ + sh_state* st = &m.st; + const char* ifs = get_ifs(st); + switch (phase) { + case Phase::Start: + return start(m); + case Phase::Strip: { + char* owned = nullptr; + const char* v = param_raw(st, name.c_str(), &owned); + const char* subject = v ? v : ""; + char* stripped = (stripKind == '#') + ? sh_strip_prefix(subject, m.result.text.c_str(), longest) + : sh_strip_suffix(subject, m.result.text.c_str(), longest); + append_val(*field, *out, stripped, split, ifs); + free(stripped); + free(owned); + break; + } + case Phase::Plus: + case Phase::Minus: + append_val(*field, *out, m.result.text.c_str(), quoted ? false : split, ifs); + break; + case Phase::Assign: + if (is_var_name(name.c_str())) sh_set(st, name.c_str(), m.result.text.c_str()); + else fprintf(stderr, "%s: cannot assign in this way\n", name.c_str()); + append_val(*field, *out, m.result.text.c_str(), quoted ? false : split, get_ifs(st)); + break; + case Phase::Error: + if (!m.result.text.empty()) fprintf(stderr, "%s: %s\n", name.c_str(), m.result.text.c_str()); + else fprintf(stderr, "%s: parameter not set\n", name.c_str()); + st->exiting = 1; + st->exit_code = 2; + st->last_status = 2; + break; + } + return m.done(0); +} + +// Handle the interior of a ${...} expression (operators, length, plain). +Step ExpandBrace::start(Machine& m) +{ + sh_state* st = &m.st; + const char* ifs = get_ifs(st); + const char* c = content.c_str(); + + // Length form: ${#name} + if (c[0] == '#') { + char d = c[1]; + if (isalnum(static_cast(d)) || d == '_' || d == '@' || d == '*' || + (d == '#' && c[2] == 0)) { // ${##}: length of "$#" + const char* nm = c + 1; + char num[16]; + if (strcmp(nm, "@") == 0 || strcmp(nm, "*") == 0) { + snprintf(num, sizeof(num), "%d", st->npos); + } else { + char* owned = nullptr; + const char* v = param_raw(st, nm, &owned); + if (nounset_fire(st, nm, v)) { free(owned); return m.done(0); } + snprintf(num, sizeof(num), "%d", v ? static_cast(strlen(v)) : 0); + free(owned); + } + append_val(*field, *out, num, split, ifs); + return m.done(0); + } + } + + // Parse the parameter name. + const char* r = c; + if (*r == '@' || *r == '*' || *r == '?' || *r == '!' || *r == '$' || *r == '-' || *r == '#') { + name.push_back(*r++); + } else if (isdigit(static_cast(*r))) { + while (isdigit(static_cast(*r)) && name.size() < 127) name.push_back(*r++); + } else if (isalpha(static_cast(*r)) || *r == '_') { + while ((isalnum(static_cast(*r)) || *r == '_') && name.size() < 127) name.push_back(*r++); + } + + // No operator: plain ${name}. + if (*r == 0) { + if (name == "@" || name == "*") { + expand_at_star(st, name[0] == '*', quoted, *field, *out, split, ifs); + } else { + char* owned = nullptr; + const char* v = param_raw(st, name.c_str(), &owned); + if (!nounset_fire(st, name.c_str(), v)) append_val(*field, *out, v ? v : "", split, ifs); + free(owned); + } + return m.done(0); + } + + // Prefix/suffix strip: ${v#p} ${v##p} ${v%p} ${v%%p} + if (*r == '#' || *r == '%') { + stripKind = *r++; + if (*r == stripKind) { longest = true; r++; } + phase = Phase::Strip; + return m.call(Expand::single(r)); + } + + // Value operators: :- - :+ + := = :? ? + bool colon = false; + if (*r == ':' && (r[1] == '-' || r[1] == '+' || r[1] == '=' || r[1] == '?')) { + colon = true; + r++; + } + char op = *r; + if (op == '-' || op == '+' || op == '=' || op == '?') { + const char* word = r + 1; + char* owned = nullptr; + const char* val = param_raw(st, name.c_str(), &owned); + bool set = val != nullptr; + bool active = colon ? (!set || val[0] == 0) : !set; // "empty or unset" test + value = val ? val : ""; + free(owned); + + if (op == '+') { + // Use word if the parameter IS set (non-empty for colon form). + if (active) return m.done(0); + phase = Phase::Plus; + } else if (!active) { + append_val(*field, *out, value.c_str(), quoted ? false : split, ifs); + return m.done(0); + } else { + phase = (op == '-') ? Phase::Minus : (op == '=') ? Phase::Assign : Phase::Error; + } + return m.call(Expand::single(word, !quoted, quoted)); + } + + // Unknown operator: fall back to plain value. + char* owned = nullptr; + const char* v = param_raw(st, name.c_str(), &owned); + append_val(*field, *out, v ? v : "", split, ifs); + free(owned); + return m.done(0); +} + +// endregion + +} // namespace sh diff --git a/Tactility/Source/app/shell/shell/sh_glob.c b/Tactility/Source/app/shell/shell/sh_glob.c index 3dee492ec..d92405823 100644 --- a/Tactility/Source/app/shell/shell/sh_glob.c +++ b/Tactility/Source/app/shell/shell/sh_glob.c @@ -61,38 +61,48 @@ static int bracket_match(const char **pp, unsigned char c) return negate ? !matched : matched; } +// Iterative with a single backtrack point: on a mismatch, the most recent '*' +// absorbs one more character. That is sufficient for '*' semantics and keeps +// the native stack flat regardless of how many stars the pattern has. int sh_pattern_match(const char *p, const char *s) { - while (*p) { - if (*p == '[') { - const char *pp = p + 1; - int r = bracket_match(&pp, (unsigned char)*s); - if (r >= 0) { - if (!*s || !r) return 0; - p = pp; s++; + const char *star_p = NULL; // pattern position just after the last '*' + const char *star_s = NULL; // subject position that '*' currently ends at + for (;;) { + if (*p) { + if (*p == '[') { + const char *pp = p + 1; + int r = bracket_match(&pp, (unsigned char)*s); + if (r >= 0) { + if (*s && r) { p = pp; s++; continue; } + goto mismatch; + } + // fall through: unterminated bracket, '[' is literal + } + if (*p == '*') { + p++; + if (!*p) return 1; // trailing '*' matches the rest + star_p = p; + star_s = s; continue; } - // fall through: unterminated bracket, '[' is literal - } - if (*p == '*') { - p++; - if (!*p) return 1; // trailing '*' matches the rest - for (const char *t = s; ; t++) { - if (sh_pattern_match(p, t)) return 1; - if (!*t) return 0; + if (*p == '?') { + if (*s) { p++; s++; continue; } + goto mismatch; } - } else if (*p == '?') { - if (!*s) return 0; - p++; s++; - } else if (*p == '\\' && p[1]) { - if (*s != p[1]) return 0; - p += 2; s++; - } else { - if (*s != *p) return 0; - p++; s++; + if (*p == '\\' && p[1]) { + if (*s == p[1]) { p += 2; s++; continue; } + goto mismatch; + } + if (*s == *p) { p++; s++; continue; } + } else if (!*s) { + return 1; } + mismatch: + if (!star_p || !*star_s) return 0; + p = star_p; + s = ++star_s; } - return *s == 0; } char *sh_strip_prefix(const char *s, const char *p, int longest) diff --git a/Tactility/Source/app/shell/shell/sh_lex.c b/Tactility/Source/app/shell/shell/sh_lex.c index 2589b95ee..f7015bb53 100644 --- a/Tactility/Source/app/shell/shell/sh_lex.c +++ b/Tactility/Source/app/shell/shell/sh_lex.c @@ -39,33 +39,75 @@ typedef struct { char *buf; int len; int cap; } wbuf; // src[j+1]=='('), copying the whole balanced expression verbatim into `w`. // Parens inside single/double quotes don't affect nesting; nested $() do. // sh_expand later runs the inner command. Returns the index past the ')'. +// +// Nesting ($( inside "..." inside $(...)) is tracked with an explicit context +// stack, so arbitrarily deep input can't exhaust the native stack. Each $( +// context carries its own paren depth. +typedef struct { char kind; int depth; } scan_ctx; // kind: '(' command, '"' double quote + +typedef struct { + scan_ctx fixed[16]; + scan_ctx *items; + int cap; + int top; +} scan_ctx_stack; + +static void push_ctx(scan_ctx_stack *cs, char kind, int depth) +{ + if (cs->top == cs->cap) { + scan_ctx *grown = malloc(cs->cap * 2 * sizeof(scan_ctx)); + memcpy(grown, cs->items, cs->cap * sizeof(scan_ctx)); + if (cs->items != cs->fixed) free(cs->items); + cs->items = grown; + cs->cap *= 2; + } + cs->items[cs->top].kind = kind; + cs->items[cs->top].depth = depth; + cs->top++; +} + static int scan_cmdsub(const char *src, int j, wbuf *w) { + scan_ctx_stack cs; + cs.items = cs.fixed; + cs.cap = 16; + cs.top = 0; + push_ctx(&cs, '(', 1); WPUT(w, src[j]); j++; // '$' WPUT(w, src[j]); j++; // '(' - int depth = 1; - while (src[j] && depth > 0) { + + while (src[j] && cs.top > 0) { char d = src[j]; + scan_ctx *c = &cs.items[cs.top - 1]; + if (c->kind == '"') { + if (d == '$' && src[j + 1] == '(') { + WPUT(w, d); j++; WPUT(w, src[j]); j++; + push_ctx(&cs, '(', 1); + } else if (d == '\\' && src[j + 1]) { + WPUT(w, d); j++; WPUT(w, src[j]); j++; + } else { + WPUT(w, d); j++; + if (d == '"') cs.top--; + } + continue; + } if (d == '\'') { WPUT(w, d); j++; while (src[j] && src[j] != '\'') { WPUT(w, src[j]); j++; } if (src[j] == '\'') { WPUT(w, src[j]); j++; } } else if (d == '"') { WPUT(w, d); j++; - while (src[j] && src[j] != '"') { - if (src[j] == '$' && src[j + 1] == '(') { j = scan_cmdsub(src, j, w); } - else if (src[j] == '\\' && src[j + 1]) { WPUT(w, src[j]); j++; WPUT(w, src[j]); j++; } - else { WPUT(w, src[j]); j++; } - } - if (src[j] == '"') { WPUT(w, src[j]); j++; } + push_ctx(&cs, '"', 0); } else if (d == '\\' && src[j + 1]) { WPUT(w, d); j++; WPUT(w, src[j]); j++; } else { - if (d == '(') depth++; - else if (d == ')') depth--; + if (d == '(') c->depth++; + else if (d == ')') c->depth--; WPUT(w, d); j++; + if (c->depth == 0) cs.top--; } } + if (cs.items != cs.fixed) free(cs.items); return j; } diff --git a/Tactility/Source/app/shell/shell/sh_parse.c b/Tactility/Source/app/shell/shell/sh_parse.c deleted file mode 100644 index d96b0825b..000000000 --- a/Tactility/Source/app/shell/shell/sh_parse.c +++ /dev/null @@ -1,504 +0,0 @@ -#include -#include -#include -#include - -typedef struct { - sh_toklist *tl; - int pos; - const char *err; -} P; - -static sh_tok *cur(P *p) { return &p->tl->toks[p->pos]; } -static tok_type curt(P *p){ return p->tl->toks[p->pos].type; } -static tok_type peekt(P *p){ return p->tl->toks[p->pos].type == T_EOF ? T_EOF : p->tl->toks[p->pos + 1].type; } -static void adv(P *p) { if (p->tl->toks[p->pos].type != T_EOF) p->pos++; } - -// Skip newlines/semicolons acting as blank separators. -static void skip_seps(P *p) -{ - while (curt(p) == T_NEWLINE || curt(p) == T_SEMI) adv(p); -} -static void skip_newlines(P *p) -{ - while (curt(p) == T_NEWLINE) adv(p); -} - -static node *new_node(node_kind k) -{ - node *n = calloc(1, sizeof(node)); - n->kind = k; - return n; -} - -// Is the current token a bare word equal to `kw`? -static int is_word(P *p, const char *kw) -{ - sh_tok *t = cur(p); - return t->type == T_WORD && t->text && strcmp(t->text, kw) == 0; -} - -static int is_reserved(P *p) -{ - static const char *kw[] = { "then","else","elif","fi","do","done","esac","}", NULL }; - if (curt(p) != T_WORD) return 0; - for (int i = 0; kw[i]; i++) if (is_word(p, kw[i])) return 1; - return 0; -} - -static node *parse_list(P *p); -static node *parse_and_or(P *p); -static node *parse_pipeline(P *p); -static node *parse_command(P *p); - -static void push_child(node *n, node *c) -{ - n->children = realloc(n->children, (n->nchild + 1) * sizeof(node *)); - n->children[n->nchild++] = c; -} - -// --- redirections ----------------------------------------------------------- - -static int is_redir_tok(tok_type t) -{ - return t == T_LT || t == T_GT || t == T_GTGT || t == T_GTAMP || - t == T_LTAMP || t == T_CLOBBER || t == T_DLESS || t == T_DLESSDASH; -} - -static sh_redir *push_redir(node *n) -{ - n->redirs = realloc(n->redirs, (n->nredir + 1) * sizeof(sh_redir)); - sh_redir *r = &n->redirs[n->nredir++]; - r->kind = R_OUT; r->fd = 1; r->dupfd = 0; r->word = NULL; r->heredoc_quoted = 0; - return r; -} - -static int all_digits(const char *s) -{ - if (!*s) return 0; - for (; *s; s++) if (*s < '0' || *s > '9') return 0; - return 1; -} - -// Parse the redirection at the current token onto `n`. Returns 1 on success, -// 0 on error (p->err set). -static int parse_redir(P *p, node *n) -{ - tok_type t = curt(p); - int fd = cur(p)->rfd; - // No explicit fd digit (rfd == -1): default to stdin for input operators, - // stdout for output operators. - if (fd < 0) { - fd = (t == T_LT || t == T_LTAMP || t == T_DLESS || t == T_DLESSDASH) - ? 0 : 1; - } - - if (t == T_DLESS || t == T_DLESSDASH) { - char *body = cur(p)->text ? strdup(cur(p)->text) : strdup(""); - int quoted = cur(p)->hd_quoted; - adv(p); - if (curt(p) != T_WORD) { free(body); p->err = "expected here-doc delimiter"; return 0; } - sh_redir *r = push_redir(n); - r->kind = R_HEREDOC; r->fd = 0; r->word = body; r->heredoc_quoted = quoted; - adv(p); - return 1; - } - - if (t == T_GTAMP || t == T_LTAMP) { - adv(p); - if (curt(p) != T_WORD) { p->err = "expected fd or filename after >&"; return 0; } - const char *tgt = cur(p)->text; - sh_redir *r = push_redir(n); - r->fd = fd; - if (all_digits(tgt)) { r->kind = R_DUP; r->dupfd = (int)strtol(tgt, NULL, 10); } - else if (strcmp(tgt, "-") == 0) { r->kind = R_CLOSE; } - else { r->kind = (t == T_GTAMP) ? R_OUT : R_IN; r->word = strdup(tgt); } - adv(p); - return 1; - } - - // T_LT / T_GT / T_GTGT / T_CLOBBER: filename target. - redir_kind rk = (t == T_LT) ? R_IN : (t == T_GTGT) ? R_APPEND : R_OUT; - adv(p); - if (curt(p) != T_WORD) { p->err = "expected filename after redirection"; return 0; } - sh_redir *r = push_redir(n); - r->kind = rk; r->fd = fd; r->word = strdup(cur(p)->text); - adv(p); - return 1; -} - -// Consume any trailing redirections onto a compound command. Returns 1/0. -static int parse_redir_list(P *p, node *n) -{ - while (is_redir_tok(curt(p))) { - if (!parse_redir(p, n)) return 0; - } - return 1; -} - -// --- simple command --------------------------------------------------------- - -static int looks_like_assign(const char *w) -{ - if (!(*w == '_' || (*w >= 'A' && *w <= 'Z') || (*w >= 'a' && *w <= 'z'))) return 0; - const char *q = w + 1; - while (*q && *q != '=') { - if (!(*q == '_' || (*q >= 'A' && *q <= 'Z') || (*q >= 'a' && *q <= 'z') || - (*q >= '0' && *q <= '9'))) return 0; - q++; - } - return *q == '='; -} - -static node *parse_simple(P *p) -{ - node *n = new_node(N_SIMPLE); - int seen_word = 0; - - for (;;) { - tok_type t = curt(p); - if (t == T_WORD) { - if (!seen_word && looks_like_assign(cur(p)->text)) { - n->assigns = realloc(n->assigns, (n->nassign + 1) * sizeof(char *)); - n->assigns[n->nassign++] = strdup(cur(p)->text); - adv(p); - continue; - } - // Reserved words are only special in command position (first word); - // as arguments they are ordinary words (e.g. `echo done`). - if (!seen_word && is_reserved(p)) break; - n->words = realloc(n->words, (n->nword + 1) * sizeof(char *)); - n->words[n->nword++] = strdup(cur(p)->text); - seen_word = 1; - adv(p); - } else if (is_redir_tok(t)) { - // Redirects may appear before, between, or after words and do not - // count as the command word (so `FOO=x >f BAR=y cmd` still assigns). - if (!parse_redir(p, n)) { sh_free_node(n); return NULL; } - } else { - break; - } - } - if (n->nword == 0 && n->nassign == 0 && n->nredir == 0) { - p->err = "expected command"; - sh_free_node(n); - return NULL; - } - return n; -} - -// --- control flow ----------------------------------------------------------- - -// Collect the raw words after `for NAME in` up to a separator. -static node *parse_for(P *p) -{ - adv(p); // 'for' - if (curt(p) != T_WORD) { p->err = "expected name after for"; return NULL; } - // The loop variable must be a valid name (dash rejects e.g. `for -`). - const char *nm = cur(p)->text; - if (!(nm[0] == '_' || (nm[0] >= 'a' && nm[0] <= 'z') || (nm[0] >= 'A' && nm[0] <= 'Z'))) { - p->err = "bad for loop variable"; return NULL; - } - for (const char *q = nm + 1; *q; q++) - if (!(*q == '_' || (*q >= '0' && *q <= '9') || - (*q >= 'a' && *q <= 'z') || (*q >= 'A' && *q <= 'Z'))) { - p->err = "bad for loop variable"; return NULL; - } - node *n = new_node(N_FOR); - n->for_name = strdup(nm); - adv(p); - skip_newlines(p); // `for i in ...` is legal (`for i; in` is not) - if (is_word(p, "in")) { - adv(p); - while (curt(p) == T_WORD && !is_reserved(p)) { - n->for_words = realloc(n->for_words, (n->for_nword + 1) * sizeof(char *)); - n->for_words[n->for_nword++] = strdup(cur(p)->text); - adv(p); - } - } else { - // `for x; do ...` with no `in`: iterate over the positional params. - n->for_implicit = 1; - } - skip_seps(p); - if (!is_word(p, "do")) { p->err = "expected 'do'"; sh_free_node(n); return NULL; } - adv(p); - n->body = parse_list(p); - if (!n->body) { sh_free_node(n); return NULL; } - if (n->body->nchild == 0) { p->err = "empty do/done body"; sh_free_node(n); return NULL; } - if (!is_word(p, "done")) { p->err = "expected 'done'"; sh_free_node(n); return NULL; } - adv(p); - return n; -} - -static node *parse_while(P *p) -{ - int until = is_word(p, "until"); - adv(p); // 'while' / 'until' - node *n = new_node(N_WHILE); - n->until = until; - n->cond = parse_list(p); - if (!n->cond) { sh_free_node(n); return NULL; } - if (!is_word(p, "do")) { p->err = "expected 'do'"; sh_free_node(n); return NULL; } - adv(p); - n->body = parse_list(p); - if (!n->body) { sh_free_node(n); return NULL; } - if (n->body->nchild == 0) { p->err = "empty do/done body"; sh_free_node(n); return NULL; } - if (!is_word(p, "done")) { p->err = "expected 'done'"; sh_free_node(n); return NULL; } - adv(p); - return n; -} - -static void push_clause(node *n, node *cond, node *body) -{ - n->conds = realloc(n->conds, (n->nclause + 1) * sizeof(node *)); - n->bodies = realloc(n->bodies, (n->nclause + 1) * sizeof(node *)); - n->conds[n->nclause] = cond; - n->bodies[n->nclause] = body; - n->nclause++; -} - -static node *parse_if(P *p) -{ - adv(p); // 'if' - node *n = new_node(N_IF); - for (;;) { - node *cond = parse_list(p); - if (!cond) { sh_free_node(n); return NULL; } - if (!is_word(p, "then")) { p->err = "expected 'then'"; sh_free_node(cond); sh_free_node(n); return NULL; } - adv(p); - node *body = parse_list(p); - if (!body) { sh_free_node(cond); sh_free_node(n); return NULL; } - if (body->nchild == 0) { p->err = "empty then body"; sh_free_node(cond); sh_free_node(body); sh_free_node(n); return NULL; } - push_clause(n, cond, body); - if (is_word(p, "elif")) { adv(p); continue; } - break; - } - if (is_word(p, "else")) { - adv(p); - n->else_body = parse_list(p); - if (!n->else_body) { sh_free_node(n); return NULL; } - if (n->else_body->nchild == 0) { p->err = "empty else body"; sh_free_node(n); return NULL; } - } - if (!is_word(p, "fi")) { p->err = "expected 'fi'"; sh_free_node(n); return NULL; } - adv(p); - return n; -} - -// case WORD in [(] pat [| pat]... ) list ;; ... esac -static node *parse_case(P *p) -{ - adv(p); // 'case' - if (curt(p) != T_WORD) { p->err = "expected word after case"; return NULL; } - node *n = new_node(N_CASE); - n->case_word = strdup(cur(p)->text); - adv(p); - if (!is_word(p, "in")) { p->err = "expected 'in' after case word"; sh_free_node(n); return NULL; } - adv(p); - skip_seps(p); - while (!is_word(p, "esac") && curt(p) != T_EOF) { - if (curt(p) == T_LPAREN) adv(p); // optional leading '(' - sh_case_clause cl; - cl.pats = NULL; cl.npat = 0; cl.body = NULL; - for (;;) { - if (curt(p) != T_WORD) { - p->err = "expected case pattern"; - for (int i = 0; i < cl.npat; i++) free(cl.pats[i]); - free(cl.pats); sh_free_node(n); return NULL; - } - cl.pats = realloc(cl.pats, (cl.npat + 1) * sizeof(char *)); - cl.pats[cl.npat++] = strdup(cur(p)->text); - adv(p); - if (curt(p) == T_BAR) { adv(p); continue; } - break; - } - if (curt(p) != T_RPAREN) { - p->err = "expected ')' in case"; - for (int i = 0; i < cl.npat; i++) free(cl.pats[i]); - free(cl.pats); sh_free_node(n); return NULL; - } - adv(p); - cl.body = parse_list(p); - if (!cl.body) { - for (int i = 0; i < cl.npat; i++) free(cl.pats[i]); - free(cl.pats); sh_free_node(n); return NULL; - } - n->clauses = realloc(n->clauses, (n->nclause_case + 1) * sizeof(sh_case_clause)); - n->clauses[n->nclause_case++] = cl; - if (curt(p) == T_DSEMI) { adv(p); skip_seps(p); } - else break; // last clause may omit ';;' - } - if (!is_word(p, "esac")) { p->err = "expected 'esac'"; sh_free_node(n); return NULL; } - adv(p); - return n; -} - -static node *parse_group(P *p) -{ - adv(p); // '{' - node *n = new_node(N_GROUP); - n->subshell = 0; - n->body = parse_list(p); - if (!n->body) { sh_free_node(n); return NULL; } - if (!is_word(p, "}")) { p->err = "expected '}'"; sh_free_node(n); return NULL; } - adv(p); - return n; -} - -static node *parse_subshell(P *p) -{ - adv(p); // '(' - node *n = new_node(N_GROUP); - n->subshell = 1; - n->body = parse_list(p); - if (!n->body) { sh_free_node(n); return NULL; } - if (curt(p) != T_RPAREN) { p->err = "expected ')'"; sh_free_node(n); return NULL; } - adv(p); - return n; -} - -static node *parse_funcdef(P *p) -{ - node *n = new_node(N_FUNCDEF); - n->func_name = strdup(cur(p)->text); - adv(p); // name - adv(p); // '(' - if (curt(p) != T_RPAREN) { p->err = "expected ')' in function definition"; sh_free_node(n); return NULL; } - adv(p); // ')' - skip_newlines(p); - n->body = parse_command(p); - if (!n->body) { sh_free_node(n); return NULL; } - return n; -} - -static node *parse_command(P *p) -{ - // Simple commands handle their own (possibly interleaved) redirects. - if (curt(p) != T_LPAREN && !is_word(p, "{") && - !(curt(p) == T_WORD && !is_reserved(p) && peekt(p) == T_LPAREN) && - !is_word(p, "if") && !is_word(p, "while") && !is_word(p, "until") && - !is_word(p, "for") && - !is_word(p, "case")) - return parse_simple(p); - - // Compound command: parse it, then attach any trailing redirect list. - node *n; - if (curt(p) == T_LPAREN) n = parse_subshell(p); - else if (is_word(p, "{")) n = parse_group(p); - else if (is_word(p, "if")) n = parse_if(p); - else if (is_word(p, "while") || is_word(p, "until")) n = parse_while(p); - else if (is_word(p, "for")) n = parse_for(p); - else if (is_word(p, "case")) n = parse_case(p); - else n = parse_funcdef(p); // WORD '(' ')' body - if (!n) return NULL; - if (!parse_redir_list(p, n)) { sh_free_node(n); return NULL; } - return n; -} - -static node *parse_pipeline(P *p) -{ - // A leading `!` (as its own word, at command-word position) negates the - // pipeline's exit status. `! ! x` toggles, matching bash/dash. - int negated = 0; - while (is_word(p, "!")) { negated = !negated; adv(p); skip_newlines(p); } - - node *first = parse_command(p); - if (!first) return NULL; - if (curt(p) != T_BAR) { first->negated = negated; return first; } - - node *n = new_node(N_PIPE); - push_child(n, first); - while (curt(p) == T_BAR) { - adv(p); - skip_newlines(p); - node *c = parse_command(p); - if (!c) { sh_free_node(n); return NULL; } - push_child(n, c); - } - n->negated = negated; - return n; -} - -static node *parse_and_or(P *p) -{ - node *left = parse_pipeline(p); - if (!left) return NULL; - while (curt(p) == T_AMPAMP || curt(p) == T_BARBAR) { - int op = curt(p); - adv(p); - skip_newlines(p); - node *right = parse_pipeline(p); - if (!right) { sh_free_node(left); return NULL; } - node *n = new_node(N_ANDOR); - n->left = left; - n->right = right; - n->andor_op = op; - left = n; - } - return left; -} - -// A list runs until EOF or a reserved terminator word (then/else/elif/fi/do/done). -static node *parse_list(P *p) -{ - node *n = new_node(N_LIST); - skip_seps(p); - while (curt(p) != T_EOF && curt(p) != T_RPAREN && curt(p) != T_DSEMI && - !is_reserved(p)) { - node *ao = parse_and_or(p); - if (!ao) { sh_free_node(n); return NULL; } - push_child(n, ao); - // separators between and_or units - if (curt(p) == T_SEMI || curt(p) == T_NEWLINE) { - skip_seps(p); - } else { - break; // next token is EOF or a reserved word - } - } - return n; -} - -node *sh_parse(sh_toklist *tl, const char **errmsg) -{ - P p = { tl, 0, NULL }; - node *n = parse_list(&p); - if (!n) { if (errmsg) *errmsg = p.err ? p.err : "parse error"; return NULL; } - if (curt(&p) != T_EOF) { - if (errmsg) *errmsg = "unexpected token"; - sh_free_node(n); - return NULL; - } - return n; -} - -void sh_free_node(node *n) -{ - if (!n) return; - for (int i = 0; i < n->nchild; i++) sh_free_node(n->children[i]); - free(n->children); - sh_free_node(n->left); - sh_free_node(n->right); - for (int i = 0; i < n->nassign; i++) free(n->assigns[i]); - free(n->assigns); - for (int i = 0; i < n->nword; i++) free(n->words[i]); - free(n->words); - for (int i = 0; i < n->nredir; i++) free(n->redirs[i].word); - free(n->redirs); - for (int i = 0; i < n->nclause; i++) { sh_free_node(n->conds[i]); sh_free_node(n->bodies[i]); } - free(n->conds); - free(n->bodies); - sh_free_node(n->else_body); - sh_free_node(n->cond); - sh_free_node(n->body); - free(n->for_name); - for (int i = 0; i < n->for_nword; i++) free(n->for_words[i]); - free(n->for_words); - free(n->case_word); - for (int i = 0; i < n->nclause_case; i++) { - for (int j = 0; j < n->clauses[i].npat; j++) free(n->clauses[i].pats[j]); - free(n->clauses[i].pats); - sh_free_node(n->clauses[i].body); - } - free(n->clauses); - free(n->func_name); - free(n); -} diff --git a/Tactility/Source/app/shell/shell/sh_parse.cpp b/Tactility/Source/app/shell/shell/sh_parse.cpp new file mode 100644 index 000000000..03d7589eb --- /dev/null +++ b/Tactility/Source/app/shell/shell/sh_parse.cpp @@ -0,0 +1,656 @@ +// Shell grammar parser: token list -> AST. +// +// A push-down parser: each grammar rule is a frame on an explicit stack, and a +// rule that needs a nested rule (a compound command's body, a pipeline's next +// command, ...) pushes it and resumes when it completes. Nesting depth is +// bounded by SH_MAX_PARSE_FRAMES instead of the native stack. +#include + +#include +#include +#include +#include +#include +#include + +#define SH_MAX_PARSE_FRAMES 1024 + +namespace { + +struct NodeDeleter { + void operator()(node* n) const { sh_free_node(n); } +}; +using NodePtr = std::unique_ptr; + +node* new_node(node_kind k) +{ + auto* n = static_cast(calloc(1, sizeof(node))); + n->kind = k; + return n; +} + +void push_child(node* n, node* c) +{ + n->children = static_cast(realloc(n->children, (n->nchild + 1) * sizeof(node*))); + n->children[n->nchild++] = c; +} + +void push_clause(node* n, node* cond, node* body) +{ + n->conds = static_cast(realloc(n->conds, (n->nclause + 1) * sizeof(node*))); + n->bodies = static_cast(realloc(n->bodies, (n->nclause + 1) * sizeof(node*))); + n->conds[n->nclause] = cond; + n->bodies[n->nclause] = body; + n->nclause++; +} + +int is_redir_tok(tok_type t) +{ + return t == T_LT || t == T_GT || t == T_GTGT || t == T_GTAMP || + t == T_LTAMP || t == T_CLOBBER || t == T_DLESS || t == T_DLESSDASH; +} + +sh_redir* push_redir(node* n) +{ + n->redirs = static_cast(realloc(n->redirs, (n->nredir + 1) * sizeof(sh_redir))); + sh_redir* r = &n->redirs[n->nredir++]; + r->kind = R_OUT; r->fd = 1; r->dupfd = 0; r->word = nullptr; r->heredoc_quoted = 0; + return r; +} + +int all_digits(const char* s) +{ + if (!*s) return 0; + for (; *s; s++) if (*s < '0' || *s > '9') return 0; + return 1; +} + +int looks_like_assign(const char* w) +{ + if (!(*w == '_' || (*w >= 'A' && *w <= 'Z') || (*w >= 'a' && *w <= 'z'))) return 0; + const char* q = w + 1; + while (*q && *q != '=') { + if (!(*q == '_' || (*q >= 'A' && *q <= 'Z') || (*q >= 'a' && *q <= 'z') || + (*q >= '0' && *q <= '9'))) return 0; + q++; + } + return *q == '='; +} + +class Parser; + +enum class Step { Call, Return, Fail }; + +// One frame per grammar rule. step() is called on first entry with a null +// `child`, and again after each nested rule it pushed has completed, with that +// rule's result. +struct ParseList { NodePtr n; bool started = false; Step step(Parser& p, NodePtr child); }; +struct ParseAndOr { NodePtr left; int op = 0; enum { Start, Left, Right } phase = Start; Step step(Parser& p, NodePtr child); }; +struct ParsePipeline { NodePtr pipe; int negated = 0; enum { Start, First, Next } phase = Start; Step step(Parser& p, NodePtr child); }; +struct ParseCommand { bool started = false; Step step(Parser& p, NodePtr child); }; +struct ParseGroup { NodePtr n; bool subshell; bool started = false; Step step(Parser& p, NodePtr child); }; +struct ParseFuncdef { NodePtr n; bool started = false; Step step(Parser& p, NodePtr child); }; +struct ParseIf { NodePtr n; NodePtr cond; enum { Start, Cond, Body, Else } phase = Start; Step step(Parser& p, NodePtr child); }; +struct ParseWhile { NodePtr n; enum { Start, Cond, Body } phase = Start; Step step(Parser& p, NodePtr child); }; +struct ParseFor { NodePtr n; bool started = false; Step step(Parser& p, NodePtr child); }; + +struct ParseCase { + NodePtr n; + std::vector pats; // patterns of the clause whose body is being parsed + bool started = false; + + ParseCase() = default; + ParseCase(ParseCase&&) = default; + ~ParseCase() { for (char* pat : pats) free(pat); } + + Step step(Parser& p, NodePtr child); +}; + +using ParseFrame = std::variant; + +class Parser { +public: + explicit Parser(sh_toklist* tl) : tl(tl) {} + + sh_toklist* tl; + int pos = 0; + const char* err = nullptr; + + sh_tok* cur() { return &tl->toks[pos]; } + tok_type curt() { return tl->toks[pos].type; } + tok_type peekt() { return tl->toks[pos].type == T_EOF ? T_EOF : tl->toks[pos + 1].type; } + void adv() { if (tl->toks[pos].type != T_EOF) pos++; } + + // Skip newlines/semicolons acting as blank separators. + void skip_seps() { while (curt() == T_NEWLINE || curt() == T_SEMI) adv(); } + void skip_newlines() { while (curt() == T_NEWLINE) adv(); } + + // Is the current token a bare word equal to `kw`? + bool is_word(const char* kw) + { + sh_tok* t = cur(); + return t->type == T_WORD && t->text && strcmp(t->text, kw) == 0; + } + + bool is_reserved() + { + static const char* kw[] = { "then", "else", "elif", "fi", "do", "done", "esac", "}", nullptr }; + if (curt() != T_WORD) return false; + for (int i = 0; kw[i]; i++) if (is_word(kw[i])) return true; + return false; + } + + Step fail(const char* message) + { + err = message; + return Step::Fail; + } + + Step call(ParseFrame frame) + { + pending.emplace(std::move(frame)); + return Step::Call; + } + + Step finish(NodePtr n) + { + result = std::move(n); + return Step::Return; + } + + node* run() + { + std::vector frames; + frames.emplace_back(ParseList {}); + NodePtr child; + while (!frames.empty()) { + Step step = std::visit([&](auto& frame) { return frame.step(*this, std::move(child)); }, frames.back()); + switch (step) { + case Step::Call: + if (frames.size() >= SH_MAX_PARSE_FRAMES) { + err = "nesting too deep"; + return nullptr; + } + frames.push_back(std::move(*pending)); + pending.reset(); + break; + case Step::Return: + frames.pop_back(); + child = std::move(result); + break; + case Step::Fail: + return nullptr; // frames' partial nodes are freed as the stack unwinds + } + } + return child.release(); + } + + // Parse the redirection at the current token onto `n`. Returns 1 on success, + // 0 on error (err set). + int parse_redir(node* n) + { + tok_type t = curt(); + int fd = cur()->rfd; + // No explicit fd digit (rfd == -1): default to stdin for input operators, + // stdout for output operators. + if (fd < 0) { + fd = (t == T_LT || t == T_LTAMP || t == T_DLESS || t == T_DLESSDASH) ? 0 : 1; + } + + if (t == T_DLESS || t == T_DLESSDASH) { + char* body = cur()->text ? strdup(cur()->text) : strdup(""); + int quoted = cur()->hd_quoted; + adv(); + if (curt() != T_WORD) { free(body); err = "expected here-doc delimiter"; return 0; } + sh_redir* r = push_redir(n); + r->kind = R_HEREDOC; r->fd = 0; r->word = body; r->heredoc_quoted = quoted; + adv(); + return 1; + } + + if (t == T_GTAMP || t == T_LTAMP) { + adv(); + if (curt() != T_WORD) { err = "expected fd or filename after >&"; return 0; } + const char* tgt = cur()->text; + sh_redir* r = push_redir(n); + r->fd = fd; + if (all_digits(tgt)) { r->kind = R_DUP; r->dupfd = static_cast(strtol(tgt, nullptr, 10)); } + else if (strcmp(tgt, "-") == 0) { r->kind = R_CLOSE; } + else { r->kind = (t == T_GTAMP) ? R_OUT : R_IN; r->word = strdup(tgt); } + adv(); + return 1; + } + + // T_LT / T_GT / T_GTGT / T_CLOBBER: filename target. + redir_kind rk = (t == T_LT) ? R_IN : (t == T_GTGT) ? R_APPEND : R_OUT; + adv(); + if (curt() != T_WORD) { err = "expected filename after redirection"; return 0; } + sh_redir* r = push_redir(n); + r->kind = rk; r->fd = fd; r->word = strdup(cur()->text); + adv(); + return 1; + } + + // Consume any trailing redirections onto a compound command. Returns 1/0. + int parse_redir_list(node* n) + { + while (is_redir_tok(curt())) { + if (!parse_redir(n)) return 0; + } + return 1; + } + + NodePtr parse_simple() + { + NodePtr n(new_node(N_SIMPLE)); + int seen_word = 0; + + for (;;) { + tok_type t = curt(); + if (t == T_WORD) { + if (!seen_word && looks_like_assign(cur()->text)) { + n->assigns = static_cast(realloc(n->assigns, (n->nassign + 1) * sizeof(char*))); + n->assigns[n->nassign++] = strdup(cur()->text); + adv(); + continue; + } + // Reserved words are only special in command position (first word); + // as arguments they are ordinary words (e.g. `echo done`). + if (!seen_word && is_reserved()) break; + n->words = static_cast(realloc(n->words, (n->nword + 1) * sizeof(char*))); + n->words[n->nword++] = strdup(cur()->text); + seen_word = 1; + adv(); + } else if (is_redir_tok(t)) { + // Redirects may appear before, between, or after words and do not + // count as the command word (so `FOO=x >f BAR=y cmd` still assigns). + if (!parse_redir(n.get())) return nullptr; + } else { + break; + } + } + if (n->nword == 0 && n->nassign == 0 && n->nredir == 0) { + err = "expected command"; + return nullptr; + } + return n; + } + +private: + std::optional pending; + NodePtr result; +}; + +// A list runs until EOF or a reserved terminator word (then/else/elif/fi/do/done). +Step ParseList::step(Parser& p, NodePtr child) +{ + if (!started) { + started = true; + n.reset(new_node(N_LIST)); + p.skip_seps(); + } else { + push_child(n.get(), child.release()); + // separators between and_or units + if (p.curt() == T_SEMI || p.curt() == T_NEWLINE) { + p.skip_seps(); + } else { + return p.finish(std::move(n)); // next token is EOF or a reserved word + } + } + if (p.curt() != T_EOF && p.curt() != T_RPAREN && p.curt() != T_DSEMI && !p.is_reserved()) { + return p.call(ParseAndOr {}); + } + return p.finish(std::move(n)); +} + +Step ParseAndOr::step(Parser& p, NodePtr child) +{ + switch (phase) { + case Start: + phase = Left; + return p.call(ParsePipeline {}); + case Left: + left = std::move(child); + break; + case Right: { + node* n = new_node(N_ANDOR); + n->left = left.release(); + n->right = child.release(); + n->andor_op = op; + left.reset(n); + break; + } + } + if (p.curt() == T_AMPAMP || p.curt() == T_BARBAR) { + op = p.curt(); + p.adv(); + p.skip_newlines(); + phase = Right; + return p.call(ParsePipeline {}); + } + return p.finish(std::move(left)); +} + +Step ParsePipeline::step(Parser& p, NodePtr child) +{ + switch (phase) { + case Start: + // A leading `!` (as its own word, at command-word position) negates the + // pipeline's exit status. `! ! x` toggles, matching bash/dash. + while (p.is_word("!")) { negated = !negated; p.adv(); p.skip_newlines(); } + phase = First; + return p.call(ParseCommand {}); + case First: + if (p.curt() != T_BAR) { + child->negated = negated; + return p.finish(std::move(child)); + } + pipe.reset(new_node(N_PIPE)); + push_child(pipe.get(), child.release()); + break; + case Next: + push_child(pipe.get(), child.release()); + break; + } + if (p.curt() == T_BAR) { + p.adv(); + p.skip_newlines(); + phase = Next; + return p.call(ParseCommand {}); + } + pipe->negated = negated; + return p.finish(std::move(pipe)); +} + +Step ParseCommand::step(Parser& p, NodePtr child) +{ + if (started) { + // Compound command parsed: attach any trailing redirect list. + if (!p.parse_redir_list(child.get())) return Step::Fail; + return p.finish(std::move(child)); + } + started = true; + + // Simple commands handle their own (possibly interleaved) redirects. + if (p.curt() != T_LPAREN && !p.is_word("{") && + !(p.curt() == T_WORD && !p.is_reserved() && p.peekt() == T_LPAREN) && + !p.is_word("if") && !p.is_word("while") && !p.is_word("until") && + !p.is_word("for") && + !p.is_word("case")) { + NodePtr n = p.parse_simple(); + if (!n) return Step::Fail; + return p.finish(std::move(n)); + } + + if (p.curt() == T_LPAREN) return p.call(ParseGroup { .subshell = true }); + if (p.is_word("{")) return p.call(ParseGroup { .subshell = false }); + if (p.is_word("if")) return p.call(ParseIf {}); + if (p.is_word("while") || p.is_word("until")) return p.call(ParseWhile {}); + if (p.is_word("for")) return p.call(ParseFor {}); + if (p.is_word("case")) return p.call(ParseCase {}); + return p.call(ParseFuncdef {}); // WORD '(' ')' body +} + +// { list; } or ( list ) +Step ParseGroup::step(Parser& p, NodePtr child) +{ + if (!started) { + started = true; + p.adv(); // '{' or '(' + n.reset(new_node(N_GROUP)); + n->subshell = subshell ? 1 : 0; + return p.call(ParseList {}); + } + n->body = child.release(); + if (subshell) { + if (p.curt() != T_RPAREN) return p.fail("expected ')'"); + } else { + if (!p.is_word("}")) return p.fail("expected '}'"); + } + p.adv(); + return p.finish(std::move(n)); +} + +Step ParseFuncdef::step(Parser& p, NodePtr child) +{ + if (!started) { + started = true; + n.reset(new_node(N_FUNCDEF)); + n->func_name = strdup(p.cur()->text); + p.adv(); // name + p.adv(); // '(' + if (p.curt() != T_RPAREN) return p.fail("expected ')' in function definition"); + p.adv(); // ')' + p.skip_newlines(); + return p.call(ParseCommand {}); + } + n->body = child.release(); + return p.finish(std::move(n)); +} + +Step ParseIf::step(Parser& p, NodePtr child) +{ + switch (phase) { + case Start: + p.adv(); // 'if' + n.reset(new_node(N_IF)); + phase = Cond; + return p.call(ParseList {}); + case Cond: + cond = std::move(child); + if (!p.is_word("then")) return p.fail("expected 'then'"); + p.adv(); + phase = Body; + return p.call(ParseList {}); + case Body: + if (child->nchild == 0) return p.fail("empty then body"); + push_clause(n.get(), cond.release(), child.release()); + if (p.is_word("elif")) { + p.adv(); + phase = Cond; + return p.call(ParseList {}); + } + if (p.is_word("else")) { + p.adv(); + phase = Else; + return p.call(ParseList {}); + } + break; + case Else: + n->else_body = child.release(); + if (n->else_body->nchild == 0) return p.fail("empty else body"); + break; + } + if (!p.is_word("fi")) return p.fail("expected 'fi'"); + p.adv(); + return p.finish(std::move(n)); +} + +Step ParseWhile::step(Parser& p, NodePtr child) +{ + switch (phase) { + case Start: { + int until = p.is_word("until"); + p.adv(); // 'while' / 'until' + n.reset(new_node(N_WHILE)); + n->until = until; + phase = Cond; + return p.call(ParseList {}); + } + case Cond: + n->cond = child.release(); + if (!p.is_word("do")) return p.fail("expected 'do'"); + p.adv(); + phase = Body; + return p.call(ParseList {}); + case Body: + break; + } + n->body = child.release(); + if (n->body->nchild == 0) return p.fail("empty do/done body"); + if (!p.is_word("done")) return p.fail("expected 'done'"); + p.adv(); + return p.finish(std::move(n)); +} + +// Collect the raw words after `for NAME in` up to a separator. +Step ParseFor::step(Parser& p, NodePtr child) +{ + if (started) { + n->body = child.release(); + if (n->body->nchild == 0) return p.fail("empty do/done body"); + if (!p.is_word("done")) return p.fail("expected 'done'"); + p.adv(); + return p.finish(std::move(n)); + } + started = true; + + p.adv(); // 'for' + if (p.curt() != T_WORD) return p.fail("expected name after for"); + // The loop variable must be a valid name (dash rejects e.g. `for -`). + const char* nm = p.cur()->text; + if (!(nm[0] == '_' || (nm[0] >= 'a' && nm[0] <= 'z') || (nm[0] >= 'A' && nm[0] <= 'Z'))) { + return p.fail("bad for loop variable"); + } + for (const char* q = nm + 1; *q; q++) { + if (!(*q == '_' || (*q >= '0' && *q <= '9') || + (*q >= 'a' && *q <= 'z') || (*q >= 'A' && *q <= 'Z'))) { + return p.fail("bad for loop variable"); + } + } + n.reset(new_node(N_FOR)); + n->for_name = strdup(nm); + p.adv(); + p.skip_newlines(); // `for i in ...` is legal (`for i; in` is not) + if (p.is_word("in")) { + p.adv(); + while (p.curt() == T_WORD && !p.is_reserved()) { + n->for_words = static_cast(realloc(n->for_words, (n->for_nword + 1) * sizeof(char*))); + n->for_words[n->for_nword++] = strdup(p.cur()->text); + p.adv(); + } + } else { + // `for x; do ...` with no `in`: iterate over the positional params. + n->for_implicit = 1; + } + p.skip_seps(); + if (!p.is_word("do")) return p.fail("expected 'do'"); + p.adv(); + return p.call(ParseList {}); +} + +// case WORD in [(] pat [| pat]... ) list ;; ... esac +Step ParseCase::step(Parser& p, NodePtr child) +{ + if (!started) { + started = true; + p.adv(); // 'case' + if (p.curt() != T_WORD) return p.fail("expected word after case"); + n.reset(new_node(N_CASE)); + n->case_word = strdup(p.cur()->text); + p.adv(); + if (!p.is_word("in")) return p.fail("expected 'in' after case word"); + p.adv(); + p.skip_seps(); + } else { + sh_case_clause cl; + cl.npat = static_cast(pats.size()); + cl.pats = static_cast(malloc(pats.size() * sizeof(char*))); + memcpy(cl.pats, pats.data(), pats.size() * sizeof(char*)); + pats.clear(); + cl.body = child.release(); + n->clauses = static_cast(realloc(n->clauses, (n->nclause_case + 1) * sizeof(sh_case_clause))); + n->clauses[n->nclause_case++] = cl; + if (p.curt() == T_DSEMI) { + p.adv(); + p.skip_seps(); + } else { + // last clause may omit ';;' + if (!p.is_word("esac")) return p.fail("expected 'esac'"); + p.adv(); + return p.finish(std::move(n)); + } + } + + if (p.is_word("esac") || p.curt() == T_EOF) { + if (!p.is_word("esac")) return p.fail("expected 'esac'"); + p.adv(); + return p.finish(std::move(n)); + } + + if (p.curt() == T_LPAREN) p.adv(); // optional leading '(' + for (;;) { + if (p.curt() != T_WORD) return p.fail("expected case pattern"); + pats.push_back(strdup(p.cur()->text)); + p.adv(); + if (p.curt() == T_BAR) { p.adv(); continue; } + break; + } + if (p.curt() != T_RPAREN) return p.fail("expected ')' in case"); + p.adv(); + return p.call(ParseList {}); +} + +} // namespace + +extern "C" { + +node* sh_parse(sh_toklist* tl, const char** errmsg) +{ + Parser p(tl); + node* n = p.run(); + if (!n) { + if (errmsg) *errmsg = p.err ? p.err : "parse error"; + return nullptr; + } + if (p.curt() != T_EOF) { + if (errmsg) *errmsg = "unexpected token"; + sh_free_node(n); + return nullptr; + } + return n; +} + +void sh_free_node(node* root) +{ + // A worklist keeps freeing deeply nested trees off the native stack. + std::vector pending; + if (root) pending.push_back(root); + while (!pending.empty()) { + node* n = pending.back(); + pending.pop_back(); + auto later = [&pending](node* c) { if (c) pending.push_back(c); }; + + for (int i = 0; i < n->nchild; i++) later(n->children[i]); + free(n->children); + later(n->left); + later(n->right); + for (int i = 0; i < n->nassign; i++) free(n->assigns[i]); + free(n->assigns); + for (int i = 0; i < n->nword; i++) free(n->words[i]); + free(n->words); + for (int i = 0; i < n->nredir; i++) free(n->redirs[i].word); + free(n->redirs); + for (int i = 0; i < n->nclause; i++) { later(n->conds[i]); later(n->bodies[i]); } + free(n->conds); + free(n->bodies); + later(n->else_body); + later(n->cond); + later(n->body); + free(n->for_name); + for (int i = 0; i < n->for_nword; i++) free(n->for_words[i]); + free(n->for_words); + free(n->case_word); + for (int i = 0; i < n->nclause_case; i++) { + for (int j = 0; j < n->clauses[i].npat; j++) free(n->clauses[i].pats[j]); + free(n->clauses[i].pats); + later(n->clauses[i].body); + } + free(n->clauses); + free(n->func_name); + free(n); + } +} + +} diff --git a/Tactility/Tests/CMakeLists.txt b/Tactility/Tests/CMakeLists.txt index 418f4b9cd..db4b09abb 100644 --- a/Tactility/Tests/CMakeLists.txt +++ b/Tactility/Tests/CMakeLists.txt @@ -25,7 +25,10 @@ target_compile_definitions(TactilityTests PRIVATE "-DTT_TERMINAL_FONT_SYMBOL=ibmplexmono_14_font" ) -add_test(NAME TactilityTests COMMAND TactilityTests) +# The POSIX platform registers its "data" filesystem relative to the working directory, and the +# shell interpreter tests need it for temp files (command substitution, pipelines). +file(MAKE_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/data) +add_test(NAME TactilityTests COMMAND TactilityTests WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) # No --wrap flags declared here: linking platform-posix and app-module below already brings all # of pthread_attr_setstack/read/write/close/the printf family along (see their own CMakeLists.txt diff --git a/Tactility/Tests/Scripts/shell_test.sh b/Tactility/Tests/Scripts/shell_test.sh new file mode 100644 index 000000000..12cfcf79c --- /dev/null +++ b/Tactility/Tests/Scripts/shell_test.sh @@ -0,0 +1,256 @@ +# Shell interpreter self-test. +# +# Copy it to the device (e.g. the SD card) and run it from the Terminal: +# sh /sdcard/shell_test.sh +# It prints a FAIL line per failed check and a summary, and exits with 1 when anything failed. +# Temp files are written next to the script. +# +# Command substitution nests only 3 deep here: each level keeps a temp file open, and ESP32 +# filesystems allow 4 open files at a time. + +pass=0 +fail=0 + +# check +check() { + if [ "$2" = "$3" ]; then + pass=$((pass + 1)) + else + fail=$((fail + 1)) + echo "FAIL: $1 (expected '$2', got '$3')" + fi +} + +case $0 in + */*) dir=${0%/*} ;; + *) dir=. ;; +esac + +echo "Shell test: writing temp files to $dir" + +# --- Variables and quoting --- + +x='a b' +check "unquoted splitting" "a b" "$(echo $x)" +check "double quotes keep spaces" "a b" "$(echo "$x")" +check "single quotes are literal" '$x' "$(echo '$x')" +check "backslash escape" '$x' "$(echo \$x)" +false +check "exit status" 1 "$?" + +set -- one "two words" three +check "positional count" 3 "$#" +check "positional parameter" "two words" "$2" +n=0 +for p in "$@"; do n=$((n + 1)); done +check "quoted \$@" 3 "$n" + +IFS=: +set -- $(echo a:b:c) +IFS=' ' +check "custom IFS" 3 "$#" + +# --- Parameter expansion --- + +path=a/b/c.tar.gz +check "default" def "${unset_var:-def}" +check "assign default" set "${assigned:=set}" +check "assigned variable" set "$assigned" +check "alternate" alt "${path:+alt}" +check "length" 12 "${#path}" +check "shortest prefix" b/c.tar.gz "${path#*/}" +check "longest prefix" c.tar.gz "${path##*/}" +check "shortest suffix" a/b/c.tar "${path%.*}" +check "longest suffix" a/b/c "${path%%.*}" +check "nested default" deep "${unset1:-${unset2:-deep}}" +lazy=set +check "lazy default word" set "${lazy:-$(ran=yes; echo no)}" +check "lazy word not run" "" "$ran" + +# --- Arithmetic --- + +a=5 +expr='a+1' +check "precedence" 7 "$((1 + 2 * 3))" +check "parentheses" 9 "$(( (1 + 2) * 3 ))" +check "division and modulo" 3/1 "$((10 / 3))/$((10 % 3))" +check "unary minus" -5 "$((-3 + -2))" +check "variables" 10 "$((a * 2))" +check "compound assignment" 6 "$((a += 1))" +check "expression-valued variable" 14 "$((expr * 2))" +check "chained assignment" 4 "$((p = q = 4))" +check "hex and octal" 16/8 "$((0x10))/$((010))" +check "comparison and logic" 1 "$((1 < 2 && 3 != 4))" + +# --- Control flow --- + +r= +for v in 1 2 3; do + if [ $v = 1 ]; then r="${r}one"; elif [ $v = 2 ]; then r="${r}two"; else r="${r}other"; fi +done +check "if/elif/else" onetwoother "$r" + +i=0 +while [ $i -lt 1000 ]; do i=$((i + 1)); done +check "1000-iteration while loop" 1000 "$i" + +j=0 +until [ $j -ge 3 ]; do j=$((j + 1)); done +check "until loop" 3 "$j" + +s= +for i in 1 2 3 4; do + if [ $i = 2 ]; then continue; fi + if [ $i = 4 ]; then break; fi + s="$s$i" +done +check "break and continue" 13 "$s" + +t= +for a in 1 2; do for b in 1 2; do if [ $b = 2 ]; then break 2; fi; t="$t$a$b"; done; done +check "break 2" 11 "$t" + +r= +for f in a.txt b.c x; do + case $f in + *.txt) r="${r}T" ;; + a|b.c) r="${r}C" ;; + [xy]) r="${r}X" ;; + *) r="${r}?" ;; + esac +done +check "case patterns" TCX "$r" + +false && x1=1 || x2=2 +check "and-or lists" "/2" "$x1/$x2" + +# --- Functions --- + +greet() { + g="hello $1" + return 4 +} +greet world +check "function return status" 4 "$?" +check "function arguments" "hello world" "$g" + +outer=outer +scoped() { + local outer=inner + seen=$outer +} +scoped +check "local inside function" inner "$seen" +check "local restored" outer "$outer" + +fact() { + if [ $1 -le 1 ]; then f=1; else fact $(($1 - 1)); f=$((f * $1)); fi +} +fact 6 +check "recursive factorial" 720 "$f" + +# --- Subshells, command substitution, eval --- + +y=1 +(y=2) +check "subshell isolation" 1 "$y" +(exit 3) +check "subshell exit status" 3 "$?" +{ y=4; } +check "brace group" 4 "$y" + +check "nested command substitution" deep "$(echo $(echo $(echo deep)))" +check "trailing newlines stripped" a "$(echo a; echo; echo)" +check "backticks" tick "`echo tick`" +eval 'e=5' +check "eval" 5 "$e" + +# --- Redirection, here-documents, pipelines --- + +tmp="$dir/shell_test.tmp" +echo one > "$tmp" +echo two >> "$tmp" +{ read l1; read l2; } < "$tmp" +check "redirect and append" one/two "$l1/$l2" +check "stderr to stdout" "out err" "$( { echo out; echo err >&2; } 2>&1 | { read o; read e; echo $o $e; } )" + +hd=w +read h1 < "$tmp" +. "$tmp" arg +check "source with arguments" 9/arg "$sourced/$first" + +# --- test builtin --- + +[ ! -z x ] && [ \( a = a \) -a \( b = c -o d = d \) ] +check "test grouping and negation" 0 "$?" +test -f "$tmp" +check "test -f" 0 "$?" +test -d "$tmp" +check "test -d" 1 "$?" + +# --- Deep nesting (the interpreter must not run out of stack) --- + +depth() { + if [ $1 -lt 50 ]; then depth $(($1 + 1)); else deepest=$1; fi +} +depth 0 +check "50 nested function calls" 50 "$deepest" + +open=; close=; i=0 +while [ $i -lt 100 ]; do open="$open{ "; close="$close} "; i=$((i + 1)); done +eval "${open}r=groups; ${close}" +check "100 nested brace groups" groups "$r" + +open=; close=; i=0 +while [ $i -lt 100 ]; do open="$open\${u:-"; close="$close}"; i=$((i + 1)); done +eval "r=${open}braces${close}" +check "100 nested \${...}" braces "$r" + +open=; close=; i=0 +while [ $i -lt 100 ]; do open="$open("; close="$close)"; i=$((i + 1)); done +eval "r=\$(( ${open}1 + 1${close} ))" +check "100 nested arithmetic parentheses" 2 "$r" + +# --- Scripts run as their own sh instance --- + +echo 'exit 7' > "$tmp" +sh "$tmp" +check "sh script exit status" 7 "$?" + +open=; close=; i=0 +while [ $i -lt 300 ]; do open="$open{ "; close="$close} "; i=$((i + 1)); done +echo "${open}x=1; ${close}" > "$tmp" +sh "$tmp" +check "excessive nesting is reported, not a crash" 2 "$?" + +echo 'x=$((1 / 0)); echo unreachable' > "$tmp" +check "arithmetic error aborts the script" "" "$(sh "$tmp")" + +echo 'set -e; false; echo unreachable' > "$tmp" +check "set -e" "" "$(sh "$tmp")" + +rm "$tmp" + +# --- Summary --- + +echo "passed: $pass, failed: $fail" +if [ $fail -gt 0 ]; then + exit 1 +fi diff --git a/Tactility/Tests/Source/ShellCompletionTest.cpp b/Tactility/Tests/Source/ShellCompletionTest.cpp new file mode 100644 index 000000000..4c2e9978b --- /dev/null +++ b/Tactility/Tests/Source/ShellCompletionTest.cpp @@ -0,0 +1,37 @@ +#include "doctest.h" + +#include + +#include + +#include +#include + +namespace { + +std::string complete(const std::string& line) { + char suffix[FILE_MAX_PATH_STRING_LENGTH]; + bool listed = false; + if (!Shell::complete(line.c_str(), suffix, sizeof(suffix), &listed)) { + return ""; + } + return suffix; +} + +} // namespace + +TEST_CASE("shell completion: first word completes directories as well as commands") { + char temp[FILE_MAX_PATH_STRING_LENGTH]; + REQUIRE_EQ(paths_get_temp_path(temp, sizeof(temp)), ERROR_NONE); + const std::string base = std::string(temp) + "/completiontest"; + mkdir(base.c_str(), 0755); + mkdir((base + "/appfolder").c_str(), 0755); + + // A path typed as the command completes like any other path + CHECK_EQ(complete(base + "/app"), "folder/"); + CHECK_EQ(complete("ls " + base + "/app"), "folder/"); + // A relative first word completes against the working directory, which is "/" without an app instance + CHECK_EQ(complete("./tm"), "p/"); + CHECK_EQ(complete("tm"), "p/"); + CHECK_EQ(complete("./nothing"), ""); +} diff --git a/Tactility/Tests/Source/ShellInterpreterTest.cpp b/Tactility/Tests/Source/ShellInterpreterTest.cpp new file mode 100644 index 000000000..4509cfeb6 --- /dev/null +++ b/Tactility/Tests/Source/ShellInterpreterTest.cpp @@ -0,0 +1,582 @@ +#include "doctest.h" + +#include + +extern "C" { +#include +} + +#include +#include +#include +#include + +namespace { + +void makeDirectories(const std::string& path) { + for (size_t i = 1; i <= path.size(); i++) { + if (i == path.size() || path[i] == '/') { + mkdir(path.substr(0, i).c_str(), 0755); + } + } +} + +/** A fresh interpreter state per test, with $TMP pointing at a writable scratch directory. */ +class Shell { +public: + sh_state state {}; + std::string tmp; + + Shell() { + char path[FILE_MAX_PATH_STRING_LENGTH]; + REQUIRE_EQ(paths_get_temp_path(path, sizeof(path)), ERROR_NONE); + tmp = std::string(path) + "/shelltest"; + makeDirectories(tmp); + sh_state_init(&state); + sh_set(&state, "TMP", tmp.c_str()); + } + + ~Shell() { + sh_state_free(&state); + } + + int run(const char* source) { + return sh_run_string(&state, source); + } + + std::string var(const char* name) { + const char* value = sh_get(&state, name); + return value != nullptr ? value : ""; + } +}; + +} // namespace + +// region Variables and quoting + +TEST_CASE("shell: assignment and command substitution") { + Shell sh; + CHECK_EQ(sh.run("x=1; y=$(echo hi)"), 0); + CHECK_EQ(sh.var("x"), "1"); + CHECK_EQ(sh.var("y"), "hi"); +} + +TEST_CASE("shell: exit status in $?") { + Shell sh; + sh.run("false; a=$?; true; b=$?"); + CHECK_EQ(sh.var("a"), "1"); + CHECK_EQ(sh.var("b"), "0"); +} + +TEST_CASE("shell: quoting and word splitting") { + Shell sh; + sh.run("x='a b'; unquoted=$(echo $x); quoted=$(echo \"$x\"); single='$x'; escaped=\\$x"); + CHECK_EQ(sh.var("unquoted"), "a b"); + CHECK_EQ(sh.var("quoted"), "a b"); + CHECK_EQ(sh.var("single"), "$x"); + CHECK_EQ(sh.var("escaped"), "$x"); +} + +TEST_CASE("shell: IFS splitting") { + Shell sh; + sh.run("IFS=:; set -- $(echo a:b:c); n=$#; second=$2"); + CHECK_EQ(sh.var("n"), "3"); + CHECK_EQ(sh.var("second"), "b"); +} + +TEST_CASE("shell: positional parameters") { + Shell sh; + sh.run("set -- a 'b c' d; n=$#; second=$2; all=\"$*\"; count=0; for p in \"$@\"; do count=$((count+1)); done"); + CHECK_EQ(sh.var("n"), "3"); + CHECK_EQ(sh.var("second"), "b c"); + CHECK_EQ(sh.var("all"), "a b c d"); + CHECK_EQ(sh.var("count"), "3"); +} + +TEST_CASE("shell: parameter expansion operators") { + Shell sh; + sh.run( + "p=a/b/c; empty=\n" + "d1=${unset:-def}; d2=${unset-def}; d3=${empty:-def}; d4=${empty-def}\n" + ": ${assigned:=set}\n" + "alt=${p:+alt}; noalt=${unset:+alt}; len=${#p}\n" + "s1=${p#*/}; s2=${p##*/}; s3=${p%/*}; s4=${p%%/*}\n" + "nested=${unset:-${unset2:-deep}}" + ); + CHECK_EQ(sh.var("d1"), "def"); + CHECK_EQ(sh.var("d2"), "def"); + CHECK_EQ(sh.var("d3"), "def"); + CHECK_EQ(sh.var("d4"), ""); + CHECK_EQ(sh.var("assigned"), "set"); + CHECK_EQ(sh.var("alt"), "alt"); + CHECK_EQ(sh.var("noalt"), ""); + CHECK_EQ(sh.var("len"), "5"); + CHECK_EQ(sh.var("s1"), "b/c"); + CHECK_EQ(sh.var("s2"), "c"); + CHECK_EQ(sh.var("s3"), "a/b"); + CHECK_EQ(sh.var("s4"), "a"); + CHECK_EQ(sh.var("nested"), "deep"); +} + +TEST_CASE("shell: default word is only expanded when needed") { + Shell sh; + sh.run("x=set; y=${x:-$(ran=yes; echo v)}"); + CHECK_EQ(sh.var("y"), "set"); + CHECK_EQ(sh.var("ran"), ""); +} + +TEST_CASE("shell: unset and set -u") { + Shell sh; + sh.run("x=1; unset x"); + CHECK_EQ(sh.var("x"), ""); + + Shell strict; + CHECK_EQ(strict.run("set -u; y=$undefined_variable; after=1"), 2); + CHECK_EQ(strict.var("after"), ""); +} + +// endregion + +// region Arithmetic + +TEST_CASE("shell: arithmetic") { + Shell sh; + sh.run( + "a=5; c='a+1'\n" + "r1=$((1+2*3)); r2=$(( (1+2)*3 )); r3=$((10/3)); r4=$((10%3)); r5=$((-3 + -2))\n" + "r6=$((a*2)); r7=$((a+=1)); r8=$((1<2)); r9=$((1==2 || 3)); r10=$((0x10)); r11=$((010))\n" + "r12=$((!0)); r13=$((1,2)); r14=$((c*2)); r15=$(( ((((2)))) ))" + ); + CHECK_EQ(sh.var("r1"), "7"); + CHECK_EQ(sh.var("r2"), "9"); + CHECK_EQ(sh.var("r3"), "3"); + CHECK_EQ(sh.var("r4"), "1"); + CHECK_EQ(sh.var("r5"), "-5"); + CHECK_EQ(sh.var("r6"), "10"); + CHECK_EQ(sh.var("r7"), "6"); + CHECK_EQ(sh.var("a"), "6"); + CHECK_EQ(sh.var("r8"), "1"); + CHECK_EQ(sh.var("r9"), "1"); + CHECK_EQ(sh.var("r10"), "16"); + CHECK_EQ(sh.var("r11"), "8"); + CHECK_EQ(sh.var("r12"), "1"); + CHECK_EQ(sh.var("r13"), "2"); + CHECK_EQ(sh.var("r14"), "14"); + CHECK_EQ(sh.var("r15"), "2"); +} + +TEST_CASE("shell: arithmetic variables, assignment and precedence") { + Shell sh; + sh.run( + "b='2*3'; m='1+1'\n" + "r1=$((b+1)); r2=$((p=q=4)); r3=$((m*=3)); r4=$((- -3)); r5=$((!!5))\n" + "r6=$((1+2*3-4/2)); r7=$((2<3==1)); r8=$(( ((((((1+1)))))) )); r9=$((-b))" + ); + CHECK_EQ(sh.var("r1"), "7"); + CHECK_EQ(sh.var("r2"), "4"); + CHECK_EQ(sh.var("p"), "4"); + CHECK_EQ(sh.var("q"), "4"); + CHECK_EQ(sh.var("r3"), "6"); + CHECK_EQ(sh.var("m"), "6"); + CHECK_EQ(sh.var("r4"), "3"); + CHECK_EQ(sh.var("r5"), "1"); + CHECK_EQ(sh.var("r6"), "5"); + CHECK_EQ(sh.var("r7"), "1"); + CHECK_EQ(sh.var("r8"), "2"); + CHECK_EQ(sh.var("r9"), "-6"); +} + +TEST_CASE("shell: arithmetic syntax errors") { + const std::string scripts[] = { + "x=$((1+)); after=1", "x=$((1 2)); after=1", "v='(1'; x=$((v)); after=1", "x=$((1)+1)); after=1", + "x=$((2**3)); after=1", "v='1)'; x=$((v)); after=1", "r=r; x=$((r)); after=1" + }; + for (const std::string& script : scripts) { + Shell sh; + CAPTURE(script); + CHECK_EQ(sh.run(script.c_str()), 2); + CHECK_EQ(sh.var("after"), ""); + } +} + +TEST_CASE("shell: arithmetic errors are fatal") { + Shell sh; + CHECK_EQ(sh.run("x=$((1/0)); after=1"), 2); + CHECK_EQ(sh.var("after"), ""); +} + +// endregion + +// region Control flow + +TEST_CASE("shell: if, elif and else") { + Shell sh; + sh.run( + "for v in 1 2 3; do\n" + " if [ $v = 1 ]; then r=\"${r}one\"; elif [ $v = 2 ]; then r=\"${r}two\"; else r=\"${r}other\"; fi\n" + "done" + ); + CHECK_EQ(sh.var("r"), "onetwoother"); +} + +TEST_CASE("shell: while and until loops") { + Shell sh; + sh.run("i=0; while [ $i -lt 5 ]; do i=$((i+1)); done; j=0; until [ $j -ge 3 ]; do j=$((j+1)); done"); + CHECK_EQ(sh.var("i"), "5"); + CHECK_EQ(sh.var("j"), "3"); +} + +TEST_CASE("shell: break and continue") { + Shell sh; + sh.run( + "for i in 1 2 3 4; do if [ $i = 2 ]; then continue; fi; if [ $i = 4 ]; then break; fi; s=\"$s$i\"; done\n" + "for a in 1 2; do for b in 1 2; do if [ $b = 2 ]; then break 2; fi; t=\"$t$a$b\"; done; done" + ); + CHECK_EQ(sh.var("s"), "13"); + CHECK_EQ(sh.var("t"), "11"); +} + +TEST_CASE("shell: case patterns") { + Shell sh; + sh.run( + "for f in a.txt b.c x; do\n" + " case $f in *.txt) r=\"${r}T\";; a|b.c) r=\"${r}C\";; [xy]) r=\"${r}X\";; *) r=\"${r}?\";; esac\n" + "done" + ); + CHECK_EQ(sh.var("r"), "TCX"); +} + +TEST_CASE("shell: and-or lists and negation") { + Shell sh; + sh.run("false && x=1 || y=2; ! false; neg=$?"); + CHECK_EQ(sh.var("x"), ""); + CHECK_EQ(sh.var("y"), "2"); + CHECK_EQ(sh.var("neg"), "0"); +} + +TEST_CASE("shell: exit") { + Shell sh; + CHECK_EQ(sh.run("exit 3; after=1"), 3); + CHECK_EQ(sh.var("after"), ""); +} + +// endregion + +// region Functions + +TEST_CASE("shell: functions, arguments, return and local") { + Shell sh; + sh.run( + "greet() { g=\"hello $1\"; return 4; }\n" + "greet world; status=$?\n" + "x=outer; scoped() { local x=inner; seen=$x; }; scoped\n" + "fact() { if [ $1 -le 1 ]; then r=1; else fact $(($1-1)); r=$((r*$1)); fi; }; fact 5" + ); + CHECK_EQ(sh.var("g"), "hello world"); + CHECK_EQ(sh.var("status"), "4"); + CHECK_EQ(sh.var("seen"), "inner"); + CHECK_EQ(sh.var("x"), "outer"); + CHECK_EQ(sh.var("r"), "120"); +} + +TEST_CASE("shell: return from inside a loop") { + Shell sh; + sh.run("f() { for i in 1 2 3; do if [ $i = 2 ]; then return 7; fi; last=$i; done; }; f; s=$?"); + CHECK_EQ(sh.var("s"), "7"); + CHECK_EQ(sh.var("last"), "1"); +} + +TEST_CASE("shell: function recursion limit") { + Shell sh; + sh.run("f() { f; }; f; s=$?"); + CHECK_EQ(sh.var("s"), "1"); +} + +// endregion + +// region Subshells, groups and command substitution + +TEST_CASE("shell: subshell isolation and groups") { + Shell sh; + sh.run("x=1; (x=2); a=$x; (exit 3); s=$?; { x=4; }; b=$x"); + CHECK_EQ(sh.var("a"), "1"); + CHECK_EQ(sh.var("s"), "3"); + CHECK_EQ(sh.var("b"), "4"); +} + +TEST_CASE("shell: nested command substitution") { + Shell sh; + sh.run("x=$(echo $(echo $(echo deep))); y=$(echo a; echo; echo); z=`echo tick`"); + CHECK_EQ(sh.var("x"), "deep"); + CHECK_EQ(sh.var("y"), "a"); + CHECK_EQ(sh.var("z"), "tick"); +} + +TEST_CASE("shell: eval") { + Shell sh; + sh.run("eval 'x=5'; eval \"y=\\$x\""); + CHECK_EQ(sh.var("x"), "5"); + CHECK_EQ(sh.var("y"), "5"); +} + +TEST_CASE("shell: source") { + Shell sh; + sh.run( + "echo 'z=9; a1=$1' > $TMP/sourced.sh\n" + ". $TMP/sourced.sh first\n" + "echo 'return 5; never=1' > $TMP/returning.sh\n" + "source $TMP/returning.sh; s=$?" + ); + CHECK_EQ(sh.var("z"), "9"); + CHECK_EQ(sh.var("a1"), "first"); + CHECK_EQ(sh.var("s"), "5"); + CHECK_EQ(sh.var("never"), ""); +} + +// endregion + +// region Redirection and pipelines + +TEST_CASE("shell: output and input redirection") { + Shell sh; + sh.run("echo one > $TMP/f; echo two >> $TMP/f; { read a; read b; } < $TMP/f"); + CHECK_EQ(sh.var("a"), "one"); + CHECK_EQ(sh.var("b"), "two"); +} + +TEST_CASE("shell: stderr duplication") { + Shell sh; + sh.run("x=$( { echo out; echo err >&2; } 2>&1 )"); + CHECK_EQ(sh.var("x"), "out\nerr"); +} + +TEST_CASE("shell: here-documents") { + Shell sh; + sh.run("x=w\nread a < $TMP/exists; test -f $TMP/exists; d=$?; test -d $TMP/exists; e=$?" + ); + CHECK_EQ(sh.var("a"), "0"); + CHECK_EQ(sh.var("b"), "0"); + CHECK_EQ(sh.var("c"), "1"); + CHECK_EQ(sh.var("d"), "0"); + CHECK_EQ(sh.var("e"), "1"); +} + +TEST_CASE("shell: test builtin negation and grouping") { + Shell sh; + sh.run( + "[ ! ! x ]; a=$?; [ ! \\( a = b \\) ]; b=$?; [ \\( \\( x \\) \\) -o '' ]; c=$?\n" + "[ a = a -a \\( b = c -o d = d \\) ]; d=$?; [ \\( a ]; e=$?; [ a -a ]; f=$?" + ); + CHECK_EQ(sh.var("a"), "0"); + CHECK_EQ(sh.var("b"), "0"); + CHECK_EQ(sh.var("c"), "0"); + CHECK_EQ(sh.var("d"), "0"); + CHECK_EQ(sh.var("e"), "2"); + CHECK_EQ(sh.var("f"), "1"); +} + +TEST_CASE("shell: pattern matching with multiple stars") { + Shell sh; + sh.run( + "p=a/b/c.tar.gz\n" + "case abcabc in *b*c*c) m1=y;; esac; case abc in *x*) m2=y;; *) m2=n;; esac\n" + "case ab in a*b*) m3=y;; esac; case a\\*b in 'a*b') m4=y;; esac\n" + "s1=${p%.*}; s2=${p%%.*}; s3=${p#*.}; s4=${p##*/*.}" + ); + CHECK_EQ(sh.var("m1"), "y"); + CHECK_EQ(sh.var("m2"), "n"); + CHECK_EQ(sh.var("m3"), "y"); + CHECK_EQ(sh.var("s1"), "a/b/c.tar"); + CHECK_EQ(sh.var("s2"), "a/b/c"); + CHECK_EQ(sh.var("s3"), "tar.gz"); + CHECK_EQ(sh.var("s4"), "gz"); +} + +TEST_CASE("shell: echo options and escapes") { + Shell sh; + sh.run("a=$(echo -n x; echo y); b=$(echo 'p\\tq')"); + CHECK_EQ(sh.var("a"), "xy"); + CHECK_EQ(sh.var("b"), "p\tq"); +} + +TEST_CASE("shell: pathname globbing") { + Shell sh; + sh.run( + "echo > $TMP/g1.txt; echo > $TMP/g2.txt; echo > $TMP/g3.log\n" + "n=0; for f in $TMP/g*.txt; do n=$((n+1)); done; none=$(echo $TMP/nomatch*.zzz)" + ); + CHECK_EQ(sh.var("n"), "2"); + CHECK_EQ(sh.var("none"), sh.tmp + "/nomatch*.zzz"); +} + +// endregion + +// region errexit + +TEST_CASE("shell: set -e") { + Shell sh; + CHECK_EQ(sh.run("set -e; false; after=1"), 1); + CHECK_EQ(sh.var("after"), ""); + + Shell exempt; + exempt.run("set -e; if false; then :; fi; false || true; ! true; x=1"); + CHECK_EQ(exempt.var("x"), "1"); +} + +// endregion + +// region Syntax errors + +TEST_CASE("shell: syntax errors") { + Shell sh; + CHECK_EQ(sh.run("if true; then"), 2); + CHECK_EQ(sh.run("echo 'unterminated"), 2); + CHECK_EQ(sh.run("case x in"), 2); + CHECK_EQ(sh.run("x=1"), 0); +} + +// endregion + +// region Deep nesting + +namespace { + +std::string repeat(const std::string& text, int count) { + std::string result; + for (int i = 0; i < count; i++) result += text; + return result; +} + +} // namespace + +TEST_CASE("shell: deep command substitution nesting") { + Shell sh; + sh.run(("x=" + repeat("$(echo ", 80) + "deep" + repeat(")", 80)).c_str()); + CHECK_EQ(sh.var("x"), "deep"); +} + +TEST_CASE("shell: excessive nesting fails cleanly and the shell keeps working") { + Shell sh; + // Parse nesting + CHECK_EQ(sh.run((repeat("{ ", 300) + "x=1; " + repeat("} ", 300)).c_str()), 2); + CHECK_EQ(sh.var("x"), ""); + // Execution nesting + CHECK_EQ(sh.run(("for i in 1; do y=" + repeat("$(echo ", 200) + "deep" + repeat(")", 200) + "; done").c_str()), 2); + CHECK_EQ(sh.var("y"), ""); + CHECK_EQ(sh.state.loop_depth, 0); + CHECK_EQ(sh.state.call_depth, 0); + CHECK_EQ(sh.run("for i in 1 2; do z=$i; done; w=$(echo ok)"), 0); + CHECK_EQ(sh.var("z"), "2"); + CHECK_EQ(sh.var("w"), "ok"); +} + +#if defined(__linux__) + +#include + +#include +#include + +// platform-posix wraps pthread_attr_setstack() into a no-op, the linker still provides the real one. +extern "C" int __real_pthread_attr_setstack(pthread_attr_t* attr, void* stackaddr, size_t stacksize); + +namespace { + +struct StackRun { + sh_state* state; + const char* script; + int status; +}; + +/** + * Runs a script on a thread with a pattern-filled stack and returns how many bytes of that stack it touched. + * Only for scripts that stay inside the interpreter: this thread is not a FreeRTOS task. + */ +size_t runMeasuringStack(sh_state* state, const std::string& script, int* status) { + constexpr size_t STACK_SIZE = 512 * 1024; + constexpr unsigned char PATTERN = 0xA5; + auto* stack = static_cast(aligned_alloc(4096, STACK_SIZE)); + memset(stack, PATTERN, STACK_SIZE); + + pthread_attr_t attr; + pthread_attr_init(&attr); + __real_pthread_attr_setstack(&attr, stack, STACK_SIZE); + StackRun run { state, script.c_str(), -1 }; + pthread_t thread; + pthread_create(&thread, &attr, [](void* parameter) -> void* { + auto* run = static_cast(parameter); + run->status = sh_run_string(run->state, run->script); + return nullptr; + }, &run); + pthread_join(thread, nullptr); + pthread_attr_destroy(&attr); + + // The stack grows down: everything above the lowest overwritten byte was used. + size_t untouched = 0; + while (untouched < STACK_SIZE && stack[untouched] == PATTERN) untouched++; + free(stack); + *status = run.status; + return STACK_SIZE - untouched; +} + +} // namespace + +TEST_CASE("shell: native stack use does not grow with nesting") { + Shell sh; + int status = -1; + size_t baseline = runMeasuringStack(&sh.state, "f() { r=$1; }; if [ 1 = 1 ]; then f ${u:-v}; fi", &status); + CHECK_EQ(status, 0); + + const std::string scripts[] = { + // 60 nested function calls + "f() { if [ $1 -lt 60 ]; then f $(($1+1)); else r=$1; fi; }; f 0", + // 150 nested brace groups + repeat("{ ", 150) + "r=groups; " + repeat("} ", 150), + // 200 nested ${...:-...} defaults + "r=" + repeat("${u:-", 200) + "braces" + repeat("}", 200), + // 60 nested loops, each iterating once + repeat("for i in 1; do ", 60) + "r=loops; " + repeat("done; ", 60), + // 200 nested arithmetic parentheses + "r=$((" + repeat("(", 200) + "1" + repeat(")", 200) + "))", + }; + const char* expected[] = { "60", "groups", "braces", "loops", "1" }; + + for (size_t i = 0; i < std::size(scripts); i++) { + CAPTURE(i); + Shell deep; + size_t used = runMeasuringStack(&deep.state, scripts[i], &status); + CHECK_EQ(status, 0); + CHECK_EQ(deep.var("r"), expected[i]); + CHECK_LE(used, baseline + 2048); + } +} + +#endif + +// endregion From d229bcea511cb76e8d9cc37cfe9b9262fee4ff13 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 26 Sep 2026 21:45:47 +0200 Subject: [PATCH 2/4] Improve terminal performance Typing input was very slow (+- 100ms) and is now at most 16ms --- .../Private/Tactility/app/terminal/Shell.h | 5 ++- .../Tactility/app/terminal/vterm/vterm.h | 4 ++ Tactility/Source/app/terminal/Shell.cpp | 45 ++++++++++++++++--- Tactility/Source/app/terminal/Terminal.cpp | 22 ++++++--- Tactility/Source/app/terminal/vterm/vterm.c | 14 +++++- 5 files changed, 75 insertions(+), 15 deletions(-) diff --git a/Tactility/Private/Tactility/app/terminal/Shell.h b/Tactility/Private/Tactility/app/terminal/Shell.h index 227007264..ba9808b58 100644 --- a/Tactility/Private/Tactility/app/terminal/Shell.h +++ b/Tactility/Private/Tactility/app/terminal/Shell.h @@ -1,9 +1,12 @@ #pragma once +#include + /** * Launches the shell app and relays its stdio to/from vterm until it exits on its own or * `*stopRequested` asks it to (touch-to-exit); this is the same relationship a real terminal * emulator has to the shell it runs over a pty. Sets `*stopRequested` before returning either way, * so the caller's own I/O task loop (still running independently) winds down too. + * `renderTask` is notified (xTaskNotifyGive) whenever shell output reaches vterm, so it can draw it right away. */ -void runShell(int columns, int rows, volatile bool* stopRequested); +void runShell(int columns, int rows, volatile bool* stopRequested, TaskHandle_t renderTask); diff --git a/Tactility/Private/Tactility/app/terminal/vterm/vterm.h b/Tactility/Private/Tactility/app/terminal/vterm/vterm.h index 6b6e04314..20048f7de 100644 --- a/Tactility/Private/Tactility/app/terminal/vterm/vterm.h +++ b/Tactility/Private/Tactility/app/terminal/vterm/vterm.h @@ -67,6 +67,10 @@ void vterm_get_cursor(int vt_id, int *col, int *row, int *visible); bool vterm_take_dirty(void); void vterm_set_switch_callback(void (*cb)(int new_vt)); +// Tactility addition: called after each byte is queued by vterm_send_input(), so a reader can wait +// on its own event source instead of polling vterm_getchar(). Pass NULL to remove. +void vterm_set_input_callback(void (*cb)(void *context), void *context); + // Tactility addition: called just before a line scrolls off the top, while it is still readable // via vterm_get_direct_buffer(). Lets a caller keep scrollback history that vterm itself does not. // Pass NULL to remove. diff --git a/Tactility/Source/app/terminal/Shell.cpp b/Tactility/Source/app/terminal/Shell.cpp index deb03afc2..ed9c50f86 100644 --- a/Tactility/Source/app/terminal/Shell.cpp +++ b/Tactility/Source/app/terminal/Shell.cpp @@ -22,12 +22,23 @@ namespace { constexpr auto* TAG = "terminal"; -// How often the pump loop below checks for new keyboard input while draining the shell app. +// The pump loop wakes on keyboard input and shell output. This bound only matters for noticing a +// stop request, or for polling input if no event bit could be claimed for it. constexpr uint32_t SHELL_PUMP_INTERVAL_MS = 50; +struct InputSignal { + TaskEventGroup* group; + uint32_t bit; +}; + +void signalInput(void* context) { + auto* signal = static_cast(context); + task_event_group_signal(signal->group, signal->bit); +} + } // namespace -void runShell(int columns, int rows, volatile bool* stopRequested) { +void runShell(int columns, int rows, volatile bool* stopRequested, TaskHandle_t renderTask) { static uint8_t stdinBuffer[256]; static uint8_t stdoutBuffer[1024]; AppStream stdinStream {}; @@ -65,20 +76,36 @@ void runShell(int columns, int rows, volatile bool* stopRequested) { return; } + // Keyboard input wakes the loop below through the same event group as the shell's output. + InputSignal inputSignal { &eventGroup, 0 }; + const bool inputSignalled = task_event_group_claim_bit(&eventGroup, &inputSignal.bit) == ERROR_NONE; + if (inputSignalled) { + vterm_set_input_callback(signalInput, &inputSignal); + } + bool shellStdinClosed = false; bool shellDone = false; uint8_t drain[256]; while (!shellDone) { - const int c = vterm_getchar(vterm_get_active(), pdMS_TO_TICKS(SHELL_PUMP_INTERVAL_MS)); - if (c >= 0 && !shellStdinClosed) { - const char ch = static_cast(c); - app_stream_write(&stdinStream, &ch, 1); + task_event_group_wait_any(&eventGroup, nullptr, pdMS_TO_TICKS(SHELL_PUMP_INTERVAL_MS)); + + int c; + while ((c = vterm_getchar(vterm_get_active(), 0)) >= 0) { + if (!shellStdinClosed) { + const char ch = static_cast(c); + app_stream_write(&stdinStream, &ch, 1); + } } + bool wroteOutput = false; size_t n; while ((n = app_stream_read(&stdoutStream, drain, sizeof(drain))) > 0) { vterm_write_translated(reinterpret_cast(drain), n); + wroteOutput = true; + } + if (wroteOutput) { + xTaskNotifyGive(renderTask); } // The shell app never sees stopRequested directly: its stdin is closed instead, which @@ -101,6 +128,12 @@ void runShell(int columns, int rows, volatile bool* stopRequested) { while ((n = app_stream_read(&stdoutStream, drain, sizeof(drain))) > 0) { vterm_write_translated(reinterpret_cast(drain), n); } + xTaskNotifyGive(renderTask); + + if (inputSignalled) { + vterm_set_input_callback(nullptr, nullptr); + task_event_group_release_bit(&eventGroup, inputSignal.bit); + } // Only app_stream_unsubscribe() guarantees the fd-table binding is gone and no AppFileOps call // is still in flight, which is what makes these stack-local AppStreams safe to let go out of diff --git a/Tactility/Source/app/terminal/Terminal.cpp b/Tactility/Source/app/terminal/Terminal.cpp index 4e56e1d3d..43d2b3024 100644 --- a/Tactility/Source/app/terminal/Terminal.cpp +++ b/Tactility/Source/app/terminal/Terminal.cpp @@ -26,9 +26,9 @@ extern "C" { constexpr auto* TAG = "terminal"; -// Redraw cadence. A text grid only changes when something is written, so this is a polling -// interval rather than a frame rate. -constexpr uint32_t RENDER_INTERVAL_MS = 33; +// Keyboard polling and redraw cadence. Shell output wakes the I/O task early, so this mostly bounds +// how long a keypress waits to be picked up. +constexpr uint32_t RENDER_INTERVAL_MS = 16; // The I/O task polls the keyboards, paints the screen, and watches for the touch-to-exit gesture. // It runs a step above this app's own task: input, drawing and the exit gesture must keep working @@ -48,6 +48,10 @@ namespace { * pump loop, which closes the shell app's stdin to unstick it (see that loop's own comment). */ volatile bool stopRequested = false; +/** Set once runShell() has returned. The I/O task keeps running until then, because runShell() + * notifies it about output until the very end. */ +volatile bool shellFinished = false; + /** * Translates a kernel keyboard event into the byte a terminal expects. * Returns 0 for keys with no terminal representation. @@ -153,18 +157,19 @@ struct IoTaskParams { void ioTask(void* arg) { auto* params = static_cast(arg); - while (!stopRequested) { + while (!shellFinished) { // A scroll replaces every row at once, so the renderer is told to repaint rather than rely // on its per-cell comparison. const bool viewMoved = params->keyboards->pump(handleKey); params->renderer->render(viewMoved); - if (params->touch->touched(!params->keyboards->empty())) { + if (!stopRequested && params->touch->touched(!params->keyboards->empty())) { LOG_I(TAG, "Touch detected - stopping"); stopRequested = true; } - vTaskDelay(pdMS_TO_TICKS(RENDER_INTERVAL_MS)); + // runShell() notifies this task when output arrives, so it is drawn without waiting out the interval + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(RENDER_INTERVAL_MS)); } xSemaphoreGive(params->doneSem); @@ -175,6 +180,7 @@ void ioTask(void* arg) { void runTerminal(Device* display) { stopRequested = false; + shellFinished = false; KeyboardInput keyboards; TouchInput touch; @@ -227,7 +233,9 @@ void runTerminal(Device* display) { #ifdef ESP_PLATFORM esp_log_level_set("ELF", ESP_LOG_WARN); #endif - runShell(renderer.columns(), renderer.rows(), &stopRequested); + runShell(renderer.columns(), renderer.rows(), &stopRequested, ioHandle); + shellFinished = true; + xTaskNotifyGive(ioHandle); #ifdef ESP_PLATFORM esp_log_level_set("ELF", ESP_LOG_INFO); #endif diff --git a/Tactility/Source/app/terminal/vterm/vterm.c b/Tactility/Source/app/terminal/vterm/vterm.c index 8bc8facc4..6c78dd9a2 100644 --- a/Tactility/Source/app/terminal/vterm/vterm.c +++ b/Tactility/Source/app/terminal/vterm/vterm.c @@ -81,6 +81,8 @@ void vterm_set_scroll_callback(void (*callback)(void)) { } static void (*s_on_switch_cb)(int new_vt) = NULL; +static void (*s_on_input_cb)(void *context) = NULL; +static void *s_on_input_context = NULL; /* * How many rows the writing task should treat as the bottom of the screen. @@ -683,6 +685,8 @@ void vterm_deinit(void) s_dirty = true; s_scroll_callback = NULL; s_on_switch_cb = NULL; + s_on_input_cb = NULL; + s_on_input_context = NULL; vterm_clear_size_override(); mutex_destruct(&s_input_mux); @@ -847,6 +851,11 @@ void vterm_write_translated(const char *data, size_t size) // Helpers void vterm_set_switch_callback(void (*cb)(int)) { s_on_switch_cb = cb; } +void vterm_set_input_callback(void (*cb)(void *), void *context) { + s_on_input_cb = NULL; + s_on_input_context = context; + s_on_input_cb = cb; +} int vterm_get_active(void) { return s_active_vt; } // Per-task size override (for SSH sessions with different terminal dimensions) static TaskHandle_t s_size_override_task = NULL; @@ -898,7 +907,10 @@ int vterm_getchar(int vt_id, int timeout_ms) { } void vterm_send_input(int vt_id, char c) { - if (vt_id >= 0 && vt_id < VTERM_COUNT) xQueueSend(s_vterms[vt_id].input_queue, &c, 0); + if (vt_id < 0 || vt_id >= VTERM_COUNT) return; + xQueueSend(s_vterms[vt_id].input_queue, &c, 0); + void (*cb)(void *) = s_on_input_cb; + if (cb) cb(s_on_input_context); } void vterm_input_flush(int vt_id) { From ca8125ddb5a659ec88f6ddc8cc7eb69fe02af697 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 26 Sep 2026 22:21:27 +0200 Subject: [PATCH 3/4] Fixes --- Modules/app-module/source/stdio_wrap.cpp | 26 ++++++++++++++++--- .../Tactility/app/shell/shell/sh_machine.h | 1 + .../Tactility/app/terminal/vterm/vterm.h | 3 ++- Tactility/Source/app/shell/Run.cpp | 18 ++++++------- Tactility/Source/app/shell/Shell.cpp | 13 +++++++++- Tactility/Source/app/shell/shell/sh_exec.cpp | 7 ++++- .../Source/app/shell/shell/sh_expand.cpp | 6 ++++- Tactility/Source/app/terminal/Terminal.cpp | 7 ++++- Tactility/Source/app/terminal/vterm/vterm.c | 14 +++++++--- Tactility/Tests/CMakeLists.txt | 5 +--- Tactility/Tests/Source/Main.cpp | 20 ++++++++++++++ .../Tests/Source/ShellCompletionTest.cpp | 21 ++++++++++++--- .../Tests/Source/ShellInterpreterTest.cpp | 20 ++++++++++++++ 13 files changed, 133 insertions(+), 28 deletions(-) diff --git a/Modules/app-module/source/stdio_wrap.cpp b/Modules/app-module/source/stdio_wrap.cpp index 3be2ca77b..e4620f66b 100644 --- a/Modules/app-module/source/stdio_wrap.cpp +++ b/Modules/app-module/source/stdio_wrap.cpp @@ -333,9 +333,9 @@ int chdir(const char* path) { // wraps above can't reach, so these redirect calls to printf/fprintf/etc directly. POSIX-only: // newlib's stdio already goes through the wrappable syscall stubs. // -// Scoped to printf/getc: fread/fwrite are used sitewide for real file I/O, so wrapping them would -// be a correctness risk for unrelated code. putc/getc are macros, not real calls, so wrapping -// those symbols wouldn't reliably intercept them. +// Only the process' original stdin/stdout/stderr are routed to the app's fds; every other stream +// (real file I/O, including fwrite() to a file) goes straight to libc. fread() is not wrapped. +// putc/getc are macros, not real calls, so wrapping those symbols wouldn't reliably intercept them. #if !defined(ESP_PLATFORM) @@ -349,6 +349,7 @@ extern "C" { int __real_vfprintf(FILE* stream, const char* format, va_list args); int __real_fputs(const char* s, FILE* stream); int __real_fputc(int c, FILE* stream); +size_t __real_fwrite(const void* data, size_t size, size_t count, FILE* stream); int __real_fgetc(FILE* stream); char* __real_fgets(char* buffer, int size, FILE* stream); } @@ -372,6 +373,11 @@ int __real_fputc(int c, FILE* stream) { return real(c, stream); } +size_t __real_fwrite(const void* data, size_t size, size_t count, FILE* stream) { + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fwrite")); + return real(data, size, count, stream); +} + int __real_fgetc(FILE* stream) { static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "fgetc")); return real(stream); @@ -506,6 +512,15 @@ int __wrap_fputc(int c, FILE* stream) { return __real_fputc(c, stream); } +size_t __wrap_fwrite(const void* data, size_t size, size_t count, FILE* stream) { + int fd = targetFdOf(stream); + if (fd < 0) { + return __real_fwrite(data, size, count, stream); + } + writeAllTo(fd, data, size * count); + return count; +} + int __wrap_fgetc(FILE* stream) { if (stream != originalStdin) { return __real_fgetc(stream); @@ -555,6 +570,7 @@ TT_DYLD_INTERPOSE(__wrap_puts, puts) TT_DYLD_INTERPOSE(__wrap_fputs, fputs) TT_DYLD_INTERPOSE(__wrap_putchar, putchar) TT_DYLD_INTERPOSE(__wrap_fputc, fputc) +TT_DYLD_INTERPOSE(__wrap_fwrite, fwrite) TT_DYLD_INTERPOSE(__wrap_getchar, getchar) TT_DYLD_INTERPOSE(__wrap_fgetc, fgetc) TT_DYLD_INTERPOSE(__wrap_fgets, fgets) @@ -602,6 +618,10 @@ int fputc(int c, FILE* stream) { return __wrap_fputc(c, stream); } +size_t fwrite(const void* data, size_t size, size_t count, FILE* stream) { + return __wrap_fwrite(data, size, count, stream); +} + int getchar() { return __wrap_getchar(); } diff --git a/Tactility/Private/Tactility/app/shell/shell/sh_machine.h b/Tactility/Private/Tactility/app/shell/shell/sh_machine.h index 12e4eb8d9..4af93309e 100644 --- a/Tactility/Private/Tactility/app/shell/shell/sh_machine.h +++ b/Tactility/Private/Tactility/app/shell/shell/sh_machine.h @@ -210,6 +210,7 @@ struct ExecGroup { int savedPositionalCount = 0; char* savedArg0 = nullptr; std::string savedCwd; + char* savedLogicalCwd = nullptr; // sh_state::cwd, which `cd` replaces FlowFlags savedFlow {}; int savedErrexit = 0; int savedNounset = 0; diff --git a/Tactility/Private/Tactility/app/terminal/vterm/vterm.h b/Tactility/Private/Tactility/app/terminal/vterm/vterm.h index 20048f7de..5eabbe3b4 100644 --- a/Tactility/Private/Tactility/app/terminal/vterm/vterm.h +++ b/Tactility/Private/Tactility/app/terminal/vterm/vterm.h @@ -68,7 +68,8 @@ bool vterm_take_dirty(void); void vterm_set_switch_callback(void (*cb)(int new_vt)); // Tactility addition: called after each byte is queued by vterm_send_input(), so a reader can wait -// on its own event source instead of polling vterm_getchar(). Pass NULL to remove. +// on its own event source instead of polling vterm_getchar(). Pass NULL to remove. Returns only +// once a callback already in progress has finished, so the previous context may be released after. void vterm_set_input_callback(void (*cb)(void *context), void *context); // Tactility addition: called just before a line scrolls off the top, while it is still readable diff --git a/Tactility/Source/app/shell/Run.cpp b/Tactility/Source/app/shell/Run.cpp index 468ec4b64..996825a81 100644 --- a/Tactility/Source/app/shell/Run.cpp +++ b/Tactility/Source/app/shell/Run.cpp @@ -121,6 +121,15 @@ constexpr int RUN_APP_START_FAILED = -1; bool lastByteWasNewline = true; +/** Copies what a child wrote to one of its output streams to `target`, which may be redirected. */ +void drainOutput(AppStream& stream, FILE* target, uint8_t* buffer, size_t size) { + size_t n; + while ((n = app_stream_read(&stream, buffer, size)) > 0) { + fwrite(buffer, 1, n, target); // byte-counted: output may contain NUL bytes + lastByteWasNewline = buffer[n - 1] == '\n'; + } +} + /** * Runs an app instance to completion, piping its stdio to/from this task's own, like a shell * piping a child process. `context` must already have its location and arguments set. @@ -130,15 +139,6 @@ bool lastByteWasNewline = true; * * @return the child's exit status, or RUN_APP_START_FAILED if it never started */ -/** Copies what a child wrote to one of its output streams to `target`, which may be redirected. */ -void drainOutput(AppStream& stream, FILE* target, uint8_t* buffer, size_t size) { - size_t n; - while ((n = app_stream_read(&stream, buffer, size)) > 0) { - fprintf(target, "%.*s", static_cast(n), reinterpret_cast(buffer)); - lastByteWasNewline = buffer[n - 1] == '\n'; - } -} - int runApp(AppStartContext& context) { constexpr size_t STDIN_BUFFER_SIZE = 256; constexpr size_t STDOUT_BUFFER_SIZE = 1024; diff --git a/Tactility/Source/app/shell/Shell.cpp b/Tactility/Source/app/shell/Shell.cpp index 15eb836e0..13c8b525b 100644 --- a/Tactility/Source/app/shell/Shell.cpp +++ b/Tactility/Source/app/shell/Shell.cpp @@ -91,6 +91,9 @@ struct Candidates { int count; // Printed lazily, so a unique match completes silently without disturbing the prompt. char first[FILE_MAX_PATH_STRING_LENGTH]; + // A bare first word only runs registered commands, so a plain file there would complete to + // something that can't run. Directories are still offered, to continue typing a path into. + bool directoriesOnly; }; void offerCandidate(Candidates& candidates, const char* name) { @@ -120,7 +123,11 @@ void offerCommand(const Shell::Command& command, void* context) { } void offerEntry(const DirectoryEntry* entry, void* context) { - offerCandidate(*static_cast(context), entry->name); + auto* candidates = static_cast(context); + if (candidates->directoriesOnly && !entry->is_directory) { + return; + } + offerCandidate(*candidates, entry->name); } void printCandidateCommand(const Shell::Command& command, void* context) { @@ -132,6 +139,9 @@ void printCandidateCommand(const Shell::Command& command, void* context) { void printCandidateEntry(const DirectoryEntry* entry, void* context) { auto* candidates = static_cast(context); + if (candidates->directoriesOnly && !entry->is_directory) { + return; + } if (strncmp(entry->name, candidates->word, candidates->wordLength) == 0) { printf("%s%s ", entry->name, entry->is_directory ? "/" : ""); } @@ -152,6 +162,7 @@ bool complete(const char* line, char* outSuffix, size_t suffixSize, bool* outLis const bool includeCommands = isFirstWord && strchr(wordStart, '/') == nullptr; Candidates candidates {}; + candidates.directoriesOnly = includeCommands; // Paths complete against a directory, which may be named in the word itself ("ls /data/fo"). char directory[FILE_MAX_PATH_STRING_LENGTH] = {}; diff --git a/Tactility/Source/app/shell/shell/sh_exec.cpp b/Tactility/Source/app/shell/shell/sh_exec.cpp index 276cfe681..18017fa4e 100644 --- a/Tactility/Source/app/shell/shell/sh_exec.cpp +++ b/Tactility/Source/app/shell/shell/sh_exec.cpp @@ -558,6 +558,8 @@ ExecGroup::~ExecGroup() st.npos = savedPositionalCount; free(st.arg0); st.arg0 = savedArg0; + free(st.cwd); + st.cwd = savedLogicalCwd; sh_port_chdir(savedCwd.c_str()); savedFlow.restore(st); st.opt_errexit = savedErrexit; @@ -589,6 +591,7 @@ Step ExecGroup::step(Machine& m) char cwd[512]; sh_port_getcwd(cwd, sizeof(cwd)); savedCwd = cwd; + savedLogicalCwd = st.cwd ? strdup(st.cwd) : nullptr; savedFlow = FlowFlags::save(st); savedErrexit = st.opt_errexit; savedNounset = st.opt_nounset; @@ -1021,7 +1024,9 @@ Step CommandSubst::step(Machine& m) std::string output; if (FILE* file = fopen(tempFile.c_str(), "rb")) { int c; - while ((c = fgetc(file)) != EOF) output.push_back(static_cast(c)); + while ((c = fgetc(file)) != EOF) { + if (c != '\0') output.push_back(static_cast(c)); // values are C strings, so NUL bytes are dropped (like bash) + } fclose(file); } remove(tempFile.c_str()); diff --git a/Tactility/Source/app/shell/shell/sh_expand.cpp b/Tactility/Source/app/shell/shell/sh_expand.cpp index 10c1713d4..14493f83b 100644 --- a/Tactility/Source/app/shell/shell/sh_expand.cpp +++ b/Tactility/Source/app/shell/shell/sh_expand.cpp @@ -171,7 +171,11 @@ void expand_at_star(sh_state* st, bool star, bool quoted, FieldBuilder& b, Field } // $@ : one field per positional param. - if (n == 0) return; // "$@" with no params vanishes (prefix field kept) + if (n == 0) { + // "$@" with no params vanishes; a non-empty prefix ("x$@") keeps the field. + if (quoted && !star && b.text.empty()) b.started = false; + return; + } for (int i = 0; i < n; i++) { if (i > 0) b.emit(out); if (quoted) { diff --git a/Tactility/Source/app/terminal/Terminal.cpp b/Tactility/Source/app/terminal/Terminal.cpp index 43d2b3024..0bbec14cb 100644 --- a/Tactility/Source/app/terminal/Terminal.cpp +++ b/Tactility/Source/app/terminal/Terminal.cpp @@ -16,6 +16,8 @@ #include #include +#include + #ifdef ESP_PLATFORM #include #endif @@ -50,7 +52,7 @@ volatile bool stopRequested = false; /** Set once runShell() has returned. The I/O task keeps running until then, because runShell() * notifies it about output until the very end. */ -volatile bool shellFinished = false; +std::atomic shellFinished = false; /** * Translates a kernel keyboard event into the byte a terminal expects. @@ -172,6 +174,9 @@ void ioTask(void* arg) { ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(RENDER_INTERVAL_MS)); } + // The shell's last output can arrive after the final pass above, so draw once more + params->renderer->render(false); + xSemaphoreGive(params->doneSem); vTaskDelete(nullptr); } diff --git a/Tactility/Source/app/terminal/vterm/vterm.c b/Tactility/Source/app/terminal/vterm/vterm.c index 6c78dd9a2..89d204527 100644 --- a/Tactility/Source/app/terminal/vterm/vterm.c +++ b/Tactility/Source/app/terminal/vterm/vterm.c @@ -83,6 +83,8 @@ void vterm_set_scroll_callback(void (*callback)(void)) { static void (*s_on_switch_cb)(int new_vt) = NULL; static void (*s_on_input_cb)(void *context) = NULL; static void *s_on_input_context = NULL; +// Held while the input callback is replaced or invoked, so replacing it waits for a call in progress. +static struct Mutex s_input_cb_mux; /* * How many rows the writing task should treat as the bottom of the screen. @@ -596,6 +598,7 @@ static struct Mutex s_input_mux; error_t vterm_init(void) { mutex_construct(&s_input_mux); + mutex_construct(&s_input_cb_mux); const struct MemoryPolicy internal_policy = { .required = MEMORY_CAPABILITY_INTERNAL, .desired = 0, .alignment = 0 }; @@ -690,6 +693,7 @@ void vterm_deinit(void) vterm_clear_size_override(); mutex_destruct(&s_input_mux); + mutex_destruct(&s_input_cb_mux); } vterm_cell_t *vterm_get_direct_buffer(void) @@ -852,9 +856,10 @@ void vterm_write_translated(const char *data, size_t size) // Helpers void vterm_set_switch_callback(void (*cb)(int)) { s_on_switch_cb = cb; } void vterm_set_input_callback(void (*cb)(void *), void *context) { - s_on_input_cb = NULL; - s_on_input_context = context; + mutex_lock(&s_input_cb_mux); s_on_input_cb = cb; + s_on_input_context = context; + mutex_unlock(&s_input_cb_mux); } int vterm_get_active(void) { return s_active_vt; } // Per-task size override (for SSH sessions with different terminal dimensions) @@ -909,8 +914,9 @@ int vterm_getchar(int vt_id, int timeout_ms) { void vterm_send_input(int vt_id, char c) { if (vt_id < 0 || vt_id >= VTERM_COUNT) return; xQueueSend(s_vterms[vt_id].input_queue, &c, 0); - void (*cb)(void *) = s_on_input_cb; - if (cb) cb(s_on_input_context); + mutex_lock(&s_input_cb_mux); + if (s_on_input_cb) s_on_input_cb(s_on_input_context); + mutex_unlock(&s_input_cb_mux); } void vterm_input_flush(int vt_id) { diff --git a/Tactility/Tests/CMakeLists.txt b/Tactility/Tests/CMakeLists.txt index db4b09abb..418f4b9cd 100644 --- a/Tactility/Tests/CMakeLists.txt +++ b/Tactility/Tests/CMakeLists.txt @@ -25,10 +25,7 @@ target_compile_definitions(TactilityTests PRIVATE "-DTT_TERMINAL_FONT_SYMBOL=ibmplexmono_14_font" ) -# The POSIX platform registers its "data" filesystem relative to the working directory, and the -# shell interpreter tests need it for temp files (command substitution, pipelines). -file(MAKE_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/data) -add_test(NAME TactilityTests COMMAND TactilityTests WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) +add_test(NAME TactilityTests COMMAND TactilityTests) # No --wrap flags declared here: linking platform-posix and app-module below already brings all # of pthread_attr_setstack/read/write/close/the printf family along (see their own CMakeLists.txt diff --git a/Tactility/Tests/Source/Main.cpp b/Tactility/Tests/Source/Main.cpp index 77907da35..e898a6e3b 100644 --- a/Tactility/Tests/Source/Main.cpp +++ b/Tactility/Tests/Source/Main.cpp @@ -4,6 +4,14 @@ #include "FreeRTOS.h" #include "task.h" +#include +#include +#include +#include + +// app-module overrides chdir() with an app-aware version, which needs the kernel to be running +extern "C" int __real_chdir(const char* path); + #include #include #include @@ -39,6 +47,15 @@ void test_task(void* parameter) { } int main(int argc, char** argv) { + // The POSIX platform registers its "data" filesystem relative to the working directory, and + // tests that need temp files (e.g. the shell interpreter) require it. Running in a scratch + // directory makes that independent of where the binary is started from. + char scratch[] = "/tmp/tactility-tests-XXXXXX"; + if (mkdtemp(scratch) == nullptr || __real_chdir(scratch) != 0 || mkdir("data", 0755) != 0) { + perror("Failed to set up the test working directory"); + return 1; + } + TestTaskData data = { .argc = argc, .argv = argv, @@ -60,5 +77,8 @@ int main(int argc, char** argv) { vTaskStartScheduler(); + std::error_code ignored; + std::filesystem::remove_all(scratch, ignored); + return data.result; } diff --git a/Tactility/Tests/Source/ShellCompletionTest.cpp b/Tactility/Tests/Source/ShellCompletionTest.cpp index 4c2e9978b..2797c9da6 100644 --- a/Tactility/Tests/Source/ShellCompletionTest.cpp +++ b/Tactility/Tests/Source/ShellCompletionTest.cpp @@ -2,10 +2,11 @@ #include +#include #include +#include #include -#include namespace { @@ -24,8 +25,7 @@ TEST_CASE("shell completion: first word completes directories as well as command char temp[FILE_MAX_PATH_STRING_LENGTH]; REQUIRE_EQ(paths_get_temp_path(temp, sizeof(temp)), ERROR_NONE); const std::string base = std::string(temp) + "/completiontest"; - mkdir(base.c_str(), 0755); - mkdir((base + "/appfolder").c_str(), 0755); + REQUIRE_EQ(directory_make((base + "/appfolder").c_str(), true), ERROR_NONE); // A path typed as the command completes like any other path CHECK_EQ(complete(base + "/app"), "folder/"); @@ -35,3 +35,18 @@ TEST_CASE("shell completion: first word completes directories as well as command CHECK_EQ(complete("tm"), "p/"); CHECK_EQ(complete("./nothing"), ""); } + +TEST_CASE("shell completion: plain files complete when the word names a path") { + char temp[FILE_MAX_PATH_STRING_LENGTH]; + REQUIRE_EQ(paths_get_temp_path(temp, sizeof(temp)), ERROR_NONE); + const std::string base = std::string(temp) + "/completiontest"; + REQUIRE_EQ(directory_make(base.c_str(), true), ERROR_NONE); + FILE* file = fopen((base + "/runme.sh").c_str(), "w"); + REQUIRE_NE(file, nullptr); + fclose(file); + + // With a '/', the file can be run, so it completes + CHECK_EQ(complete(base + "/run"), "me.sh "); + // As an argument it completes too + CHECK_EQ(complete("cat " + base + "/run"), "me.sh "); +} diff --git a/Tactility/Tests/Source/ShellInterpreterTest.cpp b/Tactility/Tests/Source/ShellInterpreterTest.cpp index 4509cfeb6..407ab4eac 100644 --- a/Tactility/Tests/Source/ShellInterpreterTest.cpp +++ b/Tactility/Tests/Source/ShellInterpreterTest.cpp @@ -77,6 +77,20 @@ TEST_CASE("shell: quoting and word splitting") { CHECK_EQ(sh.var("escaped"), "$x"); } +TEST_CASE("shell: quoted $@ without parameters") { + Shell sh; + sh.run( + "set --\n" + "n=0; for a in \"$@\"; do n=$((n+1)); done\n" + "p=0; for a in x\"$@\"; do p=$((p+1)); v=$a; done\n" + "s=0; for a in \"$*\"; do s=$((s+1)); done" + ); + CHECK_EQ(sh.var("n"), "0"); + CHECK_EQ(sh.var("p"), "1"); + CHECK_EQ(sh.var("v"), "x"); + CHECK_EQ(sh.var("s"), "1"); +} + TEST_CASE("shell: IFS splitting") { Shell sh; sh.run("IFS=:; set -- $(echo a:b:c); n=$#; second=$2"); @@ -312,6 +326,12 @@ TEST_CASE("shell: nested command substitution") { CHECK_EQ(sh.var("z"), "tick"); } +TEST_CASE("shell: command substitution drops NUL bytes") { + Shell sh; + sh.run("x=$(echo 'a\\0b')"); + CHECK_EQ(sh.var("x"), "ab"); +} + TEST_CASE("shell: eval") { Shell sh; sh.run("eval 'x=5'; eval \"y=\\$x\""); From 2c84a1ccb1f2755c575b75446b443a6ee233bb2a Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 26 Sep 2026 23:56:00 +0200 Subject: [PATCH 4/4] Fix for return value --- Modules/app-module/source/stdio_wrap.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Modules/app-module/source/stdio_wrap.cpp b/Modules/app-module/source/stdio_wrap.cpp index e4620f66b..5c06281da 100644 --- a/Modules/app-module/source/stdio_wrap.cpp +++ b/Modules/app-module/source/stdio_wrap.cpp @@ -392,7 +392,8 @@ char* __real_fgets(char* buffer, int size, FILE* stream) { namespace { -void writeAllTo(int fd, const void* data, size_t size) { +/** @return the number of bytes written, less than `size` if the fd stopped accepting data */ +size_t writeAllTo(int fd, const void* data, size_t size) { const auto* bytes = static_cast(data); size_t remaining = size; while (remaining > 0) { @@ -403,6 +404,7 @@ void writeAllTo(int fd, const void* data, size_t size) { bytes += written; remaining -= static_cast(written); } + return size - remaining; } int formatTo(int fd, const char* format, va_list args) { @@ -517,8 +519,10 @@ size_t __wrap_fwrite(const void* data, size_t size, size_t count, FILE* stream) if (fd < 0) { return __real_fwrite(data, size, count, stream); } - writeAllTo(fd, data, size * count); - return count; + if (size == 0 || count == 0) { + return 0; + } + return writeAllTo(fd, data, size * count) / size; } int __wrap_fgetc(FILE* stream) {