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

@@ -67,7 +67,15 @@ std::string pylist(strings_t words)
{
std::transform(words.begin(), words.end(), words.begin(),
[](const std::string& s) { return pyformat_string({s}); });
return "[" + join(words, ", ") + "]";
// std::string(...) on the leading literal, not "[" + ... : a literal on the
// LEFT of + selects operator+(const char*, string&&), which is insert(0,
// ...) -- it shifts the whole string to make room. The explicit temporary
// selects the append overload instead. Cheaper, and it silences a gcc-12
// -Wrestrict FALSE POSITIVE (it reports a 2**63-byte overlap at a negative
// offset, from value-range propagation failing to bound size() after
// inlining; g++-15 and clang-18 do not). Verified 2 -> 0 warnings on
// gcc 12.2.0 with the container's flags.
return std::string("[") + join(words, ", ") + "]";
}
std::string pyformat_list(const strings_t& value)

View File

@@ -111,15 +111,18 @@ void Argv::flag(const std::string& name, const std::string& desc)
update_width(arg);
}
void Argv::req(const std::string& name, const std::string& desc, const std::string& regex_pattern)
void Argv::req(const std::string& name, const std::string& desc,
const std::string& regex_pattern, bool required)
{
(void)K::log(2, name, desc, regex_pattern);
Arg arg {};
arg.m_type = "req";
// "posopt" is a positional that may be absent. It stays in m_req_names so
// it keeps its POSITION; only its absence is tolerated.
arg.m_type = required ? "req" : "posopt";
arg.m_name = name;
arg.m_desc = get_regex_desc(desc);
arg.make_regex(regex_pattern);
arg.m_syntax = "<" + name + ">";
arg.m_syntax = required ? "<" + name + ">" : "[<" + name + ">]";
m_args[name] = arg;
m_names.push_back(name);
m_req_names.push_back(name);
@@ -191,17 +194,26 @@ void Argv::usage_line(Arg arg)
<< wrap_around(arg.m_desc, m_syntax_size + 6) << "\n";
}
// A positional argument, whether or not it may be absent. Both kinds are
// listed under "Arguments:" and must therefore be skipped when the options are
// listed -- testing only for "req" printed an optional positional twice, once
// in each section (kdiag's "[<input>]").
static bool is_positional(const std::string& type)
{
return type == "req" || type == "posopt";
}
void Argv::usage(const std::string& command)
{
std::cout << "\nUsage: " << command << " ";
for (const std::string& name : m_req_names) {
std::cout << "<" + name + "> ";
std::cout << m_args[name].m_syntax << " ";
}
if (m_opt_names.size() + m_flag_names.size() > 5) {
std::cout << "[<optional-arguments>]\n";
} else {
for (const std::string& name : m_names) {
if (m_args[name].m_type == "req") {
if (is_positional(m_args[name].m_type)) {
continue;
}
std::cout << "[" + m_args[name].m_syntax + "] ";
@@ -222,7 +234,7 @@ void Argv::usage(const std::string& command)
}
for (const auto& name : m_names) {
if (m_args[name].m_type == "req") {
if (is_positional(m_args[name].m_type)) {
continue;
}
usage_line(m_args[name]);
@@ -350,6 +362,12 @@ void Argv::parse_positional(const std::string& command, //strings_t words,
auto arg = m_args[req];
auto [substring, rest, found] = regex_split_prefix(arg.m_rgx, pos_args);
if (!found) {
// An optional positional simply stays empty; only a required one
// is an error.
if (arg.m_type == "posopt") {
named_args[req] = "";
continue;
}
std::stringstream ss {};
ss << "The argument " << q_(req) << " was not found in:\n " << command;
throw Argument_error(ss.str(), Locator(), false);
@@ -409,7 +427,8 @@ void Argv::check_required(
if (required > given) {
std::string missing = m_req_names[required - given - 1];
if (given == 0 && m_args[missing].m_rgx_symbol == "'list'") {
if (given == 0 && (m_args[missing].m_rgx_symbol == "'list'"
|| m_args[missing].m_type == "posopt")) {
return;
}
std::stringstream ss {};
@@ -583,7 +602,8 @@ int Argv::as_verbosity(const std::string& name)
std::string Argv::as_string(const std::string& name)
{
(void)K::log(2, name);
return get(name);
auto result = get(name);
return result;
}
strings_t Argv::as_vector(const std::string& name)
@@ -622,7 +642,7 @@ std::pair<std::string, strings_t> Argv::as_input(const std::string& name, bool a
return { input_text, input_filenames };
}
void Argv::describe()
void Argv::describe(std::ostream& os)
{
std::size_t width = std::accumulate(
m_names.begin(), m_names.end(), 0,
@@ -642,6 +662,6 @@ void Argv::describe()
// whose whole job is to display what Argv parsed, was silent. The
// callers already decide whether to call it (the commands gate it on
// verbose_level > 0), so it prints unconditionally here.
std::cout << ss.str() << "\n";
os << ss.str() << "\n";
}
}

View File

@@ -6,6 +6,7 @@
#include <set>
#include <ranges>
#include <algorithm>
#include <iostream>
#include <regex>
inline std::map<std::string, std::string> regex_symbols {
@@ -51,7 +52,12 @@ class Argv
public:
static std::string delimiter;
void flag(const std::string& name, const std::string& desc);
void req(const std::string& name, const std::string& desc, const std::string& regex_pattern="'text'");
// A positional argument. `required` false makes it OPTIONAL: the command
// may be run without it, and its value is then empty. kdiag uses that for
// its input text, so that "kdiag --machine" can show the Machine's initial
// state -- a question about the machine, not about any document.
void req(const std::string& name, const std::string& desc,
const std::string& regex_pattern="'text'", bool required = true);
void opt(const std::string& name, const std::string& desc="", const std::string& parameter="", const std::string& default_value="", const std::string& regex_pattern="text");
// A variadic option: --name collects every following word up to the
// next -/-- token (zero or more). get(name) returns the words
@@ -99,7 +105,11 @@ public:
void usage_line(Arg arg);
void usage(const std::string& command_name);
void describe();
// The default is stderr: the three commands call this under "-v 1", and
// logging never shares stdout with the command's result (a ktext document
// may be piped). argv_test passes std::cout, because there the parsed
// arguments ARE the result.
void describe(std::ostream& os = std::cerr);
// void describe(Argv original);
bool is_flag(const std::string& name) const {

View File

@@ -235,7 +235,8 @@ void Checker::check_list(
} // namespace
std::vector<Diagnostic> check_machine(Machine& machine, const std::string& target)
std::vector<Diagnostic> check_machine(Machine& machine, const katom_list& document,
const std::string& target)
{
std::vector<Diagnostic> diagnostics {};
Checker checker(machine, diagnostics);
@@ -253,7 +254,7 @@ std::vector<Diagnostic> check_machine(Machine& machine, const std::string& targe
}
for (const auto& t : targets) {
checker.check_list(machine.m_katoms, t, "document");
checker.check_list(document, t, "document");
for (const auto& [name, klammer] : machine.m_klammers.m_klammers) {
auto body = klammer.m_body.find(t);
if (body == klammer.m_body.end()) continue;

View File

@@ -5,6 +5,7 @@
#include <vector>
#include "locator.h"
#include "util.h" // katom_list
class Machine;
@@ -58,7 +59,16 @@ struct Diagnostic
// (Target_registry::general_name) checks every defined target. Diagnostics
// accumulate: checking never stops at the first failure, because the point is
// to see all of them at once.
std::vector<Diagnostic> check_machine(Machine& machine, const std::string& target);
// `document` is the katom list to check. It is a parameter rather than being
// taken from machine.m_katoms because the caller is the one that has it, and
// the two are not always the same list: Machine::read() fills m_katoms, but
// Machine::process() -- which is how kdiag builds its katoms -- does not.
// Reading m_katoms therefore checked an EMPTY document under kdiag and
// reported "0 diagnostics, 0 errors" for anything, which is worse than not
// checking: it reports success for input it never examined. Passing the list
// makes that mistake impossible to write.
std::vector<Diagnostic> check_machine(Machine& machine, const katom_list& document,
const std::string& target);
// Print diagnostics, grouped in the order found, and return the number of
// errors (warnings do not count). Used by "ktext --check".

View File

@@ -3,6 +3,7 @@
#include "argv.h"
#include "file.h"
#include "log.h"
#include "machine.h"
using namespace std::string_literals;
@@ -109,3 +110,48 @@ parse_args(
}
return {output_target, output_dir, output_basename, output_filename, write_files, display_only};
}
void load_klammersets(Machine& machine, const strings_t& symbols)
{
// "none" is the absence of a klammerset, not the name of one, so it says
// nothing about what should be loaded ALONGSIDE it: written first it would
// silently discard the rest of the list, written last it would be looked up
// as a symbol and reported missing. Both readings are wrong, so neither is
// guessed at -- "none" is accepted only alone.
if (is_in("none"s, symbols) && symbols.size() > 1) {
throw Klammerset_error(
"The klammerset \"none\" cannot be combined with other klammersets: "
"it means that none is loaded. Give \"none\" alone, or name only the "
"klammersets to load.",
Locator());
}
if (symbols.empty()) {
// Which klammerset was loaded, and from where, is a DERIVED value: the
// user wrote no --klammersets at all and got the SKS from
// $KLAMMERTEXT_HOME. Only the symbol branch below reported its
// resolution, so the commonest case -- the default -- said nothing.
std::string sks = machine.m_state.value("KLAMMERTEXT_HOME") + "/sks/sks.k";
(void)K::log(1, "Klammerset (default): " + sks);
machine.read(fs::path(sks));
} else if (symbols[0] == "none") {
(void)K::log(1, "Klammersets: none");
} else {
// The whitespace-delimited words in symbols must be klammerset symbols:
for (const auto& symbol : symbols) {
// Same guard as ":requires" (Machine::load_klammerset_files): a set
// named twice on the command line is loaded once.
if (machine.m_klammersets.has(symbol)) {
(void)K::log(1, "Klammerset \"" + symbol + "\": already loaded, skipped");
continue;
}
std::string klammerset_filename = resolve_klammerset_symbol(
symbol, machine.m_state.value("K_input_dir"), Locator()).string();
// Symbol -> file is the search path's answer, and the search
// path has three stages with shadowing: the file it landed on is
// exactly what a user cannot read off "--klammersets x".
(void)K::log(1, "Klammerset \"" + symbol + "\": " + klammerset_filename);
machine.read(fs::path(klammerset_filename));
}
}
}

View File

@@ -5,6 +5,9 @@
#include <vector>
#include "file.h"
#include "util.h"
class Machine;
void set_verbose_level(int argc, char* argv[]);
bool show_usage(int argc, char* argv[]);
@@ -15,3 +18,5 @@ std::tuple<std::string, std::string, std::string, std::string, bool, bool>
parse_args(
const std::vector<std::string>& input_filenames, std::string target,
std::string output_basename, bool display_only);
void load_klammersets(Machine& machine, const strings_t& symbols);

View File

@@ -131,7 +131,8 @@ std::string short_path(const std::string& path)
} // namespace
std::vector<Klammer_coverage> klammer_coverage(const Machine& machine)
std::vector<Klammer_coverage> klammer_coverage(const Machine& machine,
const strings_t& defined_outside)
{
target_set all_targets {};
for (const auto& t : machine.m_targets.user_defined()) {
@@ -188,9 +189,15 @@ std::vector<Klammer_coverage> klammer_coverage(const Machine& machine)
}
}
// Pass 3: classify and collect.
// Pass 3: classify and collect. The filter applies HERE and not earlier:
// passes 1 and 2 must see every klammer, because a derived coverage is the
// intersection of what the called klammers cover and those are mostly the
// klammerset's.
std::vector<Klammer_coverage> result {};
for (const auto& [name, klammer] : machine.m_klammers.m_klammers) {
if (!defined_outside.empty() && !klammer.defined_outside(defined_outside)) {
continue;
}
const Written& w = written.at(name);
Klammer_coverage kc {};
kc.m_name = name;
@@ -274,8 +281,9 @@ void report_coverage(const Machine& machine,
strings_t targets = machine.m_targets.user_defined();
os << "Klammer coverage\n"
<< "================\n\n"
<< coverage.size() << " klammers, " << targets.size()
<< " targets: " << join(targets, " ") << "\n";
<< coverage.size() << " " << plural("klammer", static_cast<int>(coverage.size()))
<< ", " << targets.size() << " " << plural("target", static_cast<int>(targets.size()))
<< ": " << join(targets, " ") << "\n";
auto of_kind = [&coverage](coverage_t kind) {
std::vector<const Klammer_coverage*> result {};

View File

@@ -4,6 +4,8 @@
#include <string>
#include <vector>
#include "util.h" // strings_t
class Machine;
// Target coverage: which targets a klammer can actually render to.
@@ -91,7 +93,13 @@ struct Klammer_coverage
// Compute the coverage of every klammer the machine has loaded. Analysis
// only: nothing in the Machine is modified.
std::vector<Klammer_coverage> klammer_coverage(const Machine& machine);
// `defined_outside` restricts the RESULT to klammers with a definition from a
// file not in the list. The ANALYSIS still sees every klammer: a general
// body's coverage is the intersection of what the klammers it calls cover, and
// most of those come from the klammerset. So the klammersets are loaded, used
// to derive, and left out of the report -- which is what "kdesc -i" wants.
std::vector<Klammer_coverage> klammer_coverage(const Machine& machine,
const strings_t& defined_outside = {});
// The report behind "kdesc --coverage". `full` ("--coverage all") adds the
// source-file column and shows every "Needs attention" category, including

View File

@@ -15,9 +15,14 @@ bool display_source(const std::string& filename_arg)
void Error::print_message(const std::string& epilog)
{
if (epilog != "")
//desc += " " + epilog + "\n";
m_desc += epilog + "\n";
// The separator is not cosmetic: the description ends with a sentence, and
// appending the advice straight onto it ran the two together into one word
// ("...for an unspecified targetkdiag loads no klammerset..."). m_desc is
// then justified to 80 columns, so the break has to be a blank line rather
// than a single newline, which justify() would fold back into the flow.
if (epilog != "") {
m_desc += "\n\n" + epilog + "\n";
}
if (m_just)
m_desc = justify(m_desc, 80, 0);
std::cerr << "\n" << red << command_name << " (" << m_type << " error)";
@@ -31,5 +36,4 @@ void Error::print_message(const std::string& epilog)
std::cerr << ", character " << m_loc.m_chr + 1;
}
std::cerr << ":\n\n" << m_desc << reset << "\n";
std::cout << reset;
}

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";

View File

@@ -209,12 +209,13 @@ std::string Eval_python::eval(std::string code)
{
(void)K::log(3, code);
code = m_machine.m_state.subst(code, true);
/*
msg() << "\n"
<< std::string(80, '-') << "\n"
<< code << "\n"
<< std::string(80, '-') << "\n";
*/
// Kept as a record of a value worth watching; line comments rather than a
// block, so the msg() guard can see it is inert (doc/check_output_policy.sh
// is line-based and cannot tell it is inside a /* */).
// msg() << "\n"
// << std::string(80, '-') << "\n"
// << code << "\n"
// << std::string(80, '-') << "\n";
if (std::regex_search(code, statement_delimiter)) {
return eval_statements(code);
} else {
@@ -229,7 +230,7 @@ std::string Eval_python::eval_katom_list(
katom_iter code_begin = begin + 1;
katom_iter code_end = end - 1;
std::string code_result = eval(as_string(code_begin, code_end, true));
msg() << "code_result: " << code_result << "\n";
(void)K::log(3, "code_result:", code_result);
katom_list code_katoms = m_machine.process(code_result, command_name);
for (auto kiter = code_begin; kiter < code_end; kiter++) {
kiter->m_type = katom_t::replaced;

View File

@@ -160,6 +160,7 @@ strings_t group_filename_tokens(const strings_t& tokens, const std::string& base
// name exists. A name that never resolves is kept as given, so the
// missing-file error downstream reports what the user wrote.
size_t i = 0;
bool reported = false; // at most one "tried" report per list
while (i < tokens.size()) {
if (filename_exists(tokens[i], base_dir)) {
result.push_back(tokens[i]);
@@ -173,8 +174,13 @@ strings_t group_filename_tokens(const strings_t& tokens, const std::string& base
acc += " " + tokens[j];
++j;
if (filename_exists(acc, base_dir)) {
std::cerr << command_name << ": interpreting \""
<< acc << "\" as one filename\n";
// A successful regrouping is a DERIVED value -- the
// command completed a name the input left ambiguous -- so
// it is reported at "-v 1" and not at every run. Silence
// is safe here because a rescue only succeeds when the
// joined name names an existing file: a mistyped name
// fails rather than resolving to something else.
K::log(1, "interpreting", q_(acc), "as one filename");
result.push_back(acc);
i = j;
found = true;
@@ -182,6 +188,27 @@ strings_t group_filename_tokens(const strings_t& tokens, const std::string& base
}
}
if (!found) {
// Nothing resolved. The groupings that were TRIED are what
// the user needs at verbosity 0, because the downstream error
// names only the first token and cannot say why the others
// were not joined to it. Recorded on the name so the caller
// can report them with the missing-file error.
strings_t tried {};
std::string acc2 = tokens[i];
tried.push_back(q_(acc2));
for (size_t k = i + 1; k < tokens.size(); ++k) {
acc2 += " " + tokens[k];
tried.push_back(q_(acc2));
}
// Once: the first unresolved token's list already shows
// every joining from that point on, and one report per
// leftover word buries it.
if (tried.size() > 1 && !reported) {
std::cerr << command_name << ": no file matches "
<< q_(tokens[i]) << "; tried "
<< join(tried, ", ") << "\n";
reported = true;
}
result.push_back(tokens[i]);
++i;
}

View File

@@ -221,6 +221,11 @@ Resolved_font resolve_font(const std::string& family_name)
Resolved_font font = resolve_in_directory(family_name, dir_name, base);
if (!font.family_name.empty()) {
extract_font_metrics(font);
// A DERIVED value: an installed font may shadow a default one, and
// which directory won is not readable from ":serif_font Whatever".
// The commonest font question -- "why does it not look like the
// one I installed?" -- is answered by this line.
(void)K::log(1, "Font \"" + family_name + "\": " + base + "/" + dir_name);
return font;
}
}

View File

@@ -72,7 +72,7 @@ katom_list make_katoms_from_word(std::string s, const std::string& source_desc,
for (const auto& [desc, rgx, expanded] : katom_rewrite_rules) {
std::string modified = expand_compound_katom(s, rgx.m_regex, expanded);
if (dbg) {
msg() << "Rewrite: " << desc << " " << rgx.m_pattern << " " << expanded << "\n";
msg() << "Rewrite: " << desc << " " << rgx.m_pattern << " " << expanded << "\n"; // output-policy-ok (dbg)
}
if (modified != s) {
auto parts = word_split(modified);
@@ -118,11 +118,22 @@ void warn_unparsed_katoms(katom_list& katoms, bool warn)
if (k.m_type != katom_t::ignored
&& k.m_type != katom_t::replaced
&& k.m_type != katom_t::literal) {
std::cerr << command_name
<< " [warning]: Word not parsed in "
<< k.m_loc.m_filename << ", line " << k.m_loc.m_line << ":\n"
<< " " << k.m_text << "\n"
<< "To include a special character (@, |, #, and ^), put \"^\" before it.\n";
// One of the two surviving WARNINGS (the commands otherwise treat
// an anomaly as an error and stop -- see the output policy in
// CLAUDE.md). It stays a warning because the judgment is a
// HEURISTIC, not exact: the @eval read-back legitimately fails to
// parse raw target markup, which is why m_warn_unparsed exists. A
// heuristic that halts turns every false positive into a blocker on
// a document that would otherwise render. Promote it to an error
// when the judgment becomes exact.
//
// Through warning(), not a raw std::cerr, so every warning the
// commands issue has one format: it said "[warning]" where
// everything else says "(warning)".
warning("Word not parsed:\n " + k.m_text
+ "\nTo include a special character (@, |, #, and ^), put "
"\"^\" before it.",
k.m_loc);
k.m_unparsed = false;
}
}
@@ -131,7 +142,7 @@ void warn_unparsed_katoms(katom_list& katoms, bool warn)
katom_list split_into_katoms(std::string s, const std::string& source, int source_line)
{
if (dbg) {
msg() << "Make katoms: " << s << "<\n";
msg() << "Make katoms: " << s << "<\n"; // output-policy-ok (dbg)
}
// const std::string middle_dot { "\u00B7" };
s = string_replace(s, "\r", "");

View File

@@ -25,6 +25,7 @@ public:
, m_initial_type(other.m_initial_type)
, m_display(other.m_display)
, m_unparsed(other.m_unparsed)
, m_deferred(other.m_deferred)
{}
// Copy assignment operator
@@ -38,6 +39,7 @@ public:
m_initial_type = other.m_initial_type;
m_display = other.m_display;
m_unparsed = other.m_unparsed;
m_deferred = other.m_deferred;
}
return *this;
}
@@ -74,6 +76,13 @@ public:
// The warning is deferred to warn_unparsed_katoms(), after removal and
// literal marking, so removed text (comments, #[...]# blocks) never warns.
bool m_unparsed {};
// Inside an unresolved @cond span, and therefore INERT: the passes with
// observable effects -- @eval and @read -- skip it, so a branch that is
// never selected never runs anything. @cond is a non-strict special form
// (doc/cond_evaluation_order.md), and this flag is the mechanism; the flag
// is not the semantics. Cleared on the selected branch when the @cond is
// resolved in the apply fold, which then processes that branch normally.
bool m_deferred {};
};
bool active(const std::vector<Katom>& katoms);

View File

@@ -430,13 +430,16 @@ std::string parameter_signature(const Parameter_set& parameters)
};
bool first = true;
for (const auto& pos : parameters.m_positional) {
add(first ? typed(pos) : "| " + typed(pos));
// std::string(...) on the leading literal: see the note in
// argtype.cpp::pylist -- append rather than insert-at-front, and it
// avoids a gcc-12 -Wrestrict false positive.
add(first ? typed(pos) : std::string("| ") + typed(pos));
first = false;
}
for (const auto& rest : parameters.m_rest)
add(typed(rest));
for (const auto& opt : parameters.m_optional)
add(":" + typed(opt)
add(std::string(":") + typed(opt)
+ (opt.m_default.empty() ? "" : " " + opt.m_default));
return result.empty() ? "(no parameters)" : result;
}
@@ -555,6 +558,16 @@ void show_args(const std::string& label_text, const std::vector<Argument>& argum
}
}
bool Klammer::defined_outside(const strings_t& files) const
{
for (const auto& def : m_defs) {
if (!is_in(def.loc.m_filename, files)) {
return true;
}
}
return false;
}
strings_t Klammer::get_target_names() const
{
strings_t names {};

View File

@@ -76,6 +76,12 @@ public:
if (p.m_argtype.m_name == "literal") return true;
return false;
}
// True when at least one definition of this klammer came from a file
// OUTSIDE the given list -- that is, the input defined it rather than a
// klammerset supplying it. "At least one" is deliberate: a klammer the
// input REDEFINES (":::") came originally from the klammerset, and a
// reader of that input needs to know it was changed.
bool defined_outside(const strings_t& files) const;
strings_t get_target_names() const;
strings_t get_locations();

View File

@@ -71,7 +71,14 @@ void Klammer_registry::add(
std::string msg = result.message;
msg = string_replace(msg, "NAME", q_(name_target));
msg = string_replace(msg, "AT", at_desc);
warning(msg, begin->m_loc);
// An override is not a warning: ":::" exists to override, and
// a klammer set a user did not write may be overridden by
// design (TODO #33). A warning nobody can act on is not a
// warning. It IS worth reporting at "-v 1", because the
// definition being replaced usually lives in another file, in
// another klammerset, which the user cannot see from what they
// wrote (Andy, 2026-08-15).
(void)K::log(1, msg + " at " + begin->m_loc.desc());
}
m_klammers[klammer_name].remove_target_definition(target_name);
}
@@ -172,7 +179,8 @@ std::string Klammer_registry::instance_list(int margin) const
return ss.str();
}
std::string Klammer_registry::describe(int margin, const std::string& search) const
std::string Klammer_registry::describe(int margin, const std::string& search,
const strings_t& defined_outside) const
{
/*
strings_t names {};
@@ -182,6 +190,9 @@ std::string Klammer_registry::describe(int margin, const std::string& search) co
std::string result;
std::string query = collapse_whitespace(search);
for (const auto& [name, k] : m_klammers) {
if (!defined_outside.empty() && !k.defined_outside(defined_outside)) {
continue;
}
if (!query.empty() &&
!contains_fold(name, query) &&
!contains_fold(collapse_whitespace(k.description_text()), query)) {

View File

@@ -19,7 +19,11 @@ public:
// sides (a description written across several lines in a ".k" file must
// still match a phrase typed on one). Empty when nothing matched, which
// is how the caller knows to say so.
std::string describe(int margin=0, const std::string& search="") const;
// `defined_outside` restricts the listing to klammers with at least one
// definition from a file not in the list -- the input's own klammers,
// with the loaded klammersets' left out.
std::string describe(int margin=0, const std::string& search="",
const strings_t& defined_outside={}) const;
std::map<std::string, Klammer> m_klammers {};
};

View File

@@ -3,6 +3,8 @@
#include <string>
#include <vector>
#include "util.h" // strings_t
#include "locator.h"
// A Klammerset is the formal construct declared by the @@@klammerset system
@@ -25,4 +27,11 @@ public:
std::vector<std::string> m_requires {}; // klammerset declaration files, loaded first
std::vector<std::string> m_files {}; // definition files, loaded in list order
Locator m_loc {}; // the declaring file; relative names resolve against it
// Every file this set actually read, as a canonical path: the declaring
// file itself (a set may define klammers before or after its declaration)
// followed by the resolved :requires and :files. m_files holds the names
// as WRITTEN, which cannot be compared against a definition's location;
// this holds what was opened. It is what lets a display tell a klammer
// that came from a klammerset apart from one the input defined.
strings_t m_loaded_files {};
};

View File

@@ -37,6 +37,7 @@ std::optional<Klammerset> Klammerset_registry::add(
std::string symbol = values["symbol"];
check_symbol(symbol, begin->m_loc);
check_declaring_file(symbol, begin->m_loc);
if (has(symbol)) {
(void)K::log(2, "Klammerset \"" + symbol + "\" is already loaded; declaration skipped");
return std::nullopt;
@@ -76,6 +77,45 @@ void Klammerset_registry::check_symbol(const std::string& symbol, const Locator&
}
}
// A klammerset that can be NAMED must live where its name says: symbol X is
// declared in X/X.k (Andy, 2026-08-15). The convention already governed
// symbol RESOLUTION; making it a requirement of the declaration too is what
// turns the symbol into a function of the path -- and that is what lets the
// already-loaded guard run BEFORE a file is read rather than after, which is
// the whole of the diamond-":requires" fix. Without it the symbol is known
// only once the file has been parsed, and by then a second read has already
// re-executed the declaring file's own definitions.
//
// It also continues the reasoning behind symbols-only on the command line
// (2026-08-14): if every klammerset is reachable by symbol the set of all of
// them is ENUMERABLE; if every declaration is in X/X.k each one is also
// IDENTIFIABLE from where it sits.
//
// EXEMPT: a declaration that is not in a ".k" file at all. A document may
// declare a klammerset -- that is how a designer writes one, and kdesc's
// provenance filter depends on it -- and such a set is local to the document:
// nothing can ":requires" it, so it has no identity to protect.
void Klammerset_registry::check_declaring_file(
const std::string& symbol, const Locator& loc) const
{
fs::path declaring(loc.m_filename);
if (declaring.extension() != ".k") {
return;
}
if (declaring.stem() == symbol && declaring.parent_path().filename() == symbol) {
return;
}
throw Klammerset_error(
"The klammerset \"" + symbol + "\" must be declared in a file named \""
+ symbol + "/" + symbol + ".k\", but this declaration is in \""
+ declaring.filename().string() + "\" (in a directory named \""
+ declaring.parent_path().filename().string() + "\"). A klammerset that "
"can be named is identified by where it is, so that a symbol names one "
"file and one file declares one symbol. A klammerset local to a document "
"may be declared in the document itself.",
loc);
}
bool Klammerset_registry::has(const std::string& symbol) const
{
return m_klammersets.count(symbol) > 0;
@@ -119,13 +159,28 @@ std::vector<std::string> klammerset_search_dirs(const std::string& local_dir)
if (!local_dir.empty()) {
push_unique(local_dir);
}
std::string paths {};
const char* env = std::getenv("KLAMMERTEXT_KLAMMERSETS");
if (env && *env) {
paths = env;
} else if (const char* home = std::getenv("HOME"); home && *home) {
paths = std::string(home) + "/.klammertext/klammersets";
}
// Read ONCE per process, not once per resolution. A search path decides
// WHICH FILE a symbol means -- an identity question, not a value one -- so
// it must not move while a document is being processed: if it did, "which
// klammerset is X" would depend on evaluation order and "kdesc
// --klammersets" could not be a complete answer, which is the reason the
// command line takes symbols only.
//
// It could move. The embedded Python shares the process, so an @eval
// doing os.environ[...] = ... changed what a later getenv here returned;
// a document could extend its own search path between one declaration and
// the next. Nothing intended that -- it fell out of a fresh getenv and an
// in-process interpreter.
static const std::string paths = [] {
const char* env = std::getenv("KLAMMERTEXT_KLAMMERSETS");
if (env && *env) {
return std::string(env);
}
if (const char* home = std::getenv("HOME"); home && *home) {
return std::string(home) + "/.klammertext/klammersets";
}
return std::string();
}();
std::stringstream ss(paths);
std::string dir;
while (std::getline(ss, dir, ':')) {
@@ -153,7 +208,7 @@ fs::path resolve_klammerset_symbol(
"The klammerset \"" + symbol + "\" was not found. A symbol x names the "
"declaration file x/x.k in one of the search directories: "
+ join(dirs, ", ")
+ ". Enter \"kdesc --klammerset\" to list the available klammersets.",
+ ". Enter \"kdesc --klammersets\" to list the available klammersets.",
loc);
}
@@ -223,3 +278,21 @@ std::string Klammerset_registry::describe(int margin, bool long_format) const
}
return ss.str();
}
void Klammerset_registry::set_loaded_files(const std::string& symbol, const strings_t& files)
{
auto it = m_klammersets.find(symbol);
if (it != m_klammersets.end()) {
it->second.m_loaded_files = files;
}
}
strings_t Klammerset_registry::loaded_files() const
{
strings_t result {};
for (const auto& [symbol, klammerset] : m_klammersets) {
result.insert(result.end(),
klammerset.m_loaded_files.begin(), klammerset.m_loaded_files.end());
}
return result;
}

View File

@@ -19,12 +19,25 @@ public:
// (typically reached through :requires) is skipped, not an error.
std::optional<Klammerset> add(std::vector<Katom>::iterator begin, std::vector<Katom>::iterator end, std::vector<Katom>& katoms);
void check_symbol(const std::string& symbol, const Locator& loc) const;
// Symbol X must be declared in X/X.k, so that the symbol is a function of
// the path and the already-loaded guard can run before a file is read.
void check_declaring_file(const std::string& symbol, const Locator& loc) const;
bool has(const std::string& symbol) const;
Klammerset get(const std::string& symbol, const Locator& loc) const;
std::string describe(int margin=2, bool long_format=false) const;
// Record what a set actually opened (see Klammerset::m_loaded_files).
void set_loaded_files(const std::string& symbol, const strings_t& files);
// Every file every registered klammerset read. A klammer whose
// definitions all come from these was supplied by a klammerset; one with
// a definition elsewhere was defined by the input -- which is the
// distinction "kdesc -i" needs, since it loads a klammerset in order to
// analyse a file without wanting to LIST the klammerset's own klammers.
strings_t loaded_files() const;
std::map<std::string, Klammerset> m_klammersets {};
std::vector<std::string> m_symbols {};
Parameter_set m_parameters {};
@@ -59,5 +72,5 @@ fs::path resolve_klammerset_symbol(
// The symbols available on the search path, with provenance; a symbol
// found again in a later directory is marked as shadowed. For
// kdesc --klammerset.
// kdesc --klammersets.
std::string describe_klammerset_search(const std::string& local_dir, int margin=2);

View File

@@ -52,20 +52,20 @@ std::ostream& operator<<(std::ostream& os, const log_arg& arg)
return os;
}
void log_indent(const std::string& filename, const std::string& color)
void log_indent(const std::string& filename, const Color& color)
{
std::cout
std::cerr
<< color
<< std::setfill(' ')
<< std::setw(22-static_cast<int>(std::size(filename)))
<< std::right;
}
void log_filepos(const std::string& filename, int line, const std::string& color)
void log_filepos(const std::string& filename, int line, const Color& color)
{
// if (show_verbose_location) {
log_indent(filename, color);
std::cout
std::cerr
<< "[" << filename << ":"
<< std::setw(3) << std::setfill('0')
<< line << "] " << reset;
@@ -91,7 +91,7 @@ std::string prettify_name(const std::string& s)
void display_location(int log_level, std::source_location location)
{
if (verbose_level >= log_level) {
std::string color = blue; // cyan;
Color color = blue; // cyan;
if (log_level == 1) {
color = magenta;
}
@@ -100,9 +100,9 @@ void display_location(int log_level, std::source_location location)
location.line(), color);
}
if (verbose_level > 3) {
std::cout << location.function_name();
std::cerr << location.function_name();
} else if (verbose_level > 1) {
std::cout << prettify_name(location.function_name());
std::cerr << prettify_name(location.function_name());
}
}
}

View File

@@ -26,14 +26,18 @@ struct log
log(int log_level, Ts&&... ts, const std::source_location& location = std::source_location::current()) {
if (verbose_level >= log_level) {
display_location(log_level, location);
// stderr, always. Logging is category 1 (see the output policy in
// CLAUDE.md); stdout belongs to the command's RESULT, and for
// ktext that result is a document a user may pipe. Writing a log
// line to stdout put it inside the document.
if (verbose_level > 1 && sizeof...(ts) > 0) {
std::cout << ": ";
std::cerr << ": ";
} else if (verbose_level == 1) {
std::cout << command_name << ": ";
std::cerr << command_name << ": ";
}
if (log_level > 0) {
((std::cout << std::forward<Ts>(ts) << " "), ...);
std::cout << '\n';
((std::cerr << std::forward<Ts>(ts) << " "), ...);
std::cerr << '\n';
}
}
}

View File

@@ -81,6 +81,9 @@ void Machine::process_eval_katoms(katom_list& katoms)
if (std::find_if(katoms.begin(), katoms.end(), begin_eval) != katoms.end()) {
for (const auto& [op, cl] : find_spans(katoms, level_increase, level_decrease, true, "eval")) {
auto [begin, end] = find_span_katoms(katoms, op, cl);
// Inert: inside a @cond branch that has not been selected. An
// @eval in a discarded branch must not run.
if (begin->m_deferred) continue;
if (begin_eval(*begin)) {
Eval E(*this, begin->m_loc);
katom_list eval_katoms = E.eval(begin, end);
@@ -138,18 +141,22 @@ bool is_true(const std::string& s)
return s == "True" || s == "true" || s == "1";
}
// @cond's predicate relation is currently partial in effect: is_true()
// recognizes three strings as true and treats EVERYTHING else as false, so a
// misspelled state variable, a "TRUE", a "yes", or a Python traceback all
// silently select the false branch.
// @cond's predicate relation is TOTAL AND STRICT (Andy, 2026-08-15, deciding
// notes/Klammertext_improvements.md §4.1): there is a defined true set, a
// defined false set, and anything else is an error at the @cond.
//
// What the truth values should be is an open language-policy question (see
// notes/Klammertext_improvements.md, "The @cond predicate relation"), so the
// semantics here is deliberately unchanged. What is added is visibility: a
// predicate outside the provisionally recognized sets below is reported, with
// its value and location, so the cases can be found in real documents while
// the policy is decided. The recognized false set carries no semantics -- it
// exists only to keep the diagnostic quiet for values that plainly mean false.
// It was partial in effect until then -- is_true() recognized three strings and
// treated EVERYTHING else as false, so a misspelled state variable, a "TRUE", a
// "yes", or a Python traceback all silently selected the false branch. The
// 2026-08-01 work made that visible with a warning while the policy was
// undecided; the warning found nothing in the SKS, which is the evidence that
// the corpus uses well-formed predicates and that the blast radius is small.
//
// Empty stays in the FALSE set, and deliberately: an optional argument that
// was not written substitutes as empty, so "absent means false" is what
// carries the "@cond *opt* | ... @" idiom. The entangled sub-question in
// §4.1 -- whether empty means false or means "not supplied" -- is answered
// "false" by that use, not left open.
bool is_recognized_predicate(const std::string& s)
{
return s.empty()
@@ -157,25 +164,71 @@ bool is_recognized_predicate(const std::string& s)
|| s == "False" || s == "false" || s == "0";
}
void warn_unrecognized_predicate(const std::string& predicate, const Locator& loc)
void check_predicate(const std::string& predicate, const Locator& loc)
{
if (is_recognized_predicate(predicate)) return;
std::stringstream ss {};
ss << "The @cond predicate " << q_(predicate)
<< " is not a recognized truth value, so the false branch was taken.\n"
<< " Recognized: true, True, 1 (true); false, False, 0, empty (false).";
warning(ss.str(), loc);
ss << "The @cond predicate " << q_(predicate) << " is not a truth value. "
<< "Recognized: true, True, 1 (true); false, False, 0, and empty (false). "
<< "A value outside these is an error rather than false, so a misspelled "
<< "variable or a failed @eval cannot silently select a branch.";
throw Argument_error(ss.str(), loc);
}
void Machine::process_cond_katoms(katom_list& katoms)
// Mark the interior of every @cond span INERT. Runs during process_katoms,
// before the passes with observable effects, so that @eval and @read inside a
// branch do nothing until a branch is selected -- which is the non-strictness
// doc/cond_evaluation_order.md already specifies ("with side-effecting
// @read/@eval, wrong ... must not read the missing file") and which @eval did
// not honour: an @eval in a discarded branch used to run, because eval swept
// the list before cond did.
//
// The delimiters themselves stay unmarked, so the span is still found later.
// Nested @cond spans are marked by the enclosing one and become live only when
// the branch holding them is selected and processed.
void Machine::mark_cond_content(katom_list& katoms)
{
(void)K::log(4);
if (std::find_if(katoms.begin(), katoms.end(), begin_cond) == katoms.end()) {
return;
}
for (const auto& [op, cl] : find_spans(katoms, level_increase, level_decrease, true, "cond")) {
auto [begin, end] = find_span_katoms(katoms, op, cl);
if (!begin_cond(*begin)) continue;
// Only the BRANCHES are inert. The predicate is always evaluated --
// that is what a conditional is -- so marking from "begin + 1" would
// stop "@cond @eval 1==1 @ | yes | no @" from ever computing its own
// predicate. Mark from the first depth-0 bar onward.
std::vector<katom_iter> bars = cond_separator_bars(begin, end);
if (bars.empty()) continue; // malformed; reported when it resolves
for (auto k = bars[0] + 1; k < end - 1; ++k) {
k->m_deferred = true;
}
}
}
// Resolve the @cond spans in `katoms`, at APPLICATION time. Returns the number
// resolved, so the caller's fixed point accounts for them.
//
// The selected branch is spliced and then processed exactly as a klammer body
// is (process_katoms + apply, the two lines apply_klammer already uses): that
// is what makes the document behave like a function body whose state variables
// are its arguments -- they are bound by the substitution at the top of
// Machine::apply, BEFORE any conditional in the document is decided. Resolving
// @cond at read time meant a top-level "@cond *Flag*" saw the literal "*Flag*".
int Machine::resolve_cond_katoms(katom_list& katoms, const std::string& target)
{
int resolved = 0;
if (std::find_if(katoms.begin(), katoms.end(), begin_cond) != katoms.end()) {
(void)K::log(3);
//for (auto [op, cl] : find_spans(katoms, begin_cond, end_apply, true, "cond")) {
for (const auto& [op, cl] : find_spans(katoms, level_increase, level_decrease, true, "cond")) {
auto [begin, end] = find_span_katoms(katoms, op, cl);
// msg() << "find_spans: " << std::pair(begin, end) << "\n";
if (begin_cond(*begin)) {
// A @cond nested inside an unresolved outer @cond is still
// inert; the outer one will process it when its branch is
// selected. Without this an inner branch would be decided
// before it is known whether it is reached at all.
if (begin->m_deferred) continue;
// Delimit @cond's arguments by the bars at depth 0 within the
// span, so that bars belonging to nested klammers are not
// mistaken for @cond's own separators (see cond_separator_bars).
@@ -183,7 +236,7 @@ void Machine::process_cond_katoms(katom_list& katoms)
check_bar_count(begin, bars.size());
auto bar_1 = bars[0];
std::string predicate = to_string(begin + 1, bar_1, true);
warn_unrecognized_predicate(predicate, begin->m_loc);
check_predicate(predicate, begin->m_loc);
katom_list true_clause {};
katom_list false_clause {};
if (bars.size() == 2) {
@@ -199,11 +252,22 @@ void Machine::process_cond_katoms(katom_list& katoms)
// (@cond is a non-strict special form).
katom_list result = is_true(predicate)
? trim_whitespace(true_clause) : trim_whitespace(false_clause);
// The selected branch becomes live: clear the inert flag and
// give it the same processing a klammer body gets. The
// unselected branch is discarded still inert, so nothing in it
// ever ran.
for (Katom& k : result) {
k.m_deferred = false;
}
process_katoms(result, command_name);
apply(m_klammers, result, target);
std::for_each(begin, end, mark_as_replaced);
katoms.insert(end, result.begin(), result.end());
++resolved;
}
}
}
return resolved;
}
@@ -339,8 +403,15 @@ void Machine::process_katoms(
if (ignore) mark_ignored_katoms(katoms);
if (whitespace) process_whitespace_modifiers(katoms);
if (klammers) process_klammer_katoms(katoms);
// @cond is no longer RESOLVED here -- it is resolved in the apply fold
// (see mark_cond_content / resolve_cond_katoms). What happens here is the
// marking that makes its branches inert, and it must run BEFORE the eval
// and read passes, which are the ones with observable effects. Leaving it
// where process_cond_katoms used to sit -- after eval -- kept the old
// "eval in a discarded branch runs anyway" behaviour, since the marking
// arrived too late to stop it.
if (cond) mark_cond_content(katoms);
if (eval) process_eval_katoms(katoms);
if (cond) process_cond_katoms(katoms);
if (read) expand_read_katoms(
katoms, source,
nonascii, literal, ignore, whitespace, klammers, eval, cond, read);
@@ -402,6 +473,8 @@ void Machine::expand_read_katoms(
(void)K::log(3);
for (const auto& [op, cl] : find_spans(katoms, begin_apply, end_apply, true, "read")) {
auto [begin, end] = find_span_katoms(katoms, op, cl);
// Inert: see the same guard in process_eval_katoms.
if (begin->m_deferred) continue;
if (begin_read(*begin)) {
std::string read_filename = to_string(begin + 1, end - 1, true);
@@ -441,7 +514,27 @@ void Machine::expand_read_katoms(
}
void Machine::extract_machine_definitions()
// One @@@ declaration. Factored out so the tolerant and strict paths share
// it; `rescan` is set when a klammerset has spliced files into the stream,
// which invalidates the caller's span list.
void Machine::add_machine_definition(
const std::string& name, katom_iter begin, katom_iter end, bool& rescan)
{
if (name == "@@@target") {
m_targets.add(begin, end, m_katoms);
} else if (name == "@@@argtype") {
m_argtypes.add(begin, end, m_katoms);
} else if (name == "@@@state") {
m_state.parse_state_katoms(begin, end, m_katoms);
} else if (name == "@@@klammerset") {
if (auto klammerset = m_klammersets.add(begin, end, m_katoms)) {
load_klammerset_files(*klammerset, end);
rescan = true;
}
}
}
void Machine::extract_machine_definitions(bool tolerant)
{
(void)K::log(3);
if (m_katoms.empty()) {
@@ -456,20 +549,23 @@ void Machine::extract_machine_definitions()
rescan = false;
for (const auto& [op, cl] : find_spans(m_katoms, begin_machine_def, end_machine_def, true, command_name)) {
auto [begin, end] = find_span_katoms(m_katoms, op, cl);
//std::string name = trim_char(begin->m_text, '@');
std::string name = begin->m_text;
if (name == "@@@target") {
m_targets.add(begin, end, m_katoms);
} else if (name == "@@@argtype") {
m_argtypes.add(begin, end, m_katoms);
} else if (name == "@@@state") {
m_state.parse_state_katoms(begin, end, m_katoms);
} else if (name == "@@@klammerset") {
if (auto klammerset = m_klammersets.add(begin, end, m_katoms)) {
load_klammerset_files(*klammerset, end);
rescan = true;
break;
if (tolerant) {
// kdiag: a declaration that cannot be carried out -- a
// @@@klammerset naming a file that is not there, say -- is
// skipped rather than fatal. Its span stays unreplaced, so
// the katoms remain visible and report the failure themselves.
try {
add_machine_definition(name, begin, end, rescan);
} catch (Error& e) {
K::log(1, "Machine definition not registered: " + e.m_desc);
continue;
}
} else {
add_machine_definition(name, begin, end, rescan);
}
if (rescan) {
break;
}
}
}
@@ -491,17 +587,39 @@ void Machine::load_klammerset_files(const Klammerset& klammerset, katom_iter ins
// entries are always filenames (this set's own definition files).
std::vector<std::string> filenames {};
for (const auto& required : klammerset.m_requires) {
if (is_klammerset_symbol(required)) {
filenames.push_back(
resolve_klammerset_symbol(required, base_dir, klammerset.m_loc).string());
} else {
filenames.push_back(required);
std::string path = is_klammerset_symbol(required)
? resolve_klammerset_symbol(required, base_dir, klammerset.m_loc).string()
: required;
// ALREADY LOADED? Decided BEFORE the file is opened. The guard used
// to sit in Klammerset_registry::add, which only runs once the file has
// been read and its declaration reached -- by which time the declaring
// file's OWN definitions have been re-executed. Two sets requiring a
// third therefore died on "Target ... is already defined", pointing at
// a line the author wrote once.
//
// Deciding it here is possible only because a klammerset symbol X is
// declared in X/X.k, so the symbol is a function of the path: it is
// known for a ":requires" written as a filename just as much as for one
// written as a symbol. That is what the X/X.k requirement bought.
std::string symbol = is_klammerset_symbol(required)
? required : fs::path(path).stem().string();
if (m_klammersets.has(symbol)) {
(void)K::log(1, "Klammerset \"" + symbol + "\": already loaded, skipped");
continue;
}
filenames.push_back(path);
}
filenames.insert(filenames.end(), klammerset.m_files.begin(), klammerset.m_files.end());
// Collect all files into one list and insert once: insert_at is
// invalidated by the first insertion into m_katoms.
// Recorded on the set: what it actually opened, canonical, so a display
// can tell its klammers from the input's. The declaring file counts --
// definitions may sit before or after the declaration in it.
strings_t loaded_files {};
if (fs::exists(declaring)) {
loaded_files.push_back(fs::canonical(declaring).string());
}
katom_list loaded {};
for (const auto& filename : filenames) {
fs::path pathname = resolve_relative_to(filename, base);
@@ -512,6 +630,7 @@ void Machine::load_klammerset_files(const Klammerset& klammerset, katom_iter ins
klammerset.m_loc);
}
pathname = fs::canonical(pathname);
loaded_files.push_back(pathname.string());
m_state.add_search_dir(pathname.parent_path().string());
std::string text = trim_right(string_from_file(pathname.string()));
katom_list ks = katomize(line_split(text), pathname);
@@ -519,6 +638,9 @@ void Machine::load_klammerset_files(const Klammerset& klammerset, katom_iter ins
loaded.insert(loaded.end(), ks.begin(), ks.end());
}
m_katoms.insert(insert_at, loaded.begin(), loaded.end());
// The registry holds the registered copy; the parameter is a const
// reference to it, so the record goes back through the registry.
m_klammersets.set_loaded_files(klammerset.m_symbol, loaded_files);
}
// Register one "@@...@@" definition. An ".o" target declares an option set
@@ -541,19 +663,29 @@ void Machine::add_definition(katom_list& katoms, const Katom& op, const Katom& c
void Machine::extract_klammer_definitions(katom_list katoms)
{
fmsg() << katoms << "\n";
(void)K::log(3);
// fmsg() << katoms << "\n";
(void)K::log(3, katoms);
for (const auto& [op, cl] : find_spans(katoms, begin_klammer_def, end_klammer_def, true, command_name)) {
add_definition(katoms, op, cl);
}
m_klammers.rationalize(m_targets);
}
void Machine::extract_klammer_definitions()
void Machine::extract_klammer_definitions(bool tolerant)
{
(void)K::log(3);
for (const auto& [op, cl] : find_spans(m_katoms, begin_klammer_def, end_klammer_def, true, command_name)) {
add_definition(m_katoms, op, cl);
if (tolerant) {
// add_definition marks the span replaced only on success, so a
// skipped definition keeps its katoms and stays visible.
try {
add_definition(m_katoms, op, cl);
} catch (Error& e) {
K::log(1, "Definition not registered: " + e.m_desc);
}
} else {
add_definition(m_katoms, op, cl);
}
}
m_klammers.rationalize(m_targets);
}
@@ -677,7 +809,12 @@ int Machine::apply(
Klammer_registry& klammer_registry, katom_list& katoms, const std::string& target)
{
(void)K::log(3, "Klammer_registry");
int applied = 0;
// @cond is a special form handled here rather than by the klammer loop
// below: its span head is a cond_begin, which begin_klammer_apply does not
// match. Resolving first means a klammer revealed by the selected branch
// is applied in this same pass. The count is returned with the klammer
// applications, so the caller's fixed point iterates while either happens.
int applied = resolve_cond_katoms(katoms, target);
for (const auto& [op, cl] : find_spans(
katoms, begin_klammer_apply, end_klammer_apply, true, command_name)) {
auto [begin, end] = find_span_katoms(katoms, op, cl);

View File

@@ -46,7 +46,14 @@ public:
return *this;
}
void process_cond_katoms(std::vector<Katom>& katoms);
// @cond is resolved at APPLICATION time, not read time (Andy, 2026-08-15,
// deciding notes/Klammertext_improvements.md §4.2). mark_cond_content()
// runs during process_katoms and makes every branch inert;
// resolve_cond_katoms() runs inside the apply fold, selects a branch, and
// processes only that one. The document then behaves like a klammer body:
// its state variables are bound before its conditionals are decided.
void mark_cond_content(std::vector<Katom>& katoms);
int resolve_cond_katoms(std::vector<Katom>& katoms, const std::string& target);
void process_eval_katoms(std::vector<Katom>& katoms);
void mark_literal_klammer_content(std::vector<Katom>& katoms);
void escape_target_characters(const Target& target, std::vector<Katom>& katoms);
@@ -73,8 +80,19 @@ public:
void expand_constant_klammers(katom_list& katoms, const Katom& op, const Katom& cl);
void add_definition(katom_list& katoms, const Katom& op, const Katom& cl);
void load_klammerset_files(const Klammerset& klammerset, katom_iter insert_at);
void extract_machine_definitions();
void extract_klammer_definitions();
// `tolerant` is for kdiag ONLY. It dissects the structure and typing of
// the katom list, and allows modes of processing that would be errors in
// ktext: run one definition tier without the other and a definition may
// be unregisterable (a klammer names a target that "--system" would have
// registered). Tolerant then SKIPS that definition instead of throwing,
// leaving its katoms unconsumed and therefore visible -- which is the
// report. ktext must keep throwing: there, a document naming an
// undefined target is a genuine error, and skipping it silently would
// render a wrong document.
void add_machine_definition(const std::string& name, katom_iter begin,
katom_iter end, bool& rescan);
void extract_machine_definitions(bool tolerant = false);
void extract_klammer_definitions(bool tolerant = false);
void extract_klammer_definitions(katom_list katoms);
void update_state(const std::map<std::string, std::string>& arg_map);

View File

@@ -52,7 +52,8 @@ void Option_set_registry::add(
throw Definition_error(message, begin->m_loc);
}
if (result.warn) {
warning(message, begin->m_loc);
// Reported, not warned: see the same case in klammer_registry.cpp.
(void)K::log(1, message + " at " + begin->m_loc.desc());
}
}
@@ -137,11 +138,15 @@ std::string Option_set_registry::available() const
return m_names.empty() ? "(none are declared)" : join(m_names, ", ");
}
std::string Option_set_registry::describe(int margin) const
std::string Option_set_registry::describe(int margin, const strings_t& defined_outside) const
{
std::string result {};
for (const auto& name : m_names) {
result += m_option_sets.at(name).describe(margin) + "\n";
const Option_set& set = m_option_sets.at(name);
if (!defined_outside.empty() && is_in(set.m_loc.m_filename, defined_outside)) {
continue;
}
result += set.describe(margin) + "\n";
}
return result;
}

View File

@@ -21,7 +21,10 @@ public:
const Option_set& get(const std::string& name, const Locator& loc) const;
void add_user(const std::string& set_name, const std::string& klammer_name);
std::string describe(int margin = 2) const;
// `defined_outside`: list only sets declared in a file not in the list --
// the input's own, with the loaded klammersets' left out. See
// Klammer::defined_outside.
std::string describe(int margin = 2, const strings_t& defined_outside = {}) const;
// "a, b, c" -- the sets that exist, for a diagnostic about one that does not.
std::string available() const;

View File

@@ -1,5 +1,8 @@
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <iterator>
#include <unistd.h>
#include "ktype.h"
#include "show.h"
@@ -7,6 +10,37 @@
#include "log.h"
#include "file.h"
bool color_enabled(const std::ostream& os)
{
// Function-local statics: isatty is asked once per descriptor, on first
// use, so there is no static-initialization-order question and no syscall
// per colour written.
static const bool no_color = [] {
const char* v = std::getenv("NO_COLOR");
return v != nullptr && v[0] != '\0';
}();
static const bool tty_out = isatty(fileno(stdout)) != 0;
static const bool tty_err = isatty(fileno(stderr)) != 0;
if (no_color) {
return false;
}
if (&os == &std::cout) {
return tty_out;
}
if (&os == &std::cerr || &os == &std::clog) {
return tty_err;
}
return false;
}
std::ostream& operator<<(std::ostream& os, const Color& color)
{
if (color_enabled(os)) {
os << color.code();
}
return os;
}
std::ostream& msg(Locator loc)
{
std::cout << blue << loc.abbrev(false) << black << " ";

View File

@@ -30,17 +30,46 @@ const std::string left_square_bracket { "\u2045" };
const std::string right_square_bracket { "\u2046" };
const std::string check { "\u2713" };
const std::string black("\033[0;30m");
const std::string boldblack("\033[1;30m");
const std::string green("\033[0;32m");
const std::string boldgreen("\033[1;32m");
const std::string cyan("\033[0;36m");
const std::string blue("\033[0;34m");
const std::string boldblue("\033[1;34m");
const std::string magenta("\033[0;35m");
const std::string red("\033[31m");
const std::string yellow("\033[0;33m");
const std::string reset("\033[0m");
// A colour is a STREAM-AWARE object, not a string, because the same name is
// written to two streams whose destinations differ: "ktext -d > doc.txt" has a
// file on stdout and a terminal on stderr at the same time, so one global
// on/off cannot be right for both. Inserting a Color emits its escape
// sequence only when THAT stream is a terminal, so escape codes can never
// reach a redirected document, a pipe, or a captured log.
//
// Streams other than std::cout/std::cerr -- a stringstream building an error
// message, a file -- are never coloured. That is the conservative reading and
// it also stops escapes from being baked into strings whose length is then
// measured (abbreviate() in util.cpp counted them).
class Color {
public:
explicit Color(const std::string& code) : m_code(code) {}
// The raw sequence, for the rare place that needs a string rather than an
// insertion. Prefer inserting the Color: this form cannot be suppressed.
const std::string& code() const { return m_code; }
private:
std::string m_code {};
};
// True when colour should be emitted on this stream: it is a terminal and the
// NO_COLOR environment variable is unset. NO_COLOR (https://no-color.org) is
// honoured for ANY non-empty value, which is what the convention specifies; it
// is the escape hatch for the case isatty cannot see, such as a pager or a CI
// capture that does want, or does not want, colour.
bool color_enabled(const std::ostream& os);
std::ostream& operator<<(std::ostream& os, const Color& color);
const Color black("\033[0;30m");
const Color boldblack("\033[1;30m");
const Color green("\033[0;32m");
const Color boldgreen("\033[1;32m");
const Color cyan("\033[0;36m");
const Color blue("\033[0;34m");
const Color boldblue("\033[1;34m");
const Color magenta("\033[0;35m");
const Color red("\033[31m");
const Color yellow("\033[0;33m");
const Color reset("\033[0m");
const auto seqout = [](auto x) { std::cout << "seq: " << x << "\n"; };
//const auto mapout = [](auto m) { auto const& key std::cout << m.first << sp_arrow << m.second << "\n"; };

View File

@@ -136,8 +136,19 @@ Var State::get(const std::string& name, bool error_if_not_defined, const Locator
}
}
if (error_if_not_defined) {
msg() << describe();
throw Argument_error("Variable " + q_(name) + " not defined", loc);
// Which frames were searched belongs IN the error, not printed beside
// it. This was a msg() dumping the whole state to stdout before the
// throw -- scaffolding, on the wrong stream, and separated from the
// message it was explaining. The frame NAMES are the useful part: a
// variable missing because the expected frame was never opened looks
// exactly like one that was never set.
strings_t frame_names {};
for (const auto& f : m_frames) {
frame_names.push_back(f.m_name);
}
throw Argument_error(
"Variable " + q_(name) + " not defined (searched: "
+ join(frame_names, ", ") + ")", loc);
} else {
return Var();
}
@@ -187,7 +198,7 @@ void State::subst(katom_iter begin, katom_iter end)
//auto [var, found] = get(match[1]);
auto var_value = value(match[2], true, begin->m_loc);
if (var_value != klammerstate::no_value) {
msg() << "Found subst: " << match[1] << sp_arrow << var_value << "\n";
(void)K::log(3, "Found subst:", match[1].str(), var_value);
std::stringstream ss {};
ss << match[1] << var_value << match[3];
ki->m_text = ss.str(); // match[1] + var_value + match[3];

View File

@@ -16,12 +16,15 @@ std::string Target_registry::optionset_name = "o";
Target_registry::Target_registry()
: m_parameters(Parameter_set("name | desc :after_apply :after_write :includes :escape | transforms.rest"))
{
// Registration order is display order. The two that declare an interface
// come first -- "k" a klammer's, "o" an option set's -- and then "*", the
// targets themselves beginning after it.
Target k(declare_name, "Description of parameters and klammer result", Locator());
Target general(general_name, "Every target: written \".*\" to declare a klammer works for all of them, or implied when a definition names no target", Locator());
Target option_set(optionset_name, "Declaration of an option set: parameters shared by klammers", Locator());
Target general(general_name, "All targets", Locator());
add(k);
add(general);
add(option_set);
add(general);
}
void Target_registry::add(Target target)
@@ -122,8 +125,18 @@ std::vector<std::string> Target_registry::applicable() const
&& name != Target_registry::optionset_name; });
}
std::string Target_registry::describe(int margin, bool long_format) const
std::string Target_registry::describe(int margin, bool long_format,
const strings_t& defined_outside) const
{
// The built-in pseudo-targets belong to the language rather than to any
// klammerset, so a filtered listing keeps them.
auto shown = [&](const std::string& name) {
if (defined_outside.empty()) return true;
if (name == declare_name || name == general_name || name == optionset_name) {
return true;
}
return !is_in(m_targets.at(name).m_loc.m_filename, defined_outside);
};
std::string tab(margin, ' ');
std::stringstream ss {};
std::vector<std::string> descs {};
@@ -133,6 +146,7 @@ std::string Target_registry::describe(int margin, bool long_format) const
auto name_width = max_length(m_names);
auto desc_width = max_length(descs);
for (const std::string& name : m_names) {
if (!shown(name)) continue;
const Target& t = m_targets.at(name);
if (long_format) {
ss << tab << std::setfill(' ') << std::setw(name_width) << std::right << name << " "

View File

@@ -38,7 +38,11 @@ public:
std::vector<std::string> user_defined() const;
std::vector<std::string> applicable() const;
std::string describe(int margin=2, bool long_format=false) const;
// `defined_outside`: list only targets declared in a file not in the list.
// The three pseudo-targets (k, o, *) are built in and always shown -- they
// are the language's, not any klammerset's.
std::string describe(int margin=2, bool long_format=false,
const strings_t& defined_outside = {}) const;
//Argtype_registry m_argtypes {};
std::map<std::string, Target> m_targets {};

View File

@@ -114,7 +114,7 @@ std::string regex_escape(const std::string& s)
strings_t regex_split(const std::string& s, const std::regex& re, bool trim_parts)
{
strings_t result = {};
if (s.size() == 0) {
if (s.empty()) {
return result;
}
auto it = std::sregex_token_iterator(s.begin(), s.end(), re, -1);
@@ -419,8 +419,14 @@ std::string abbrev(const std::string& s, unsigned int max_length, bool remove_ne
result = std::regex_replace(result, std::regex("\n"), broken_bar);
}
int suffix_size = 8;
std::string ellipsis { red + "[...]" + black };
int prefix_end = max_length - suffix_size - ellipsis.size();
// Plain, deliberately. This string is spliced into another and shown
// later, on a stream this function cannot know -- so a colour baked in
// here could reach a pipe or a file. It also fixes an arithmetic
// error that the colours caused: ellipsis.size() counted the 14
// characters of the two escape sequences as visible width, so the
// abbreviation was cut 14 characters shorter than max_length asked.
std::string ellipsis { "[...]" };
int prefix_end = max_length - suffix_size - static_cast<int>(ellipsis.size());
result = result.replace(result.begin() + prefix_end,
result.end() - suffix_size,
ellipsis);