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

@@ -41,7 +41,7 @@ are regenerated on each release — patches cannot be merged directly.
Report problems (or send patches) to the author; accepted changes are
applied to the development tree and appear in a following snapshot.
This snapshot was assembled from development commit `46f54080bd9a`.
This snapshot was assembled from development commit `6c8ee6c22fca`.
## License

View File

@@ -61,8 +61,7 @@ static void katom_usage()
std::cout <<
"Katom commands:\n"
" --katoms List the katom types\n"
" --katoms full The same, with the regex each type is\n"
" matched by\n"
" --katoms full The list, with the regex syntax used for a march\n"
" --katoms help This description\n";
}
@@ -82,33 +81,47 @@ static void klammerset_usage()
{
std::cout <<
"Klammerset commands:\n"
" --klammerset List the klammersets on the search path\n"
" --klammerset list The same\n"
" --klammerset help This description\n"
" --klammersets List the klammersets on the search path\n"
" --klammersets <symbols> Load the klammersets specified by <symbols>\n"
" --klammersets help This description\n"
"\n"
"Without an argument, all available klammersets are listed. With one or more\n"
"klammerset symbols, those klammersets are loaded for analysis by the -k, -t,\n"
"--argtypes, --optionsets, and --coverage arguments.\n\n"
"SEVERAL SETS COMBINE. The symbols are a list, loaded in the order given, and\n"
"the klammers of all of them share ONE flat namespace -- membership in a set is\n"
"provenance, not containment. Where two sets define the same klammer, the\n"
"definition modes decide: \":\" is an error if the name is already defined,\n"
"\":::\" replaces the earlier definition (with a warning), and \"::::\" yields to\n"
"any later one. So a set of house overrides is loaded after the set it\n"
"adjusts, and a set of defaults before it.\n\n"
"The Standard Klammer Set is loaded by default. If the <symbols> argument is\n"
"\"none\", no klammerset is loaded.\n\n"
"A klammerset symbol x names the declaration file x/x.k, searched for in:\n"
"the current directory (for ktext, the input document's directory), the\n"
"KLAMMERTEXT_KLAMMERSETS directories (colon-separated; default\n"
"$HOME/.klammertext/klammersets), and $KLAMMERTEXT_HOME. The first hit\n"
"1) the current directory (for ktext, the input document's directory); 2) the\n"
"KLAMMERTEXT_KLAMMERSETS directories (colon-separated; the default is\n"
"$HOME/.klammertext/klammersets); and 3) $KLAMMERTEXT_HOME. The first hit\n"
"wins, so a document-local klammerset shadows an installed one, which\n"
"shadows a distributed one.\n";
}
static void klammerset_command(const strings_t& words)
static void klammerset_command(Machine& machine, const strings_t& words)
{
std::string verb = words.empty() ? "list" : words[0];
if (verb == "list") {
if (words.empty()) {
std::cout << boldblack << "Klammersets\n" << black
<< describe_klammerset_search(fs::current_path().string());
} else if (verb == "help") {
} else if (words[0] == "help") {
klammerset_usage();
} else {
std::cout << "Unrecognized klammerset command: --klammerset "
<< join(words, " ") << "\n\n";
klammerset_usage();
// "none" is not filtered out here: load_klammersets() is the single
// place that decides what the word means, including that it may not be
// combined with other symbols. Deciding it twice is how kdesc came to
// accept "--klammersets none sks" and silently load neither.
load_klammersets(machine, words);
}
}
static void font_command(const strings_t& words)
{
std::string verb = words.empty() ? "list" : words[0];
@@ -147,21 +160,24 @@ int main(int argc, char* argv[])
// regexes and the coverage file column are words of their own options
// rather than verbosity levels.
args.var("k", "Show klammers. With <text>, only those whose name or description contains <text>.", "text");
args.flag("t", "Show the targets for Klammertext output");
args.flag("c", "Show the codes for accented vowels and other special characters");
args.opt("i", "Input filename. If given, it is used instead of the SKS.", "filename", "", "'text'");
args.flag("t", "Show the targets for Klammertext output");
args.opt("i", "Input filename to analyze containing klammer and other definitions", "filename", "", "'text'");
args.var("klammersets", "List the available klammersets, or load one or more for analysis. Enter \"--klammersets help\" for details.", "symbols");
args.var("font", "List installed fonts. Enter \"--font help\" for font maintenance commands.");
args.flag("argtypes", "Show the klammer argument types");
args.var("katoms", "Show the katom types. Enter \"--katoms help\" for details.");
args.flag("rewrite", "Show the katom rewrite patterns");
args.flag("optionsets", "Show the option sets declared by the input");
args.var("coverage", "Show the targets each klammer covers. Enter \"--coverage help\" for details.");
args.var("klammerset", "List the klammersets on the search path. Enter \"--klammerset help\" for details.");
args.opt("v", "'verbosity'", "n", "0", "'verbosity'");
if (show_usage(argc, argv)) {
// A bare command is a REQUEST for information, not a failure:
// usage goes to stdout (it is the result being asked for) and the
// exit status is 0, so "kdesc && echo ok" reports what happened.
args.usage(file_basename(argv[0]));
exit(1);
exit(0);
}
auto p = [&](const std::string& name) { return args.get(name) == "true"; };
@@ -208,11 +224,45 @@ int main(int argc, char* argv[])
return 0;
}
// The --klammerset subcommands operate on the search path
// (filesystem enumeration) and load no klammer set.
if (args.given("klammerset")) {
klammerset_command(option_words(args, "klammerset"));
return 0;
Machine M;
// Same rule as kdiag: a command that specifies no target evaluates
// under the GENERAL target (Andy, 2026-08-15). kdesc has no target
// argument either -- it describes what a klammer set provides for ALL
// targets -- and a designer's ".k" holding a top-level @eval hit the
// identical "Variable "K_target" not defined".
M.m_state.set("K_target", Target_registry::general_name);
strings_t input_filenames = resolve_filename_list(args.get("i"));
if (verbose_level > 0 && !input_filenames.empty()) {
std::cout << "input_filenames: " << input_filenames << "\n";
}
if (!input_filenames.empty()) {
M.m_state.set(
"K_input_dir", absolute_pathname(file_directory(input_filenames[0])));
} else {
M.m_state.set("K_input_dir", fs::current_path().string());
}
// --klammersets both lists and loads: with no words it enumerates the
// search path, with symbols it loads them for the analyses below.
if (args.given("klammersets")) {
klammerset_command(M, option_words(args, "klammersets"));
} else { // By default, SKS is loaded for analysis
M.read(fs::path(M.m_state.value("KLAMMERTEXT_HOME") + "/sks/sks.k"));
}
// Snapshot BEFORE the input is read. The distinction is not "declared
// by a klammerset" but "loaded as context": an input file may itself
// declare @@@klammerset -- that is how a designer writes one -- and
// its klammers are still the ones being asked about.
strings_t context_files = M.m_klammersets.loaded_files();
for (auto fname : input_filenames) {
if (verbose_level > 0) {
std::cout << "Read " << fname << "\n";
}
M.read(fs::path(absolute_pathname(fname)));
}
// --coverage is validated here, before a klammer set is read, so a
@@ -236,41 +286,31 @@ int main(int argc, char* argv[])
}
}
Machine M;
strings_t input_filenames = resolve_filename_list(args.get("i"));
if (verbose_level > 0) {
std::cout << "input_filenames: " << input_filenames << "\n";
}
if (input_filenames.empty()) {
M.read(fs::path(M.m_state.value("KLAMMERTEXT_HOME") + "/sks/sks.k"));
} else {
for (auto fname : input_filenames) {
if (is_klammerset_symbol(fname)) {
// A bare symbol resolves on the klammerset search
// path; kdesc has no input document, so the local
// stage is the cwd.
fname = resolve_klammerset_symbol(
fname, fs::current_path().string(), Locator()).string();
}
if (verbose_level > 0) {
std::cout << "Read " << fname << "\n";
}
M.read(fs::path(absolute_pathname(fname)));
}
strings_t own_klammers_only {};
if (!input_filenames.empty()) {
own_klammers_only = context_files;
}
if (p("t")) {
std::cout << boldblack << "Targets\n" << black << M.m_targets.describe(2, true);
std::cout << boldblack << "Targets\n" << black
<< M.m_targets.describe(2, true, own_klammers_only);
}
// "-k <text>" searches names AND descriptions, case-insensitively,
// with whitespace collapsed on both sides. An empty listing for a
// search that was actually made is reported: silence would read as a
// broken command. It is not an error -- finding nothing is a result.
// With an input file, the klammersets are loaded so the input can be
// ANALYSED against them, but they are not what the user is asking
// about: a designer wants the klammers this file defines, and a reader
// of an unfamiliar document wants the custom klammers it carries. So
// the listing is restricted to klammers with a definition outside the
// klammerset files. Without -i there is nothing else to show, so the
// klammerset itself is the answer and no filter applies.
if (args.given("k")) {
std::string search = join(option_words(args, "k"), " ");
std::string listing = M.m_klammers.describe(2, search);
std::string listing = M.m_klammers.describe(2, search, own_klammers_only);
if (listing.empty() && !search.empty()) {
std::cout << "No klammer names or descriptions contained "
<< q_(collapse_whitespace(search)) << ".\n";
@@ -280,15 +320,15 @@ int main(int argc, char* argv[])
}
if (p("optionsets")) {
std::cout << boldblack << "Option sets\n" << black << M.m_option_sets.describe(2);
std::cout << boldblack << "Option sets\n" << black
<< M.m_option_sets.describe(2, own_klammers_only);
}
// Analysis only: klammer_coverage() reads the registry and modifies
// nothing, so what a document renders to is unaffected by asking.
if (args.given("coverage")) {
report_coverage(M, klammer_coverage(M), coverage_all, std::cout);
report_coverage(M, klammer_coverage(M, own_klammers_only), coverage_all, std::cout);
}
}
catch (Error& e) {
// Nonzero, as ktext does: a command that prints an error and exits 0

View File

@@ -3,6 +3,7 @@
#include <map>
#include "util.h"
#include "check.h"
#include "argument_set.h"
#include "argv.h"
#include "argument.h"
@@ -19,7 +20,11 @@ int main(int argc, char* argv[])
set_verbose_level(argc, argv);
Argv args {};
args.req("input", "Klammertext input text", "'text'");
// Optional: "kdiag --machine" with no input shows the Machine's initial
// state, which is a question about the machine rather than about any
// document -- and the one thing a programmer wants before feeding it
// anything.
args.req("input", "Klammertext input text", "'text'", false);
args.flag("type", "Show katom types in subscript");
args.flag("index", "Show the list index of the katom");
args.flag("text", "Show text katoms with selected attributes");
@@ -37,13 +42,27 @@ int main(int argc, char* argv[])
args.flag("literal", "Process literal: ^'...'^");
args.flag("ignore", "Process ignored: #, ##, #[...]#");
args.flag("ws", "Process whitespace: #-, #+, #/");
args.flag("klammer", "Process klammer definitions: @@<name> ... @@");
args.flag("process", "Process all");
args.flag("klammer", "Register klammer definitions: @@<name> ... @@. Their katoms "
"are consumed, so they appear only with --replaced.");
args.flag("system", "Register machine definitions: @@@target, @@@argtype, @@@state, "
"@@@klammerset. Their katoms are consumed, so they appear only "
"with --replaced. The two tiers are ORDERED: a klammer names a "
"target, so --klammer alone cannot register a klammer whose "
"target the input itself declares. That is not an error here: "
"the definition is skipped and its katoms stay visible, which is "
"the report.");
args.flag("process", "Process all, both tiers of definition included");
args.flag("check", "Check every klammer application in the input and in the "
"body of every defined klammer");
args.flag("machine", "Show the state of the Klammermachine after all processing");
args.opt("v", "'verbosity'", "degree", "0", "'verbosity'");
if (show_usage(argc, argv)) {
// A bare command is a REQUEST for information, not a failure:
// usage goes to stdout (it is the result being asked for) and the
// exit status is 0, so "kdiag && echo ok" reports what happened.
args.usage(file_basename(argv[0]));
exit(1);
exit(0);
}
auto p = [&](const std::string& name) { return args.get(name) == "true"; };
@@ -57,19 +76,48 @@ int main(int argc, char* argv[])
show_rewrite_rules = true;
}
// kdiag deliberately has no --klammersets: a klammerset is reached here
// the way the input reaches anything else, with "@read sks/sks.k @".
// The symbol mechanism is a higher-level convenience, and a debugger
// must not depend on the machinery it debugs -- if symbol resolution
// breaks, kdiag must still run, and the two paths to the same file can
// then be compared.
Machine machine;
// kdiag has no target argument, and evaluating needs one: Eval::eval
// re-reads its result under K_target, so with the variable unset every
// @eval died with "Variable "K_target" not defined" -- an error naming
// something the user never wrote. A command that does not specify a
// target evaluates under the GENERAL target (Andy, 2026-08-15), which
// is also what kdiag means: it loads no klammerset, so nothing
// target-specific is in scope to begin with.
machine.m_state.set("K_target", Target_registry::general_name);
machine.m_state.set("K_input_dir", fs::current_path().string());
std::string input = args.as_string("input");
std::string command = construct_command_pathname(argv[0]);
katom_list katoms {};
if (p("process")) {
katoms = machine.process(input, command);
} else {
katoms = machine.process(
//args.as_string("input"), construct_command_pathname(argv[0]),
input, command,
p("nonascii"), p("literal"), p("ignore"), p("ws"), p("klammer"),
p("eval"), p("cond"), p("read"));
}
// ONE authoritative katom list. process() returns the katoms but
// registers nothing -- read() is what appends to m_katoms and extracts
// the definitions -- so kdiag does that itself, in the order read()
// uses, and then displays m_katoms. Displaying its own copy instead
// would show the definition katoms unconsumed, because extraction
// marks them "replaced" in the Machine's list, not in a copy.
bool all = p("process");
katom_list processed = all
? machine.process(input, command)
: machine.process(input, command,
p("nonascii"), p("literal"), p("ignore"), p("ws"),
p("klammer"), p("eval"), p("cond"), p("read"));
machine.m_katoms.insert(
machine.m_katoms.end(), processed.begin(), processed.end());
// Machine definitions first: a klammer definition names a target, so
// the target must be registered before the klammer that uses it.
// Tolerant: a definition that cannot be registered because the other
// tier was not run is skipped, not fatal -- its katoms stay visible.
// kdiag dissects structure and typing, which means allowing modes of
// processing that would be errors in ktext.
if (all || p("system")) machine.extract_machine_definitions(true);
if (all || p("klammer")) machine.extract_klammer_definitions(true);
katom_list& katoms = machine.m_katoms;
if (p("spans")) {
describe_spans(katoms);
} else {
@@ -79,8 +127,10 @@ int main(int argc, char* argv[])
if (p("text")) std::cout << kall;
if (p("all")) std::cout << kall << kreplaced << kignored;
if (p("replaced")) std::cout << kreplaced;
if (!p("check")) {
std::cout << katoms << "\n";
}
}
if (p("args")) {
int req_count = args.as_int("pos");
@@ -105,11 +155,45 @@ int main(int argc, char* argv[])
std::cout << std::right << std::setw(label_width) << "rest:" << " " << rest << "\n";
}
}
int check_errors = 0;
if (p("check")) {
check_errors = report_diagnostics(check_machine(machine, katoms, "*"), std::cout);
// The commonest cause of a wall of "not defined" is that nothing
// was registered. The advice belongs here rather than in the
// catch block: --check REPORTS, it does not throw, so an exception
// handler never sees this case. The condition is the CAUSE, not a
// proxy for it.
if (check_errors > 0 && machine.m_klammers.m_klammers.empty()) {
std::cout << "\nNo klammers are registered, so every application is "
"undefined.\nkdiag loads no klammerset: read one in the input, "
"as \"@read sks/sks.k @\",\nand add --process (or --system "
"--klammer --read) to register what it defines.\n";
}
}
// Last, and after everything else: the state shown is the result of
// whatever the other arguments did. This is why --check defers its
// exit status rather than returning as soon as it finds errors.
if (p("machine")) {
std::cout << machine;
}
if (check_errors > 0) {
std::cout << black;
return 1;
}
}
catch (Error& e) {
// The advice must name a way in that kdiag actually has. It named
// "--klammersets sks" until 2026-08-15, a flag the 2026-08-14 redesign
// deliberately removed from this command -- a debugger must not depend
// on the machinery it debugs -- so the one remedy offered was one that
// errors. Same wording as the --check hint above, for the same reason.
std::string advice = "";
if (e.m_type == "target")
advice = "To include the Standard Klammer Set, add flag \"--sks\".";
if (e.m_type == "target" || e.m_type == "definition") {
advice = "kdiag loads no klammerset: read one in the input, as "
"\"@read sks/sks.k @\", and add --process to register what it "
"defines.";
}
e.print_message(advice);
// Nonzero, as ktext does: a command that prints an error and exits 0
// reports success, and a script cannot tell the difference.

View File

@@ -1,6 +1,5 @@
#include <iostream>
#include "error.h"
#include "check.h"
#include "command.h"
#include "log.h"
#include "argv.h"
@@ -22,18 +21,20 @@ int main(int argc, char* argv[])
Target_registry::general_name, "'word'");
args.opt("o", "Output basename; meaning and default defined by target.", "basename",
"", "'word'");
args.opt("k", "Klammerset symbol (resolved on the klammerset search path) or the pathname of a klammerset definition file; default is the Standard Klammer Set. With a value of \"none\", no klammerset is loaded.",
"pathname", "", "'word'");
args.flag("d", "Display the output to the screen, rather than writing files.");
args.flag("m", "Show the Klammermachine state at the beginning of processing.");
args.flag("check", "Check every klammer application in the input and in the "
"body of every defined klammer, report all problems found, and exit "
"without producing output.");
args.flag("d", "Display the output to the screen, rather than writing a file.");
args.opt("klammersets", "Klammersets to load, as a list of symbols resolved on the "
"klammerset search path. Several combine: they are loaded in the order given "
"and share one namespace. The default is the Standard Klammer Set; "
"\"none\" loads none.",
"symbols", "", "'list'");
args.opt("v", "'verbosity'", "degree", "0", "'verbosity'");
if (show_usage(argc, argv)) {
// A bare command is a REQUEST for information, not a failure:
// usage goes to stdout (it is the result being asked for) and the
// exit status is 0, so "ktext && echo ok" reports what happened.
args.usage(file_basename(argv[0]));
exit(1);
exit(0);
}
args.parse(argc, argv);
if (verbose_level > 0) {
@@ -56,19 +57,6 @@ int main(int argc, char* argv[])
parse_args(input_filenames, args.as_string("t"),
expand_tilde(args.as_string("o")), args.as_bool("d"));
// --check produces no output, so it needs neither a target nor -d.
// With no -t it checks every defined target, which is the more useful
// default here: "does this document hold together at all?"
bool check_only = args.as_bool("check");
if (*(output_filename.end() - 1) == '*'
&& !display_only && !check_only) {
throw Argument_error(
"You must specify an output target or "
"display the results with the \"-d\" flag.",
Locator());
}
Machine M;
M.m_state.open_frame("ktext");
M.m_state.set("K_target", target);
@@ -89,20 +77,7 @@ int main(int argc, char* argv[])
M.m_state.set("K_input_dir", fs::current_path().string());
}
std::string klammerset_filename = args.as_string("k");
if (klammerset_filename != "none") {
if (klammerset_filename.empty()) {
klammerset_filename = M.m_state.value("KLAMMERTEXT_HOME") + "/sks/sks.k";
} else if (is_klammerset_symbol(klammerset_filename)) {
// A bare symbol resolves on the klammerset search path;
// the local stage is the input document's directory (the
// cwd when the input is a string).
klammerset_filename = resolve_klammerset_symbol(
klammerset_filename, M.m_state.value("K_input_dir"), Locator()).string();
}
K::log(1, "Reading klammerset filename: " + klammerset_filename);
M.read(fs::path(klammerset_filename));
}
load_klammersets(M, word_split(args.as_string("klammersets")));
if (!input_text.empty()) {
M.read(input_text + "\n");
@@ -111,30 +86,17 @@ int main(int argc, char* argv[])
M.read(fs::path(p));
}
if (args.as_bool("m")) {
std::cout << M << "\n";
}
// --check reports statically, before anything is applied: all problems
// at once, including ones in @cond branches that are not selected and
// in klammer bodies that this render would never reach.
if (check_only) {
int errors = report_diagnostics(check_machine(M, target), std::cout);
return errors > 0 ? 1 : 0;
}
std::string result = trim(M.apply(target));
if (display_only && !result.empty()) {
std::cout << result << "\n";
} else if (!result.empty()) {
msg() << "Output filename: " << output_filename << "\n";
string_to_file(output_filename, result + "\n");
(void)K::log(1, "Wrote file: " + output_filename);
}
}
catch (Error& err) {
err.print_message();
std::cout << "\n";
std::cerr << "\n";
return 1;
}
return 0;

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 {};
// 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) {
paths = env;
} else if (const char* home = std::getenv("HOME"); home && *home) {
paths = std::string(home) + "/.klammertext/klammersets";
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)
{
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")) {
(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 (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)) {
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);

View File

@@ -29,7 +29,6 @@ def is_comment(s):
return s.strip().startswith("//")
def split_blocks(s):
kutil.msg()
blocks = []
in_code = True
block = ""
@@ -53,7 +52,6 @@ def split_blocks(s):
return blocks
def get_lines(comment, code, n):
kutil.msg()
lines = code.split("\n")
if len(lines) < n:
raise Exception(
@@ -64,7 +62,6 @@ def get_lines(comment, code, n):
return ("\n".join(lines[:n]), "\n".join(lines[n:]))
def parse_blocks(blocks):
kutil.msg()
box_comment_rgx = re.compile("\\s*//(\\d+)\\s+.*", re.S)
i = 0
block_pairs = []

View File

@@ -30,6 +30,12 @@ std::string document(Machine& machine)
// width check (tex_width_check() in table.py) left in the xelatex log,
// followed by a short :column_width primer. Plain line scanning -- no
// std::regex over the (arbitrarily large) log text.
// One of the two surviving WARNINGS (see the output policy in CLAUDE.md; the
// other is warn_unparsed_katoms). It stays a warning because the judgment is
// a HEURISTIC and a question of layout QUALITY rather than well-formedness:
// the check carries a 2pt tolerance for exactly-full tables, and a document
// with one over-wide table still renders. Halting would turn a false positive
// into a blocker. Promote it to an error when the measurement is exact.
static void warn_wide_tables(const std::string& xelatex_log)
{
bool any = false;

View File

@@ -149,12 +149,19 @@ fs::path parse_input_filename(const std::string& s, const std::string& input_dir
// (K_input_dir), so a document renders identically wherever ktext is
// run from; then the legacy kt/ subdirectory; a name found in neither
// is returned as given (cwd-relative) and errors downstream.
// Which of the three a name landed on is a DERIVED value -- the ".kt" may
// have been supplied, and the directory certainly was -- so "-v 1" reports
// it. A ":files chapter1" that quietly found kt/chapter1.kt rather than
// the file beside the document is exactly what the author cannot see.
fs::path in_input_dir = fs::path(input_dir) / p;
if (file_exists(in_input_dir.string())) {
(void)K::log(1, "Input file \"" + s + "\": " + in_input_dir.string());
return in_input_dir;
}
fs::path in_kt_dir = fs::path(input_dir) / "kt" / p;
if (file_exists(in_kt_dir.string())) {
(void)K::log(1, "Input file \"" + s + "\": " + in_kt_dir.string()
+ " (found in the kt/ subdirectory)");
return in_kt_dir;
}
return p;
@@ -171,7 +178,10 @@ void write_file(const std::string& filename, const std::string& contents, bool w
if (write_p) {
string_to_file(filename, contents);
} else {
msg() << "Output filename: " << filename << "\n";
// Not writing: name the file that would have been written. A
// derived value, so "-v 1" -- and never on stdout, which carries
// the document itself.
(void)K::log(1, "Output filename:", filename);
}
}

View File

@@ -190,7 +190,9 @@ std::string Document_class::create_html_output_directories()
m_cache_dir = cache_directory("_html_pages");
if (m_no_cache && fs::exists(m_cache_dir)) {
for (auto& entry : std::filesystem::directory_iterator(m_cache_dir)) {
msg() << " Removing cache directory: " << entry << "\n";
// What ":cache false" actually removed: a derived consequence of
// an option, so "-v 1".
(void)K::log(1, "Removing cache directory:", entry.path().string());
std::filesystem::remove_all(entry);
}
}
@@ -696,12 +698,16 @@ std::string Document_class::make_single_html_page(const std::string& output_dire
std::string Document_class::make_html_navigation_structure(
const std::string& output_directory, std::vector<Heading> headings)
{
msg() << "Navigation format\n";
// Which structure path was taken is a DERIVED fact -- ":structure book"
// chose it -- so it belongs at "-v 1". It was a msg(), which printed it
// to stdout on every book render, ahead of the document.
(void)K::log(1, "Navigation format");
std::vector<std::string> pages_basenames {};
for (auto [basename, page_text] : m_file_components) {
pages_basenames.push_back(basename);
msg() << basename << ": " << abbrev(page_text, 96) << "\n";
// A trace of the work, not a decision: verbosity 3.
(void)K::log(3, basename + ": " + abbrev(page_text, 96));
}
std::string pages_basenames_filename = m_cache_dir + "/_pages_basenames.js";
write_basenames_js_file(pages_basenames_filename, pages_basenames);

View File

@@ -71,17 +71,13 @@ namespace latex {
std::string test = trim(title + subtitle + author + date + version);
std::stringstream ss {};
if (!test.empty()) {
// ss << Pagestyle?
std::string full_title = title;
if (!full_title.empty() && !subtitle.empty()) {
full_title += " --- " + subtitle;
}
ss << title_line(full_title, "1.8", "8pt")
ss << "\\thispagestyle{empty}\n";
ss << title_line(title, "1.8", "6pt")
<< title_line(subtitle, "1.5", "16pt", true)
<< title_line(author)
<< title_line(date)
<< title_line(version)
<< "\n";
}
return ss.str();
}

View File

@@ -9,6 +9,7 @@
.PHONY: test
test:
./cond_test.sh
./eval_test.sh
./recursion_test.sh
./check_test.sh
./deftype_test.sh
@@ -22,5 +23,7 @@ test:
./target_list_test.sh
./coverage_test.sh
./command_option_test.sh
./verbosity_test.sh
./kdesc_test.sh
./kdiag_test.sh
./editor_test.sh

View File

@@ -23,7 +23,7 @@
# would take "some words" as the value and the alone value would never be
# reached. That is a definition-time error.
#
# Engine tier: these tests run with -k none and define their own argtypes
# Engine tier: these tests run with --klammersets none and define their own argtypes
# and klammers inline, so they do not depend on the Standard Klammer Set.
#
# Usage: ./alone_test.sh (LSan suppressions come from env/runtime.env)
@@ -114,29 +114,29 @@ echo
check_eq \
" 1. name absent — the default" \
"[0]" \
-k none -s "$DEPTH $DK @d@" -d
--klammersets none -s "$DEPTH $DK @d@" -d
check_eq \
" 2. name written alone — the type's alone value" \
"[3]" \
-k none -s "$DEPTH $DK @d :n @" -d
--klammersets none -s "$DEPTH $DK @d :n @" -d
check_eq \
" 3. name written with a value — that value" \
"[7]" \
-k none -s "$DEPTH $DK @d :n 7 @" -d
--klammersets none -s "$DEPTH $DK @d :n 7 @" -d
# --- A type with an alone value but no default ---
check_eq \
" 4. no default declared — absent is empty" \
"[]" \
-k none -s "$MARK $MK @m@" -d
--klammersets none -s "$MARK $MK @m@" -d
check_eq \
" 5. no default declared — alone still applies" \
"[*]" \
-k none -s "$MARK $MK @m :c @" -d
--klammersets none -s "$MARK $MK @m :c @" -d
# --- The alone value belongs to the type, so every parameter of that
# type gets it, and a parameter default does not disturb it ---
@@ -144,73 +144,73 @@ check_eq \
check_eq \
" 6. two parameters of one type share the alone value" \
"[3][3]" \
-k none -s "$DEPTH @@d2 :a.depth :b.depth : [*a*][*b*] @@ @d2 :a :b @" -d
--klammersets none -s "$DEPTH @@d2 :a.depth :b.depth : [*a*][*b*] @@ @d2 :a :b @" -d
check_eq \
" 7. a parameter default overrides the type default, not the alone value" \
"[5]|[3]" \
-k none -s "$DEPTH @@d3 :n.depth 5 : [*n*] @@ @d3@|@d3 :n @" -d
--klammersets none -s "$DEPTH @@d3 :n.depth 5 : [*n*] @@ @d3@|@d3 :n @" -d
# --- bool: the convention that a bare boolean option means true ---
check_eq \
" 8. bool written alone is true" \
"[true]" \
-k none -s '@@b :f.bool : [*f*] @@ @b :f @' -d
--klammersets none -s '@@b :f.bool : [*f*] @@ @b :f @' -d
check_eq \
" 9. bool written with false stays false" \
"[false]" \
-k none -s '@@b :f.bool : [*f*] @@ @b :f false @' -d
--klammersets none -s '@@b :f.bool : [*f*] @@ @b :f false @' -d
check_eq \
"10. bool absent with no default is empty" \
"[]" \
-k none -s '@@b :f.bool : [*f*] @@ @b@' -d
--klammersets none -s '@@b :f.bool : [*f*] @@ @b@' -d
check_eq \
"11. bool absent with a true parameter default" \
"[true]" \
-k none -s '@@b :f.bool true : [*f*] @@ @b@' -d
--klammersets none -s '@@b :f.bool true : [*f*] @@ @b@' -d
check_eq \
"12. bool false explicitly against a true default" \
"[false]" \
-k none -s '@@b :f.bool true : [*f*] @@ @b :f false @' -d
--klammersets none -s '@@b :f.bool true : [*f*] @@ @b :f false @' -d
# --- The value reaching @eval ---
check_eq \
"13. python value of a bool written alone" \
"True" \
-k none -s '@@b :f.bool : @eval repr(K.f) @ @@ @b :f @' -d
--klammersets none -s '@@b :f.bool : @eval repr(K.f) @ @@ @b :f @' -d
check_eq \
"14. python value of an absent bool" \
"None" \
-k none -s '@@b :f.bool : @eval repr(K.f) @ @@ @b@' -d
--klammersets none -s '@@b :f.bool : @eval repr(K.f) @ @@ @b@' -d
check_eq \
"15. python value of a user type written alone" \
"'3'" \
-k none -s "$DEPTH @@d4 :n.depth : @eval repr(K.n) @ @@ @d4 :n @" -d
--klammersets none -s "$DEPTH @@d4 :n.depth : @eval repr(K.n) @ @@ @d4 :n @" -d
# --- Types that cannot delimit a bare option name ---
check_error \
"16. :alone refused on a type matching running text" \
"cannot declare an :alone value" \
-k none -s '@@@argtype loose | anything at all :alone x @@@' -d
--klammersets none -s '@@@argtype loose | anything at all :alone x @@@' -d
check_error \
"17. :alone refused on an explicit match-everything pattern" \
"cannot declare an :alone value" \
-k none -s '@@@argtype loose | anything :pattern (?:.^|\n)* :alone x @@@' -d
--klammersets none -s '@@@argtype loose | anything :pattern (?:.^|\n)* :alone x @@@' -d
check_error \
"18. an alone value must match its own type's pattern" \
"does not match its own pattern" \
-k none -s '@@@argtype depth | a depth :pattern \d+ :alone many @@@' -d
--klammersets none -s '@@@argtype depth | a depth :pattern \d+ :alone many @@@' -d
# --- Delimitation: an option value still runs to the next bar or option
# name, so text after a bare name is taken as the value and rejected
@@ -219,19 +219,19 @@ check_error \
check_error \
"19. text after a bare option name is taken as its value" \
"does not match" \
-k none -s "$DEPTH $DK @d :n some words @" -d
--klammersets none -s "$DEPTH $DK @d :n some words @" -d
check_eq \
"20. a bar separates a bare option name from following text" \
"[3]two" \
-k none -s "$DEPTH @@d5 :n.depth | t : [*n*]*t* @@ @d5 :n | two @" -d
--klammersets none -s "$DEPTH @@d5 :n.depth | t : [*n*]*t* @@ @d5 :n | two @" -d
# --- A type with no alone value is unchanged: a bare name is empty ---
check_eq \
"21. bare option of a type with no alone value is empty" \
"[]" \
-k none -s '@@s :t.word : [*t*] @@ @s :t @' -d
--klammersets none -s '@@s :t.word : [*t*] @@ @s :t @' -d
echo
echo "================================"

View File

@@ -1,6 +1,6 @@
#!/bin/bash
#
# check_test.sh — Static checking of klammer applications ("ktext --check").
# check_test.sh — Static checking of klammer applications ("kdiag --check").
#
# The engine applies klammers as it meets them, so it can only complain about
# what it reaches. Two things it therefore never reaches:
@@ -24,14 +24,17 @@
# literal parameters, which are code, filenames, and raw text -- not
# applications.
#
# Engine tier: no klammer set (-k none), every klammer defined inline.
# Engine tier: kdiag loads no klammerset, so every klammer is defined inline.
# The one case that needs the SKS reads it explicitly with "@read" -- which is
# how a klammerset reaches kdiag at all: the symbol mechanism is deliberately
# absent there, so that kdiag still works when symbol resolution is what broke.
#
# Usage: ./check_test.sh
# Exit code: 0 if all tests pass, 1 otherwise.
PASS=0
FAIL=0
KTEXT=ktext
KDIAG=kdiag
K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set}
red=$'\033[31m'
@@ -39,7 +42,7 @@ green=$'\033[32m'
bold=$'\033[1m'
reset=$'\033[0m'
# check_finds TEST_NAME PATTERN KTEXT_ARGS...
# check_finds TEST_NAME PATTERN KDIAG_ARGS...
# --check must exit nonzero and report PATTERN.
check_finds() {
local test_name="$1"
@@ -47,7 +50,7 @@ check_finds() {
shift 2
local output status
output=$("$KTEXT" --check "$@" 2>&1)
output=$("$KDIAG" --process --check "$@" 2>&1)
status=$?
if [ $status -eq 0 ]; then
@@ -65,14 +68,14 @@ check_finds() {
fi
}
# check_clean TEST_NAME KTEXT_ARGS...
# check_clean TEST_NAME KDIAG_ARGS...
# --check must exit 0 and report no diagnostics.
check_clean() {
local test_name="$1"
shift
local output status
output=$("$KTEXT" --check "$@" 2>&1)
output=$("$KDIAG" --process --check "$@" 2>&1)
status=$?
if [ $status -eq 0 ] && echo "$output" | grep -q "0 diagnostics"; then
@@ -85,7 +88,7 @@ check_clean() {
fi
}
# check_count TEST_NAME N KTEXT_ARGS...
# check_count TEST_NAME N KDIAG_ARGS...
# --check must report exactly N diagnostics.
check_count() {
local test_name="$1"
@@ -93,7 +96,7 @@ check_count() {
shift 2
local output got
output=$("$KTEXT" --check "$@" 2>&1)
output=$("$KDIAG" --process --check "$@" 2>&1)
got=$(echo "$output" | sed -nE 's/^([0-9]+) diagnostics?,.*/\1/p')
if [ "$got" = "$expected" ]; then
@@ -116,87 +119,95 @@ echo
check_finds " 1. undefined klammer in an unselected @cond branch (in a body)" \
"@nosuch is not defined" \
-k none -s "@@pick p : @cond *p* | @nosuch x @ | ok @ @@ @pick false @"
"@@pick p : @cond *p* | @nosuch x @ | ok @ @@ @pick false @"
check_finds " 2. wrong arity in an unselected @cond branch (in a body)" \
"is given 3" \
-k none -s "$GREET @@pick p : @cond *p* | @greet a | b | c @ | ok @ @@ @pick false @"
"$GREET @@pick p : @cond *p* | @greet a | b | c @ | ok @ @@ @pick false @"
# A @cond written at the top level of a DOCUMENT is resolved when the file is
# read, so its unselected branch is gone before anything can be checked. This
# test records that limitation rather than asserting the behavior is right; see
# notes/Klammertext_improvements.md, "When @cond is resolved".
check_clean " 2a. LIMITATION: a top-level @cond branch is resolved before checking" \
-k none -s "@cond false | @nosuch x @ | ok @"
# A top-level @cond branch is now checked like any other. It was the one
# documented gap in the checker: a @cond written at the top level of a DOCUMENT
# was resolved when the file was READ, so its unselected branch was gone before
# anything could look at it. Resolving @cond at APPLICATION time instead
# (notes/Klammertext_improvements.md §4.2, decided 2026-08-15) closed it -- the
# branches survive read, so both are visible to a pass that applies nothing.
check_finds " 2a. a top-level @cond branch is checked too" \
"@nosuch is not defined" \
"@cond false | @nosuch x @ | ok @"
check_finds " 2b. ... including the branch that WOULD be selected" \
"@nosuch is not defined" \
"@cond true | @nosuch x @ | ok @"
check_finds " 3. undefined klammer in a body that is never applied" \
"@nosuch is not defined" \
-k none -s "@@unused : @nosuch x @ @@ nothing applies it"
"@@unused : @nosuch x @ @@ nothing applies it"
check_finds " 4. the body it was found in is named" \
"in body of @unused" \
-k none -s "@@unused : @nosuch x @ @@ nothing applies it"
"@@unused : @nosuch x @ @@ nothing applies it"
# --- Arity ---
check_finds " 5. too few positional arguments" \
"needs 2 positional arguments but is given 1" \
-k none -s '@@pair a | b : *a**b* @@ @pair x @'
'@@pair a | b : *a**b* @@ @pair x @'
check_finds " 6. too many positional arguments" \
"takes 1 positional argument but is given 2" \
-k none -s "$GREET @greet a | b @"
"$GREET @greet a | b @"
check_finds " 7. undefined optional argument" \
'has no optional argument ":nope"' \
-k none -s "$GREET @greet a :nope 1 @"
"$GREET @greet a :nope 1 @"
check_finds " 8. the accepted optional arguments are listed" \
"It accepts: :loud" \
-k none -s '@@greet name :loud : *name* @@ @greet a :nope 1 @'
'@@greet name :loud : *name* @@ @greet a :nope 1 @'
check_clean " 9. a rest argument accepts extra positional arguments" \
-k none -s '@@many a | rest.rest : *a* @@ @many x | y | z @'
'@@many a | rest.rest : *a* @@ @many x | y | z @'
# --- Nesting. A bar or an option name belonging to a nested klammer is not
# this klammer's; the checker counts at depth 0, as @cond does. ---
check_clean "10. nested klammer's bars are not counted as the outer's" \
-k none -s '@@frac a | b : *a*/*b* @@ @@one x : [*x*] @@ @one @frac 1 | 2 @ @'
'@@frac a | b : *a*/*b* @@ @@one x : [*x*] @@ @one @frac 1 | 2 @ @'
check_clean "11. nested klammer's option name is not counted as the outer's" \
-k none -s '@@inner a :flag : *a* @@ @@outer x : [*x*] @@ @outer @inner q :flag y @ @'
'@@inner a :flag : *a* @@ @@outer x : [*x*] @@ @outer @inner q :flag y @ @'
# --- Target coverage ---
check_finds "12. klammer not defined for a target" \
'is not defined for the target "tex"' \
-k none -s '@@@target html | HTML output @@@ @@@target tex | TeX output @@@ @@only.html : H @@ @only@'
'@@@target html | HTML output @@@ @@@target tex | TeX output @@@ @@only.html : H @@ @only@'
check_clean "13. defined for every target is clean" \
-k none -s '@@@target html | HTML output @@@ @@@target tex | TeX output @@@ @@both : B @@ @both@'
'@@@target html | HTML output @@@ @@@target tex | TeX output @@@ @@both : B @@ @both@'
# --- What the checker deliberately does not see ---
# In a body, so the @eval is not evaluated at read time: what is being tested
# is that the checker does not read the eval's ARGUMENT as an application.
check_clean "14. @eval argument content is code, not applications" \
-k none -s '@@w : @eval len("@nosuch") @ @@'
'@@w : @eval len("@nosuch") @ @@'
check_clean "15. a literal parameter's content is raw text" \
-k none -s '@@lit t.literal : *t* @@ @lit @nosuch x @ lit@'
'@@lit t.literal : *t* @@ @lit @nosuch x @ lit@'
# --- Reporting ---
check_count "16. a target-independent fault is reported once, not per target" \
1 \
-k none -s '@@@target html | HTML output @@@ @@@target tex | TeX output @@@ @@g : @nosuch@ @@'
'@@@target html | HTML output @@@ @@@target tex | TeX output @@@ @@g : @nosuch@ @@'
check_clean "17. a correct document checks clean" \
-k none -s "$GREET @greet World @"
"$GREET @greet World @"
# An absolute path: "make -C tst test" runs from tst/, and @read resolves
# against the current directory.
check_clean "18. the Standard Klammer Set checks clean" \
-s 'x'
"@read $K/sks/sks.k @"
echo
echo "============================="

View File

@@ -113,6 +113,76 @@ else
echo "SKIP 13/14. two positionals (argv_test not built; run make -C tst)"
fi
echo
echo "-- the output policy: three categories, two streams --"
# The three commands display text in exactly three cases:
# 1. logging under "-v" > 0 -> STDERR
# 2. an error before termination -> STDERR
# 3. output the user asked for -> STDOUT
# For ktext, category 3 is a DOCUMENT that may be piped, so nothing else may
# share the stream. It did: K::log and msg() both wrote to stdout, so
# "ktext -d -v 1 > doc.txt" put the whole argument dump inside the document.
OUTF=$(mktemp /tmp/kout.XXXXXX); ERRF=$(mktemp /tmp/kerr.XXXXXX)
trap 'rm -f "$OUTF" "$ERRF"' EXIT
ktext --klammersets none -s 'hello' -d -v 1 > "$OUTF" 2> "$ERRF"
if [ "$(cat "$OUTF")" = "hello" ]; then
echo "${green}PASS${reset} 15. stdout under -d -v 1 is the document, nothing else"
PASS=$((PASS+1))
else
echo "${red}FAIL${reset} 15. stdout carried more than the document"
echo " got: $(head -3 "$OUTF")"; FAIL=$((FAIL+1))
fi
if [ -s "$ERRF" ]; then
echo "${green}PASS${reset} 16. ... and the -v 1 logging went to stderr"; PASS=$((PASS+1))
else
echo "${red}FAIL${reset} 16. -v 1 produced no stderr"; FAIL=$((FAIL+1))
fi
# -v 0 adds nothing anywhere: silence is the default, on both streams.
ktext --klammersets none -s 'hello' -d > "$OUTF" 2> "$ERRF"
if [ "$(cat "$OUTF")" = "hello" ] && [ ! -s "$ERRF" ]; then
echo "${green}PASS${reset} 17. -v 0 is silent on both streams"; PASS=$((PASS+1))
else
echo "${red}FAIL${reset} 17. -v 0 was not silent (stderr: $(head -2 "$ERRF"))"
FAIL=$((FAIL+1))
fi
# An error is category 2: stderr, and nothing on stdout to confuse a pipe.
ktext --klammersets none -s '@nosuch x @' -d > "$OUTF" 2> "$ERRF"
if [ ! -s "$OUTF" ] && [ -s "$ERRF" ]; then
echo "${green}PASS${reset} 18. an error goes to stderr, leaving stdout empty"; PASS=$((PASS+1))
else
echo "${red}FAIL${reset} 18. error stream discipline (stdout: $(head -2 "$OUTF"))"
FAIL=$((FAIL+1))
fi
# Colour is emitted only to a terminal, so a redirected stream never carries
# escape sequences -- into a document, a pipe, or a captured log.
for pair in "ktext:--klammersets none -s hello -d" "kdesc:-k table" "kdiag:@i-x"; do
cmd=${pair%%:*}; rest=${pair#*:}
# shellcheck disable=SC2086
$cmd $rest > "$OUTF" 2> "$ERRF"
if ! grep -q $'\033' "$OUTF" && ! grep -q $'\033' "$ERRF"; then
echo "${green}PASS${reset} 19. no escape sequences from $cmd when redirected"
PASS=$((PASS+1))
else
echo "${red}FAIL${reset} 19. $cmd emitted colour to a non-terminal"; FAIL=$((FAIL+1))
fi
done
echo
echo "-- a bare command is a request, not a failure --"
# Usage is the result being asked for: stdout, exit 0. It exited 1, so
# "kdesc && echo ok" reported failure for a successful help request.
for cmd in ktext kdesc kdiag; do
$cmd > "$OUTF" 2> "$ERRF"; status=$?
if [ $status -eq 0 ] && [ -s "$OUTF" ] && ! [ -s "$ERRF" ]; then
echo "${green}PASS${reset} 20. $cmd with no arguments: usage on stdout, exit 0"
PASS=$((PASS+1))
else
echo "${red}FAIL${reset} 20. $cmd bare: exit $status, stdout $(wc -l < "$OUTF") lines, stderr $(wc -l < "$ERRF") lines"
FAIL=$((FAIL+1))
fi
done
echo
echo "===================="
echo "Results: ${PASS} passed, ${FAIL} failed"

View File

@@ -117,6 +117,11 @@ check_error() {
FRAC='@@frac a | b : *a*/*b* @@'
# A file for the @read non-strictness cases (21-21b).
READ_FIXTURE=$(mktemp /tmp/cond_read.XXXXXX)
printf 'READ-FIXTURE\n' > "$READ_FIXTURE"
trap 'rm -f "$READ_FIXTURE"' EXIT
echo "${bold}@cond argument delimitation tests${reset}"
echo "================================="
echo
@@ -174,6 +179,99 @@ check_error "14. zero bars is still an error" \
"one or two bar characters" \
-s '@cond true @' -d
# --- The predicate relation: 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. It was
# partial until then: is_true() recognized three strings and everything else
# took the false branch, so a misspelled variable, a "TRUE", a "yes", or a
# Python traceback all silently selected a branch. A warning had made that
# visible while the policy was open; it never fired on the SKS, which is the
# evidence that the blast radius is small.
echo
echo "-- the predicate relation --"
for p in true True 1; do
check_eq "15. \"$p\" is true" "T" -s "@cond $p | T | F @" -d
done
for p in false False 0; do
check_eq "16. \"$p\" is false" "F" -s "@cond $p | T | F @" -d
done
# Empty stays FALSE, and load-bearing: an optional argument that was not
# written substitutes as empty, which is what carries the "@cond *opt*" idiom.
# The entangled sub-question in §4.1 -- empty means false, or means "not
# supplied"? -- is answered "false" by that use.
check_eq "17. an absent optional argument is false" "F" \
-s '@@g :opt : @cond *opt* | T | F @ @@ @g@' -d
check_eq "17a. ... and the same argument written true is true" "T" \
-s '@@g :opt : @cond *opt* | T | F @ @@ @g :opt true @' -d
check_error "18. an unrecognized predicate is an error, not false" \
"is not a truth value" \
-s '@cond yes | T | F @' -d
check_error "18a. ... including a near miss of a true value" \
"is not a truth value" \
-s '@cond TRUE | T | F @' -d
# A state variable in a klammer BODY works: a body is processed at application
# time, after substitution, so @cond sees the value.
check_eq "19. a state variable in a body reaches the @cond" "T" \
-s '@@@state Flag :value true @@@ @@g : @cond *Flag* | T | F @ @@ @g@' -d
# A top-level state variable reaches the predicate too, since @cond is resolved
# at APPLICATION time (notes/Klammertext_improvements.md §4.2, decided
# 2026-08-15). It did not until then: a top-level @cond was resolved when the
# file was READ, which is before state substitution, so it saw the literal
# "*Flag*" and silently took the false branch -- the WRONG answer for a flag
# whose value was true. The document now behaves like a klammer body: its
# state variables are bound before its conditionals are decided.
check_eq "19a. a top-level state variable reaches the @cond" "T" \
-s '@@@state Flag :value true @@@ @cond *Flag* | T | F @' -d
check_eq "19b. ... and selects the false branch when it is false" "F" \
-s '@@@state Flag :value false @@@ @cond *Flag* | T | F @' -d
# --- Non-strictness: nothing in a discarded branch runs ---
#
# doc/cond_evaluation_order.md states this ("with side-effecting @read/@eval,
# wrong ... must not read the missing file"). @read honoured it; @eval did not,
# because the eval pass swept the list before the cond pass did. Both honour it
# now: mark_cond_content() makes a branch inert BEFORE either pass runs.
check_eq "21. a @read in a discarded branch is not performed" "ok" \
-s "@cond false | @read $READ_FIXTURE @ | ok @" -d
check_eq "21a. ... and IS performed when the branch is selected" "READ-FIXTURE" \
-s "@cond true | @read $READ_FIXTURE @ | ok @" -d
# The case the design document names: the file need not even exist.
check_eq "21b. ... so a missing file in a discarded branch is not an error" "ok" \
-s '@cond false | @read /nonexistent/no-such-file.txt @ | ok @' -d
# An @eval side effect is the observable test: the branch either touched the
# file or it did not.
SIDE=$(mktemp -u /tmp/cond_side.XXXXXX)
"$KTEXT" --klammersets none -s "@cond false | @eval :shell touch $SIDE @ | ok @" -d >/dev/null 2>&1
if [ -f "$SIDE" ]; then
echo "${red}FAIL${reset} 22. an @eval in a discarded branch ran"; FAIL=$((FAIL+1)); rm -f "$SIDE"
else
echo "${green}PASS${reset} 22. an @eval in a discarded branch does not run"; PASS=$((PASS+1))
fi
"$KTEXT" --klammersets none -s "@cond true | @eval :shell touch $SIDE @ | ok @" -d >/dev/null 2>&1
if [ -f "$SIDE" ]; then
echo "${green}PASS${reset} 22a. ... and does run when the branch is selected"; PASS=$((PASS+1)); rm -f "$SIDE"
else
echo "${red}FAIL${reset} 22a. an @eval in the selected branch did not run"; FAIL=$((FAIL+1))
fi
# The PREDICATE is always evaluated -- a conditional that could not compute its
# own predicate would be useless. Only the branches are non-strict.
check_eq "23. the predicate is evaluated even though the branches are not" "yes" \
-s '@cond @eval 1==1 @ | yes | no @' -d
# The recognized sets are named in the message, since the whole point is that
# the writer has to know what they are.
check_error "20. the message names the recognized values" \
"true, True, 1" \
-s '@cond yes | T | F @' -d
rm -f /tmp/cond_test_err.$$
echo

View File

@@ -17,7 +17,10 @@
# REPORTED, and a companion case checks that rendering is unaffected.
#
# Engine tier: the fixtures in tst/coverage/ declare their own targets with
# @@@target, so no klammer set is involved.
# @@@target, so no klammer set is involved -- hence "--klammersets none".
# Since the 2026-08-14 redesign "-i" ADDS a file to whatever klammersets are
# loaded (the SKS by default) rather than replacing them, so the exclusion has
# to be explicit or every count here would include the SKS.
#
# Usage: ./coverage_test.sh (needs KLAMMERTEXT_HOME set; kdesc on PATH)
# Exit code: 0 if all tests pass, 1 otherwise.
@@ -34,23 +37,50 @@ green=$'\033[32m'
bold=$'\033[1m'
reset=$'\033[0m'
# Each fixture is analysed once; the tests match against the saved report.
declare -A REPORT VREPORT
# GNU coreutils' "timeout" is NOT present on macOS, and Homebrew's is named
# "gtimeout", so a bare "timeout" made this suite fail WHOLESALE there -- every
# case, because the command never ran at all (found on Olion, 2026-08-16). The
# guard is a safety net against a hung command, not part of what is being
# tested, so it is optional: bound the command where the tool exists, run it
# directly where it does not.
if command -v timeout >/dev/null 2>&1; then
limited() { timeout 60 "$@"; }
elif command -v gtimeout >/dev/null 2>&1; then
limited() { gtimeout 60 "$@"; }
else
limited() { "$@"; }
fi
# Each fixture is analysed once and its report SAVED TO A FILE, keyed by name.
#
# It used to be a pair of associative arrays. "declare -A" is bash 4, and
# macOS ships bash 3.2 as /bin/bash -- where the declaration fails and every
# string subscript then evaluates to 0, so all five fixtures overwrote one
# slot and the whole suite compared the wrong report against the wrong test.
# Files have no such floor and read the same on both systems.
REPORTS=$(mktemp -d /tmp/coverage_reports.XXXXXX)
trap 'rm -rf "$REPORTS"' EXIT
for f in basic undecidable cycle split clean; do
REPORT[$f]=$(timeout 30 "$KDESC" -i "$DIR/$f.k" --coverage 2>&1 |
sed 's/\x1b\[[0-9;]*m//g')
limited "$KDESC" --klammersets none -i "$DIR/$f.k" --coverage 2>&1 |
sed 's/\x1b\[[0-9;]*m//g' > "$REPORTS/$f"
# "all" adds the source-file column AND the empty problem categories.
VREPORT[$f]=$(timeout 30 "$KDESC" -i "$DIR/$f.k" --coverage all 2>&1 |
sed 's/\x1b\[[0-9;]*m//g')
if [ -z "${REPORT[$f]}" ] || [ -z "${VREPORT[$f]}" ]; then
limited "$KDESC" --klammersets none -i "$DIR/$f.k" --coverage all 2>&1 |
sed 's/\x1b\[[0-9;]*m//g' > "$REPORTS/$f.all"
if [ ! -s "$REPORTS/$f" ] || [ ! -s "$REPORTS/$f.all" ]; then
echo "${red}FAIL${reset} $f.k produced no report"; FAIL=$((FAIL+1))
fi
done
# report FIXTURE [all] — the saved report, on stdout.
report() { cat "$REPORTS/$1"; }
vreport() { cat "$REPORTS/$1.all"; }
# vlacks NAME FIXTURE REGEX — the VERBOSE report does NOT match REGEX.
vlacks() {
local name="$1" fixture="$2" rgx="$3"
if printf '%s\n' "${VREPORT[$fixture]}" | grep -Eq "$rgx"; then
if vreport "$fixture" | grep -Eq "$rgx"; then
echo "${red}FAIL${reset} $name — unexpected match: $rgx"; FAIL=$((FAIL+1))
else
echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
@@ -60,7 +90,7 @@ vlacks() {
# vhas NAME FIXTURE REGEX — the VERBOSE report matches REGEX.
vhas() {
local name="$1" fixture="$2" rgx="$3"
if printf '%s\n' "${VREPORT[$fixture]}" | grep -Eq "$rgx"; then
if vreport "$fixture" | grep -Eq "$rgx"; then
echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
else
echo "${red}FAIL${reset} $name"
@@ -71,7 +101,7 @@ vhas() {
# has NAME FIXTURE REGEX — the report matches REGEX.
has() {
local name="$1" fixture="$2" rgx="$3"
if printf '%s\n' "${REPORT[$fixture]}" | grep -Eq "$rgx"; then
if report "$fixture" | grep -Eq "$rgx"; then
echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
else
echo "${red}FAIL${reset} $name"
@@ -86,7 +116,7 @@ has() {
section_lacks() {
local name="$1" fixture="$2" header="$3" rgx="$4"
local body
body=$(printf '%s\n' "${REPORT[$fixture]}" |
body=$(report "$fixture" |
awk -v h="$header" 'index($0, h) == 1 { f = 1; next } f && /^$/ { exit } f')
if [ -z "$body" ]; then
echo "${red}FAIL${reset} $name — section [$header] not found"; FAIL=$((FAIL+1)); return
@@ -101,7 +131,7 @@ section_lacks() {
# lacks NAME FIXTURE REGEX — the report does NOT match REGEX.
lacks() {
local name="$1" fixture="$2" rgx="$3"
if printf '%s\n' "${REPORT[$fixture]}" | grep -Eq "$rgx"; then
if report "$fixture" | grep -Eq "$rgx"; then
echo "${red}FAIL${reset} $name — unexpected match: $rgx"; FAIL=$((FAIL+1))
else
echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
@@ -243,7 +273,7 @@ echo
echo "-- the analysis changes nothing --"
# Coverage is a report, not a policy: a klammer whose general body cannot be
# interpreted is still offered to every target, exactly as before.
out=$("$KTEXT" -k none -s '@@@target ta | Target A @@@
out=$("$KTEXT" --klammersets none -s '@@@target ta | Target A @@@
@@e : @eval 6 * 7 @ @@ x @e@' -t ta -d 2>&1 | tr -d '\n ')
if [ "$out" = "x42" ]; then
echo "${green}PASS${reset} 22. an underivable klammer still renders"; PASS=$((PASS+1))

View File

@@ -62,7 +62,16 @@ check_eq() {
fi
}
# check_warn NAME EXPECTED KTEXT_ARGS... — exit 0, stdout==EXPECTED, AND a warning on stderr.
# check_warn NAME EXPECTED KTEXT_ARGS... — exit 0, stdout==EXPECTED, the
# override REPORTED at "-v 1" and SILENT at the default verbosity.
#
# It was a warning until 2026-08-15. ":::" exists to override, and a klammer
# set the user did not write may be overridden by design (TODO #33), so the
# notice fired on the sanctioned use of a feature: a warning nobody can act on
# is not a warning. It is still worth reporting, because the definition being
# replaced usually lives in another file in another klammerset, which the user
# cannot see from what they wrote -- so it is "-v 1" (see the output policy in
# CLAUDE.md).
check_warn() {
local name="$1" expected="$2"; shift 2
local out status err
@@ -72,8 +81,14 @@ check_warn() {
if [ $status -ne 0 ]; then
echo "${red}FAIL${reset} $name — ktext exited $status"; FAIL=$((FAIL+1)); return
fi
if ! printf '%s' "$err" | grep -qiF "warning"; then
echo "${red}FAIL${reset} $nameexpected a warning, got none"; FAIL=$((FAIL+1)); return
if [ -s "$ERR" ]; then
echo "${red}FAIL${reset} $namethe default run was not silent: $(head -1 "$ERR")"
FAIL=$((FAIL+1)); return
fi
"$KTEXT" "$@" -v 1 >/dev/null 2>"$ERR"
err=$(cat "$ERR")
if ! printf '%s' "$err" | grep -qiF "overridden"; then
echo "${red}FAIL${reset} $name — no override reported at -v 1"; FAIL=$((FAIL+1)); return
fi
if [ "$out" = "$expected" ]; then
echo "${green}PASS${reset} $name"; PASS=$((PASS+1))

View File

@@ -7,7 +7,7 @@
# mac/machine.cpp), not the SKS. A target is a MACHINE construct — declared
# with the @@@target system command, not owned by any klammer set — so the
# idiomatic engine-level test defines its own fixture target inline and loads
# no klammer set (`-k none`); it does not "avoid" the SKS so much as have no
# no klammer set (`--klammersets none`); it does not "avoid" the SKS so much as have no
# need of it. Target `t` here escapes `& -> AMP`, `_ -> UND`, `\ -> BSL`
# (arbitrary tokens, easy to assert). The SKS's own targets (tex, html) and
# the specific characters they declare are exercised by the SKS suite.
@@ -75,29 +75,29 @@ echo "======================================================================="
echo
# A self-contained fixture target, defined inline via the @@@target system
# command, escaping & _ \ to distinct tokens. -k none loads no klammer set,
# command, escaping & _ \ to distinct tokens. --klammersets none loads no klammer set,
# so nothing below depends on the SKS.
T='@@@target t | test target :escape & AMP _ UND \ BSL @@@'
check_eq " 1. top-level text: & escaped" 'A AMP B' -k none -t t -s "$T A & B"
check_eq " 2. general klammer body: & escaped" 'A AMP B' -k none -t t -s "$T @@g : A & B @@ @g@"
check_eq " 3. general klammer body: _ escaped" 'AUNDB' -k none -t t -s "$T @@g : A_B @@ @g@"
check_eq " 4. general klammer body: backslash escaped" 'aBSLb' -k none -t t -s "$T @@g : a\\b @@ @g@"
check_eq " 5. target-specific body: NOT escaped" 'A & B' -k none -t t -s "$T @@g.k : d @@ @@g.t :: A & B @@ @g@"
check_eq " 6. general body ^'...'^ literal: NOT escaped" 'a&b' -k none -t t -s "$T @@g : ^'a&b'^ @@ @g@"
check_eq " 7. nested target-native klammer survives escape" 'X AMP Y \newline Z' -k none -t t -s "$T @@n.t : \\newline @@ @@g : X & Y @n@ Z @@ @g@"
check_eq " 1. top-level text: & escaped" 'A AMP B' --klammersets none -t t -s "$T A & B"
check_eq " 2. general klammer body: & escaped" 'A AMP B' --klammersets none -t t -s "$T @@g : A & B @@ @g@"
check_eq " 3. general klammer body: _ escaped" 'AUNDB' --klammersets none -t t -s "$T @@g : A_B @@ @g@"
check_eq " 4. general klammer body: backslash escaped" 'aBSLb' --klammersets none -t t -s "$T @@g : a\\b @@ @g@"
check_eq " 5. target-specific body: NOT escaped" 'A & B' --klammersets none -t t -s "$T @@g.k : d @@ @@g.t :: A & B @@ @g@"
check_eq " 6. general body ^'...'^ literal: NOT escaped" 'a&b' --klammersets none -t t -s "$T @@g : ^'a&b'^ @@ @g@"
check_eq " 7. nested target-native klammer survives escape" 'X AMP Y \newline Z' --klammersets none -t t -s "$T @@n.t : \\newline @@ @@g : X & Y @n@ Z @@ @g@"
# 8-9: @eval inside a general body. Code (with underscores) must not be
# escaped or Python breaks; the Klammertext it returns is klammer output and
# must not be escaped either. chr(64) builds a literal '@' so the returned
# klammer call is not parsed as one in this source line.
check_eq " 8. general body @eval code NOT escaped" '3' -k none -t t -s "$T @@g : @eval (1).__add__(2) @ @@ @g@"
check_eq " 9. general body @eval klammer result NOT escaped" '\textbf{hi}' -k none -t t -s "$T @@b.k s : d @@ @@b.t :: \\textbf{*s*} @@ @@g : @eval chr(64)+'b hi '+chr(64) @ @@ @g@"
check_eq " 8. general body @eval code NOT escaped" '3' --klammersets none -t t -s "$T @@g : @eval (1).__add__(2) @ @@ @g@"
check_eq " 9. general body @eval klammer result NOT escaped" '\textbf{hi}' --klammersets none -t t -s "$T @@b.k s : d @@ @@b.t :: \\textbf{*s*} @@ @@g : @eval chr(64)+'b hi '+chr(64) @ @@ @g@"
# 10-11: an @eval result that still holds klammers is a GENERATOR (Klammertext
# with data) -- its writer text is escaped for the target before the klammers
# are applied; a result with no klammers is a RENDERER (final markup) -- left
# untouched. The signal is "does the read-back result contain a klammer".
check_eq "10. @eval generator: klammer result's data escaped" '[a AMP b]' -k none -t t -s "$T @@wrap z : [*z*] @@ @@g : @eval chr(64)+'wrap a & b '+chr(64) @ @@ @g@"
check_eq "11. @eval renderer: final markup NOT escaped" 'raw & markup' -k none -t t -s "$T @@g : @eval 'raw & markup' @ @@ @g@"
check_eq "10. @eval generator: klammer result's data escaped" '[a AMP b]' --klammersets none -t t -s "$T @@wrap z : [*z*] @@ @@g : @eval chr(64)+'wrap a & b '+chr(64) @ @@ @g@"
check_eq "11. @eval renderer: final markup NOT escaped" 'raw & markup' --klammersets none -t t -s "$T @@g : @eval 'raw & markup' @ @@ @g@"
# 12-18: quoted KLAMMERTEXT specials (^@ ^| ^# ^^ ^: ^*) and ^'...'^ literal
# regions. The katomizer strips the "^"; hide_special_katoms() and
@@ -108,21 +108,21 @@ check_eq "11. @eval renderer: final markup NOT escaped" 'raw & markup'
# leaked as a bare apply-end katom into re-read text ("A klammer ends
# without a beginning"). Exact-match expectations also guard against KTESC
# markers leaking into output.
check_eq "12. quoted @ | # resolve to the characters" 'x @ | # y' -k none -t t -s "$T x ^@ ^| ^# y"
check_eq "13. quoted ^ : * resolve to the characters" 'x ^ : * y' -k none -t t -s "$T x ^^ ^: ^* y"
check_eq "14. ^'...'^ region: specials stay literal" 'a @ | b' -k none -t t -s "$T a ^' @ | '^ b"
check_eq "15. general body: quoted @ resolves" 'x @ y' -k none -t t -s "$T @@g : x ^@ y @@ @g@"
check_eq "12. quoted @ | # resolve to the characters" 'x @ | # y' --klammersets none -t t -s "$T x ^@ ^| ^# y"
check_eq "13. quoted ^ : * resolve to the characters" 'x ^ : * y' --klammersets none -t t -s "$T x ^^ ^: ^* y"
check_eq "14. ^'...'^ region: specials stay literal" 'a @ | b' --klammersets none -t t -s "$T a ^' @ | '^ b"
check_eq "15. general body: quoted @ resolves" 'x @ y' --klammersets none -t t -s "$T @@g : x ^@ y @@ @g@"
# 16: inside an @eval argument span a quoted special reaches the CODE as the
# character (the span is skipped by hide_special_katoms, like the escape pass).
check_eq "16. @eval code: quoted : reaches shell as ':'" 'x:y' -k none -t t -s "$T @@g : @eval :shell echo x^:y @ @@ @g@"
check_eq "16. @eval code: quoted : reaches shell as ':'" 'x:y' --klammersets none -t t -s "$T @@g : @eval :shell echo x^:y @ @@ @g@"
# 17: an @eval result emitting the two characters ^ @ is re-read as a quoted
# special and survives to the output as a literal @ (the generator idiom for
# a literal @; a bare @ in a result is a parse error by design).
check_eq "17. @eval result ^@ survives read-back as @" '@' -k none -t t -s "$T @@g : @eval chr(94)+chr(64) @ @@ @g@"
check_eq "17. @eval result ^@ survives read-back as @" '@' --klammersets none -t t -s "$T @@g : @eval chr(94)+chr(64) @ @@ @g@"
# 18: a bare-Python :after_apply phase receives the RESOLVED result text
# (K_result) and its return is taken as raw target text, not re-read as
# Klammertext -- a resolved @ in the result must not be re-parsed.
check_eq "18. :after_apply phase: raw result, @ intact" 'A @ B' -k none -t u -s '@@@target u | up :after_apply string.capwords @@@ a ^@ b'
check_eq "18. :after_apply phase: raw result, @ intact" 'A @ B' --klammersets none -t u -s '@@@target u | up :after_apply string.capwords @@@ a ^@ b'
rm -f "$ERR"

226
tst/eval_test.sh Executable file
View File

@@ -0,0 +1,226 @@
#!/bin/bash
#
# eval_test.sh — the @eval primitive's contract with the outside world.
#
# @eval is the one primitive that reaches OUT of Klammertext, and until
# 2026-08-15 nothing tested what it did with what came back. ":shell" is
# covered here; the other modes (Python, :cpp, :haskell) are exercised
# incidentally by other suites and can grow into this one.
#
# The two defects this suite exists to hold shut, both of them the same shape
# as a msg() on the wrong stream -- output nobody chose to see, and a failure
# nobody was told about:
#
# * The command's STDERR went straight to the user's terminal, unattributed
# and unsuppressable. 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
# now captured and reported at "-v 1".
# * The EXIT STATUS was discarded, so a command that failed contributed its
# partial output (or nothing) to the document and said nothing at all.
#
# NOT to be confused with "eval_test", the C++ diagnostic program built from
# eval_test.cpp in this directory: that one constructs engine objects and prints
# what it gets, for a person to read, and asserts nothing (see the `smoke`
# target in tst/Makefile). This is the regression suite. The ".sh" is the only
# thing distinguishing them, and it is the first such collision in tst/.
#
# Usage: ./eval_test.sh (needs KLAMMERTEXT_HOME set; ktext on PATH)
# Exit code: 0 if all tests pass, 1 otherwise.
PASS=0
FAIL=0
KTEXT=ktext
K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set}
red=$'\033[31m'
green=$'\033[32m'
bold=$'\033[1m'
reset=$'\033[0m'
OUTF=$(mktemp /tmp/eval_out.XXXXXX)
ERRF=$(mktemp /tmp/eval_err.XXXXXX)
trap 'rm -f "$OUTF" "$ERRF"' EXIT
plain() { sed 's/\x1b\[[0-9;]*m//g'; }
# run SOURCE [EXTRA...] — sets STATUS, and fills OUTF/ERRF.
STATUS=0
run() {
local src="$1"; shift
"$KTEXT" --klammersets none -s "$src" -d "$@" >"$OUTF" 2>"$ERRF"
STATUS=$?
}
pass() { echo "${green}PASS${reset} $1"; PASS=$((PASS + 1)); }
fail() { echo "${red}FAIL${reset} $1"; [ -n "$2" ] && echo " $2"; FAIL=$((FAIL + 1)); }
echo "${bold}@eval tests${reset}"
echo "==========="
echo
echo "-- :shell, the ordinary case --"
run '@eval :shell echo hello @'
if [ $STATUS -eq 0 ] && [ "$(cat "$OUTF")" = "hello" ]; then
pass " 1. the command's stdout becomes document text"
else
fail " 1. exit $STATUS, stdout [$(cat "$OUTF")]"
fi
echo
echo "-- stderr belongs to the command, not to the terminal --"
# A signal death is not an error exit, and a crash must never read as a pass:
# every case here checks the status numerically.
run '@eval :shell echo OUT; echo NOISE >&2 @'
if [ $STATUS -eq 0 ] && [ "$(cat "$OUTF")" = "OUT" ]; then
pass " 2. stdout is the document; stderr is not in it"
else
fail " 2. exit $STATUS, stdout [$(cat "$OUTF")]"
fi
if [ ! -s "$ERRF" ]; then
pass " 3. ... and nothing leaks to the terminal at the default verbosity"
else
fail " 3. stderr leaked: $(head -1 "$ERRF")"
fi
run '@eval :shell echo OUT; echo NOISE >&2 @' -v 1
if grep -q "NOISE" "$ERRF"; then
pass " 4. ... while -v 1 reports what the command said"
else
fail " 4. -v 1 did not report the command's stderr" "$(head -2 "$ERRF")"
fi
if grep -qi "stderr" "$ERRF"; then
pass " 5. ... and says that is what it is"
else
fail " 5. the -v 1 report does not identify the stream"
fi
echo
echo "-- a failing command is an error, not silence --"
run '@eval :shell exit 3 @'
if [ $STATUS -ne 0 ] && [ $STATUS -lt 128 ]; then
pass " 6. a nonzero exit status fails the run"
else
fail " 6. exit $STATUS (128+ would be a signal death, 0 a silent pass)"
fi
for want in "exit status 3" "The shell command failed"; do
if grep -qF "$want" "$ERRF"; then
pass " 7. the error says [$want]"
else
fail " 7. the error does not say [$want]" "$(plain < "$ERRF" | head -2)"
fi
done
# What the command itself reported is the useful half of the diagnosis.
run '@eval :shell echo WHY-IT-FAILED >&2; exit 1 @'
if grep -qF "WHY-IT-FAILED" "$ERRF"; then
pass " 8. ... and includes what the command wrote to stderr"
else
fail " 8. the command's own message was dropped" "$(plain < "$ERRF" | head -3)"
fi
# An error is category 2: stderr, and nothing on stdout to confuse a pipe.
if [ ! -s "$OUTF" ]; then
pass " 9. ... and leaves stdout empty"
else
fail " 9. stdout carried [$(head -c 60 "$OUTF")]"
fi
echo
echo "-- the escape hatch, because some commands exit nonzero on purpose --"
# "grep" finding no match is the usual one. Strictness with an explicit way to
# say "I meant that" is the same shape as the @cond predicate rule.
run '@eval :shell exit 3 @'
if grep -qF "|| true" "$ERRF"; then
pass "10. the error names the way to say a nonzero status was intended"
else
fail "10. the error does not offer the remedy" "$(plain < "$ERRF" | head -3)"
fi
# NOT "exit N || true": exit terminates the shell before "||" is reached, so
# that spelling cannot work and is not what the message suggests. A command
# that merely RETURNS nonzero is the case the remedy is for.
run '@eval :shell echo kept; grep -q zzz /dev/null || true @'
if [ $STATUS -eq 0 ] && [ "$(cat "$OUTF")" = "kept" ]; then
pass "11. ... and it works"
else
fail "11. exit $STATUS, stdout [$(cat "$OUTF")]"
fi
# The same command without the remedy is an error, or case 11 proves nothing.
run '@eval :shell echo kept; grep -q zzz /dev/null @'
if [ $STATUS -ne 0 ] && [ $STATUS -lt 128 ]; then
pass "11a. ... and without it the same command fails"
else
fail "11a. exit $STATUS — expected a nonzero, non-signal exit"
fi
echo
echo "-- the command may contain its own pipeline --"
# The redirection that captures stderr must not disturb the writer's command.
run '@eval :shell echo one two three | tr " " "-" @'
if [ $STATUS -eq 0 ] && [ "$(cat "$OUTF")" = "one-two-three" ]; then
pass "12. a pipeline inside the command still works"
else
fail "12. exit $STATUS, stdout [$(cat "$OUTF")]"
fi
run '@eval :shell echo a > /dev/null; echo b @'
if [ $STATUS -eq 0 ] && [ "$(cat "$OUTF")" = "b" ]; then
pass "13. ... and so does a redirection of its own"
else
fail "13. exit $STATUS, stdout [$(cat "$OUTF")]"
fi
echo
echo "-- the other modes still work --"
run '@eval 6*7 @'
if [ $STATUS -eq 0 ] && [ "$(cat "$OUTF")" = "42" ]; then
pass "14. a Python expression"
else
fail "14. exit $STATUS, stdout [$(cat "$OUTF")]"
fi
echo
echo "-- :haskell, the same contract --"
# Skipped where GHC is absent: runghc is an optional dependency (the
# akopra/klammertext:haskell image, or a local GHCup install), and a suite that
# fails for its absence would be reporting the machine, not the code.
if ! command -v runghc >/dev/null 2>&1; then
echo "SKIP 15-18. :haskell (runghc not installed)"
else
# The defect: runghc 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.
HS='@eval :haskell import System.IO
main = hPutStrLn stderr "HS-NOISE" >> putStrLn "HS-OUT" @'
run "$HS"
if [ $STATUS -eq 0 ] && [ "$(cat "$OUTF")" = "HS-OUT" ]; then
pass "15. the program's stdout is the document; its stderr is not"
else
fail "15. exit $STATUS, stdout [$(cat "$OUTF")]"
fi
run "$HS" -v 1
if grep -q "HS-NOISE" "$ERRF"; then
pass "16. ... and -v 1 reports what it wrote to stderr"
else
fail "16. -v 1 did not report it" "$(plain < "$ERRF" | head -2)"
fi
# A compile error was ALREADY reported rather than swallowed -- the exit
# status was checked -- so this pins behaviour that was right, and that the
# detail now comes from the captured stderr rather than a merged stream.
run '@eval :haskell main = putStrLn (1 + "x") @'
if [ $STATUS -ne 0 ] && [ $STATUS -lt 128 ] && grep -qi "error" "$ERRF"; then
pass "17. a compile error fails the run and shows what runghc said"
else
fail "17. exit $STATUS" "$(plain < "$ERRF" | head -3)"
fi
# A program that compiles, runs, and then exits nonzero is the other half.
run '@eval :haskell import System.Exit
main = putStrLn "partial" >> exitWith (ExitFailure 3) @'
if [ $STATUS -ne 0 ] && grep -qF "exit status 3" "$ERRF"; then
pass "18. a nonzero exit from the program itself is reported too"
else
fail "18. exit $STATUS" "$(plain < "$ERRF" | head -3)"
fi
fi
echo
echo "==========="
echo "Results: ${PASS} passed, ${FAIL} failed"
[ "$FAIL" -eq 0 ] || exit 1
exit 0

View File

@@ -14,7 +14,7 @@
# regrouping is announced on stderr.
# - A leading "~/" expands to $HOME (shells do not expand a quoted tilde).
#
# Engine tier: uses -k none; no SKS.
# Engine tier: uses --klammersets none; no SKS.
#
# Usage: ./filename_test.sh
# Exit code: 0 if all tests pass, 1 otherwise.
@@ -98,36 +98,49 @@ echo "======================="
check "1. quoted filename with a space is one file" \
"ALPHA" "" \
"my file.kt" -d -k none
"my file.kt" -d --klammersets none
check "2. unquoted spaces rescued into an existing file (announced)" \
# The regrouping is a DERIVED value -- the command completed a name the input
# left ambiguous -- so since 2026-08-15 it is reported at "-v 1" and the
# default run is silent. Silence is safe because a rescue only succeeds when
# the joined name names an existing file: a mistyped name fails instead of
# resolving to something else.
check "2. unquoted spaces are rescued into an existing file" \
"ALPHA" "" \
my file.kt -d --klammersets none
check "2a. ... silently at the default verbosity" \
"ALPHA" "" \
my file.kt -d --klammersets none
check "2b. ... and reported at -v 1" \
"ALPHA" "interpreting \"my file.kt\" as one filename" \
my file.kt -d -k none
my file.kt -d --klammersets none -v 1
check "3. standalone / separates a filename list" \
"ALPHA
BETA" "" \
my file.kt / b.kt -d -k none
my file.kt / b.kt -d --klammersets none
check "4. space in a directory component" \
"GAMMA" "" \
"my dir/c.kt" -d -k none
"my dir/c.kt" -d --klammersets none
check "5. quoted name that exists is never split (b.kt also exists)" \
"ALPHA" "" \
"my file.kt" -d -k none
"my file.kt" -d --klammersets none
HOME="$DIR" check "6. quoted ~/ expands to \$HOME inside ktext" \
"BETA" "" \
"~/b.kt" -d -k none
"~/b.kt" -d --klammersets none
check "7. @read argument keeps its internal space" \
"ALPHA" "" \
-s '@read my file.kt @' -d -k none
-s '@read my file.kt @' -d --klammersets none
check_error "8. unrescuable name is reported as written" \
"no such.kt" \
"no such.kt" -d -k none
"no such.kt" -d --klammersets none
echo
echo "======================="

View File

@@ -8,7 +8,7 @@
# * a flag a user reaches for often gets a single letter (-k klammers,
# -t targets, -c character codes, -i input); a more specialised topic gets
# a multi-letter name (--argtypes, --katoms, --rewrite, --optionsets,
# --coverage, --klammerset, --font);
# --coverage, --klammersets, --font);
# * -v says how much to show about the command's PROCESSING and never what
# its RESULT contains. So the katom regex column is "--katoms full" and
# the coverage file column is "--coverage all", not verbosity levels.
@@ -34,11 +34,25 @@ reset=$'\033[0m'
plain() { sed 's/\x1b\[[0-9;]*m//g'; }
# GNU coreutils' "timeout" is NOT present on macOS, and Homebrew's is named
# "gtimeout", so a bare "timeout" made this suite fail WHOLESALE there -- every
# case, because the command never ran at all (found on Olion, 2026-08-16). The
# guard is a safety net against a hung command, not part of what is being
# tested, so it is optional: bound the command where the tool exists, run it
# directly where it does not.
if command -v timeout >/dev/null 2>&1; then
limited() { timeout 60 "$@"; }
elif command -v gtimeout >/dev/null 2>&1; then
limited() { gtimeout 60 "$@"; }
else
limited() { "$@"; }
fi
# shows NAME PATTERN CMD... — exits 0 and the output matches PATTERN.
shows() {
local name="$1" pattern="$2"; shift 2
local out status
out=$(timeout 60 "$@" 2>&1 | plain); status=$?
out=$(limited "$@" 2>&1 | plain); status=$?
if [ $status -gt 128 ]; then
echo "${red}FAIL${reset} $name — died by signal $((status-128))"; FAIL=$((FAIL+1)); return
fi
@@ -53,7 +67,7 @@ shows() {
# absent NAME PATTERN CMD... — the output does NOT match PATTERN.
absent() {
local name="$1" pattern="$2"; shift 2
if timeout 60 "$@" 2>&1 | plain | grep -Eq -- "$pattern"; then
if limited "$@" 2>&1 | plain | grep -Eq -- "$pattern"; then
echo "${red}FAIL${reset} $name — unexpected match [$pattern]"; FAIL=$((FAIL+1))
else
echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
@@ -93,17 +107,17 @@ absent "12. --coverage alone omits it" 'sks/table/table\.k' "$KDESC"
# reason that has nothing to do with what it is testing. (It did: adopting
# ".*" in the SKS made "All targets, declared" non-empty.)
shows "12a. --coverage all shows an empty reporting category" \
'All targets, declared \(0\)' "$KDESC" -i "$DIR/cycle.k" --coverage all
'All targets, declared \(0\)' "$KDESC" --klammersets none -i "$DIR/cycle.k" --coverage all
absent "12b. ... hidden without it" \
'All targets, declared \(0\)' "$KDESC" -i "$DIR/cycle.k" --coverage
'All targets, declared \(0\)' "$KDESC" --klammersets none -i "$DIR/cycle.k" --coverage
absent "12c. an empty problem category stays hidden" \
'Declared but never defined \(0\)' "$KDESC" -i "$DIR/cycle.k" --coverage all
'Declared but never defined \(0\)' "$KDESC" --klammersets none -i "$DIR/cycle.k" --coverage all
absent "13. -v adds no result detail" 'Regex' "$KDESC" --katoms -v 3
echo
echo "-- -v is about processing only --"
shows "14. -v names the input it read" 'input_filenames' "$KDESC" -t -v 1
absent "15. ... and is silent without it" 'input_filenames' "$KDESC" -t
shows "14. -v names the input it read" 'input_filenames' "$KDESC" -i "$DIR/clean.k" -t -v 1
absent "15. ... and is silent without it" 'input_filenames' "$KDESC" -i "$DIR/clean.k" -t
echo
echo "-- the klammer search --"
@@ -126,13 +140,46 @@ shows "25. --katoms help explains" 'Katom commands' "$KDESC" --katom
shows "26. an unknown coverage word" 'Unrecognized coverage command' "$KDESC" --coverage nonsense
shows "27. an unknown katom word" 'Unrecognized katom command' "$KDESC" --katoms nonsense
echo
echo "-- provenance: -i shows what the INPUT defines --"
# A klammerset is loaded so the input can be ANALYSED against it, but it is
# not what the user asked about. A designer wants the klammers this file
# defines; a reader of an unfamiliar document wants the custom ones it
# carries. So "-i" restricts every listing to definitions from outside the
# loaded klammersets -- while the klammersets stay loaded.
PROV=$(mktemp /tmp/kdesc_prov.XXXXXX.k)
printf '@@shout.k s : Say it loudly @@\n@@shout.html :: <b>*s*</b> @@\n' > "$PROV"
shows "32. -i lists the input's own klammer" '@shout' "$KDESC" -i "$PROV" -k
absent "33. ... and not the klammerset's" '@table' "$KDESC" -i "$PROV" -k
shows "34. without -i the klammerset is shown" '@table' "$KDESC" -k
# The klammerset is still LOADED: a coverage derived from a klammer it
# supplies proves the analysis saw it even though the report does not.
DERIVE=$(mktemp /tmp/kdesc_derive.XXXXXX.k)
printf '@@wave.k s : Wave @@\n@@wave :: @i *s* @ @@\n' > "$DERIVE"
shows "35. coverage is derived through the unlisted klammerset" \
'@wave +html pdf tex txt +from @i' "$KDESC" -i "$DERIVE" --coverage
absent "36. ... and the klammerset is not reported" '@table' "$KDESC" -i "$DERIVE" --coverage
# A klammer the input REDEFINES came from the klammerset, but the input
# changed it -- which is exactly what a reader needs to know.
REDEF=$(mktemp /tmp/kdesc_redef.XXXXXX.k)
printf '@@i.html ::: <em class="mine">*text*</em> @@\n' > "$REDEF"
shows "37. a redefined klammer is the input's too" '@i' "$KDESC" -i "$REDEF" -k
# An input that declares its own klammerset is still the input.
DECL=$(mktemp -d /tmp/kdesc_decl.XXXXXX)
mkdir -p "$DECL/own"
printf '@@@klammerset own | A designer set @@@\n@@yell.k s : Yell @@\n@@yell.html :: <b>*s*</b> @@\n' \
> "$DECL/own/own.k"
shows "38. an input declaring a klammerset is not filtered out" \
'@yell' "$KDESC" -i "$DECL/own/own.k" -k
rm -rf "$PROV" "$DERIVE" "$REDEF" "$DECL"
echo
echo "-- the usage text --"
shows "28. -k shows its optional argument" '\-k \[<text>\]' "$KDESC"
shows "29. -i shows its filename" '\-i <filename>' "$KDESC"
# Ordered by likely use: the single letters come before the long names.
# Line numbers, not a multi-line pattern -- grep is line-oriented.
usage=$(timeout 60 "$KDESC" 2>&1 | plain)
usage=$(limited "$KDESC" 2>&1 | plain)
k_line=$(printf '%s\n' "$usage" | grep -n -- '-k \[<text>\]' | head -1 | cut -d: -f1)
katoms_line=$(printf '%s\n' "$usage" | grep -n -- '--katoms' | head -1 | cut -d: -f1)
if [ -n "$k_line" ] && [ -n "$katoms_line" ] && [ "$k_line" -lt "$katoms_line" ]; then
@@ -144,7 +191,7 @@ fi
echo
echo "-- a no-result search is not an error --"
timeout 60 "$KDESC" -k zzqq >/dev/null 2>&1
limited "$KDESC" -k zzqq >/dev/null 2>&1
if [ $? -eq 0 ]; then
echo "${green}PASS${reset} 31. finding nothing exits 0"; PASS=$((PASS+1))
else

311
tst/kdiag_test.sh Executable file
View File

@@ -0,0 +1,311 @@
#!/bin/bash
#
# kdiag_test.sh — the kdiag command's interface.
#
# kdiag is the PROGRAMMER's command in the 2026-08-14 three-command redesign:
# ktext renders a document for an author, kdesc describes a klammer set for a
# designer, kdiag dissects katoms and the Machine. It had no suite of its own,
# which is how four defects survived in it -- including a "--check" that
# reported "0 diagnostics, 0 errors" for every input there is, while every
# other suite stayed green. An absent test is what let that read as a pass, so
# each of the four is pinned here by OUTCOME.
#
# Its peculiarities, and why each is what it is:
#
# * Its input positional is OPTIONAL, so "kdiag --machine" answers a question
# about the Machine rather than about a document.
# * --klammer registers the @@ tier and --system the @@@ tier; --process does
# both. A registered definition's katoms are CONSUMED, so they show up
# only under --replaced -- their disappearance is the evidence.
# * Extraction is TOLERANT here and nowhere else. The tiers are ordered (a
# klammer names a target), so running one without the other leaves
# definitions that cannot be registered. In kdiag that is not an error:
# the definition is skipped and its katoms stay visible, which IS the
# report. ktext must keep throwing on the same input, and the contrast is
# asserted, not assumed.
# * It deliberately has NO --klammersets. A klammerset arrives the way
# anything else does, "@read sks/sks.k @": a debugger must not depend on
# the machinery it debugs, so kdiag still runs when symbol resolution is
# what broke.
#
# Engine tier: no klammerset is loaded, so every fixture defines what it needs
# inline. The one case that wants the SKS reads it by path.
#
# Usage: ./kdiag_test.sh (needs KLAMMERTEXT_HOME set; kdiag and ktext on PATH)
# Exit code: 0 if all tests pass, 1 otherwise.
PASS=0
FAIL=0
KDIAG=kdiag
KTEXT=ktext
K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set}
red=$'\033[31m'
green=$'\033[32m'
bold=$'\033[1m'
reset=$'\033[0m'
plain() { printf '%s' "$1" | sed 's/\x1b\[[0-9;]*m//g'; }
# GNU coreutils' "timeout" is NOT present on macOS, and Homebrew's is named
# "gtimeout", so a bare "timeout" made this suite fail WHOLESALE there -- every
# case, because the command never ran at all (found on Olion, 2026-08-16). The
# guard is a safety net against a hung command, not part of what is being
# tested, so it is optional: bound the command where the tool exists, run it
# directly where it does not.
if command -v timeout >/dev/null 2>&1; then
limited() { timeout 60 "$@"; }
elif command -v gtimeout >/dev/null 2>&1; then
limited() { gtimeout 60 "$@"; }
else
limited() { "$@"; }
fi
# run KDIAG_ARGS... — sets OUT (colour codes stripped) and STATUS. The status
# must be taken from the command itself: a pipeline inside a command
# substitution reports the SED's status, so "kdiag | plain" would call every
# failure a success. Strip afterwards instead.
OUT=''
STATUS=0
run() {
OUT=$(limited "$@" 2>&1); STATUS=$?
OUT=$(plain "$OUT")
}
# A signal death is not an error exit: 128+n. Report it distinctly, so a
# crash can never be read as a reported error.
died_by_signal() { [ "$1" -gt 128 ]; }
pass() { echo "${green}PASS${reset} $1"; PASS=$((PASS + 1)); }
fail() { echo "${red}FAIL${reset} $1"; [ -n "$2" ] && echo " $2"; FAIL=$((FAIL + 1)); }
# shows NAME PATTERN KDIAG_ARGS... — exits 0 and the output matches PATTERN.
shows() {
local name="$1" pattern="$2"; shift 2
run "$KDIAG" "$@"
if died_by_signal "$STATUS"; then
fail "$name" "died by signal $((STATUS - 128))"; return
fi
if [ "$STATUS" -ne 0 ]; then
fail "$name" "exit $STATUS: $(printf '%s' "$OUT" | head -2)"; return
fi
if printf '%s' "$OUT" | grep -Eq -- "$pattern"; then
pass "$name"
else
fail "$name" "no match for [$pattern]; got: $(printf '%s' "$OUT" | head -2)"
fi
}
# absent NAME PATTERN KDIAG_ARGS... — exits 0 and the output does NOT match.
absent() {
local name="$1" pattern="$2"; shift 2
run "$KDIAG" "$@"
if died_by_signal "$STATUS"; then
fail "$name" "died by signal $((STATUS - 128))"; return
fi
if [ "$STATUS" -ne 0 ]; then
fail "$name" "exit $STATUS: $(printf '%s' "$OUT" | head -2)"; return
fi
if printf '%s' "$OUT" | grep -Eq -- "$pattern"; then
fail "$name" "unexpected match [$pattern]"
else
pass "$name"
fi
}
# reports NAME PATTERN KDIAG_ARGS... — NONZERO exit, no signal, PATTERN shown.
reports() {
local name="$1" pattern="$2"; shift 2
run "$KDIAG" "$@"
if died_by_signal "$STATUS"; then
fail "$name" "died by signal $((STATUS - 128))"; return
fi
if [ "$STATUS" -eq 0 ]; then
fail "$name" "expected a nonzero exit, got 0"; return
fi
if printf '%s' "$OUT" | grep -Eq -- "$pattern"; then
pass "$name"
else
fail "$name" "no match for [$pattern]; got: $(printf '%s' "$OUT" | head -2)"
fi
}
# A klammer definition and an application of it, with no target named: the
# general target needs no @@@target, so the @@ tier can be exercised alone.
GREET='@@greet name : Hello, *name*. @@ @greet World @'
# Input that declares its own target AND a klammer for it. The two tiers are
# ordered, so this is what tells --klammer and --system apart.
OWNTARGET='@@@target foo | Foo output @@@ @@bar.foo : B @@ text'
# A file for --read to pull in.
READFIXTURE=$(mktemp /tmp/kdiag_read.XXXXXX)
printf 'READFILE\n' > "$READFIXTURE"
trap 'rm -f "$READFIXTURE"' EXIT
echo "${bold}kdiag interface tests${reset}"
echo "====================="
echo
echo "-- the input positional is optional --"
# The Machine's initial state is a question about the Machine, and the one
# thing a programmer wants before feeding it anything.
shows " 1. --machine with no input at all" 'Machine' --machine
shows " 2. ... and shows the built-in argtypes" 'Argtypes \([0-9]+\)' --machine
shows " 3. ... and the pseudo-targets" 'Targets \([0-9]+\)' --machine
shows " 4. an input is still accepted" '⟨@i' --type '@i x @'
# Listed under "Arguments:", so it must not be repeated under "Options:".
usage=$(plain "$(limited "$KDIAG" 2>&1)")
n_input=$(printf '%s\n' "$usage" | grep -c '\[<input>\]')
if [ "$n_input" = "2" ]; then
pass " 5. usage lists [<input>] once as a positional (plus the Usage: line)"
else
fail " 5. usage lists [<input>] $n_input times, expected 2" \
"$(printf '%s\n' "$usage" | grep -n '\[<input>\]')"
fi
echo
echo "-- registration: one flag per definition tier --"
# A registered definition is CONSUMED. Its disappearance from the katom
# display is the evidence that it registered, and --replaced brings it back.
absent " 6. --klammer consumes the @@ definition" '⟨@@greet⟩' --klammer "$GREET"
shows " 7. ... and --replaced shows it again" '⟨@@greet⟩' --klammer --replaced "$GREET"
shows " 8. ... and the klammer is in the registry" 'Klammers \(1\)' --klammer --machine "$GREET"
absent " 9. --system consumes the @@@ definition" '⟨@@@target⟩' --system "$OWNTARGET"
shows "10. ... and the target is in the registry" 'foo → ' --system --machine "$OWNTARGET"
shows "11. --process registers both tiers" 'Klammers \(1\)' --process --machine "$OWNTARGET"
absent "12. ... and consumes both" '⟨@@' --process "$OWNTARGET"
echo
echo "-- tolerant extraction: kdiag only --"
# --klammer alone cannot register @@bar.foo, because the target "foo" it names
# is declared by the @@@ tier this run did not process. Skipping it is the
# report, not a failure -- the definition's katoms stay on screen.
shows "13. --klammer alone does not fail on an unregisterable klammer" \
'⟨@@bar.foo⟩' --klammer "$OWNTARGET"
absent "14. ... and does not register it either" \
'Klammers \(1\)' --klammer --machine "$OWNTARGET"
shows "15. --system alone leaves the @@ katoms visible" \
'⟨@@bar.foo⟩' --system "$OWNTARGET"
# The contrast. In ktext the same input is a genuine error: a document naming
# an undefined target would otherwise render wrongly and silently.
# The pattern must sit on ONE line of the message: an error is wrapped for the
# terminal, so "is not defined" can arrive with a newline inside it and a
# line-oriented grep will never see it.
run "$KTEXT" --klammersets none -s '@@bar.nosuchtarget : B @@' -d
if [ "$STATUS" -ne 0 ] && printf '%s' "$OUT" | grep -q 'target "nosuchtarget"'; then
pass "16. ktext still THROWS on a klammer naming an undefined target"
else
fail "16. ktext accepted an undefined target (exit $STATUS)" \
"$(printf '%s' "$OUT" | head -2)"
fi
echo
echo "-- --check --"
# The regression: check_machine() read machine.m_katoms, which read() fills and
# process() -- how kdiag builds its katoms -- does not. So it had nothing to
# check and said so, for every input there is. These two are the guard: a
# clean check must be able to fail.
shows "17. a correct input checks clean" '0 diagnostics' --process --check "$GREET"
reports "18. an undefined klammer is found" '@nosuch is not defined' \
--process --check '@@g : @nosuch x @ @@'
reports "19. ... and the body it sits in is named" 'in body of @g' \
--process --check '@@g : @nosuch x @ @@'
reports "20. a wrong argument count is found" 'is given 2' \
--process --check "@@greet name : Hello, *name*. @@ @greet a | b @"
# Without --klammer/--system/--process nothing is registered, so every
# application is undefined. Reporting 80 spurious errors without saying why
# would be worse than the old silence; the hint is part of the report.
reports "21. --check alone explains why nothing is registered" \
'No klammers are registered' --check "$GREET"
reports "22. ... and names the way to fix it" '--process' --check "$GREET"
# --machine must print even when the check failed: the state shown last has to
# reflect everything that happened, so the nonzero exit waits for it.
reports "23. --machine still prints after a failed check" \
'Machine' --process --check --machine '@nosuch x @'
echo
echo "-- a klammerset reaches kdiag only as input --"
# No --klammersets, deliberately: a debugger must not depend on the machinery
# it debugs. The flag must be REJECTED, not silently ignored.
reports "24. --klammersets is not a kdiag argument" 'argument error' \
--klammersets sks '@i x @'
# ...so the advice on an undefined-klammer error must not offer it. It did
# until 2026-08-15: the one remedy the error suggested was a flag this command
# rejects. A wrong hint is worse than none, because it is followed.
#
# The trigger is a GENERATOR: an @eval whose result holds a klammer. A klammer
# written literally in the input is only displayed, never applied, so nothing
# reaches the catch block -- which is also why this advice went years unread.
UNDEF='@eval chr(64)+"nosuch 7 "+chr(64) @'
reports "24a. the error advice names a way kdiag has" '@read sks/sks\.k @' \
--process "$UNDEF"
# absent_in_error NAME PATTERN ARGS... -- the pattern is missing whatever the
# exit status; these run on a path that exits 1 by design.
absent_in_error() {
local name="$1" pattern="$2"; shift 2
run "$KDIAG" "$@"
if printf '%s' "$OUT" | grep -Eq -- "$pattern"; then
fail "$name" "unexpected match [$pattern]"
else
pass "$name"
fi
}
absent_in_error "24b. ... and not the flag it rejects" '\-\-klammersets' \
--process "$UNDEF"
# The advice is appended to the description, which is then justified to 80
# columns -- without a blank line between them the two ran together into one
# word ("...unspecified targetkdiag loads no klammerset...").
absent_in_error "24c. ... and does not run onto the message" 'target[a-z]' \
--process "$UNDEF"
shows "25. @read loads the SKS instead" 'Klammers \([0-9][0-9]+\)' \
"@read $K/sks/sks.k @" --process --machine
shows "26. ... and the SKS then checks clean" '0 diagnostics' \
"@read $K/sks/sks.k @" --process --check
# ...and the clean result above is a real one: the same input with a fault
# added must still be caught, or case 26 says nothing.
reports "27. ... while a fault added to it is still caught" '@nosuchklammer is not defined' \
"@read $K/sks/sks.k @ @@bad : @nosuchklammer x @ @@" --process --check
echo
echo "-- the katom display flags --"
shows "28. --type subscripts the katom type" '⟨@i.⟩' --type '@i x @'
shows "29. --index subscripts the list index" '⟨@i.' --index '@i x @'
shows "30. --spans shows span endpoints" '⟨@i.⟩.*⟨@.⟩' --spans '@i x @'
shows "31. --args parses positional arguments" 'required' --args --pos 2 'a | b | c'
shows "32. --pos sets how many are positional" 'rest: c' --args --pos 2 'a | b | c'
shows "33. --ignore removes removed text" '^a b' --ignore 'a #[gone]# b'
shows "34. --all shows what was removed" '⟨gone⟩' --ignore --all 'a #[gone]# b'
shows "35. --ws applies whitespace operators" 'ab' --ws 'a#- b'
# A DECOMPOSED sequence: the base letter followed by U+0308 COMBINING
# DIAERESIS, not the precomposed U+00FC. The diacritic FOLLOWS the base
# letter -- that is the design of the ^ codes -- so the two spellings look
# identical in an editor and only one of them matches.
shows "36. --nonascii decodes to a combining sequence" 'ü' --nonascii '^u"'
shows "37. --literal marks a literal span" 'x' --literal "^'x'^"
# Evaluating needs a target: Eval::eval re-reads its result under K_target, and
# kdiag has no target argument, so with the variable unset EVERY @eval died
# with an argument error naming something the user never wrote. A command that
# specifies no target evaluates under the GENERAL target (Andy, 2026-08-15),
# which is also what kdiag means -- it loads no klammerset, so nothing
# target-specific is in scope. All three eval modes are checked, because the
# failure was in the shared read-back and not in any one of them.
shows "38. --eval evaluates a Python expression" '^4$' --eval '@eval 2 + 2 @'
shows "38a. ... and --process does too" '^4$' --process '@eval 2 + 2 @'
shows "38b. ... in :shell mode" '^hi$' --process '@eval :shell echo hi @'
shows "38c. ... and a module.function auto-imports" '^3$' --process '@eval len("abc") @'
shows "38d. --read reads a file" 'READFILE' --read "@read $READFIXTURE @"
shows "39. --cond selects a branch" 'yes' --cond '@cond true | yes | no @'
shows "40. --rewrite reports a rewrite" '.' --rewrite '@i x @'
shows "41. --text shows text katoms" 'x' --text '@i x @'
shows "42. --ignored shows ignored katoms" '.' --ignore --ignored 'a #[gone]# b'
echo
echo "-- verbosity is about processing --"
shows "43. -v takes a value" '.' -v 1 '@i x @'
echo
echo "====================="
echo "Results: ${PASS} passed, ${FAIL} failed"
[ "$FAIL" -eq 0 ] || exit 1
exit 0

View File

@@ -0,0 +1,12 @@
# Engine-tier fixture: the base of the klammerset COMBINING tests. It owns the
# target and the ".k" declaration that the two "house" sets override, so each
# of them can require it and neither has to be loaded first on the command
# line -- which is what makes command-line ORDER the only thing under test.
#
# The definitions sit in a :files entry rather than in this declaring file,
# which is NOT a style choice: the already-loaded guard skips a repeated
# declaration's :files, but the declaring file itself is read again every time
# a :requires names it. With the "@@@target fx" written here, requiring
# "base" from two sets reads it twice and the second read is a fatal "Target
# fx is already defined". See case 21c, which pins that.
@@@klammerset base | Base set for combining tests :date 2026-08-15 :files defs.k @@@

View File

@@ -0,0 +1,4 @@
# The base set's definitions. See base.k for why they are not in it.
@@@target fx | Combining fixture target @@@
@@who.k : Which klammerset supplied this definition @@
@@who.fx :: BASE @@

View File

@@ -1,11 +0,0 @@
# Engine-tier fixture: a klammerset with metadata, a dependency, an ordered
# file list (klammers.k uses the target defined in base.k), and a trailing
# definition that must load after the files (program order).
@@@klammerset kit | Engine-test klammerset
:name Klammerset Integration Test
:author Klammertext tests
:date 2026-07-30
:requires util.k
:files base.k / klammers.k
@@@
@@after : AFTER @@

View File

@@ -1 +0,0 @@
@@@klammerset kit | Duplicate declaration of kit :files dup_only.k @@@

View File

@@ -0,0 +1,9 @@
# One of two independent sets of house overrides. Both require "base" (loaded
# once, whichever asks first) and both override the same klammer with ":::", so
# the one written LAST on the command line is the one that wins. Its own
# klammer @a_only shows that the sets share one flat namespace rather than
# each keeping its klammers to itself.
@@@klammerset housea | House overrides A :requires base :date 2026-08-15 @@@
@@who.fx ::: HOUSE-A @@
@@a_only.k : A klammer only set A defines @@
@@a_only.fx :: ONLY-A @@

View File

@@ -0,0 +1,5 @@
# The mirror image of housea; see the comment there.
@@@klammerset houseb | House overrides B :requires base :date 2026-08-15 @@@
@@who.fx ::: HOUSE-B @@
@@b_only.k : A klammer only set B defines @@
@@b_only.fx :: ONLY-B @@

18
tst/klammerset/kit/kit.k Normal file
View File

@@ -0,0 +1,18 @@
# Engine-tier fixture: a klammerset with metadata, a dependency, an ordered
# file list (klammers.k uses the target defined in base.k), and a trailing
# definition that must load after the files (program order).
#
# Laid out as <symbol>/<symbol>.k, which is what a symbol names: since
# 2026-08-14 the command line takes symbols only, so a klammerset that
# cannot be named by one cannot be loaded at all. ":requires util" is a
# SYMBOL, resolved on the search path (this directory is not util's parent,
# so it is found at stage 2, KLAMMERTEXT_KLAMMERSETS); ":files" entries are
# filenames, resolved against this file's directory.
@@@klammerset kit | Engine-test klammerset
:name Klammerset Integration Test
:author Klammertext tests
:date 2026-07-30
:requires util
:files base.k / klammers.k
@@@
@@after : AFTER @@

View File

@@ -0,0 +1,2 @@
# Requires selfdef; paired with needs_b to make the diamond of case 21c.
@@@klammerset needs_a | Requires selfdef :requires selfdef :date 2026-08-15 @@@

View File

@@ -0,0 +1,2 @@
# Requires selfdef; paired with needs_a to make the diamond of case 21c.
@@@klammerset needs_b | Requires selfdef :requires selfdef :date 2026-08-15 @@@

View File

@@ -0,0 +1,8 @@
# A klammerset whose declaring file carries its own definitions (the
# documented alternative to a :text option: program order plays that role).
# Requiring it from two different sets reads this file twice, and the second
# read of the @@@target is fatal. Case 21c pins that defect; when it is
# fixed, that case fails and must be rewritten.
@@@klammerset selfdef | Definitions in the declaring file :date 2026-08-15 @@@
@@@target fy | Self-defining fixture target @@@
@@sd.fy : SELFDEF @@

6
tst/klammerset/sp/sp.k Normal file
View File

@@ -0,0 +1,6 @@
# The target and @greet come from kit, required by symbol; this set adds
# only the file whose name contains a space (standalone "/" separator).
@@@klammerset sp | Spacey filename in the list
:requires kit
:files space name.k
@@@

View File

@@ -1 +0,0 @@
@@@klammerset sp | Spacey filename in the list :files base.k / space name.k @@@

View File

@@ -0,0 +1,6 @@
# A declaration of the symbol "kit" from a file that is NOT kit/kit.k.
# Since 2026-08-15 that is an ERROR: a klammerset that can be named is
# identified by where it is, so a symbol names one file and one file declares
# one symbol. This fixture exists to prove the error; it formerly proved that
# such a duplicate was tolerated and skipped.
@@@klammerset kit | Duplicate declaration of kit :files dup_only.k @@@

View File

@@ -0,0 +1,3 @@
# Requires "kit" twice over: once by symbol, once through dup.k, which
# re-declares it. Loading "wrap" is therefore an error now -- see dup.k.
@@@klammerset wrap | Wrapper requiring kit twice :requires kit / dup.k @@@

View File

@@ -1 +0,0 @@
@@@klammerset wrap | Wrapper requiring kit twice :requires decl.k / decl2.k @@@

View File

@@ -23,6 +23,14 @@ KTEXT=ktext
K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set}
FIX=$K/tst/klammerset
# The fixtures are laid out as <symbol>/<symbol>.k under $FIX, because since
# 2026-08-14 the command line takes klammerset SYMBOLS only -- a set that
# cannot be named by one cannot be loaded. Putting $FIX on the search path is
# how the suite makes its own fixtures nameable, and it also exercises stage 2
# (KLAMMERTEXT_KLAMMERSETS) for both the command line and ":requires".
# Tests that need a different path set it themselves on the command.
export KLAMMERTEXT_KLAMMERSETS=$FIX
red=$'\033[31m'
green=$'\033[32m'
bold=$'\033[1m'
@@ -127,23 +135,23 @@ echo
# that base.k declares, so base.k must have been read first.
check_eq "1. :files load in order (target before klammer)" \
"Hello World" \
-k "$FIX/decl.k" -s '@greet World @' -t fix
--klammersets kit -s '@greet World @' -t fix
# 2. :requires loads the dependency before the set's own files.
check_eq "2. :requires loads the dependency" \
"--" \
-k "$FIX/decl.k" -s '@dash@' -t fix
--klammersets kit -s '@dash@' -t fix
# 3. Program order: a definition AFTER the declaration in the declaring file
# is available (there is no :text argument; the declaring file's own
# content plays that role).
check_eq "3. trailing definition in the declaring file" \
"AFTER" \
-k "$FIX/decl.k" -s '@after@' -t fix
--klammersets kit -s '@after@' -t fix
# 4. Relative :files names resolve against the DECLARING file's directory,
# not the cwd (run from an unrelated directory).
output=$( (cd /tmp && "$KTEXT" -k "$FIX/decl.k" -s '@greet Elsewhere @' -t fix) 2>/dev/null | trim )
output=$( (cd /tmp && "$KTEXT" --klammersets kit -s '@greet Elsewhere @' -t fix) 2>/dev/null | trim )
if [ "$output" = "Hello Elsewhere" ]; then
echo "${green}PASS${reset} 4. :files resolve against the declaring file's directory"
PASS=$((PASS + 1))
@@ -157,45 +165,83 @@ fi
# 5. A filename with a space in the :files list (standalone "/" separator).
check_eq "5. spacey filename in :files" \
"SPACEY" \
-k "$FIX/spacey.k" -s '@spacey@' -t fix
--klammersets sp -s '@spacey@' -t fix
# --- The already-loaded guard ------------------------------------------------
# --- A klammerset is identified by where it is -------------------------------
#
# Symbol X must be declared in X/X.k (Andy, 2026-08-15). The convention already
# governed symbol RESOLUTION; requiring it of the DECLARATION makes the symbol a
# function of the path, which is what lets the already-loaded guard run before a
# file is opened (cases 23-23b below) instead of after it has been re-executed.
#
# It also continues the reasoning behind symbols-only on the command line: 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, so a symbol names one file and one file declares one symbol.
#
# "wrap" is the fixture that violates it: wrap/dup.k declares the symbol "kit".
# It formerly tested that such a duplicate was tolerated and SKIPPED; the
# duplicate is now impossible instead, which is the stronger guarantee.
check_fails "6. a declaration outside X/X.k is an error" \
'must be declared in a file named "kit/kit.k"' \
--klammersets wrap -s '@greet Again @' -t fix
check_fails "6a. ... and the error names the file it is actually in" \
'dup.k' \
--klammersets wrap -s '@greet Again @' -t fix
# 6. A second declaration of an already-registered symbol is skipped, not an
# error: wrapper.k requires decl.k (registers "kit") and then decl2.k
# (re-declares "kit"); the wrapper still loads and kit's klammers work.
check_eq "6. duplicate declaration is skipped, not an error" \
"Hello Again" \
-k "$FIX/wrapper.k" -s '@greet Again @' -t fix
# 7. ...and the skipped declaration's files are NOT loaded.
check_fails "7. skipped declaration loads none of its files" \
"only_dup" \
-k "$FIX/wrapper.k" -s '@only_dup@' -t fix
# 7. EXEMPT: a document may declare a klammerset -- that is how a designer
# writes one, and kdesc's provenance filter depends on it. Such a set is
# local to the document: nothing can ":requires" it, so it has no identity to
# protect and needs no file named after it.
check_eq "7. a klammerset declared in a document is exempt" \
"ok" \
--klammersets none -s '@@@klammerset mine | Local to this document @@@ ok'
# --- Introspection -----------------------------------------------------------
# 8. -m lists the registered klammersets.
check_contains "8. -m shows the klammerset symbol" \
"kit" \
-k "$FIX/decl.k" -s 'x' -t fix -m
# The machine state moved from "ktext -m" to "kdiag --machine" in the
# 2026-08-14 argument redesign: the Machine's internals are the programmer's
# question, and kdiag is the programmer's command. kdiag's input is optional,
# so the state can be shown without a document at all.
kdiag_contains() {
local test_name="$1" expected="$2"; shift 2
local output
output=$(kdiag "$@" 2>/dev/null)
if printf '%s' "$output" | grep -qF -- "$expected"; then
echo "${green}PASS${reset} $test_name"; PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} $test_name"
echo " expected to contain: [$expected]"
FAIL=$((FAIL + 1))
fi
}
check_contains "9. -m shows the required klammerset too" \
# 8. --machine lists the registered klammersets. kdiag has no --klammersets:
# a klammerset reaches it the way anything else does, by being read in the
# input. That is deliberate -- the symbol mechanism is a higher-level
# convenience, and kdiag must still work when symbol resolution is what
# broke. (The :requires inside kit.k still uses a symbol, resolved by the
# Machine, so this exercises both paths at once.)
kdiag_contains "8. --machine shows the klammerset symbol" \
"kit" \
"@read $FIX/kit/kit.k @" --process --machine
kdiag_contains "9. --machine shows the required klammerset too" \
"Utility klammers for engine tests" \
-k "$FIX/decl.k" -s 'x' -t fix -m
"@read $FIX/kit/kit.k @" --process --machine
# --- Errors --------------------------------------------------------------------
# 10. A listed file that does not exist is a clean klammerset error.
check_fails "10. missing file in :files" \
"missing.k" \
-k "$FIX/bad_file.k" -s 'x' -t fix
--klammersets broken -s 'x' -t fix
# 11. A symbol must be an identifier (starts with a letter; letters, digits,
# underscores).
check_fails "11. invalid symbol rejected" \
"not valid" \
-k none -s '@@@klammerset 9bad | Bad symbol @@@' -d
--klammersets none -s '@@@klammerset 9bad | Bad symbol @@@' -d
# --- The search path (symbol -> <dir>/<symbol>/<symbol>.k) -------------------
@@ -217,10 +263,10 @@ printf '@@@klammerset locset | Shadow candidate @@@\n@@@target fixL | Fixture @@
# 12. Stage 1 for a file input: the document's directory.
check_eq "12. symbol resolves in the document's directory" \
"LOCAL" \
"$DOCDIR/doc.kt" -k locset -t fixL -d
"$DOCDIR/doc.kt" --klammersets locset -t fixL -d
# 13. Stage 1 for string input: the cwd stands in for the document.
output=$( (cd "$DOCDIR" && "$KTEXT" -s '@local_k@' -k locset -t fixL) 2>/dev/null | trim )
output=$( (cd "$DOCDIR" && "$KTEXT" -s '@local_k@' --klammersets locset -t fixL) 2>/dev/null | trim )
if [ "$output" = "LOCAL" ]; then
echo "${green}PASS${reset} 13. cwd stands in for the document (string input)"
PASS=$((PASS + 1))
@@ -231,7 +277,7 @@ else
fi
# 14. Stage 2: the KLAMMERTEXT_KLAMMERSETS directories.
output=$(KLAMMERTEXT_KLAMMERSETS=$ENVDIR "$KTEXT" -s '@env_k@' -k envset -t fixE 2>/dev/null | trim)
output=$(KLAMMERTEXT_KLAMMERSETS=$ENVDIR "$KTEXT" -s '@env_k@' --klammersets envset -t fixE 2>/dev/null | trim)
if [ "$output" = "ENV" ]; then
echo "${green}PASS${reset} 14. symbol resolves in KLAMMERTEXT_KLAMMERSETS"
PASS=$((PASS + 1))
@@ -242,7 +288,7 @@ else
fi
# 15. Shadowing: the document-local set wins over the installed one.
output=$(KLAMMERTEXT_KLAMMERSETS=$ENVDIR "$KTEXT" "$DOCDIR/doc.kt" -k locset -t fixL -d 2>/dev/null | trim)
output=$(KLAMMERTEXT_KLAMMERSETS=$ENVDIR "$KTEXT" "$DOCDIR/doc.kt" --klammersets locset -t fixL -d 2>/dev/null | trim)
if [ "$output" = "LOCAL" ]; then
echo "${green}PASS${reset} 15. document-local set shadows the installed one"
PASS=$((PASS + 1))
@@ -257,26 +303,170 @@ fi
mkdir -p "$DOCDIR/kit2/locset"
cp "$DOCDIR/locset/locset.k" "$DOCDIR/kit2/locset/locset.k"
printf '@@@klammerset kit2 | Requires by symbol :requires locset @@@\n' > "$DOCDIR/kit2/kit2.k"
check_eq "16. :requires accepts a symbol (declaring-dir stage)" \
"LOCAL" \
-k "$DOCDIR/kit2/kit2.k" -s '@local_k@' -t fixL
output=$(KLAMMERTEXT_KLAMMERSETS=$DOCDIR "$KTEXT" --klammersets kit2 \
-s '@local_k@' -t fixL 2>/dev/null | trim)
if [ "$output" = "LOCAL" ]; then
echo "${green}PASS${reset} 16. :requires accepts a symbol (declaring-dir stage)"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} 16. :requires accepts a symbol (declaring-dir stage)"
echo " expected: [LOCAL] got: [$output]"
FAIL=$((FAIL + 1))
fi
# 17. Stage 3: $KLAMMERTEXT_HOME — the kdesc listing enumerates sks
# (sks/sks.k already satisfies the <symbol>/<symbol>.k convention).
output=$(kdesc --klammerset 2>/dev/null)
output=$(kdesc --klammersets 2>/dev/null)
if printf '%s' "$output" | grep -q 'sks/sks\.k'; then
echo "${green}PASS${reset} 17. kdesc --klammerset lists sks from KLAMMERTEXT_HOME"
echo "${green}PASS${reset} 17. kdesc --klammersets lists sks from KLAMMERTEXT_HOME"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} 17. kdesc --klammerset lists sks from KLAMMERTEXT_HOME"
echo "${red}FAIL${reset} 17. kdesc --klammersets lists sks from KLAMMERTEXT_HOME"
echo " got: $(echo "$output" | head -3)"
FAIL=$((FAIL + 1))
fi
# 18. An unknown symbol is a clean error naming the search directories.
check_fails "18. unknown symbol names the search path" \
# 18. An unknown symbol is a clean error, and it names both the convention and
# the directories searched -- without those a user cannot tell whether the
# name or the location is wrong. This is the failure mode the symbol-only
# rule makes common: there is no longer a pathname to fall back on.
check_fails "18. unknown symbol is a clean error" \
"was not found" \
-s 'x' -k nosuchset -d
-s 'x' --klammersets nosuchset -d
check_fails "18a. ... naming the <symbol>/<symbol>.k convention" \
"x/x.k" \
-s 'x' --klammersets nosuchset -d
check_fails "18b. ... and the directories searched" \
"$FIX" \
-s 'x' --klammersets nosuchset -d
# 19. A pathname is no longer accepted: only symbols resolve. The error must
# say so rather than reporting a missing file.
check_fails "19. a pathname is rejected as a symbol" \
"not found" \
-s 'x' --klammersets "$FIX/kit/kit.k" -d
# 20. One bad symbol among good ones fails the whole load rather than
# silently loading the rest.
check_fails "20. a bad symbol among good ones still fails" \
"was not found" \
-s 'x' --klammersets kit nosuchset -t fix -d
# --- Several klammersets combine ---------------------------------------------
#
# The headline claim of "--klammersets" (plural), stated in the ktext usage and
# in "kdesc --klammersets help": the symbols are a LIST, loaded in the order
# given, and the klammers of all of them share ONE flat namespace -- membership
# is provenance, not containment. Where two sets define the same klammer the
# definition modes decide, so a set of house overrides is loaded after the set
# it adjusts and a set of defaults before it.
#
# The fixtures: "base" declares the target and the ".k", "housea" and "houseb"
# each require base and each override @who with ":::". Both requiring base is
# what lets either be written first, so command-line order is the only variable.
# 21. One namespace: each set's own klammers are available whatever the order.
check_eq "21. combined sets share one namespace" \
"ONLY-A ONLY-B" \
--klammersets housea houseb -s '@a_only@ @b_only@' -t fx
check_eq "21a. ... in either order" \
"ONLY-A ONLY-B" \
--klammersets houseb housea -s '@a_only@ @b_only@' -t fx
# 22. Order decides: two ":::" overrides of the same klammer, so the set
# written LAST is the one whose definition survives.
check_eq "22. the last set written wins an override" \
"HOUSE-B" \
--klammersets housea houseb -s '@who@' -t fx
check_eq "22a. ... and reversing the order reverses the outcome" \
"HOUSE-A" \
--klammersets houseb housea -s '@who@' -t fx
# 23. :requires is loaded once even though both sets ask for it -- the
# already-loaded guard -- so the base definition is there exactly once.
check_eq "23. a set required by both is loaded once" \
"BASE" \
--klammersets base -s '@who@' -t fx
# 23a. A DIAMOND: two sets requiring a third. "needs_a" and "needs_b" both
# require "selfdef", whose declaring file holds its own @@@target -- which
# is legal and normal, since there is deliberately no :text option and the
# declaring file's content plays that role.
#
# This failed until 2026-08-15 with "Target fy is already defined",
# pointing at a line the author wrote once. The already-loaded guard sat
# in Klammerset_registry::add, which runs only after a file has been read
# and its declaration reached -- by which time the second read had
# re-executed the declaring file's own definitions. The guard now runs
# BEFORE the read, which is possible only because X is declared in X/X.k
# and the symbol is therefore known from the path.
check_eq "23a. a diamond :requires loads the shared set once" \
"SELFDEF" \
--klammersets needs_a needs_b -s '@sd@' -t fy
check_eq "23b. ... and requiring it once is unchanged" \
"SELFDEF" \
--klammersets needs_a -s '@sd@' -t fy
check_eq "23c. ... as is naming the same set twice on the command line" \
"SELFDEF" \
--klammersets selfdef selfdef -s '@sd@' -t fy
# 23d. The search path is FROZEN for the run. A path decides WHICH FILE a
# symbol means -- identity, not value -- so if a document could extend it
# mid-run, "which klammerset is X" would depend on evaluation order and
# "kdesc --klammersets" could not be a complete answer. It could:
# the embedded Python shares the process, so an @eval doing os.environ
# changed what a later getenv returned. The environment is now read once.
LATE=$(mktemp -d /tmp/klammerset_late.XXXXXX)
mkdir -p "$LATE/late"
printf '@@@klammerset late | Appeared mid-run @@@\n@@@target lt | T @@@\n@@l.lt : LATE @@\n' \
> "$LATE/late/late.k"
# The @eval still sets the variable -- what changed is that resolution no
# longer consults it -- so the symbol must remain unfindable.
out=$(KLAMMERTEXT_KLAMMERSETS=$FIX "$KTEXT" --klammersets none -d \
-s "@eval os.environ.__setitem__('KLAMMERTEXT_KLAMMERSETS','$LATE') or '' @" 2>&1)
if printf '%s' "$out" | grep -q "was not found" || \
! KLAMMERTEXT_KLAMMERSETS=$FIX "$KTEXT" --klammersets late -s 'x' -d >/dev/null 2>&1; then
echo "${green}PASS${reset} 23d. an @eval cannot extend the klammerset search path"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} 23d. the search path moved during the run"
FAIL=$((FAIL + 1))
fi
rm -rf "$LATE"
# --- "none" is the absence of a klammerset, not the name of one ---------------
#
# It therefore says nothing about what should be loaded alongside it. Written
# first it used to discard the rest of the list silently -- the user asked for
# a set, got none, and was told only that some target was undefined; written
# last it was looked up as a symbol and reported missing. Neither reading is
# guessed at now: "none" is accepted only alone.
# 24. "none" alone still loads nothing, which is what it is for.
check_eq "24. none alone loads no klammerset" \
"x" \
--klammersets none -s 'x' -d
check_fails "25. none before another symbol is an error" \
"cannot be combined" \
--klammersets none kit -s 'x' -t fix -d
check_fails "25a. none after another symbol is an error too" \
"cannot be combined" \
--klammersets kit none -s 'x' -t fix -d
# 26. The rule belongs to load_klammersets(), the single loader all three
# commands share -- so kdesc obeys it as well. It did not: kdesc decided
# what "none" meant a second time, and so accepted "none sks" and loaded
# neither. Deciding it twice is how the two commands came to disagree.
out=$(kdesc --klammersets none sks -k table 2>&1); status=$?
if [ $status -ne 0 ] && printf '%s' "$out" | grep -qF "cannot be combined"; then
echo "${green}PASS${reset} 26. kdesc applies the same none rule"
PASS=$((PASS + 1))
else
echo "${red}FAIL${reset} 26. kdesc applies the same none rule — exit $status"
echo " got: $(printf '%s' "$out" | head -2)"
FAIL=$((FAIL + 1))
fi
echo
echo "======================="

View File

@@ -20,7 +20,7 @@
# files read earlier. A klammer DEFINED in a read file is applied later and
# always sees its own directory — the realistic case, tested below.
#
# Engine tier: -k none, fixtures created inline in a temp tree, no SKS.
# Engine tier: --klammersets none, fixtures created inline in a temp tree, no SKS.
# Usage: ./modulepath_test.sh (ktext on PATH)
PASS=0
@@ -50,7 +50,7 @@ printf '@eval nosuch_module_xyz.f() @\n' > "$T/setb/missing.kt"
check() { # check NAME EXPECTED ARGS...
local name="$1" expected="$2"; shift 2
local out
out=$(ktext "$@" -k none -d 2>/dev/null)
out=$(ktext "$@" --klammersets none -d 2>/dev/null)
if [ "$out" = "$expected" ]; then
echo "${green}PASS${reset} $name"; PASS=$((PASS + 1))
else
@@ -75,7 +75,7 @@ check " 2. the same, input given as a relative path" \
"../setb/doc.kt"
name=" 3. an unknown module still reports cleanly"
err=$(ktext "$T/setb/missing.kt" -k none -d 2>&1 >/dev/null)
err=$(ktext "$T/setb/missing.kt" --klammersets none -d 2>&1 >/dev/null)
case "$err" in
*'Cannot import module "nosuch_module_xyz"'*)
echo "${green}PASS${reset} $name"; PASS=$((PASS + 1)) ;;
@@ -111,7 +111,7 @@ check " 7. a :cwd directory containing spaces" \
name=" 8. a nonexistent :cwd is a clean argument error"
printf '@eval :cwd /nonexistent_kt_dir :shell true @\n' > "$T/setb/bad.kt"
err=$(ktext "$T/setb/bad.kt" -k none -d 2>&1 >/dev/null)
err=$(ktext "$T/setb/bad.kt" --klammersets none -d 2>&1 >/dev/null)
case "$err" in
*'The :cwd directory does not exist'*)
echo "${green}PASS${reset} $name"; PASS=$((PASS + 1)) ;;

View File

@@ -50,7 +50,7 @@ PRELUDE='@@@target fix | a fixture target @@@
check_eq() {
local name="$1" expected="$2" source="$3" target="${4:-fix}"
local output status
output=$("$KTEXT" -k none -s "$PRELUDE
output=$("$KTEXT" --klammersets none -s "$PRELUDE
$source" -t "$target" -d 2>&1)
status=$?
output=$(printf '%s' "$output" | tr -d '\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
@@ -76,7 +76,7 @@ $source" -t "$target" -d 2>&1)
check_fails() {
local name="$1" needle="$2" source="$3"
local output status
output=$("$KTEXT" -k none -s "$PRELUDE
output=$("$KTEXT" --klammersets none -s "$PRELUDE
$source" -t fix -d 2>&1)
status=$?
if [ $status -eq 0 ]; then
@@ -98,11 +98,23 @@ $source" -t fix -d 2>&1)
# check_warns NAME SUBSTRING SOURCE — SOURCE must be accepted, and its
# combined output must contain SUBSTRING (used where a warning is expected
# beside the result, so an exact comparison would test the warning's wording).
# Since 2026-08-15 the override notice is reported at "-v 1" rather than
# warned about: ":::" exists to override, so the notice fired on the sanctioned
# use of a feature (see tst/deftype_test.sh's check_warn for the reasoning).
# The default run must be SILENT and the notice must appear at "-v 1".
check_warns() {
local name="$1" needle="$2" source="$3"
local output status
output=$("$KTEXT" -k none -s "$PRELUDE
$source" -t fix -d 2>&1)
local output status quiet
quiet=$("$KTEXT" --klammersets none -s "$PRELUDE
$source" -t fix -d 2>&1 >/dev/null)
if [ -n "$quiet" ]; then
echo "${red}FAIL${reset} $name — the default run was not silent"
echo " got: $(printf '%s' "$quiet" | head -1)"
FAIL=$((FAIL + 1))
return
fi
output=$("$KTEXT" --klammersets none -s "$PRELUDE
$source" -t fix -d -v 1 2>&1)
status=$?
if [ $status -ne 0 ]; then
echo "${red}FAIL${reset} $name — ktext exited $status"

View File

@@ -15,7 +15,7 @@
# growing -- and exceeding its round limit is an error rather than a message
# followed by rendering a document with live klammers still in it.
#
# These are engine tests: no klammer set is loaded (-k none) and every klammer
# These are engine tests: no klammer set is loaded (--klammersets none) and every klammer
# used is defined inline as a fixture.
#
# Usage: ./recursion_test.sh
@@ -100,29 +100,29 @@ echo
check_error " 1. direct self-recursion is caught" \
"does not terminate" \
-k none -s '@@f : x @f@ @@ @f@' -d
--klammersets none -s '@@f : x @f@ @@ @f@' -d
check_error " 2. the offending klammer is named" \
'applying "f"' \
-k none -s '@@f : x @f@ @@ @f@' -d
--klammersets none -s '@@f : x @f@ @@ @f@' -d
check_error " 3. mutual recursion is caught" \
"does not terminate" \
-k none -s '@@a : ( @b@ ) @@ @@b : [ @a@ ] @@ @a@' -d
--klammersets none -s '@@a : ( @b@ ) @@ @@b : [ @a@ ] @@ @a@' -d
check_error " 4. self-recursion through an argument is caught" \
"does not terminate" \
-k none -s '@@w t : < *t* > @@ @@r : @w @r@ @ @@ @r@' -d
--klammersets none -s '@@w t : < *t* > @@ @@r : @w @r@ @ @@ @r@' -d
# --- Terminating nesting is untouched ---
check_eq " 5. deep but finite nesting still reduces" \
"<<<<<x>>>>>" \
-k none -s '@@w t : <*t*> @@ @w @w @w @w @w x @ @ @ @ @' -d
--klammersets none -s '@@w t : <*t*> @@ @w @w @w @w @w x @ @ @ @ @' -d
check_eq " 6. a chain of klammers generating klammers reduces" \
"END" \
-k none -s '@@k1 : @k2@ @@ @@k2 : @k3@ @@ @@k3 : @k4@ @@ @@k4 : @k5@ @@ @@k5 : @k6@ @@ @@k6 : @k7@ @@ @@k7 : @k8@ @@ @@k8 : END @@ @k1@' -d
--klammersets none -s '@@k1 : @k2@ @@ @@k2 : @k3@ @@ @@k3 : @k4@ @@ @@k4 : @k5@ @@ @@k5 : @k6@ @@ @@k6 : @k7@ @@ @@k7 : @k8@ @@ @@k8 : END @@ @k1@' -d
# --- The fixed point ends on "nothing was applied", not "nothing was added" ---
#
@@ -131,7 +131,7 @@ check_eq " 6. a chain of klammers generating klammers reduces" \
check_eq " 7. a klammer with an empty body reduces" \
"a b" \
-k none -s '@@nothing : @@ a @nothing@ b' -d
--klammersets none -s '@@nothing : @@ a @nothing@ b' -d
echo
echo "============================="

View File

@@ -14,7 +14,7 @@
# target a "::" instance with no parameter list of its own. That is the
# migration from repeating an argument list per target to declaring it once.
#
# These are engine tests, so they use -k none and define their own targets
# These are engine tests, so they use --klammersets none and define their own targets
# inline: a target is a Machine construct (@@@target), not owned by any
# klammer set.
#
@@ -46,7 +46,7 @@ TARGETS='@@@target ta | Target A @@@
# accepted NAME SRC — the definitions are consistent and the klammer applies.
accepted() {
local name="$1" src="$2" out
out=$("$KTEXT" -k none -s "$TARGETS$src" -t ta -d 2>&1)
out=$("$KTEXT" --klammersets none -s "$TARGETS$src" -t ta -d 2>&1)
if echo "$out" | grep -qi "error"; then
fail "$name" "no error" "$(echo "$out" | grep -i -A1 error | tail -1)"
else
@@ -57,7 +57,7 @@ accepted() {
# rejected NAME SRC PATTERN — the drift is caught, and the message says how.
rejected() {
local name="$1" src="$2" want="$3" out
out=$("$KTEXT" -k none -s "$TARGETS$src" -t ta -d 2>&1)
out=$("$KTEXT" --klammersets none -s "$TARGETS$src" -t ta -d 2>&1)
if ! echo "$out" | grep -qi "error"; then
fail "$name" "a definition error" "accepted"
elif ! echo "$out" | grep -qF "$want"; then
@@ -102,7 +102,7 @@ echo "-- the message must show HOW they differ, not just where"
# With more than two targets the designer would otherwise have to diff the
# definitions by hand; each target's own signature is listed beside its name.
name=" 6. each target's signature is shown"
out=$("$KTEXT" -k none -s "$TARGETS"'@@k6.ta s :one : [*s*] @@
out=$("$KTEXT" --klammersets none -s "$TARGETS"'@@k6.ta s :one : [*s*] @@
@@k6.tb s :two : [*s*] @@
@k6 x @' -t ta -d 2>&1)
if echo "$out" | grep -q "ta .*s :one" && echo "$out" | grep -q "tb .*s :two"; then
@@ -114,7 +114,7 @@ fi
# The error path once printed internal katom detail through msg(), which is
# debugging scaffolding and must never reach a user-facing message.
name=" 7. no internal debug output on the error path"
out=$("$KTEXT" -k none -s "$TARGETS"'@@k7.k s : a declaration @@
out=$("$KTEXT" --klammersets none -s "$TARGETS"'@@k7.k s : a declaration @@
@@k7.ta s2 :other : [*s2*] @@
@k7 x @' -t ta -d 2>&1)
if echo "$out" | grep -q "klammer-definition"; then

View File

@@ -22,7 +22,7 @@
# "*arg*" variables, and the closing delimiters
# Case 14 guards the second: a comma next to an application is writer text.
#
# These are engine tests, so they use -k none and define their own targets
# These are engine tests, so they use --klammersets none and define their own targets
# inline: a target is a Machine construct (@@@target), not owned by any
# klammer set.
#
@@ -54,7 +54,7 @@ trim() { awk '{ sub(/[ \t\r]+$/, "") } { line[NR]=$0 } END { f=1; while (f<=NR &
check_eq() {
local name="$1" target="$2" expected="$3" src="$4"
local out status err
out=$("$KTEXT" -k none -s "$TARGETS$src" -t "$target" -d 2>"$ERR"); status=$?
out=$("$KTEXT" --klammersets none -s "$TARGETS$src" -t "$target" -d 2>"$ERR"); status=$?
err=$(cat "$ERR")
out=$(printf '%s' "$out" | trim)
if [ $status -ne 0 ]; then
@@ -73,18 +73,25 @@ check_eq() {
fi
}
# check_warn NAME TARGET EXPECTED SRC — exit 0, stdout==EXPECTED, AND a warning.
# check_warn NAME TARGET EXPECTED SRC — exit 0, stdout==EXPECTED, the override
# SILENT at the default verbosity and REPORTED at "-v 1" (2026-08-15; see
# tst/deftype_test.sh's check_warn for why it is no longer a warning).
check_warn() {
local name="$1" target="$2" expected="$3" src="$4"
local out status err
out=$("$KTEXT" -k none -s "$TARGETS$src" -t "$target" -d 2>"$ERR"); status=$?
err=$(cat "$ERR")
out=$("$KTEXT" --klammersets none -s "$TARGETS$src" -t "$target" -d 2>"$ERR"); status=$?
out=$(printf '%s' "$out" | trim)
if [ $status -ne 0 ]; then
echo "${red}FAIL${reset} $name — ktext exited $status"; FAIL=$((FAIL+1)); return
fi
if ! printf '%s' "$err" | grep -qiF "warning"; then
echo "${red}FAIL${reset} $nameexpected a warning, got none"; FAIL=$((FAIL+1)); return
if [ -s "$ERR" ]; then
echo "${red}FAIL${reset} $namethe default run was not silent: $(head -1 "$ERR")"
FAIL=$((FAIL+1)); return
fi
"$KTEXT" --klammersets none -s "$TARGETS$src" -t "$target" -d -v 1 >/dev/null 2>"$ERR"
err=$(cat "$ERR")
if ! printf '%s' "$err" | grep -qiF "overridden"; then
echo "${red}FAIL${reset} $name — no override reported at -v 1"; FAIL=$((FAIL+1)); return
fi
if [ "$out" = "$expected" ]; then
echo "${green}PASS${reset} $name"; PASS=$((PASS+1))
@@ -98,7 +105,7 @@ check_warn() {
check_error() {
local name="$1" target="$2" pattern="$3" src="$4"
local out status
out=$("$KTEXT" -k none -s "$TARGETS$src" -t "$target" -d 2>&1); status=$?
out=$("$KTEXT" --klammersets none -s "$TARGETS$src" -t "$target" -d 2>&1); status=$?
if [ $status -eq 0 ]; then
echo "${red}FAIL${reset} $name — expected an error but ktext succeeded"; FAIL=$((FAIL+1)); return
fi

155
tst/verbosity_test.sh Executable file
View File

@@ -0,0 +1,155 @@
#!/bin/bash
#
# verbosity_test.sh — what "-v" says, and what it must not say.
#
# The output policy (CLAUDE.md, "Command output policy") fixes what each
# verbosity level is FOR, and a policy with no test drifts back — which is how
# the suites this session repaired got where they were. The rule:
#
# -v 0 silent. Nothing but the result.
# -v 1 every decision whose outcome the user COULD NOT HAVE READ OFF THEIR
# OWN INPUT: a klammerset symbol resolved to a file, a default applied,
# "-o" expanded into a directory and a basename, a filename regrouped,
# a klammer overridden by a definition in another klammer set.
# -v 2+ the trace: what the command DID, for someone reading the code.
#
# The boundary is the part worth testing. Andy's first formulation was "-v 1
# describes a change of state", and taken literally that swallows -v 2 — every
# definition is a change of state. So the test for -v 1 is not "did something
# happen" but "could the user have predicted the outcome from what they wrote".
# Registering a klammer the user wrote, in the file they wrote, is NOT -v 1
# material; resolving a symbol through a three-stage search path with shadowing
# is.
#
# Engine tier: no klammerset beyond what a case loads deliberately. The SKS
# half of this policy — font resolution and ":files" resolution — is in
# sks/tst/verbosity_test.sh, because those are @document's decisions.
#
# Usage: ./verbosity_test.sh (needs KLAMMERTEXT_HOME set; commands on PATH)
# Exit code: 0 if all tests pass, 1 otherwise.
PASS=0
FAIL=0
KTEXT=ktext
K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set}
red=$'\033[31m'
green=$'\033[32m'
bold=$'\033[1m'
reset=$'\033[0m'
OUTF=$(mktemp /tmp/verb_out.XXXXXX)
ERRF=$(mktemp /tmp/verb_err.XXXXXX)
TMPD=$(mktemp -d /tmp/verb_dir.XXXXXX)
trap 'rm -f "$OUTF" "$ERRF"; rm -rf "$TMPD"' EXIT
plain() { sed 's/\x1b\[[0-9;]*m//g'; }
pass() { echo "${green}PASS${reset} $1"; PASS=$((PASS + 1)); }
fail() { echo "${red}FAIL${reset} $1"; [ -n "$2" ] && echo " $2"; FAIL=$((FAIL + 1)); }
# run CMD ARGS... — fills OUTF/ERRF, sets STATUS.
STATUS=0
run() { "$@" >"$OUTF" 2>"$ERRF"; STATUS=$?; }
# reports NAME PATTERN — ERRF matches PATTERN (the log is on stderr, always).
reports() {
if plain < "$ERRF" | grep -Eq -- "$2"; then pass "$1"
else fail "$1" "no [$2] in: $(plain < "$ERRF" | head -3 | tr '\n' ' ')"; fi
}
absent() {
if plain < "$ERRF" | grep -Eq -- "$2"; then fail "$1" "unexpected [$2]"
else pass "$1"; fi
}
echo "${bold}Verbosity tests${reset}"
echo "==============="
echo
echo "-- -v 0 is silent --"
run "$KTEXT" --klammersets none -s 'hello' -d
if [ $STATUS -eq 0 ] && [ "$(cat "$OUTF")" = "hello" ] && [ ! -s "$ERRF" ]; then
pass " 1. a successful run says nothing but its result"
else
fail " 1. exit $STATUS, stdout [$(cat "$OUTF")], stderr [$(head -1 "$ERRF")]"
fi
echo
echo "-- -v 1 answers \"what did the command think I asked for?\" --"
run "$KTEXT" --klammersets none -s 'hello' -d -v 1
reports " 2. the resolved argument list is shown" '\-s +: hello'
reports " 3. ... including a default the user did not write" '\-v +: 1'
# The document is on stdout and nothing shares it, whatever -v says.
if [ "$(cat "$OUTF")" = "hello" ]; then
pass " 4. and the result is still the only thing on stdout"
else
fail " 4. stdout carried [$(head -c 60 "$OUTF")]"
fi
echo
echo "-- -v 1 reports DERIVED values --"
# The output path: the user gave a target and an input name; the directory, the
# basename and the final filename were all constructed.
printf 'hello\n' > "$TMPD/doc.kt"
run "$KTEXT" "$TMPD/doc.kt" -t txt --klammersets none -v 1
reports " 5. the output directory it constructed" 'Output directory: '
reports " 6. the output basename it constructed" 'Output basename: doc'
reports " 7. the output filename it constructed" 'Output filename: .*doc\.txt'
# A klammerset symbol resolves through a three-stage search path with
# shadowing, so the FILE it landed on is the derived value. Both branches are
# checked: the default (no --klammersets at all) reported nothing until
# 2026-08-15, which was the commonest case of all.
run "$KTEXT" -s 'x' -d -v 1
reports " 8. the DEFAULT klammerset names the file it loaded" 'Klammerset \(default\): .*sks/sks\.k'
mkdir -p "$TMPD/kset"
printf '@@@klammerset kset | A set @@@\n' > "$TMPD/kset/kset.k"
run env KLAMMERTEXT_KLAMMERSETS="$TMPD" "$KTEXT" -s 'x' -d --klammersets kset -v 1
reports " 9. a symbol names the file it resolved to" 'Klammerset "kset": .*kset/kset\.k'
run "$KTEXT" -s 'x' -d --klammersets none -v 1
reports "10. and \"none\" says that none was loaded" 'Klammersets: none'
# An override: the definition being replaced usually lives somewhere the user
# cannot see, which is what earns it a place here rather than a warning.
run "$KTEXT" --klammersets none -t fx -d -v 1 \
-s '@@@target fx | F @@@ @@w.k : W @@ @@w.fx :: A @@ @@w.fx ::: B @@ @w@'
reports "11. a klammer overridden by a later definition" 'overridden'
echo
echo "-- the boundary: -v 2 is the trace, -v 1 is not --"
run "$KTEXT" --klammersets none -s 'hello' -d -v 1
v1=$(wc -l < "$ERRF")
run "$KTEXT" --klammersets none -s 'hello' -d -v 2
v2=$(wc -l < "$ERRF")
if [ "$v2" -gt "$v1" ]; then
pass "12. -v 2 says more than -v 1 ($v1 lines -> $v2)"
else
fail "12. -v 2 added nothing ($v1 -> $v2)"
fi
# The trace names the code; the -v 1 report never should. A source location in
# a level-1 line means a msg() or a stray log level, not a decision.
run "$KTEXT" --klammersets none -s 'hello' -d -v 1
absent "13. -v 1 does not name source files" '\[[a-z_]+\.cpp:[0-9]+\]'
run "$KTEXT" --klammersets none -s 'hello' -d -v 2
reports "14. ... while -v 2 does" '\[[a-z_]+\.(cpp|h):[0-9]+\]'
echo
echo "-- the other two commands honour -v as well --"
run kdesc --katoms -v 1
if [ $STATUS -eq 0 ] && [ -s "$OUTF" ]; then
pass "15. kdesc -v 1 still produces its result on stdout"
else
fail "15. exit $STATUS, stdout $(wc -c < "$OUTF") bytes"
fi
run kdiag -v 1 '@i-x'
if [ $STATUS -eq 0 ] && [ -s "$OUTF" ]; then
pass "16. kdiag -v 1 still produces its result on stdout"
else
fail "16. exit $STATUS, stdout $(wc -c < "$OUTF") bytes"
fi
echo
echo "==============="
echo "Results: ${PASS} passed, ${FAIL} failed"
[ "$FAIL" -eq 0 ] || exit 1
exit 0