From 4cc47ee53d7f25c6a296859b7d627465f3797c1a Mon Sep 17 00:00:00 2001 From: ruinivist Date: Sun, 14 Jun 2026 13:41:16 +0000 Subject: [PATCH] init: migrate from chal repo --- .clangd | 3 + .gitignore | 14 + CMakeLists.txt | 13 + Makefile | 11 + README.md | 26 ++ src/main.cpp | 986 +++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1053 insertions(+) create mode 100644 .clangd create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 Makefile create mode 100644 README.md create mode 100644 src/main.cpp diff --git a/.clangd b/.clangd new file mode 100644 index 0000000..381900e --- /dev/null +++ b/.clangd @@ -0,0 +1,3 @@ +CompileFlags: + Add: [-std=c++23, -Wall, -Wextra, -Wpedantic] + Compiler: c++ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b8fe0c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +build/ +.cache/ + +# Local binaries and compiler output +/catsh +*.o +*.d +*.out + +# CMake generated files +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +compile_commands.json diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..74a35f9 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.20) + +project(catsh LANGUAGES CXX) + +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +add_executable(catsh src/main.cpp) +target_compile_features(catsh PRIVATE cxx_std_23) +set_target_properties(catsh PROPERTIES CXX_EXTENSIONS OFF) + +if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU") + target_compile_options(catsh PRIVATE -Wall -Wextra -Wpedantic) +endif() diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..53ac196 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +.PHONY: build run clean + +build: + cmake -S . -B build + cmake --build build + +run: build + ./build/catsh + +clean: + rm -rf build \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5143943 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# catsh + +A small (ish) shell written in ( \*hopefully ) modern C++. + +Made while doing the codecrafters' shell implementation challenge -- but let's see I'll add stuff that I find interesting. +Given it's at ~1000 LOC the goal is still to keep it as small as possible, as part of that + +- Almost no error handling on POSIX apis or otherwise +- Linux only +- Edge cases are intended to be unhandled wherever they are +- focus on "terse" modern cpp code and practices, even if they are not the most efficient + +what we DO have is + +- no external dependencies ( not even GNU readline ), except POSIX and C++ standard library +- support for pipelines, redirection, and background processes + +## Running + +`make` wrapper over `cmake` + +```sh +make build +make run +make clean +``` diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..62d0488 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,986 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace sutils { + +// this resolved based on cwd too, cpp handles that +std::optional resolve_env_var(std::string_view env_str) { + // MUST make a copy as the view might not be null terminated + const char *env = std::getenv(std::string{env_str}.data()); + if (env == nullptr) { + return std::nullopt; + } + // implicit conversion of return types + return std::string{env}; +} + +std::string resolve_path(std::string_view path_str) { + auto path = std::filesystem::path(path_str); + + // home path + if (path_str.starts_with("~")) { + auto home = resolve_env_var("HOME").value(); + if (path_str.size() > 1) { + home += path_str.substr(1); + } + return home; + } + + // rel path or absol path + return std::filesystem::absolute(path).lexically_normal().string(); +} + +std::vector executable_names_in_path() { + auto path_str = resolve_env_var("PATH").value(); + std::vector ret{}; + for (auto &&p : path_str | std::views::split(':')) { + auto path_dir = std::filesystem::path(std::string{p.begin(), p.end()}); + + for (auto &&bin : std::filesystem::directory_iterator(path_dir)) { + auto bin_path = bin.path(); + if (bin.is_regular_file() && access(bin_path.c_str(), X_OK) == 0) { + ret.push_back(bin_path.filename()); + } + } + } + + return ret; +} + +std::optional bin_path(std::string_view cmd) { + namespace fs = std::filesystem; + + if (cmd.contains('/')) { + auto path = sutils::resolve_path(cmd); + return access(path.c_str(), X_OK) == 0 ? std::optional{path} : std::nullopt; + } + + auto path_env = sutils::resolve_env_var("PATH").value(); + + for (auto dir : path_env | std::views::split(':')) { + auto path = fs::path(std::string{dir.begin(), dir.end()}) / cmd; + + if (access(path.c_str(), X_OK) == 0) + return path.string(); + } + + return std::nullopt; +} + +std::string join_cmd(std::span cmd) { + return cmd | std::views::join_with(std::string_view{" "}) | + std::ranges::to(); +} + +struct FDs { + int fdin, fdout, fderr; +}; + +// v indicates that this is NOT expected to return +[[noreturn]] void exec(std::string path, std::vector args) { + std::vector c_args; + for (auto &&e : args) { + c_args.push_back(e.data()); + } + c_args.push_back(nullptr); + + execv(path.c_str(), c_args.data()); + std::exit(-1); // in case ^ fails +} + +pid_t fork_and_exec(std::string path, std::vector args, FDs fds) { + pid_t pid = fork(); + + if (pid == 0) { + // child + if (fds.fdin != STDIN_FILENO) { + dup2(fds.fdin, STDIN_FILENO); + close(fds.fdin); + } + if (fds.fderr != STDERR_FILENO) { + dup2(fds.fderr, STDERR_FILENO); + close(fds.fderr); + } + if (fds.fdout != STDOUT_FILENO) { + dup2(fds.fdout, STDOUT_FILENO); + close(fds.fdout); + } + // INFO: why do we care about closing when a process exits + // and closes both anyway? well say subprocess redirects to + // a different one and closes stdout manually but keeps running still + // then I'll still be stuck on a blocking read because desc are open + + exec(path, args); + } + + return pid; +} + +class ScopedFDRedirect { +public: + ScopedFDRedirect(const FDs &fds) + : old_stdin_(dup(STDIN_FILENO)), old_stdout_(dup(STDOUT_FILENO)), + old_stderr_(dup(STDERR_FILENO)) { + dup2(fds.fdin, STDIN_FILENO); + dup2(fds.fdout, STDOUT_FILENO); + dup2(fds.fderr, STDERR_FILENO); + } + + ~ScopedFDRedirect() { + dup2(old_stdin_, STDIN_FILENO); + dup2(old_stdout_, STDOUT_FILENO); + dup2(old_stderr_, STDERR_FILENO); + + // Only close descriptors this object created with dup(). + close(old_stdin_); + close(old_stdout_); + close(old_stderr_); + } + +private: + int old_stdin_; + int old_stdout_; + int old_stderr_; +}; + +class FileFd { +public: + FileFd(std::string_view path, int flags) + : fd_(open(std::string(path).c_str(), flags, 0644)) {} + + ~FileFd() { close(fd_); } + + int get() const { return fd_; } + +private: + int fd_; +}; + +bool is_valid_variable(std::string_view var) { + return !var.empty() && !std::isdigit(var[0]) && + std::ranges::all_of( + var, [](char c) -> bool { return std::isalnum(c) || c == '_'; }); +}; +} // namespace sutils + +namespace sproc { + +using BuiltinArgs = std::span; +// prev I was using std::function wrapper around this but all I really have are +// simple functions so just using a fn pointer is simpler +using BuiltinFn = void (*)(BuiltinArgs); +using Builtin = std::pair; + +void builtin_complete(BuiltinArgs); +void builtin_pwd(BuiltinArgs); +void builtin_echo(BuiltinArgs); +void builtin_cd(BuiltinArgs); +void builtin_type(BuiltinArgs); +void builtin_jobs(BuiltinArgs); +void builtin_exit(BuiltinArgs); +void builtin_declare(BuiltinArgs); +void builtin_history(BuiltinArgs); + +inline constexpr std::array builtins = {{ + {"complete", builtin_complete}, + {"pwd", builtin_pwd}, + {"echo", builtin_echo}, + {"type", builtin_type}, + {"cd", builtin_cd}, + {"exit", builtin_exit}, + {"history", builtin_history}, + {"declare", builtin_declare}, + {"jobs", builtin_jobs}, +}}; + +auto find_builtin(std::string_view name) { + return std::ranges::find(builtins, name, &Builtin::first); +} + +std::vector history; +std::size_t history_synced_till = 0; +std::map> variables; +} // namespace sproc + +// TODO: special stuff I don't handle like echo $PATH, * and ? etc etc +namespace sread { + +// RAII based Raw mode enabling +class WithRawMode { +private: + struct termios orig_termios_; + +public: + ~WithRawMode() { tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_termios_); } + + WithRawMode() { + tcgetattr(STDIN_FILENO, &orig_termios_); + struct termios new_settings = orig_termios_; + new_settings.c_lflag &= ~(ICANON | ECHO); + // I had a typo earlier where it set on stderr instead but it worked still + // because both pointed to the same device ( these settings are applied on + // device ) + tcsetattr(STDIN_FILENO, TCSAFLUSH, &new_settings); + } +}; + +std::map> completer_scripts; + +struct Completion { + std::vector matches; + std::string lcs; +}; + +Completion complete_from(std::span candidates, + std::string_view prefix) { + auto matches = candidates | std::views::filter([&](const auto &candidate) { + return candidate.starts_with(prefix); + }) | + std::ranges::to(); + + std::ranges::sort(matches); + auto duplicates = std::ranges::unique(matches); + matches.erase(duplicates.begin(), duplicates.end()); + + if (matches.empty()) + return {}; + + auto lcs = matches.front(); + for (const auto &match : matches | std::views::drop(1)) { + auto [common_end, _] = std::ranges::mismatch(lcs, match); + lcs.erase(common_end, lcs.end()); + } + + return {std::move(matches), std::move(lcs)}; +} + +std::vector completion_candidates(std::span tokens, + const std::string &line) { + std::vector candidates; + + if (tokens.size() <= 1) { + // builtins + path + for (auto &&e : sproc::builtins) { + candidates.emplace_back(e.first); + } + auto all = sutils::executable_names_in_path(); + candidates.append_range(all); + return candidates; + } + + if (completer_scripts.contains(tokens[0])) { + // i make a pipe, run the script and use the lines + int fd[2]; + pipe(fd); + + std::vector args; + args.push_back(completer_scripts[tokens[0]]); + args.push_back(tokens[0]); + // cur token + args.push_back(tokens.size() >= 2 ? tokens.back() : ""); + // prev token + args.push_back(tokens.size() >= 2 ? tokens[tokens.size() - 2] : ""); + + auto pid = fork(); + if (pid == 0) { + // previously I had a whole map for envs supported by + // sutils::exec and using execve but this is better as now I "inherit" the + // currents vars too and inherit + modify in child is the POSIX model (and + // my inherit wasn't implemented prev) + auto comp_point = std::to_string(line.size()); + setenv("COMP_LINE", line.c_str(), 1); + setenv("COMP_POINT", comp_point.c_str(), 1); + dup2(fd[1], STDOUT_FILENO); + close(fd[0]); + close(fd[1]); + sutils::exec(args[0], args); + } + + close(fd[1]); // so as not to block read forever + + std::string lines; + char buffer[4096]; // 4kb is the default for pipes so this is it + ssize_t n; + while ((n = read(fd[0], buffer, sizeof(buffer))) > 0) { + // WARN: a lines += buffer which I did earlier is blatantly wrong as + // buffer is not null-terminated + lines.append(buffer, n); + } + close(fd[0]); + + waitpid(pid, nullptr, 0); + + for (auto &&chunk : lines | std::views::split('\n')) { + if (chunk.begin() != chunk.end()) + candidates.emplace_back(chunk.begin(), chunk.end()); + } + + return candidates; + } + + // complete paths from cwd + std::string path_prefix = tokens.back(); + std::string search_dir; + auto cwd = std::filesystem::current_path(); + + if (path_prefix.contains('/')) { + search_dir = sutils::resolve_path( + path_prefix.substr(0, path_prefix.find_last_of('/'))); + } else { + search_dir = cwd.string(); + } + + if (!std::filesystem::exists(search_dir)) { + return candidates; + } + + for (auto &&e : std::filesystem::directory_iterator(search_dir)) { + auto rel_path_str = std::filesystem::relative(e, cwd).string(); + if (e.is_directory()) + rel_path_str.push_back('/'); + candidates.push_back(std::move(rel_path_str)); + } + + return candidates; +} + +std::vector tokenise(std::string_view raw_string) { + std::vector out; + std::string tok; + char quote = 0; + bool building = false; + + auto flush = [&] { + if (building) { + // even if it's a ${SOME_MISSING_VAR} those are just truncated + if (tok.size()) { + out.push_back(std::move(tok)); + tok.clear(); + } + building = false; + } + }; + + auto read_var = [&](size_t &i) -> std::string { + auto variable_value = [](std::string_view name) -> std::string { + auto it = sproc::variables.find(std::string{name}); + return it == sproc::variables.end() ? "" : it->second; + }; + + if (i + 1 >= raw_string.size()) { + return "$"; + } + + // not handling variable name being valid here + + if (raw_string[i + 1] == '{') { + size_t start = i + 2; + size_t end = raw_string.find('}', start); + + assert(end != std::string_view::npos && "unclosed brace in variable"); + + auto name = raw_string.substr(start, end - start); + i = end; + return variable_value(name); + } + + size_t start = i + 1; + size_t end = start + 1; + // extend till it's valid + while (end < raw_string.size() && + (std::isalnum(raw_string[end]) || raw_string[end] == '_')) { + ++end; + } + + auto name = raw_string.substr(start, end - start); + i = end - 1; + return variable_value(name); + }; + + for (size_t i = 0; i < raw_string.size(); ++i) { + char c = raw_string[i]; + + if (c == '\\' && quote != '\'') { + if (++i < raw_string.size()) + tok += raw_string[i]; + building = true; + } else if (quote) { + if (c == quote) { + quote = 0; + } else if (c == '$' && quote == '"') { + tok += read_var(i); + building = true; + } else { + tok += c; + building = true; + } + } else if (c == '\'' || c == '"') { + quote = c; + building = true; + } else if (c == '$') { + tok += read_var(i); + building = true; + } else if (c == ' ') { + flush(); + } else { + tok += c; + building = true; + } + } + + flush(); + return out; +} + +std::string readline() { + // \x makes all the following chars a hex seq but since [ is not hext it + // breaks into normal chars -- so this is then 1b as 1 char, then [ an and A + // as two more + constexpr std::string_view KEY_UP = "\x1b[A"; + constexpr std::string_view KEY_DOWN = "\x1b[B"; + constexpr std::string_view PROMPT = "\x1b[38;5;51m~(\x1b[38;5;213m=^‥^\x1b[" + "38;5;51m)/ \x1b[38;5;118m>\x1b[0m "; + constexpr std::string_view BACKSPACE = "\b \b"; + + WithRawMode _{}; + + std::string raw; + char quote = 0; + bool last_tab = false; + std::string multibye_buffer; // key up and down + int history_index = sproc::history.size(); + + auto quoted = [&] { return quote != 0; }; + + auto erase = [&] { + if (raw.empty()) + return; + + if ((raw.back() == '\'' || raw.back() == '"') && quote == 0) + quote = raw.back(); // imperfect but ok for simple interactive state + + raw.pop_back(); + std::cout << BACKSPACE; + }; + + auto erase_all = [&] { + // would be wrong for multi-byte chars I think where visual width is not + // same as bytes taken + for (int i = 0; i < (int)raw.size(); i++) + std::cout << BACKSPACE; + raw.clear(); + }; + + auto complete = [&] { + if (quoted()) + return; + + auto tokens = tokenise(raw); + if (raw.empty() || raw.back() == ' ') + tokens.emplace_back(); + + auto &cur = tokens.back(); + auto [matches, lcs] = + complete_from(completion_candidates(tokens, raw), cur); + + if (lcs.size() > cur.size()) { + auto rest = std::string(lcs.begin() + cur.size(), lcs.end()); + if (matches.size() == 1 && matches[0] == lcs && + !matches[0].ends_with('/')) + rest.push_back(' '); + raw += rest; + std::cout << rest; + } else if (last_tab) { + std::cout << '\n'; + + constexpr std::string_view sep = " "; + for (auto &&ch : matches | std::views::join_with(sep)) + std::cout << ch; + + std::cout << '\n' << PROMPT << raw; + } else { + std::cout << '\a'; + } + + last_tab = true; + }; + + auto use_history = [&](std::string_view key) -> void { + // this below so clamp is valid + int hsize = sproc::history.size(); + if (!hsize) + return; + int delta = key == KEY_UP ? -1 : 1; + history_index += delta; + history_index = std::clamp(history_index, 0, hsize); + + // clear and don't populate for the very last strings + // ideally it replaces whatever u had partially written but not here + erase_all(); + + if (history_index < hsize) { + auto &&hline = sproc::history[history_index]; + std::cout << hline; + raw = hline; + } + }; + + std::cout << PROMPT; + + auto process_char = [&](char c) -> int { + switch (c) { + case '\n': + std::cout << '\n'; + return 1; + + case '\t': + complete(); + break; + + case 127: + erase(); + last_tab = false; + break; + + case '\'': + case '"': + quote = quote == c ? 0 : quote == 0 ? c : quote; + [[fallthrough]]; + + default: + raw += c; + std::cout << c; + last_tab = false; + break; + } + + return 0; + }; + + for (char c; read(STDIN_FILENO, &c, 1) == 1;) { + multibye_buffer.push_back(c); + + bool keep_buffering = + multibye_buffer.size() < 3 && (KEY_DOWN.starts_with(multibye_buffer) || + KEY_UP.starts_with(multibye_buffer)); + + if (!keep_buffering) { + if (multibye_buffer == KEY_UP || multibye_buffer == KEY_DOWN) { + use_history(multibye_buffer); + } else { + for (char c : multibye_buffer) { + if (process_char(c)) { + return raw; + } + } + } + + multibye_buffer.clear(); + } + } + + return raw; +} + +} // namespace sread + +namespace sproc { + +void builtin_complete(std::span tokens) { + // unchecked access on tokens + if (tokens[0] == "-p") { + auto comp_entry = sread::completer_scripts.find(tokens[1]); + if (comp_entry != sread::completer_scripts.end()) { + std::cout << std::format("complete -C '{}' {}\n", comp_entry->second, + tokens[1]); + } else { + std::cout << "complete: " << tokens[1] + << ": no completion specification\n"; + } + } else if (tokens[0] == "-C") { + // [] NEEDS value type to be defalt ctorible but this below + // does not, well not that it matters for strings + sread::completer_scripts.insert_or_assign(std::string{tokens[2]}, + std::string{tokens[1]}); + } else if (tokens[0] == "-r") { + sread::completer_scripts.erase(std::string{tokens[1]}); + } +} + +void builtin_pwd(std::span) { + auto path = std::filesystem::current_path(); + std::cout << path.string() << "\n"; +} + +void builtin_echo(std::span tokens) { + for (char c : tokens | std::views::join_with(' ')) { + std::cout << c; + } + std::cout << '\n'; +} + +void builtin_cd(std::span tokens) { + std::string absolute_path = sutils::resolve_path(tokens[0]); + if (std::filesystem::exists(absolute_path)) { + chdir(absolute_path.c_str()); + } else { + std::cout << "cd: " << absolute_path << ": No such file or directory\n"; + } +} + +void builtin_declare(std::span tokens) { + + if (tokens[0] == "-p") { + auto entry = variables.find(tokens[1]); + if (entry == variables.end()) { + std::cout << std::format("declare: {}: not found\n", tokens[1]); + } else { + std::cout << std::format("declare -- {}=\"{}\"\n", entry->first, + entry->second); + } + } else { + // expecting a=b + auto eq_at = tokens[0].find_last_of('='); + auto key = std::string{tokens[0].substr(0, eq_at)}; + auto val = std::string{tokens[0].substr(eq_at + 1)}; + assert(!key.empty() && !val.empty() && "no spaces in decls please"); + if (!sutils::is_valid_variable(key)) { + // bash too does this weird backtick then ' quoting + std::cout << std::format("declare: `{}': not a valid identifier\n", + tokens[0]); + } else { + variables[key] = val; + } + } +} + +void sync_history_from_file(std::filesystem::path path) { + std::ifstream file(path); + std::string line; + while (getline(file, line)) { + if (!line.empty()) + history.push_back(line); + } + history_synced_till = history.size(); + file.close(); +} + +void sync_history_to_file(std::filesystem::path path, bool append) { + std::ofstream file(path, append ? std::ios::app : std::ios::trunc); + + auto from = append ? history_synced_till : 0uz; + for (; from < history.size(); from++) { + file << history[from] << "\n"; + } + history_synced_till = history.size(); + file.close(); +} + +void builtin_history(std::span tokens) { + int count = history.size(); + if (tokens.size()) { + if (tokens[0] == "-r") { + auto path = sutils::resolve_path(tokens[1]); + + history.clear(); + // i don't see why we would have this entry but fine ig + history.push_back("history " + sutils::join_cmd(tokens)); + sync_history_from_file(path); + return; + } + + if (tokens[0] == "-w" || tokens[0] == "-a") { + auto path = sutils::resolve_path(tokens[1]); + sync_history_to_file(path, tokens[0] == "-a"); + return; + } + + // assuming count as token + count = std::min((int)history.size(), std::stoi(std::string{tokens[0]})); + } + + for (int i = history.size() - count; i < (int)history.size(); i++) { + std::cout << std::format("{:5} {}\n", i + 1, history[i]); + } +} + +void builtin_type(std::span tokens) { + if (find_builtin(tokens[0]) != builtins.end()) { + std::cout << tokens[0] << " is a shell builtin\n"; + return; + } + + if (auto path = sutils::bin_path(tokens[0])) { + std::cout << std::format("{} is {}\n", tokens[0], *path); + return; + } + + std::cout << tokens[0] << ": not found\n"; +} + +void builtin_exit(std::span) { + auto hfile = sutils::resolve_env_var("HISTFILE"); + if (hfile.has_value()) + sync_history_to_file(hfile.value(), true); + std::exit(0); +} + +struct job_info { + int id; + pid_t pid; + std::string cmd; +}; + +std::deque jobs; + +int next_id() { + for (int id = 1;; ++id) + if (std::ranges::none_of(jobs, [=](auto &j) { return j.id == id; })) + return id; +} + +void reaper() { + const auto old_size = std::ssize(jobs); + + for (auto it = jobs.begin(); it != jobs.end();) { + const auto i = std::distance(jobs.begin(), it); + + int status = 0; + if (waitpid(it->pid, &status, WNOHANG) <= 0 || !WIFEXITED(status)) { + ++it; + continue; + } + + const char sym = i == old_size - 1 ? '+' : i == old_size - 2 ? '-' : ' '; + std::printf("[%d]%c %-24s %s\n", it->id, sym, "Done", it->cmd.c_str()); + it = jobs.erase(it); + } +} + +void builtin_jobs(std::span) { + size_t i = 0, n = jobs.size(); + + std::erase_if(jobs, [&](job_info &j) { + int status = 0; + bool done = waitpid(j.pid, &status, WNOHANG) > 0 && WIFEXITED(status); + + char sym = i == n - 1 ? '+' : i == n - 2 ? '-' : ' '; + + std::printf("[%d]%c %-24s %s\n", j.id, sym, done ? "Done" : "Running", + j.cmd.c_str()); + + ++i; + return done; + }); +} + +void apply_redirect(std::span &cmd, sutils::FDs &fds, + std::optional &file_fd) { + if (cmd.size() < 3) + return; + + auto op = cmd[cmd.size() - 2]; + auto file = cmd[cmd.size() - 1]; + + bool stdout_redir = op == ">" || op == "1>" || op == ">>" || op == "1>>"; + bool stderr_redir = op == "2>" || op == "2>>"; + + if (!stdout_redir && !stderr_redir) + return; + + int flags = O_WRONLY | O_CREAT | (op.ends_with(">>") ? O_APPEND : O_TRUNC); + + file_fd.emplace(sutils::resolve_path(file), flags); + + if (stderr_redir) + fds.fderr = file_fd->get(); + else + fds.fdout = file_fd->get(); + + cmd = cmd.first(cmd.size() - 2); +} + +pid_t launch_command(std::span cmd, sutils::FDs fds, + bool subshell) { + std::optional file_fd; + apply_redirect(cmd, fds, file_fd); + + auto args = cmd.subspan(1); + + if (auto it = sproc::find_builtin(cmd[0]); it != sproc::builtins.end()) { + if (subshell) { + pid_t pid = fork(); + + if (pid == 0) { + sutils::ScopedFDRedirect _{fds}; + it->second(args); + std::_Exit(0); + } else { + return pid; + } + } else { + sutils::ScopedFDRedirect _{fds}; + it->second(args); + return 0; + } + } + + std::vector exec_args; + exec_args.reserve(cmd.size()); + + for (auto tok : cmd) + exec_args.emplace_back(tok); + + auto bin_opt = sutils::bin_path(cmd[0]); + + if (!bin_opt) { + std::cout << cmd[0] << ": command not found\n"; + return 0; + } + + return sutils::fork_and_exec(*bin_opt, std::move(exec_args), fds); +} + +void pipeline(std::span tokens, bool bg_mode) { + std::vector> cmds = {}; + + auto from = tokens.begin(); + for (auto cur = tokens.begin(); cur != tokens.end(); cur++) { + if (*cur == "|") { + cmds.emplace_back(from, cur); + from = cur + 1; + } + } + cmds.emplace_back(from, tokens.end()); + + int cmd_cnt = cmds.size(); + auto fds = std::vector>(cmd_cnt - 1); + for (int i = 0; i < cmd_cnt - 1; i++) { + pipe(fds[i].data()); + + // INFO: Rem direction as + // dup2(a, b) -> make B refer to the same open file as A + } + + std::vector pids; + pids.reserve(cmd_cnt); + for (int i = 0; i < cmd_cnt; i++) { + // i is used only when i > 0 + sutils::FDs fd = { + .fdin = i == 0 ? STDIN_FILENO : fds[i - 1][0], + .fdout = i == cmd_cnt - 1 ? STDOUT_FILENO : fds[i][1], + .fderr = STDERR_FILENO, + }; + + auto pid = launch_command(cmds[i], fd, true); + // can be 0 in case of cmd not found etc + if (pid != 0) + pids.push_back(pid); + + // WARN: you NEED this in here else future forks will inherit open + // pipes + if (i > 0) + close(fds[i - 1][0]); + if (i < cmd_cnt - 1) + close(fds[i][1]); + } + + if (bg_mode) { + if (!pids.empty()) { + sproc::jobs.push_back({ + .id = sproc::next_id(), + .pid = pids.back(), + .cmd = sutils::join_cmd(tokens), + }); + std::printf("[%d] %d\n", sproc::jobs.back().id, pids.back()); + } + return; + } + + for (pid_t pid : pids) { + waitpid(pid, nullptr, 0); + } +} + +void process_input(std::span tokens) { + history.push_back(sutils::join_cmd(tokens)); + bool bg_mode = tokens.back() == "&"; + if (bg_mode) + tokens = tokens.first(tokens.size() - 1); + if (std::ranges::find(tokens, std::string_view{"|"}) != tokens.end()) { + pipeline(tokens, bg_mode); + return; + } + + auto pid = launch_command(tokens, + { + .fdin = STDIN_FILENO, + .fdout = STDOUT_FILENO, + .fderr = STDERR_FILENO, + }, + false); + + if (pid == 0) // parent + return; + + if (bg_mode) { + sproc::jobs.push_back({ + .id = sproc::next_id(), + .pid = pid, + .cmd = sutils::join_cmd(tokens), + }); + + std::printf("[%d] %d\n", sproc::jobs.back().id, pid); + } else { + waitpid(pid, nullptr, 0); + } +} + +} // namespace sproc + +int main() { + std::cout << std::unitbuf; + std::cerr << std::unitbuf; + + auto hfile = sutils::resolve_env_var("HISTFILE"); + if (hfile.has_value()) + sproc::sync_history_from_file(hfile.value()); + + while (true) { + auto input = sread::readline(); + auto tokens = sread::tokenise(input); + + if (tokens.empty()) + continue; + + auto views = tokens | std::views::transform([](const std::string &s) { + return std::string_view{s}; + }) | + std::ranges::to(); + + sproc::process_input(views); + sproc::reaper(); + } +}