An output policy for the three commands, and @cond as a true special form

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.
This commit is contained in:
2026-08-16 01:37:59 +02:00
parent 59c1599bc9
commit 240cff4278
84 changed files with 2523 additions and 494 deletions

View File

@@ -1,4 +1,5 @@
#include "util.h"
#include "error.h"
#include "eval.h"
#include "eval_python.h"
#include "eval_cpp.h"
@@ -9,13 +10,51 @@
#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);
FILE* pipe = popen(command.c_str(), "r");
// 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) {
throw Parsing_error(
fs::remove(err_path);
throw Environment_error(
"Could not run command:\n" + command, loc, false);
}
char buffer[128];
@@ -23,8 +62,37 @@ std::string shell(State state, std::string command, Locator loc)
while (fgets(buffer, sizeof(buffer), pipe) != nullptr) {
result += buffer;
}
pclose(pipe);
// std::cout << "Command output:\n" << result << "\n";
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;
}
@@ -37,13 +105,34 @@ bool is_haskell_file(const std::string& text)
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 command = "runghc " + hsfile + " 2>&1";
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) {
throw Parsing_error(
"Could not run runghc.", loc, false);
fs::remove(err_path);
throw Environment_error("Could not run runghc.", loc, false);
}
char buffer[128];
std::string result = "";
@@ -51,18 +140,41 @@ std::string run_haskell(const std::string& hsfile, Locator loc)
result += buffer;
}
int status = pclose(pipe);
std::string error_output = trim_right(string_from_file(err_path));
fs::remove(err_path);
if (status != 0) {
throw Parsing_error(
"Haskell evaluation failed:\n" + result, loc, false);
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("which runghc > /dev/null 2>&1") != 0) {
throw Parsing_error(
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);
@@ -80,7 +192,7 @@ std::string haskell(State state, std::string code, Locator loc)
tmppath.push_back('\0');
int fd = mkstemp(tmppath.data());
if (fd < 0) {
throw Parsing_error(
throw Environment_error(
"Could not create temporary file for Haskell evaluation.", loc, false);
}
std::string hsfile = std::string(tmppath.data()) + ".hs";