A snapshot of the development tree. The substantial changes since the last one:
COMMAND OUTPUT POLICY. The three commands display text in exactly three cases,
and each owns a stream: LOGGING under "-v" greater than 0 and an ERROR before
termination go to STDERR; OUTPUT THE USER ASKED FOR goes to STDOUT. For ktext
that output is a document, so "ktext doc.kt -d | ..." is now safe -- logging
used to share the stream and land inside the document. A bare command prints
its usage and succeeds rather than failing. Colour is emitted only to a
terminal, per stream, and NO_COLOR is honoured.
"-v 1" reports every decision whose outcome you could not have read off your own
input: the klammerset that was loaded and from which file, a font's directory, a
":files" name's file, how "-o" was expanded. Higher levels are the trace.
The commands no longer warn and continue: an anomaly is an error, described with
its location. Two exceptions remain, each for a stated reason -- a condition
that is expected and temporary by design, and a judgment that is a heuristic
rather than exact.
@cond IS NOW A TRUE SPECIAL FORM, resolved at APPLICATION time rather than when
the file is read. Two consequences for a writer:
* a state variable reaches the predicate. "@@@state Flag :value true @@@
@cond *Flag* | T | F @" renders "T"; it used to see the literal "*Flag*" and
silently take the false branch. The document now behaves like a klammer
body, whose arguments are bound before its conditionals are decided.
* nothing in a discarded branch happens -- it is not read, not evaluated, not
expanded. An @eval in the branch not taken used to run anyway.
Its predicate relation is total and strict: true, True, 1; false, False, 0, and
empty; anything else is an error at the @cond rather than silently false.
@eval REACHING OUTSIDE. ":shell" and ":haskell" now keep the command's standard
error out of the document (it appears under "-v 1") and treat a nonzero exit as
an error naming what the command reported. A command that exits nonzero on
purpose -- "grep" finding no match -- says so with "|| true".
KLAMMER SETS. Several combine: "--klammersets a b c" loads all three in the
order given, sharing one namespace, with the definition modes deciding
collisions. "none" means none and may not be combined with other symbols. A
klammerset with symbol X is declared in a file X/X.k, which is what lets two
sets require the same third set without loading it twice.
TESTS. Four new suites: the kdiag command's interface, the @eval primitive's
contract with the outside world, and verbosity at both tiers. Three suites
that could not run on macOS at all now do.
Assembled from dev commit 6c8ee6c22fca.
356 lines
14 KiB
C++
356 lines
14 KiB
C++
#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 <algorithm>
|
|
#include <optional>
|
|
#include <unistd.h>
|
|
#include <sys/wait.h>
|
|
|
|
// 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<char> 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<char> 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<char> 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 <library-basename> @\n"
|
|
<< "or\n"
|
|
<< " @eval :cpp <library-basename> <function-name> @\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> 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;
|
|
}
|
|
|