#include "util.h" #include "error.h" #include "eval.h" #include "eval_python.h" #include "eval_cpp.h" #include "log.h" #include "show.h" #include "katom.h" #include "file.h" #include #include #include #include // Run a shell command and return its STANDARD OUTPUT, which becomes document // text. Two things the first version did not do, both of them the same defect // as a msg() on the wrong stream -- output nobody chose to see, and a failure // nobody was told about: // // * stderr went straight to the user's terminal, unattributed. It is not the // command's output in any of the three policy categories (CLAUDE.md, // "Command output policy"): it belongs to a subprocess a klammer invoked, at // a location the Locator can name. It is captured here and reported at // "-v 1" -- a derived value, the sort of thing "-v 1" exists for. // * the exit status was discarded, so a command that failed contributed its // partial output (or nothing) to the document and said nothing. A nonzero // status is now an error, with the status, the command, and whatever the // command said on stderr. Some commands exit nonzero without failing -- // "grep" finding no match is the usual one -- so the message names the // explicit way to say that was intended, "cmd || true". // // tex_to_pdf() in sks/document/document.cpp is the same pattern for xelatex: // capture, then decide what to report. std::string shell(State state, std::string command, Locator loc) { command = state.subst(command); // popen gives one pipe, so stderr goes to a temporary file. mkstemp rather // than a constructed name: several ktext runs may share /tmp. std::string err_path = (fs::temp_directory_path() / "ktext_shell_XXXXXX").string(); std::vector err_template(err_path.begin(), err_path.end()); err_template.push_back('\0'); int err_fd = mkstemp(err_template.data()); if (err_fd == -1) { throw Environment_error("Could not create a temporary file for the " "command's error output", loc, false); } close(err_fd); err_path = err_template.data(); // The braces keep the redirection outside the writer's command, so a // command containing its own pipeline or redirection still works. std::string wrapped = "{ " + command + " ; } 2>" + q_(err_path); FILE* pipe = popen(wrapped.c_str(), "r"); if (!pipe) { fs::remove(err_path); throw Environment_error( "Could not run command:\n" + command, loc, false); } char buffer[128]; std::string result = ""; while (fgets(buffer, sizeof(buffer), pipe) != nullptr) { result += buffer; } int status = pclose(pipe); std::string error_output = trim_right(string_from_file(err_path)); fs::remove(err_path); int exit_status = WIFEXITED(status) ? WEXITSTATUS(status) : -1; if (exit_status != 0) { std::stringstream ss {}; ss << "The shell command failed"; if (WIFEXITED(status)) { ss << " (exit status " << exit_status << ")"; } else if (WIFSIGNALED(status)) { ss << " (killed by signal " << WTERMSIG(status) << ")"; } ss << ":\n " << command; if (!error_output.empty()) { ss << "\nIt reported:\n " << error_output; } // Do not echo the command back into the suggestion: appending to an // arbitrary command can produce nonsense ("exit 3 || true" cannot // work, since exit terminates before || is reached). ss << "\nIf a nonzero status is expected -- \"grep\" finding no match, " "say -- end the command with \"|| true\" to say so."; // "environment", not "parsing": the failure is OUTSIDE Klammertext, // in the command the document invoked -- the same class as a missing // xelatex or an unset environment variable. throw Environment_error(ss.str(), loc, false); } if (!error_output.empty()) { (void)K::log(1, "shell command wrote to stderr:", command, "\n " + error_output); } return result; } bool is_haskell_file(const std::string& text) { std::string trimmed = trim(text); if (trimmed.size() < 4) return false; if (trimmed.find(' ') != std::string::npos) return false; if (trimmed.find('\n') != std::string::npos) return false; return trimmed.substr(trimmed.size() - 3) == ".hs"; } // Run a Haskell program with runghc and return its STANDARD OUTPUT, which // becomes document text. Same rule as shell() above, and it was broken the // same way: the command ran with "2>&1", so on a SUCCESSFUL run everything the // program (or GHC) wrote to stderr was merged into the result and became part // of the document. A program printing a progress note or a warning silently // contributed it to the output. // // A compile error was already reported rather than swallowed -- the exit status // was checked -- so what changes here is the successful case, the error's type, // and where a failure's detail comes from. std::string run_haskell(const std::string& hsfile, Locator loc) { std::string err_path = (fs::temp_directory_path() / "ktext_haskell_XXXXXX").string(); std::vector err_template(err_path.begin(), err_path.end()); err_template.push_back('\0'); int err_fd = mkstemp(err_template.data()); if (err_fd == -1) { throw Environment_error("Could not create a temporary file for runghc's " "error output", loc, false); } close(err_fd); err_path = err_template.data(); std::string command = "runghc " + q_(hsfile) + " 2>" + q_(err_path); FILE* pipe = popen(command.c_str(), "r"); if (!pipe) { fs::remove(err_path); throw Environment_error("Could not run runghc.", loc, false); } char buffer[128]; std::string result = ""; while (fgets(buffer, sizeof(buffer), pipe) != nullptr) { result += buffer; } int status = pclose(pipe); std::string error_output = trim_right(string_from_file(err_path)); fs::remove(err_path); int exit_status = WIFEXITED(status) ? WEXITSTATUS(status) : -1; if (exit_status != 0) { std::stringstream ss {}; ss << "The Haskell program failed"; if (WIFEXITED(status)) { ss << " (exit status " << exit_status << ")"; } else if (WIFSIGNALED(status)) { ss << " (killed by signal " << WTERMSIG(status) << ")"; } ss << "."; if (!error_output.empty()) { ss << "\nrunghc reported:\n" << error_output; } // A compile error arrives here, and so does a program that ran and then // exited nonzero; the message does not guess which, it shows what // runghc said. throw Environment_error(ss.str(), loc, false); } if (!error_output.empty()) { // GHC's warnings, and anything the program itself wrote to stderr. // Not document text -- reported at "-v 1", like a shell command's. (void)K::log(1, "runghc wrote to stderr:\n " + error_output); } return result; } std::string haskell(State state, std::string code, Locator loc) { if (system("command -v runghc > /dev/null 2>&1") != 0) { // "environment", not "parsing": a missing external command is the same // class as a missing xelatex, and "parsing error" misdescribes it. throw Environment_error( "@eval with the :haskell argument requires runghc, which was not found in PATH.\n" "Install it using GHCup; see https://www.haskell.org/ghcup/install/.", loc, false); } code = state.subst(code); if (is_haskell_file(code)) { return run_haskell(trim(code), loc); } const char* tmpdir = std::getenv("TMPDIR"); if (!tmpdir) tmpdir = "/tmp"; std::string tmpl = std::string(tmpdir) + "/klammertext_haskell_XXXXXX"; std::vector tmppath(tmpl.begin(), tmpl.end()); tmppath.push_back('\0'); int fd = mkstemp(tmppath.data()); if (fd < 0) { throw Environment_error( "Could not create temporary file for Haskell evaluation.", loc, false); } std::string hsfile = std::string(tmppath.data()) + ".hs"; close(fd); rename(tmppath.data(), hsfile.c_str()); FILE* f = fopen(hsfile.c_str(), "w"); if (!f) { unlink(hsfile.c_str()); throw Parsing_error( "Could not write temporary Haskell file.", loc, false); } fprintf(f, "%s\n", code.c_str()); fclose(f); std::string result = run_haskell(hsfile, loc); unlink(hsfile.c_str()); return result; } void check_cpp_arguments(katom_list args, Locator loc) { if (args.size() != 4 && args.size() != 5) { std::stringstream ss {}; ss << "Incorrect @eval format for a C++ function. Either:\n" << " @eval :cpp @\n" << "or\n" << " @eval :cpp @\n" << "In the first case, the library basename is used for the function name."; throw Argument_error(ss.str(), loc, false); } } // Save the process working directory, change to DIR, and restore on // destruction (exception-safe), so an @eval's :cwd cannot leak into the // rest of the run. NOTE: the cwd is process-global state; if input files // are ever processed in parallel, this needs rethinking. class Cwd_guard { public: explicit Cwd_guard(const std::string& dir) : m_saved(fs::current_path()) { fs::current_path(dir); } ~Cwd_guard() { std::error_code ec; fs::current_path(m_saved, ec); // never throw from a destructor } Cwd_guard(const Cwd_guard&) = delete; Cwd_guard& operator=(const Cwd_guard&) = delete; private: fs::path m_saved; }; std::string Eval::eval_command(katom_iter begin, katom_iter end) { (void)K::log(3, *begin, *(end - 1)); //msg() << "in Eval::eval:\n" << ktype << kall << kindex << std::pair(begin, end) << "\n"; katom_iter first = after_whitespace(begin + 1); std::string eval_result = "[unevaluated]"; // :cwd DIR — run the eval (any mode) with DIR as the working directory, // restoring the process cwd afterwards. The default is the directory // ktext was started in (unchanged behavior). DIR may hold state // substitutions (:cwd *K_input_dir* is the document's directory) and, // per the filenames-with-spaces convention, whitespace-separated tokens // are joined until they name an existing directory. std::optional cwd_guard {}; if (first->m_text == ":cwd") { katom_iter tok = after_whitespace(first + 1); std::string dir {}; katom_iter cursor = tok; katom_iter resume = tok; while (cursor < end - 1 && !cursor->m_text.starts_with(":")) { dir = m_machine.m_state.subst(as_string(tok, cursor + 1, true)); resume = after_whitespace(cursor + 1); if (fs::is_directory(dir)) break; cursor = resume; } if (dir.empty() || !fs::is_directory(dir)) { throw Argument_error( "The :cwd directory does not exist: \"" + dir + "\"", begin->m_loc, false); } cwd_guard.emplace(dir); first = resume; } std::string first_word = first->m_text; int offset = first_word[0] == ':' ? 1 : 0; std::string command = as_string(first + offset, end - 1, true); if (offset == 0 || first_word == ":python") { // Default is Python Eval_python E_python(m_machine, begin->m_loc); eval_result = E_python.eval(command); } else if (first_word == ":shell") { eval_result = shell(m_machine.m_state, command, begin->m_loc); } else if (first_word == ":haskell") { eval_result = haskell(m_machine.m_state, command, begin->m_loc); } else if (first_word == ":cpp") { //msg() << ":cpp: " << first_word << *(begin + 4) << "\n"; for (auto ki = begin; ki < end; ki++) { //msg() << " " << kall << kindex << *ki << "\n"; } katom_list args = text_katoms(begin, end); check_cpp_arguments(args, begin->m_loc); //msg() << "args: |" << args << "|\n"; std::string lib_text = args[2].m_text; std::string khome = m_machine.m_state.value("KLAMMERTEXT_HOME", false); if (!khome.empty()) { lib_text = string_replace(lib_text, "*KLAMMERTEXT_HOME*", khome); } fs::path libpath(lib_text + ".so"); // A relative library name not found from the cwd is searched in the // directories of the files the Machine has read (same rule as the // Python module path: the library lives next to the file using it). if (libpath.is_relative() && !fs::exists(fs::absolute(libpath))) { for (const auto& dir : m_machine.m_state.m_search_dirs) { fs::path candidate = fs::path(dir) / libpath; if (fs::exists(candidate)) { libpath = candidate; break; } } } libpath = fs::absolute(libpath); std::string funcname = args.size() == 4 ? libpath.stem().string() : args[3].m_text; Eval_cpp E_cpp(m_machine, begin->m_loc); eval_result = E_cpp.eval(libpath, funcname); } return eval_result; } katom_list Eval::eval(katom_iter begin, katom_iter end) { std::string eval_result = eval_command(begin, end); katom_list result {}; Machine M = m_machine; M.m_warn_unparsed = false; // Result text is machine-generated (see machine.h) size_t before = M.m_katoms.size(); M.read(eval_result); // If the @eval produced more Klammertext -- the read-back result still // holds klammers to reduce (a "generator", e.g. a Python klammer returning // a @table call with data) -- then its text is writer content: escape it // for the target before applying, exactly as typed input is escaped, so a // "%" in the data becomes "\%". If it produced final target markup (no // klammers left, a "renderer" such as @table itself emitting \begin{tabular}) // leave it untouched. ^'...'^ literal spans are skipped by the escape pass, // so a generator can still carry raw target markup. bool has_klammer = std::any_of( M.m_katoms.begin() + before, M.m_katoms.end(), begin_apply); M.apply(M.m_state.value("K_target"), false, has_klammer); result = trim(M.m_katoms); return result; }