diff --git a/README.md b/README.md index 35e24cf..f8e3f43 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/com/kdesc.cpp b/com/kdesc.cpp index 88ba272..eec6780 100644 --- a/com/kdesc.cpp +++ b/com/kdesc.cpp @@ -16,18 +16,18 @@ static void font_usage() { std::cout << "Font maintenance commands:\n" - " --font List the available fonts\n" - " --font list The same\n" - " --font samples Write a sample page of the available fonts\n" - " to /index.html\n" + " --font List the available fonts\n" + " --font list The same\n" + " --font samples Write a sample page of the available fonts\n" + " to /index.html\n" " --font samples \n" - " Write a sample page for the (not yet\n" - " installed) font files under \n" - " --font install Install the font files found under\n" - " into the user font directory\n" + " Write a sample page for the (not yet\n" + " installed) font files under \n" + " --font install Install the font files found under\n" + " into the user font directory\n" " --font install \n" - " Install into instead\n" - " --font help This description\n" + " Install into instead\n" + " --font help This description\n" "\n" "Fonts are searched in the directories of the KLAMMERTEXT_FONTS\n" "environment variable (colon-separated; default $HOME/.klammertext/fonts)\n" @@ -38,11 +38,11 @@ static void coverage_usage() { std::cout << "Coverage commands:\n" - " --coverage Report the targets each klammer covers\n" - " --coverage all Also name the file each klammer is written\n" - " in, and show every \"needs attention\"\n" - " category, including the empty ones\n" - " --coverage help This description\n" + " --coverage Report the targets each klammer covers\n" + " --coverage all Also name the file each klammer is written\n" + " in, and show every \"needs attention\"\n" + " category, including the empty ones\n" + " --coverage help This description\n" "\n" "Coverage is a fact about a klammer: the targets it can render to. It is\n" "DERIVED where the definitions determine it (a general body of klammer\n" @@ -60,10 +60,9 @@ 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 help This description\n"; + " --katoms List the katom types\n" + " --katoms full The list, with the regex syntax used for a march\n" + " --katoms help This description\n"; } // The words of a variadic option, with the empty ones dropped. @@ -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 Load the klammersets specified by \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 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 , only those whose name or description contains .", "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 " 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 diff --git a/com/kdiag.cpp b/com/kdiag.cpp index 17f2e1b..0a2a8be 100644 --- a/com/kdiag.cpp +++ b/com/kdiag.cpp @@ -3,6 +3,7 @@ #include #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: @@ ... @@"); - args.flag("process", "Process all"); + args.flag("klammer", "Register klammer definitions: @@ ... @@. 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,7 +127,9 @@ 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; - std::cout << katoms << "\n"; + if (!p("check")) { + std::cout << katoms << "\n"; + } } if (p("args")) { @@ -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. diff --git a/com/ktext.cpp b/com/ktext.cpp index 1b5316a..5e7a16d 100644 --- a/com/ktext.cpp +++ b/com/ktext.cpp @@ -1,6 +1,5 @@ #include #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; diff --git a/mac/argtype.cpp b/mac/argtype.cpp index 632480b..838ac8c 100644 --- a/mac/argtype.cpp +++ b/mac/argtype.cpp @@ -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) diff --git a/mac/argv.cpp b/mac/argv.cpp index 2768bd9..98aa0bc 100644 --- a/mac/argv.cpp +++ b/mac/argv.cpp @@ -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 "[]"). +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 << "[]\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 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"; } } diff --git a/mac/argv.h b/mac/argv.h index 92ad4e7..776c8b1 100644 --- a/mac/argv.h +++ b/mac/argv.h @@ -6,6 +6,7 @@ #include #include #include +#include #include inline std::map 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 { diff --git a/mac/check.cpp b/mac/check.cpp index 1bafc5f..4f0b199 100644 --- a/mac/check.cpp +++ b/mac/check.cpp @@ -235,7 +235,8 @@ void Checker::check_list( } // namespace -std::vector check_machine(Machine& machine, const std::string& target) +std::vector check_machine(Machine& machine, const katom_list& document, + const std::string& target) { std::vector diagnostics {}; Checker checker(machine, diagnostics); @@ -253,7 +254,7 @@ std::vector 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; diff --git a/mac/check.h b/mac/check.h index 3aa9890..097f5e3 100644 --- a/mac/check.h +++ b/mac/check.h @@ -5,6 +5,7 @@ #include #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 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 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". diff --git a/mac/command.cpp b/mac/command.cpp index 9e27635..1ec4a0d 100644 --- a/mac/command.cpp +++ b/mac/command.cpp @@ -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)); + } + } +} diff --git a/mac/command.h b/mac/command.h index 986c44b..67fbfd8 100644 --- a/mac/command.h +++ b/mac/command.h @@ -5,6 +5,9 @@ #include #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 parse_args( const std::vector& input_filenames, std::string target, std::string output_basename, bool display_only); + +void load_klammersets(Machine& machine, const strings_t& symbols); diff --git a/mac/coverage.cpp b/mac/coverage.cpp index 192a1fb..d51db04 100644 --- a/mac/coverage.cpp +++ b/mac/coverage.cpp @@ -131,7 +131,8 @@ std::string short_path(const std::string& path) } // namespace -std::vector klammer_coverage(const Machine& machine) +std::vector 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(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 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(coverage.size())) + << ", " << targets.size() << " " << plural("target", static_cast(targets.size())) + << ": " << join(targets, " ") << "\n"; auto of_kind = [&coverage](coverage_t kind) { std::vector result {}; diff --git a/mac/coverage.h b/mac/coverage.h index dcd5f0e..d4decf2 100644 --- a/mac/coverage.h +++ b/mac/coverage.h @@ -4,6 +4,8 @@ #include #include +#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(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(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 diff --git a/mac/error.cpp b/mac/error.cpp index dc16725..cb38f28 100644 --- a/mac/error.cpp +++ b/mac/error.cpp @@ -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; } diff --git a/mac/eval.cpp b/mac/eval.cpp index 5d9d95b..1c03ab1 100644 --- a/mac/eval.cpp +++ b/mac/eval.cpp @@ -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 #include #include +#include +// Run a shell command and return its STANDARD OUTPUT, which becomes document +// text. Two things the first version did not do, both of them the same defect +// as a msg() on the wrong stream -- output nobody chose to see, and a failure +// nobody was told about: +// +// * stderr went straight to the user's terminal, unattributed. It is not the +// command's output in any of the three policy categories (CLAUDE.md, +// "Command output policy"): it belongs to a subprocess a klammer invoked, at +// a location the Locator can name. It is captured here and reported at +// "-v 1" -- a derived value, the sort of thing "-v 1" exists for. +// * the exit status was discarded, so a command that failed contributed its +// partial output (or nothing) to the document and said nothing. A nonzero +// status is now an error, with the status, the command, and whatever the +// command said on stderr. Some commands exit nonzero without failing -- +// "grep" finding no match is the usual one -- so the message names the +// explicit way to say that was intended, "cmd || true". +// +// tex_to_pdf() in sks/document/document.cpp is the same pattern for xelatex: +// capture, then decide what to report. std::string shell(State state, std::string command, Locator loc) { command = state.subst(command); - 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 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 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"; diff --git a/mac/eval_python.cpp b/mac/eval_python.cpp index 10564a2..88faebc 100644 --- a/mac/eval_python.cpp +++ b/mac/eval_python.cpp @@ -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; diff --git a/mac/file.cpp b/mac/file.cpp index 9cb99c7..63624db 100644 --- a/mac/file.cpp +++ b/mac/file.cpp @@ -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; } diff --git a/mac/font_store.cpp b/mac/font_store.cpp index 97a9b18..be25dbf 100644 --- a/mac/font_store.cpp +++ b/mac/font_store.cpp @@ -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; } } diff --git a/mac/katom.cpp b/mac/katom.cpp index 9f869cd..8550552 100644 --- a/mac/katom.cpp +++ b/mac/katom.cpp @@ -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", ""); diff --git a/mac/katom.h b/mac/katom.h index 6de9c09..494256f 100644 --- a/mac/katom.h +++ b/mac/katom.h @@ -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& katoms); diff --git a/mac/klammer.cpp b/mac/klammer.cpp index b57ecc5..e325c21 100644 --- a/mac/klammer.cpp +++ b/mac/klammer.cpp @@ -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& 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 {}; diff --git a/mac/klammer.h b/mac/klammer.h index 901703d..3907670 100644 --- a/mac/klammer.h +++ b/mac/klammer.h @@ -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(); diff --git a/mac/klammer_registry.cpp b/mac/klammer_registry.cpp index cf97622..adb48fb 100644 --- a/mac/klammer_registry.cpp +++ b/mac/klammer_registry.cpp @@ -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)) { diff --git a/mac/klammer_registry.h b/mac/klammer_registry.h index dfcac70..836f3c7 100644 --- a/mac/klammer_registry.h +++ b/mac/klammer_registry.h @@ -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 m_klammers {}; }; diff --git a/mac/klammerset.h b/mac/klammerset.h index c971d16..3b74b82 100644 --- a/mac/klammerset.h +++ b/mac/klammerset.h @@ -3,6 +3,8 @@ #include #include +#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 m_requires {}; // klammerset declaration files, loaded first std::vector 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 {}; }; diff --git a/mac/klammerset_registry.cpp b/mac/klammerset_registry.cpp index da5e007..8e94fcb 100644 --- a/mac/klammerset_registry.cpp +++ b/mac/klammerset_registry.cpp @@ -37,6 +37,7 @@ std::optional 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 klammerset_search_dirs(const std::string& local_dir) if (!local_dir.empty()) { push_unique(local_dir); } - std::string paths {}; - const char* env = std::getenv("KLAMMERTEXT_KLAMMERSETS"); - if (env && *env) { - paths = env; - } else if (const char* home = std::getenv("HOME"); home && *home) { - paths = std::string(home) + "/.klammertext/klammersets"; - } + // Read ONCE per process, not once per resolution. A search path decides + // WHICH FILE a symbol means -- an identity question, not a value one -- so + // it must not move while a document is being processed: if it did, "which + // klammerset is X" would depend on evaluation order and "kdesc + // --klammersets" could not be a complete answer, which is the reason the + // command line takes symbols only. + // + // It could move. The embedded Python shares the process, so an @eval + // doing os.environ[...] = ... changed what a later getenv here returned; + // a document could extend its own search path between one declaration and + // the next. Nothing intended that -- it fell out of a fresh getenv and an + // in-process interpreter. + static const std::string paths = [] { + const char* env = std::getenv("KLAMMERTEXT_KLAMMERSETS"); + if (env && *env) { + return std::string(env); + } + if (const char* home = std::getenv("HOME"); home && *home) { + return std::string(home) + "/.klammertext/klammersets"; + } + return std::string(); + }(); std::stringstream ss(paths); std::string dir; while (std::getline(ss, dir, ':')) { @@ -153,7 +208,7 @@ fs::path resolve_klammerset_symbol( "The klammerset \"" + symbol + "\" was not found. A symbol x names the " "declaration file x/x.k in one of the search directories: " + join(dirs, ", ") - + ". Enter \"kdesc --klammerset\" to list the available klammersets.", + + ". Enter \"kdesc --klammersets\" to list the available klammersets.", loc); } @@ -223,3 +278,21 @@ std::string Klammerset_registry::describe(int margin, bool long_format) const } return ss.str(); } + +void Klammerset_registry::set_loaded_files(const std::string& symbol, const strings_t& files) +{ + auto it = m_klammersets.find(symbol); + if (it != m_klammersets.end()) { + it->second.m_loaded_files = files; + } +} + +strings_t Klammerset_registry::loaded_files() const +{ + strings_t result {}; + for (const auto& [symbol, klammerset] : m_klammersets) { + result.insert(result.end(), + klammerset.m_loaded_files.begin(), klammerset.m_loaded_files.end()); + } + return result; +} diff --git a/mac/klammerset_registry.h b/mac/klammerset_registry.h index e59dfe6..83b84c9 100644 --- a/mac/klammerset_registry.h +++ b/mac/klammerset_registry.h @@ -19,12 +19,25 @@ public: // (typically reached through :requires) is skipped, not an error. std::optional add(std::vector::iterator begin, std::vector::iterator end, std::vector& 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 m_klammersets {}; std::vector 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); diff --git a/mac/log.cpp b/mac/log.cpp index 1d0a652..e3b0f18 100644 --- a/mac/log.cpp +++ b/mac/log.cpp @@ -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(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()); } } } diff --git a/mac/log.h b/mac/log.h index 06b7212..4edc253 100644 --- a/mac/log.h +++ b/mac/log.h @@ -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) << " "), ...); - std::cout << '\n'; + ((std::cerr << std::forward(ts) << " "), ...); + std::cerr << '\n'; } } } diff --git a/mac/machine.cpp b/mac/machine.cpp index f16fdfb..01a3f5d 100644 --- a/mac/machine.cpp +++ b/mac/machine.cpp @@ -81,6 +81,9 @@ void Machine::process_eval_katoms(katom_list& katoms) if (std::find_if(katoms.begin(), katoms.end(), begin_eval) != katoms.end()) { for (const auto& [op, cl] : find_spans(katoms, level_increase, level_decrease, true, "eval")) { auto [begin, end] = find_span_katoms(katoms, op, cl); + // Inert: inside a @cond branch that has not been selected. An + // @eval in a discarded branch must not run. + if (begin->m_deferred) continue; if (begin_eval(*begin)) { Eval E(*this, begin->m_loc); katom_list eval_katoms = E.eval(begin, end); @@ -138,18 +141,22 @@ bool is_true(const std::string& s) return s == "True" || s == "true" || s == "1"; } -// @cond's predicate relation is currently partial in effect: is_true() -// recognizes three strings as true and treats EVERYTHING else as false, so a -// misspelled state variable, a "TRUE", a "yes", or a Python traceback all -// silently select the false branch. +// @cond's predicate relation is TOTAL AND STRICT (Andy, 2026-08-15, deciding +// notes/Klammertext_improvements.md §4.1): there is a defined true set, a +// defined false set, and anything else is an error at the @cond. // -// What the truth values should be is an open language-policy question (see -// notes/Klammertext_improvements.md, "The @cond predicate relation"), so the -// semantics here is deliberately unchanged. What is added is visibility: a -// predicate outside the provisionally recognized sets below is reported, with -// its value and location, so the cases can be found in real documents while -// the policy is decided. The recognized false set carries no semantics -- it -// exists only to keep the diagnostic quiet for values that plainly mean false. +// It was partial in effect until then -- is_true() recognized three strings and +// treated EVERYTHING else as false, so a misspelled state variable, a "TRUE", a +// "yes", or a Python traceback all silently selected the false branch. The +// 2026-08-01 work made that visible with a warning while the policy was +// undecided; the warning found nothing in the SKS, which is the evidence that +// the corpus uses well-formed predicates and that the blast radius is small. +// +// Empty stays in the FALSE set, and deliberately: an optional argument that +// was not written substitutes as empty, so "absent means false" is what +// carries the "@cond *opt* | ... @" idiom. The entangled sub-question in +// §4.1 -- whether empty means false or means "not supplied" -- is answered +// "false" by that use, not left open. bool is_recognized_predicate(const std::string& s) { return s.empty() @@ -157,25 +164,71 @@ bool is_recognized_predicate(const std::string& s) || s == "False" || s == "false" || s == "0"; } -void warn_unrecognized_predicate(const std::string& predicate, const Locator& loc) +void check_predicate(const std::string& predicate, const Locator& loc) { if (is_recognized_predicate(predicate)) return; std::stringstream ss {}; - ss << "The @cond predicate " << q_(predicate) - << " is not a recognized truth value, so the false branch was taken.\n" - << " Recognized: true, True, 1 (true); false, False, 0, empty (false)."; - warning(ss.str(), loc); + ss << "The @cond predicate " << q_(predicate) << " is not a truth value. " + << "Recognized: true, True, 1 (true); false, False, 0, and empty (false). " + << "A value outside these is an error rather than false, so a misspelled " + << "variable or a failed @eval cannot silently select a branch."; + throw Argument_error(ss.str(), loc); } -void Machine::process_cond_katoms(katom_list& katoms) +// Mark the interior of every @cond span INERT. Runs during process_katoms, +// before the passes with observable effects, so that @eval and @read inside a +// branch do nothing until a branch is selected -- which is the non-strictness +// doc/cond_evaluation_order.md already specifies ("with side-effecting +// @read/@eval, wrong ... must not read the missing file") and which @eval did +// not honour: an @eval in a discarded branch used to run, because eval swept +// the list before cond did. +// +// The delimiters themselves stay unmarked, so the span is still found later. +// Nested @cond spans are marked by the enclosing one and become live only when +// the branch holding them is selected and processed. +void Machine::mark_cond_content(katom_list& katoms) { + (void)K::log(4); + if (std::find_if(katoms.begin(), katoms.end(), begin_cond) == katoms.end()) { + return; + } + for (const auto& [op, cl] : find_spans(katoms, level_increase, level_decrease, true, "cond")) { + auto [begin, end] = find_span_katoms(katoms, op, cl); + if (!begin_cond(*begin)) continue; + // Only the BRANCHES are inert. The predicate is always evaluated -- + // that is what a conditional is -- so marking from "begin + 1" would + // stop "@cond @eval 1==1 @ | yes | no @" from ever computing its own + // predicate. Mark from the first depth-0 bar onward. + std::vector bars = cond_separator_bars(begin, end); + if (bars.empty()) continue; // malformed; reported when it resolves + for (auto k = bars[0] + 1; k < end - 1; ++k) { + k->m_deferred = true; + } + } +} + +// Resolve the @cond spans in `katoms`, at APPLICATION time. Returns the number +// resolved, so the caller's fixed point accounts for them. +// +// The selected branch is spliced and then processed exactly as a klammer body +// is (process_katoms + apply, the two lines apply_klammer already uses): that +// is what makes the document behave like a function body whose state variables +// are its arguments -- they are bound by the substitution at the top of +// Machine::apply, BEFORE any conditional in the document is decided. Resolving +// @cond at read time meant a top-level "@cond *Flag*" saw the literal "*Flag*". +int Machine::resolve_cond_katoms(katom_list& katoms, const std::string& target) +{ + int resolved = 0; if (std::find_if(katoms.begin(), katoms.end(), begin_cond) != katoms.end()) { (void)K::log(3); - //for (auto [op, cl] : find_spans(katoms, begin_cond, end_apply, true, "cond")) { for (const auto& [op, cl] : find_spans(katoms, level_increase, level_decrease, true, "cond")) { auto [begin, end] = find_span_katoms(katoms, op, cl); - // msg() << "find_spans: " << std::pair(begin, end) << "\n"; if (begin_cond(*begin)) { + // A @cond nested inside an unresolved outer @cond is still + // inert; the outer one will process it when its branch is + // selected. Without this an inner branch would be decided + // before it is known whether it is reached at all. + if (begin->m_deferred) continue; // Delimit @cond's arguments by the bars at depth 0 within the // span, so that bars belonging to nested klammers are not // mistaken for @cond's own separators (see cond_separator_bars). @@ -183,7 +236,7 @@ void Machine::process_cond_katoms(katom_list& katoms) check_bar_count(begin, bars.size()); auto bar_1 = bars[0]; std::string predicate = to_string(begin + 1, bar_1, true); - warn_unrecognized_predicate(predicate, begin->m_loc); + check_predicate(predicate, begin->m_loc); katom_list true_clause {}; katom_list false_clause {}; if (bars.size() == 2) { @@ -199,11 +252,22 @@ void Machine::process_cond_katoms(katom_list& katoms) // (@cond is a non-strict special form). katom_list result = is_true(predicate) ? trim_whitespace(true_clause) : trim_whitespace(false_clause); + // The selected branch becomes live: clear the inert flag and + // give it the same processing a klammer body gets. The + // unselected branch is discarded still inert, so nothing in it + // ever ran. + for (Katom& k : result) { + k.m_deferred = false; + } + process_katoms(result, command_name); + apply(m_klammers, result, target); std::for_each(begin, end, mark_as_replaced); katoms.insert(end, result.begin(), result.end()); + ++resolved; } } } + return resolved; } @@ -339,8 +403,15 @@ void Machine::process_katoms( if (ignore) mark_ignored_katoms(katoms); if (whitespace) process_whitespace_modifiers(katoms); if (klammers) process_klammer_katoms(katoms); + // @cond is no longer RESOLVED here -- it is resolved in the apply fold + // (see mark_cond_content / resolve_cond_katoms). What happens here is the + // marking that makes its branches inert, and it must run BEFORE the eval + // and read passes, which are the ones with observable effects. Leaving it + // where process_cond_katoms used to sit -- after eval -- kept the old + // "eval in a discarded branch runs anyway" behaviour, since the marking + // arrived too late to stop it. + if (cond) mark_cond_content(katoms); if (eval) process_eval_katoms(katoms); - if (cond) process_cond_katoms(katoms); if (read) expand_read_katoms( katoms, source, nonascii, literal, ignore, whitespace, klammers, eval, cond, read); @@ -402,6 +473,8 @@ void Machine::expand_read_katoms( (void)K::log(3); for (const auto& [op, cl] : find_spans(katoms, begin_apply, end_apply, true, "read")) { auto [begin, end] = find_span_katoms(katoms, op, cl); + // Inert: see the same guard in process_eval_katoms. + if (begin->m_deferred) continue; if (begin_read(*begin)) { std::string read_filename = to_string(begin + 1, end - 1, true); @@ -441,7 +514,27 @@ void Machine::expand_read_katoms( } -void Machine::extract_machine_definitions() +// One @@@ declaration. Factored out so the tolerant and strict paths share +// it; `rescan` is set when a klammerset has spliced files into the stream, +// which invalidates the caller's span list. +void Machine::add_machine_definition( + const std::string& name, katom_iter begin, katom_iter end, bool& rescan) +{ + if (name == "@@@target") { + m_targets.add(begin, end, m_katoms); + } else if (name == "@@@argtype") { + m_argtypes.add(begin, end, m_katoms); + } else if (name == "@@@state") { + m_state.parse_state_katoms(begin, end, m_katoms); + } else if (name == "@@@klammerset") { + if (auto klammerset = m_klammersets.add(begin, end, m_katoms)) { + load_klammerset_files(*klammerset, end); + rescan = true; + } + } +} + +void Machine::extract_machine_definitions(bool tolerant) { (void)K::log(3); if (m_katoms.empty()) { @@ -456,20 +549,23 @@ void Machine::extract_machine_definitions() rescan = false; for (const auto& [op, cl] : find_spans(m_katoms, begin_machine_def, end_machine_def, true, command_name)) { auto [begin, end] = find_span_katoms(m_katoms, op, cl); - //std::string name = trim_char(begin->m_text, '@'); std::string name = begin->m_text; - if (name == "@@@target") { - m_targets.add(begin, end, m_katoms); - } else if (name == "@@@argtype") { - m_argtypes.add(begin, end, m_katoms); - } else if (name == "@@@state") { - m_state.parse_state_katoms(begin, end, m_katoms); - } else if (name == "@@@klammerset") { - if (auto klammerset = m_klammersets.add(begin, end, m_katoms)) { - load_klammerset_files(*klammerset, end); - rescan = true; - break; + if (tolerant) { + // kdiag: a declaration that cannot be carried out -- a + // @@@klammerset naming a file that is not there, say -- is + // skipped rather than fatal. Its span stays unreplaced, so + // the katoms remain visible and report the failure themselves. + try { + add_machine_definition(name, begin, end, rescan); + } catch (Error& e) { + K::log(1, "Machine definition not registered: " + e.m_desc); + continue; } + } else { + add_machine_definition(name, begin, end, rescan); + } + if (rescan) { + break; } } } @@ -491,17 +587,39 @@ void Machine::load_klammerset_files(const Klammerset& klammerset, katom_iter ins // entries are always filenames (this set's own definition files). std::vector filenames {}; for (const auto& required : klammerset.m_requires) { - if (is_klammerset_symbol(required)) { - filenames.push_back( - resolve_klammerset_symbol(required, base_dir, klammerset.m_loc).string()); - } else { - filenames.push_back(required); + std::string path = is_klammerset_symbol(required) + ? resolve_klammerset_symbol(required, base_dir, klammerset.m_loc).string() + : required; + // ALREADY LOADED? Decided BEFORE the file is opened. The guard used + // to sit in Klammerset_registry::add, which only runs once the file has + // been read and its declaration reached -- by which time the declaring + // file's OWN definitions have been re-executed. Two sets requiring a + // third therefore died on "Target ... is already defined", pointing at + // a line the author wrote once. + // + // Deciding it here is possible only because a klammerset symbol X is + // declared in X/X.k, so the symbol is a function of the path: it is + // known for a ":requires" written as a filename just as much as for one + // written as a symbol. That is what the X/X.k requirement bought. + std::string symbol = is_klammerset_symbol(required) + ? required : fs::path(path).stem().string(); + if (m_klammersets.has(symbol)) { + (void)K::log(1, "Klammerset \"" + symbol + "\": already loaded, skipped"); + continue; } + filenames.push_back(path); } filenames.insert(filenames.end(), klammerset.m_files.begin(), klammerset.m_files.end()); // Collect all files into one list and insert once: insert_at is // invalidated by the first insertion into m_katoms. + // Recorded on the set: what it actually opened, canonical, so a display + // can tell its klammers from the input's. The declaring file counts -- + // definitions may sit before or after the declaration in it. + strings_t loaded_files {}; + if (fs::exists(declaring)) { + loaded_files.push_back(fs::canonical(declaring).string()); + } katom_list loaded {}; for (const auto& filename : filenames) { fs::path pathname = resolve_relative_to(filename, base); @@ -512,6 +630,7 @@ void Machine::load_klammerset_files(const Klammerset& klammerset, katom_iter ins klammerset.m_loc); } pathname = fs::canonical(pathname); + loaded_files.push_back(pathname.string()); m_state.add_search_dir(pathname.parent_path().string()); std::string text = trim_right(string_from_file(pathname.string())); katom_list ks = katomize(line_split(text), pathname); @@ -519,6 +638,9 @@ void Machine::load_klammerset_files(const Klammerset& klammerset, katom_iter ins loaded.insert(loaded.end(), ks.begin(), ks.end()); } m_katoms.insert(insert_at, loaded.begin(), loaded.end()); + // The registry holds the registered copy; the parameter is a const + // reference to it, so the record goes back through the registry. + m_klammersets.set_loaded_files(klammerset.m_symbol, loaded_files); } // Register one "@@...@@" definition. An ".o" target declares an option set @@ -541,19 +663,29 @@ void Machine::add_definition(katom_list& katoms, const Katom& op, const Katom& c void Machine::extract_klammer_definitions(katom_list katoms) { - fmsg() << katoms << "\n"; - (void)K::log(3); + // fmsg() << katoms << "\n"; + (void)K::log(3, katoms); for (const auto& [op, cl] : find_spans(katoms, begin_klammer_def, end_klammer_def, true, command_name)) { add_definition(katoms, op, cl); } m_klammers.rationalize(m_targets); } -void Machine::extract_klammer_definitions() +void Machine::extract_klammer_definitions(bool tolerant) { (void)K::log(3); for (const auto& [op, cl] : find_spans(m_katoms, begin_klammer_def, end_klammer_def, true, command_name)) { - add_definition(m_katoms, op, cl); + if (tolerant) { + // add_definition marks the span replaced only on success, so a + // skipped definition keeps its katoms and stays visible. + try { + add_definition(m_katoms, op, cl); + } catch (Error& e) { + K::log(1, "Definition not registered: " + e.m_desc); + } + } else { + add_definition(m_katoms, op, cl); + } } m_klammers.rationalize(m_targets); } @@ -677,7 +809,12 @@ int Machine::apply( Klammer_registry& klammer_registry, katom_list& katoms, const std::string& target) { (void)K::log(3, "Klammer_registry"); - int applied = 0; + // @cond is a special form handled here rather than by the klammer loop + // below: its span head is a cond_begin, which begin_klammer_apply does not + // match. Resolving first means a klammer revealed by the selected branch + // is applied in this same pass. The count is returned with the klammer + // applications, so the caller's fixed point iterates while either happens. + int applied = resolve_cond_katoms(katoms, target); for (const auto& [op, cl] : find_spans( katoms, begin_klammer_apply, end_klammer_apply, true, command_name)) { auto [begin, end] = find_span_katoms(katoms, op, cl); diff --git a/mac/machine.h b/mac/machine.h index 9a8d157..5730362 100644 --- a/mac/machine.h +++ b/mac/machine.h @@ -46,7 +46,14 @@ public: return *this; } - void process_cond_katoms(std::vector& 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& katoms); + int resolve_cond_katoms(std::vector& katoms, const std::string& target); void process_eval_katoms(std::vector& katoms); void mark_literal_klammer_content(std::vector& katoms); void escape_target_characters(const Target& target, std::vector& 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& arg_map); diff --git a/mac/option_set_registry.cpp b/mac/option_set_registry.cpp index f5ef52d..a2a5f41 100644 --- a/mac/option_set_registry.cpp +++ b/mac/option_set_registry.cpp @@ -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; } diff --git a/mac/option_set_registry.h b/mac/option_set_registry.h index 226f41d..6be15ad 100644 --- a/mac/option_set_registry.h +++ b/mac/option_set_registry.h @@ -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; diff --git a/mac/show.cpp b/mac/show.cpp index 88ac4a0..9170626 100644 --- a/mac/show.cpp +++ b/mac/show.cpp @@ -1,5 +1,8 @@ #include +#include +#include #include +#include #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 << " "; diff --git a/mac/show.h b/mac/show.h index d5cbee5..0d47758 100644 --- a/mac/show.h +++ b/mac/show.h @@ -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"; }; diff --git a/mac/state.cpp b/mac/state.cpp index c8e2be6..b096920 100644 --- a/mac/state.cpp +++ b/mac/state.cpp @@ -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]; diff --git a/mac/target_registry.cpp b/mac/target_registry.cpp index 628be48..67f513b 100644 --- a/mac/target_registry.cpp +++ b/mac/target_registry.cpp @@ -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 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 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 << " " diff --git a/mac/target_registry.h b/mac/target_registry.h index c110110..abddcaf 100644 --- a/mac/target_registry.h +++ b/mac/target_registry.h @@ -38,7 +38,11 @@ public: std::vector user_defined() const; std::vector 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 m_targets {}; diff --git a/mac/util.cpp b/mac/util.cpp index ac55319..3349ced 100644 --- a/mac/util.cpp +++ b/mac/util.cpp @@ -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(ellipsis.size()); result = result.replace(result.begin() + prefix_end, result.end() - suffix_size, ellipsis); diff --git a/sks/code/code_block.py b/sks/code/code_block.py index bb62ce0..850e3e7 100644 --- a/sks/code/code_block.py +++ b/sks/code/code_block.py @@ -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 = [] diff --git a/sks/document/document.cpp b/sks/document/document.cpp index da803f0..3b0650f 100644 --- a/sks/document/document.cpp +++ b/sks/document/document.cpp @@ -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; diff --git a/sks/document/document_class.cpp b/sks/document/document_class.cpp index 29561cc..f52c493 100644 --- a/sks/document/document_class.cpp +++ b/sks/document/document_class.cpp @@ -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); } } diff --git a/sks/document/document_html.cpp b/sks/document/document_html.cpp index 3525038..0209fbb 100644 --- a/sks/document/document_html.cpp +++ b/sks/document/document_html.cpp @@ -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 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 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); diff --git a/sks/target/latex_util.cpp b/sks/target/latex_util.cpp index 94e2c05..07a5526 100644 --- a/sks/target/latex_util.cpp +++ b/sks/target/latex_util.cpp @@ -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(); } diff --git a/tst/Makefile b/tst/Makefile index 0d16e54..5d3454f 100644 --- a/tst/Makefile +++ b/tst/Makefile @@ -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 diff --git a/tst/alone_test.sh b/tst/alone_test.sh index ed797b3..4cc31ef 100755 --- a/tst/alone_test.sh +++ b/tst/alone_test.sh @@ -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 "================================" diff --git a/tst/check_test.sh b/tst/check_test.sh index c4f7725..d7fee07 100755 --- a/tst/check_test.sh +++ b/tst/check_test.sh @@ -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 "=============================" diff --git a/tst/command_option_test.sh b/tst/command_option_test.sh index f9d8ee8..3358227 100755 --- a/tst/command_option_test.sh +++ b/tst/command_option_test.sh @@ -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" diff --git a/tst/cond_test.sh b/tst/cond_test.sh index b66134c..2e62e39 100755 --- a/tst/cond_test.sh +++ b/tst/cond_test.sh @@ -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 diff --git a/tst/coverage_test.sh b/tst/coverage_test.sh index 2021d55..d796a30 100755 --- a/tst/coverage_test.sh +++ b/tst/coverage_test.sh @@ -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)) diff --git a/tst/deftype_test.sh b/tst/deftype_test.sh index 575d802..004a394 100755 --- a/tst/deftype_test.sh +++ b/tst/deftype_test.sh @@ -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} $name — expected a warning, got none"; FAIL=$((FAIL+1)); return + if [ -s "$ERR" ]; then + echo "${red}FAIL${reset} $name — the 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)) diff --git a/tst/escape_test.sh b/tst/escape_test.sh index 7ff8cfe..0c282fb 100755 --- a/tst/escape_test.sh +++ b/tst/escape_test.sh @@ -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" diff --git a/tst/eval_test.sh b/tst/eval_test.sh new file mode 100755 index 0000000..b22d3b1 --- /dev/null +++ b/tst/eval_test.sh @@ -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 diff --git a/tst/filename_test.sh b/tst/filename_test.sh index 9fee2e4..472e759 100755 --- a/tst/filename_test.sh +++ b/tst/filename_test.sh @@ -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 "=======================" diff --git a/tst/kdesc_test.sh b/tst/kdesc_test.sh index b17c3e4..60e7572 100755 --- a/tst/kdesc_test.sh +++ b/tst/kdesc_test.sh @@ -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 :: *s* @@\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 ::: *text* @@\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 :: *s* @@\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 \[\]' "$KDESC" shows "29. -i shows its filename" '\-i ' "$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 \[\]' | 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 diff --git a/tst/kdiag_test.sh b/tst/kdiag_test.sh new file mode 100755 index 0000000..141ef5d --- /dev/null +++ b/tst/kdiag_test.sh @@ -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 '\[\]') +if [ "$n_input" = "2" ]; then + pass " 5. usage lists [] once as a positional (plus the Usage: line)" +else + fail " 5. usage lists [] $n_input times, expected 2" \ + "$(printf '%s\n' "$usage" | grep -n '\[\]')" +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 diff --git a/tst/klammerset/base/base.k b/tst/klammerset/base/base.k new file mode 100644 index 0000000..1420ad0 --- /dev/null +++ b/tst/klammerset/base/base.k @@ -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 @@@ diff --git a/tst/klammerset/base/defs.k b/tst/klammerset/base/defs.k new file mode 100644 index 0000000..e5c2622 --- /dev/null +++ b/tst/klammerset/base/defs.k @@ -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 @@ diff --git a/tst/klammerset/bad_file.k b/tst/klammerset/broken/broken.k similarity index 100% rename from tst/klammerset/bad_file.k rename to tst/klammerset/broken/broken.k diff --git a/tst/klammerset/decl.k b/tst/klammerset/decl.k deleted file mode 100644 index 57f5b51..0000000 --- a/tst/klammerset/decl.k +++ /dev/null @@ -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 @@ diff --git a/tst/klammerset/decl2.k b/tst/klammerset/decl2.k deleted file mode 100644 index 41c9e42..0000000 --- a/tst/klammerset/decl2.k +++ /dev/null @@ -1 +0,0 @@ -@@@klammerset kit | Duplicate declaration of kit :files dup_only.k @@@ diff --git a/tst/klammerset/housea/housea.k b/tst/klammerset/housea/housea.k new file mode 100644 index 0000000..bd5a497 --- /dev/null +++ b/tst/klammerset/housea/housea.k @@ -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 @@ diff --git a/tst/klammerset/houseb/houseb.k b/tst/klammerset/houseb/houseb.k new file mode 100644 index 0000000..0ad0243 --- /dev/null +++ b/tst/klammerset/houseb/houseb.k @@ -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 @@ diff --git a/tst/klammerset/base.k b/tst/klammerset/kit/base.k similarity index 100% rename from tst/klammerset/base.k rename to tst/klammerset/kit/base.k diff --git a/tst/klammerset/kit/kit.k b/tst/klammerset/kit/kit.k new file mode 100644 index 0000000..f5bd507 --- /dev/null +++ b/tst/klammerset/kit/kit.k @@ -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 /.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 @@ diff --git a/tst/klammerset/klammers.k b/tst/klammerset/kit/klammers.k similarity index 100% rename from tst/klammerset/klammers.k rename to tst/klammerset/kit/klammers.k diff --git a/tst/klammerset/needs_a/needs_a.k b/tst/klammerset/needs_a/needs_a.k new file mode 100644 index 0000000..63ee17d --- /dev/null +++ b/tst/klammerset/needs_a/needs_a.k @@ -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 @@@ diff --git a/tst/klammerset/needs_b/needs_b.k b/tst/klammerset/needs_b/needs_b.k new file mode 100644 index 0000000..7ae4111 --- /dev/null +++ b/tst/klammerset/needs_b/needs_b.k @@ -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 @@@ diff --git a/tst/klammerset/selfdef/selfdef.k b/tst/klammerset/selfdef/selfdef.k new file mode 100644 index 0000000..7152183 --- /dev/null +++ b/tst/klammerset/selfdef/selfdef.k @@ -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 @@ diff --git a/tst/klammerset/sp/sp.k b/tst/klammerset/sp/sp.k new file mode 100644 index 0000000..54f1b77 --- /dev/null +++ b/tst/klammerset/sp/sp.k @@ -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 +@@@ diff --git a/tst/klammerset/space name.k b/tst/klammerset/sp/space name.k similarity index 100% rename from tst/klammerset/space name.k rename to tst/klammerset/sp/space name.k diff --git a/tst/klammerset/spacey.k b/tst/klammerset/spacey.k deleted file mode 100644 index 4e63bf2..0000000 --- a/tst/klammerset/spacey.k +++ /dev/null @@ -1 +0,0 @@ -@@@klammerset sp | Spacey filename in the list :files base.k / space name.k @@@ diff --git a/tst/klammerset/util.k b/tst/klammerset/util/util.k similarity index 100% rename from tst/klammerset/util.k rename to tst/klammerset/util/util.k diff --git a/tst/klammerset/wrap/dup.k b/tst/klammerset/wrap/dup.k new file mode 100644 index 0000000..7b3fc14 --- /dev/null +++ b/tst/klammerset/wrap/dup.k @@ -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 @@@ diff --git a/tst/klammerset/dup_only.k b/tst/klammerset/wrap/dup_only.k similarity index 100% rename from tst/klammerset/dup_only.k rename to tst/klammerset/wrap/dup_only.k diff --git a/tst/klammerset/wrap/wrap.k b/tst/klammerset/wrap/wrap.k new file mode 100644 index 0000000..7363d19 --- /dev/null +++ b/tst/klammerset/wrap/wrap.k @@ -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 @@@ diff --git a/tst/klammerset/wrapper.k b/tst/klammerset/wrapper.k deleted file mode 100644 index cbfb1e4..0000000 --- a/tst/klammerset/wrapper.k +++ /dev/null @@ -1 +0,0 @@ -@@@klammerset wrap | Wrapper requiring kit twice :requires decl.k / decl2.k @@@ diff --git a/tst/klammerset_test.sh b/tst/klammerset_test.sh index c17ff45..1f4ea2a 100755 --- a/tst/klammerset_test.sh +++ b/tst/klammerset_test.sh @@ -23,6 +23,14 @@ KTEXT=ktext K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set} FIX=$K/tst/klammerset +# The fixtures are laid out as /.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 -> //.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 /.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 /.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 "=======================" diff --git a/tst/modulepath_test.sh b/tst/modulepath_test.sh index 1faeecf..a46b037 100755 --- a/tst/modulepath_test.sh +++ b/tst/modulepath_test.sh @@ -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)) ;; diff --git a/tst/option_set_test.sh b/tst/option_set_test.sh index 11746b3..b2ef9fb 100755 --- a/tst/option_set_test.sh +++ b/tst/option_set_test.sh @@ -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" diff --git a/tst/recursion_test.sh b/tst/recursion_test.sh index 162e2a1..93a7d2f 100755 --- a/tst/recursion_test.sh +++ b/tst/recursion_test.sh @@ -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" \ "<<<<>>>>" \ - -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 "=============================" diff --git a/tst/signature_test.sh b/tst/signature_test.sh index 566744e..31b7ed9 100755 --- a/tst/signature_test.sh +++ b/tst/signature_test.sh @@ -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 diff --git a/tst/target_list_test.sh b/tst/target_list_test.sh index 6ef6753..9719531 100755 --- a/tst/target_list_test.sh +++ b/tst/target_list_test.sh @@ -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} $name — expected a warning, got none"; FAIL=$((FAIL+1)); return + if [ -s "$ERR" ]; then + echo "${red}FAIL${reset} $name — the 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 diff --git a/tst/verbosity_test.sh b/tst/verbosity_test.sh new file mode 100755 index 0000000..213011c --- /dev/null +++ b/tst/verbosity_test.sh @@ -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