Shell and terminal improved (#664)

- Refactored shell parsing so that the callstack doesn't increase when script complexity increases.
- Improved terminal input handling: input delay from +- 100 ms to at most about 16 ms.
This commit is contained in:
Ken Van Hoeylandt 2026-09-27 00:06:36 +02:00 committed by GitHub
parent 33898ddc4e
commit 0580eb6104
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 4704 additions and 2321 deletions

View File

@ -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.

View File

@ -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<size_t (*)(const void*, size_t, size_t, FILE*)>(dlsym(RTLD_NEXT, "fwrite"));
return real(data, size, count, stream);
}
int __real_fgetc(FILE* stream) {
static auto real = reinterpret_cast<int (*)(FILE*)>(dlsym(RTLD_NEXT, "fgetc"));
return real(stream);
@ -386,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<const char*>(data);
size_t remaining = size;
while (remaining > 0) {
@ -397,6 +404,7 @@ void writeAllTo(int fd, const void* data, size_t size) {
bytes += written;
remaining -= static_cast<size_t>(written);
}
return size - remaining;
}
int formatTo(int fd, const char* format, va_list args) {
@ -425,9 +433,16 @@ int readOneFromStdin(char& out) {
return static_cast<int>(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 +450,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 +483,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 +501,7 @@ int __wrap_fputs(const char* s, FILE* stream) {
}
int __wrap_putchar(int c) {
auto ch = static_cast<char>(c);
writeAllTo(STDOUT_FILENO, &ch, 1);
return c;
return __wrap_fputc(c, stdout);
}
int __wrap_fputc(int c, FILE* stream) {
@ -495,20 +514,31 @@ int __wrap_fputc(int c, FILE* stream) {
return __real_fputc(c, stream);
}
int __wrap_getchar() {
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);
}
if (size == 0 || count == 0) {
return 0;
}
return writeAllTo(fd, data, size * count) / size;
}
int __wrap_fgetc(FILE* stream) {
if (stream != originalStdin) {
return __real_fgetc(stream);
}
char c;
return readOneFromStdin(c) == 1 ? static_cast<unsigned char>(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) {
@ -544,6 +574,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)
@ -591,6 +622,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();
}

View File

@ -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.

View File

@ -6,6 +6,10 @@
#include <stdio.h>
#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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,475 @@
// 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 <tactility/memory.h>
#include <cstddef>
#include <memory>
#include <new>
#include <optional>
#include <string>
#include <utility>
#include <variant>
#include <vector>
// 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<std::string>;
/** 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<std::string> paths; // expanded filenames
std::vector<std::string> 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<Fields> 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> 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;
char* savedLogicalCwd = nullptr; // sh_state::cwd, which `cd` replaces
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<sh_redir_saved> 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> redirections; // only for a command with redirects
struct Binding {
std::string name;
std::optional<std::string> value;
std::optional<std::string> environment;
};
std::vector<Binding> 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<Item> 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<sh_redir_saved> 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<RunProgram, ExecNode, ExecList, ExecAndOr, ExecIf, ExecWhile, ExecFor, ExecCase,
ExecGroup, ExecPipe, ExecSimple, CallFunction, ApplyRedirects, CommandSubst,
Expand, ExpandBrace>;
/** 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<Frame*>(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<Frame> 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

View File

@ -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 <op> 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

View File

@ -5,6 +5,10 @@
#include <stdio.h>
#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

View File

@ -1,9 +1,12 @@
#pragma once
#include <tactility/freertos/task.h>
/**
* 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);

View File

@ -67,6 +67,11 @@ 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. 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
// via vterm_get_direct_buffer(). Lets a caller keep scrollback history that vterm itself does not.
// Pass NULL to remove.

View File

@ -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.
@ -133,17 +142,22 @@ bool lastByteWasNewline = true;
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<uint8_t*>(memory_alloc_with_policy(STDIN_BUFFER_SIZE, &policy));
auto* stdoutBuffer = static_cast<uint8_t*>(memory_alloc_with_policy(STDOUT_BUFFER_SIZE, &policy));
if (stdinBuffer == nullptr || stdoutBuffer == nullptr) {
auto* stderrBuffer = static_cast<uint8_t*>(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<int>(n), reinterpret_cast<const char*>(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<int>(n), reinterpret_cast<const char*>(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<char*>(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<int>(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) {

View File

@ -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<Candidates*>(context), entry->name);
auto* candidates = static_cast<Candidates*>(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<Candidates*>(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 ? "/" : "");
}
@ -148,44 +158,43 @@ 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);
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] = {};
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<size_t>(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<size_t>(slash - wordStart);
if (prefixLength >= sizeof(prefix)) {
return false;
}
memcpy(prefix, wordStart, prefixLength);
prefix[prefixLength] = '\0';
candidates.word = namePart;
candidates.wordLength = strlen(namePart);
}
if (isFirstWord) {
forEachCommand(&candidates, offerCommand);
// 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 {
directory_list(directory, &candidates, offerEntry);
snprintf(directory, sizeof(directory), "%s", ShellFs::cwd());
}
candidates.word = namePart;
candidates.wordLength = strlen(namePart);
if (includeCommands) {
forEachCommand(&candidates, offerCommand);
}
directory_list(directory, &candidates, offerEntry);
if (candidates.count == 0) {
return false;
}
@ -200,11 +209,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<size_t>(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<size_t>(written) < sizeof(full) && directory_exists(full);
outSuffix[used] = directoryMatch ? '/' : ' ';
outSuffix[used + 1] = '\0';
}
@ -214,11 +221,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 +275,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<char*> shArgv;
shArgv.push_back(const_cast<char*>("sh"));
for (int i = 0; i < argc; i++) {
shArgv.push_back(argv[i]);
}
return runFromMemory("sh", static_cast<int>(shArgv.size()), shArgv.data());
}
*found = 0;
@ -279,9 +293,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.

View File

@ -12,6 +12,7 @@
#include <tactility/freertos/freertos.h>
#include <cstdio>
#include <new>
#include <unistd.h>
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<void*>(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<void*>(shMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN | APP_MANIFEST_FLAG_HEADLESS,
.stack = { .depth = 0, .desired_memory_capability = 0 },
.stack = { .depth = 8192, .desired_memory_capability = 0 },
};
}

View File

@ -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 <Tactility/app/shell/shell/sh_arith.h>
#include <ctype.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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 `<ident> <assign-op>` 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;
}

View File

@ -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 <Tactility/app/shell/shell/sh_arith.h>
#include <cctype>
#include <cstdio>
#include <string>
#include <vector>
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<char>::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<Input> inputs;
std::vector<Operator> ops;
std::vector<long> 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<unsigned char>(in.at()))) {
long value = readNumber(in);
if (error == nullptr) pushOperand(value);
return;
}
if (isalpha(static_cast<unsigned char>(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 <typename OnImmediate>
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 `<ident> <assign-op>` 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<unsigned char>(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<unsigned char>(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<unsigned char>(in.at()))) { fail("bad hex constant"); return 0; }
while (isxdigit(static_cast<unsigned char>(in.at()))) {
char c = in.at();
int digit = isdigit(static_cast<unsigned char>(c)) ? c - '0' : (tolower(static_cast<unsigned char>(c)) - 'a' + 10);
value = value * 16 + digit;
in.pos++;
}
} else if (in.at() == '0' && isdigit(static_cast<unsigned char>(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<unsigned char>(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);
}

View File

@ -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;
// 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 int t_oexpr(tparse *t);
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; }
static int t_primary(tparse *t)
// 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;
}
tstack t;
t.cap = n + 1;
t.ops = malloc(t.cap);
t.vals = malloc(t.cap * sizeof(int));
t.nops = t.nvals = 0;
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;
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;
}
return r;
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) {

View File

@ -1,559 +0,0 @@
#include <Tactility/app/shell/shell/sh.h>
#include <Tactility/app/shell/shell/sh_lex.h>
#include <Tactility/app/shell/shell/sh_parse.h>
#include <Tactility/app/shell/shell/sh_builtins.h>
#include <Tactility/app/shell/shell/sh_glob.h>
#include <Tactility/app/shell/shell/sh_port.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
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;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,691 +0,0 @@
#include <Tactility/app/shell/shell/sh.h>
#include <Tactility/app/shell/shell/sh_port.h>
#include <Tactility/app/shell/shell/sh_glob.h>
#include <Tactility/app/shell/shell/sh_arith.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
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;
}

View File

@ -0,0 +1,686 @@
// 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 <Tactility/app/shell/shell/sh_machine.h>
#include <Tactility/app/shell/shell/sh_arith.h>
#include <Tactility/app/shell/shell/sh_glob.h>
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <cstring>
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<char**>(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<unsigned char>(name[0])) || name[0] == '_')) return false;
for (const char* q = name + 1; *q; q++)
if (!(isalnum(static_cast<unsigned char>(*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<char*>(malloc(16)); snprintf(b, 16, "%d", st->last_status); *owned = b; return b; }
if (c == '#') { auto* b = static_cast<char*>(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<unsigned char>(*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) {
// "$@" 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) {
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<unsigned char>(c))) {
name.push_back(c); // bare $N is a single digit
p++;
} else if (isalpha(static_cast<unsigned char>(c)) || c == '_') {
while ((isalnum(static_cast<unsigned char>(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<unsigned char>(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<int>(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<unsigned char>(*r))) {
while (isdigit(static_cast<unsigned char>(*r)) && name.size() < 127) name.push_back(*r++);
} else if (isalpha(static_cast<unsigned char>(*r)) || *r == '_') {
while ((isalnum(static_cast<unsigned char>(*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

View File

@ -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)

View File

@ -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;
}

View File

@ -1,504 +0,0 @@
#include <Tactility/app/shell/shell/sh_parse.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
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 <newline> 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);
}

View File

@ -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 <Tactility/app/shell/shell/sh_parse.h>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <optional>
#include <variant>
#include <vector>
#define SH_MAX_PARSE_FRAMES 1024
namespace {
struct NodeDeleter {
void operator()(node* n) const { sh_free_node(n); }
};
using NodePtr = std::unique_ptr<node, NodeDeleter>;
node* new_node(node_kind k)
{
auto* n = static_cast<node*>(calloc(1, sizeof(node)));
n->kind = k;
return n;
}
void push_child(node* n, node* c)
{
n->children = static_cast<node**>(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<node**>(realloc(n->conds, (n->nclause + 1) * sizeof(node*)));
n->bodies = static_cast<node**>(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<sh_redir*>(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<char*> 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<ParseList, ParseAndOr, ParsePipeline, ParseCommand, ParseGroup,
ParseFuncdef, ParseIf, ParseWhile, ParseFor, ParseCase>;
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<ParseFrame> 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<int>(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<char**>(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<char**>(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<ParseFrame> 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 <newline> 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<char**>(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<int>(pats.size());
cl.pats = static_cast<char**>(malloc(pats.size() * sizeof(char*)));
memcpy(cl.pats, pats.data(), pats.size() * sizeof(char*));
pats.clear();
cl.body = child.release();
n->clauses = static_cast<sh_case_clause*>(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<node*> 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);
}
}
}

View File

@ -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<InputSignal*>(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<char>(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<char>(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<const char*>(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<const char*>(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

View File

@ -16,6 +16,8 @@
#include <tactility/freertos/semphr.h>
#include <tactility/freertos/task.h>
#include <atomic>
#ifdef ESP_PLATFORM
#include <esp_log.h>
#endif
@ -26,9 +28,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 +50,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. */
std::atomic<bool> shellFinished = false;
/**
* Translates a kernel keyboard event into the byte a terminal expects.
* Returns 0 for keys with no terminal representation.
@ -153,20 +159,24 @@ struct IoTaskParams {
void ioTask(void* arg) {
auto* params = static_cast<IoTaskParams*>(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));
}
// 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);
}
@ -175,6 +185,7 @@ void ioTask(void* arg) {
void runTerminal(Device* display) {
stopRequested = false;
shellFinished = false;
KeyboardInput keyboards;
TouchInput touch;
@ -227,7 +238,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

View File

@ -81,6 +81,10 @@ 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.
@ -594,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 };
@ -683,9 +688,12 @@ 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);
mutex_destruct(&s_input_cb_mux);
}
vterm_cell_t *vterm_get_direct_buffer(void)
@ -847,6 +855,12 @@ 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) {
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)
static TaskHandle_t s_size_override_task = NULL;
@ -898,7 +912,11 @@ 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);
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) {

View File

@ -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 <description> <expected> <actual>
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 <<EOF
hello $hd
EOF
read h2 <<'EOF'
hello $hd
EOF
check "here-document" "hello w" "$h1"
check "quoted here-document" 'hello $hd' "$h2"
lines=
while read l; do lines="$lines[$l]"; done <<EOF
a
b
EOF
check "while-read from here-document" "[a][b]" "$lines"
check "pipeline of builtins" "b c" "$(echo a b c | { read p q; echo $q; })"
echo 'sourced=9; first=$1' > "$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

View File

@ -4,6 +4,14 @@
#include "FreeRTOS.h"
#include "task.h"
#include <sys/stat.h>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
// 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 <tactility/check.h>
#include <tactility/dts.h>
#include <tactility/kernel_init.h>
@ -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;
}

View File

@ -0,0 +1,52 @@
#include "doctest.h"
#include <Tactility/app/shell/Shell.h>
#include <tactility/filesystem/fs.h>
#include <tactility/paths.h>
#include <cstdio>
#include <string>
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 "<none>";
}
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";
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/");
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"), "<none>");
}
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 ");
}

View File

@ -0,0 +1,602 @@
#include "doctest.h"
#include <tactility/paths.h>
extern "C" {
#include <Tactility/app/shell/shell/sh.h>
}
#include <cerrno>
#include <cstdio>
#include <string>
#include <sys/stat.h>
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 : "<unset>";
}
};
} // 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: 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");
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"), "<unset>");
}
TEST_CASE("shell: unset and set -u") {
Shell sh;
sh.run("x=1; unset x");
CHECK_EQ(sh.var("x"), "<unset>");
Shell strict;
CHECK_EQ(strict.run("set -u; y=$undefined_variable; after=1"), 2);
CHECK_EQ(strict.var("after"), "<unset>");
}
// 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"), "<unset>");
}
}
TEST_CASE("shell: arithmetic errors are fatal") {
Shell sh;
CHECK_EQ(sh.run("x=$((1/0)); after=1"), 2);
CHECK_EQ(sh.var("after"), "<unset>");
}
// 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"), "<unset>");
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"), "<unset>");
}
// 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: 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\"");
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"), "<unset>");
}
// 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 <<EOF\nhello $x\nEOF\nread b <<'EOF'\nhello $x\nEOF\n");
CHECK_EQ(sh.var("a"), "hello w");
CHECK_EQ(sh.var("b"), "hello $x");
}
TEST_CASE("shell: while-read loop fed by a here-document") {
Shell sh;
sh.run("while read l; do s=\"$s[$l]\"; done <<EOF\na\nb\nEOF\n");
CHECK_EQ(sh.var("s"), "[a][b]");
}
TEST_CASE("shell: pipelines") {
Shell sh;
sh.run("x=$(echo a b c | { read p q; echo $q; }); echo hello | read v; r=$v");
CHECK_EQ(sh.var("x"), "b c");
CHECK_EQ(sh.var("r"), "hello");
}
// endregion
// region Builtins and globbing
TEST_CASE("shell: test builtin") {
Shell sh;
sh.run(
"[ 1 -lt 2 ]; a=$?; [ ! -z x ]; b=$?; [ \\( a = a \\) -a b = c ]; c=$?\n"
"echo x > $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"), "<unset>");
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"), "<unset>");
// 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"), "<unset>");
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 <pthread.h>
#include <cstdlib>
#include <cstring>
// 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<unsigned char*>(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<StackRun*>(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