diff --git a/README.md b/README.md index 6c1d7bd..35e24cf 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 `12929fdff53b`. +This snapshot was assembled from development commit `46f54080bd9a`. ## License diff --git a/com/kdesc.cpp b/com/kdesc.cpp index 63c6435..88ba272 100644 --- a/com/kdesc.cpp +++ b/com/kdesc.cpp @@ -1,5 +1,6 @@ #include "argv.h" #include "command.h" +#include "coverage.h" #include "error.h" #include "file.h" #include "font_store.h" @@ -33,6 +34,50 @@ static void font_usage() "and then in the default font set.\n"; } +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" + "\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" + "calls covers the intersection of what those klammers cover), must be\n" + "DECLARED where they cannot (an @eval body, whose targets are named in a\n" + "comma-separated list: @@name.html,tex :: ... @@), and is UNKNOWN where\n" + "nothing is written -- which never means \"deliberately unavailable\".\n" + "\n" + "Without \"all\", an AUTHOR sees what is available and only the problems\n" + "that exist. With it, a DESIGNER building a klammer set is reminded of\n" + "the whole set of categories.\n"; +} + +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"; +} + +// The words of a variadic option, with the empty ones dropped. +static strings_t option_words(Argv& args, const std::string& name) +{ + strings_t words {}; + for (const std::string& w : args.as_vector(name)) { + if (!w.empty()) { + words.push_back(w); + } + } + return words; +} + static void klammerset_usage() { std::cout << @@ -94,15 +139,23 @@ int main(int argc, char* argv[]) set_verbose_level(argc, argv); Argv args {}; - args.flag("c", "Special characters"); - args.flag("a", "Argument types"); - args.flag("k", "Katom types"); - args.flag("r", "Katom rewrite patterns"); - args.opt("input", "Input filename", "filename", "", "'text'"); - args.flag("targets", "Show targets defined by the input file"); - args.flag("klammers", "Show klammers defined by the input file"); - args.flag("optionsets", "Show option sets declared by the input file"); + // Declaration order is display order in the usage text, so these are + // ordered by how likely a user is to want them. A single letter is a + // flag one reaches for often; a multi-letter name is a more specialised + // topic. -v stays last: it says how much to show about the command's + // PROCESSING, never what its RESULT contains -- which is why the katom + // 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.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'"); @@ -123,50 +176,72 @@ int main(int argc, char* argv[]) std::cout << "\n"; } - if (p("a")) { + if (p("argtypes")) { Argtype_registry argtypes; std::cout << boldblack << "\nStandard klammer argument types\n" << black; std::cout << argtypes.describe() << "\n"; } - if (p("k")) { - describe_katoms(verbose_level > 2); + if (args.given("katoms")) { + strings_t words = option_words(args, "katoms"); + std::string verb = words.empty() ? "" : words[0]; + if (verb == "help") { + katom_usage(); + return 0; + } + if (!verb.empty() && verb != "full") { + std::cout << "Unrecognized katom command: --katoms " << join(words, " ") << "\n\n"; + katom_usage(); + return 1; + } + describe_katoms(verb == "full"); } - if (p("r")) { + if (p("rewrite")) { describe_rewrite_patterns(); } // The --font subcommands operate on the Klammertext font store // (infrastructure) and load no klammer set. if (args.given("font")) { - strings_t words {}; - for (const std::string& w : args.as_vector("font")) { - if (!w.empty()) { - words.push_back(w); - } - } - font_command(words); + font_command(option_words(args, "font")); return 0; } // The --klammerset subcommands operate on the search path // (filesystem enumeration) and load no klammer set. if (args.given("klammerset")) { - strings_t words {}; - for (const std::string& w : args.as_vector("klammerset")) { - if (!w.empty()) { - words.push_back(w); - } - } - klammerset_command(words); + klammerset_command(option_words(args, "klammerset")); return 0; } + // --coverage is validated here, before a klammer set is read, so a + // mistyped word or "help" is answered at once. The report itself + // needs the loaded registry, so it runs at the end. + bool coverage_all = false; + if (args.given("coverage")) { + strings_t words = option_words(args, "coverage"); + std::string verb = words.empty() ? "" : words[0]; + if (verb == "help") { + coverage_usage(); + return 0; + } + if (verb == "all") { + coverage_all = true; + } else if (!verb.empty()) { + std::cout << "Unrecognized coverage command: --coverage " + << join(words, " ") << "\n\n"; + coverage_usage(); + return 1; + } + } + Machine M; - strings_t input_filenames = resolve_filename_list(args.get("input")); - std::cout << "input_filenames: " << input_filenames << "\n"; + 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 { @@ -178,25 +253,48 @@ int main(int argc, char* argv[]) fname = resolve_klammerset_symbol( fname, fs::current_path().string(), Locator()).string(); } - std::cout << "Read " << fname << "\n"; + if (verbose_level > 0) { + std::cout << "Read " << fname << "\n"; + } M.read(fs::path(absolute_pathname(fname))); } } - if (p("targets")) { + if (p("t")) { std::cout << boldblack << "Targets\n" << black << M.m_targets.describe(2, true); } - if (p("klammers")) { - std::cout << boldblack << "Klammers\n" << black << M.m_klammers.describe(2); + // "-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. + if (args.given("k")) { + std::string search = join(option_words(args, "k"), " "); + std::string listing = M.m_klammers.describe(2, search); + if (listing.empty() && !search.empty()) { + std::cout << "No klammer names or descriptions contained " + << q_(collapse_whitespace(search)) << ".\n"; + } else { + std::cout << boldblack << "Klammers\n" << black << listing; + } } if (p("optionsets")) { std::cout << boldblack << "Option sets\n" << black << M.m_option_sets.describe(2); } + // 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); + } + } catch (Error& e) { + // Nonzero, as ktext does: a command that prints an error and exits 0 + // reports success, and a script cannot tell the difference. e.print_message(); + return 1; } + return 0; } diff --git a/com/kdiag.cpp b/com/kdiag.cpp index 5642380..17f2e1b 100644 --- a/com/kdiag.cpp +++ b/com/kdiag.cpp @@ -111,6 +111,10 @@ int main(int argc, char* argv[]) if (e.m_type == "target") advice = "To include the Standard Klammer Set, add flag \"--sks\"."; 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. + std::cout << black; + return 1; } std::cout << black; return 0; diff --git a/mac/Makefile b/mac/Makefile index 5f15c69..959fe37 100644 --- a/mac/Makefile +++ b/mac/Makefile @@ -11,7 +11,7 @@ BASENAMES := util error locator file argv character ktype katom katom_list \ log show command argument argument_set argtype argtype_registry \ state eval eval_python eval_cpp klammer klammer_registry klammerset klammerset_registry \ option_set option_set_registry deftype \ - target target_registry machine font_store check + target target_registry machine font_store check coverage SOURCES := $(addsuffix .cpp,$(BASENAMES)) OBJECTS := $(addsuffix .o,$(BASENAMES)) diff --git a/mac/argv.cpp b/mac/argv.cpp index cb13ea8..2768bd9 100644 --- a/mac/argv.cpp +++ b/mac/argv.cpp @@ -145,7 +145,8 @@ void Argv::opt(const std::string& name, const std::string& desc, const std::stri update_width(arg); } -void Argv::var(const std::string& name, const std::string& desc) +void Argv::var(const std::string& name, const std::string& desc, + const std::string& parameter) { (void)K::log(2, name, desc); Arg arg {}; @@ -153,6 +154,9 @@ void Argv::var(const std::string& name, const std::string& desc) arg.m_name = name; arg.m_desc = get_regex_desc(desc); arg.m_syntax = arg.symbol(); + if (!parameter.empty()) { + arg.m_syntax += " [<" + parameter + ">]"; + } m_args[name] = arg; m_names.push_back(name); m_var_names.push_back(name); @@ -291,18 +295,23 @@ void Argv::parse_optional(strings_t& words, string_map& named_args) // std::cout << " Found: " << words[index] << "\n"; //std::vector opt_args = {}; index++; - /* + // An option declared with opt() takes a value, so the word after + // the flag must exist. Written last with nothing after it -- a + // bare "kdesc -v", or "ktext doc.kt -t" -- this read past the end + // of the vector and the command died with SIGSEGV, naming + // nothing. (Two bounds checks used to sit here, commented out; + // they would have returned a HALF-PARSED option rather than + // reporting the mistake, so this reports it instead.) A valueless + // option is declared with flag(), not opt(). if (index >= words.size()) { - break; + throw Argument_error( + "The option " + flag_name(opt) + " needs a value: " + + m_args[opt].m_syntax + ". Enter \"" + + file_basename(command_name) + "\" for the list of arguments.", + Locator(), false); } - */ std::string opt_arg = words[index] + " "; index++; - /* - if (index >= words.size()) { - break; - } - */ std::regex opt_regex = m_args[opt].m_rgx; // std::cout << "Regex match? " << std::regex_match(trim(opt_arg), opt_regex) << "\n"; @@ -346,7 +355,19 @@ void Argv::parse_positional(const std::string& command, //strings_t words, throw Argument_error(ss.str(), Locator(), false); } named_args[req] = substring; - pos_args = rest; + // Drop the whitespace that separated this positional from the next. + // regex_split_prefix() requires its match at position 0 and returns + // the remainder verbatim, so without this the SECOND required + // argument is always "not found": its pattern is offered " second" + // and cannot match a leading space. + // + // Trimming here rather than at the top of the loop is deliberate: the + // first positional still receives pos_args exactly as before, so a + // command with ONE required argument -- which is every command that + // ships (ktext's `filenames`, kdiag's `input`) -- parses + // bit-identically. Only the case that never worked changes. + size_t next = rest.find_first_not_of(" \t"); + pos_args = (next == std::string::npos) ? "" : rest.substr(next); } // std::cout << "Remaining words: " << words << "\n" << pos_args << "\n"; } @@ -436,7 +457,13 @@ void Argv::parse(int argc, char* argv[], bool full_parse) { (void)K::log(2); command_name = argv[0]; - describe(); + // No describe() here. A call sat at this point, before any value has + // been assigned, so it could only ever print a table of "" -- and + // because set_verbose_level() parses a throwaway Argv before the real + // one, EVERY command would print that table on every run. Silencing + // Argv::describe() itself was the wrong half of the fix: it also silenced + // the callers that legitimately want it (kdesc -v, argv_test). The + // caller decides; parse() does not print. auto input_args = classify_arguments(argc, argv, full_parse); // std::cout << "parse() classify:\n" << input_args << "\n"; @@ -610,10 +637,11 @@ void Argv::describe() std::stringstream ss {}; ss << " "<< std::setfill(' ') << std::setw(width) << m_args[name].symbol() << " : " << value; + // The line was built and then dropped, so this printed nothing at + // all -- which is why "kdesc -v 1" showed no arguments and argv_test, + // 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"; } - /* - if (verbose_level == 1) { - std::cout << "\n"; - } - */ } diff --git a/mac/argv.h b/mac/argv.h index ba135ce..92ad4e7 100644 --- a/mac/argv.h +++ b/mac/argv.h @@ -58,7 +58,13 @@ public: // space-joined; given(name) distinguishes "--name with no words" // from an absent --name. Used for subcommand-style interfaces // (kdesc --font install ). - void var(const std::string& name, const std::string& desc); + // A variadic option: the words after the flag, up to the next one. + // `parameter` is shown in the usage line as "[]" -- an option + // whose words are a free argument (a search phrase) should name it, so + // the usage says what may follow; a subcommand-style option (--font, + // --coverage) leaves it empty and documents its words in its own help. + void var(const std::string& name, const std::string& desc, + const std::string& parameter = ""); void update_width(Arg arg); diff --git a/mac/coverage.cpp b/mac/coverage.cpp new file mode 100644 index 0000000..192a1fb --- /dev/null +++ b/mac/coverage.cpp @@ -0,0 +1,481 @@ +#include +#include +#include +#include +#include + +#include "coverage.h" +#include "machine.h" +#include "katom.h" +#include "util.h" + +namespace { + +using target_set = std::set; + +// The written definitions of one klammer, split by what they tell us. A +// definition filed under the general name is the only one whose coverage has +// to be worked out; a definition written for a target IS its own statement. +struct Written +{ + const Klammer::components* m_general { nullptr }; + target_set m_targets {}; // targets with a definition of their own + bool m_declared { false }; // has a ".k" declaration +}; + +Written written_definitions(const Klammer& klammer) +{ + Written w {}; + for (const auto& def : klammer.m_defs) { + if (def.target == Target_registry::general_name) { + w.m_general = &def; + } else if (def.target == Target_registry::declare_name) { + w.m_declared = true; + } else if (def.target != Target_registry::optionset_name) { + w.m_targets.insert(def.target); + } + } + return w; +} + +// What a general body is made of. The three findings are ordered by how +// firmly they settle the question: anything the engine cannot interpret ends +// the analysis, and only a body of plain klammer calls is derivable. +struct Body_scan +{ + bool m_undecidable { false }; + std::string m_reason {}; + std::vector m_calls {}; +}; + +Body_scan scan_body(const katom_list& body) +{ + Body_scan scan {}; + target_set seen {}; + for (const auto& k : body) { + switch (k.m_type) { + case katom_t::eval_begin: + // Which targets a Python function answers for is undecidable, so + // the analysis stops here and the targets must be declared. + scan.m_undecidable = true; + scan.m_reason = "@eval body"; + return scan; + case katom_t::read_begin: + scan.m_undecidable = true; + scan.m_reason = "@read body"; + return scan; + case katom_t::literal_begin: + // A ^'...'^ span exists to carry raw target markup past the + // escaping pass. A general body holding one is target-specific + // with nothing for the intersection rule to see -- the blind spot + // a body of plain text would otherwise hide. + scan.m_undecidable = true; + scan.m_reason = "^'...'^ literal span"; + return scan; + case katom_t::apply_begin: { + // The body read here is the STORED body, which is the body as + // written with one exception: a general klammer that takes no + // parameters is a constant, and a constant's body is spliced into + // later definitions at definition time. So a call to a constant + // does not appear here -- what appears is whatever the constant + // expanded to. That is the right thing for coverage (the calls + // that remain are the ones that will be applied), but it makes + // the "from" list a statement about the stored body, not about + // the source text. + std::string name = trim_char(k.m_text, '@'); + if (seen.insert(name).second) { + scan.m_calls.push_back("@" + name); + } + break; + } + default: + break; + } + } + return scan; +} + +target_set intersect(const target_set& a, const target_set& b) +{ + target_set result {}; + std::set_intersection(a.begin(), a.end(), b.begin(), b.end(), + std::inserter(result, result.begin())); + return result; +} + +std::vector as_vector(const target_set& s) +{ + return { s.begin(), s.end() }; +} + +// Shorten a definition's pathname for the report. What identifies a +// definition to a reader is its tail -- "sks/block/block.k" -- not the +// absolute path the file happened to be read from, which is the same long +// prefix on every row. A klammer set outside $KLAMMERTEXT_HOME keeps its +// path in full rather than being shortened to something ambiguous. +std::string short_path(const std::string& path) +{ + auto pos = path.rfind("/sks/"); + if (pos != std::string::npos) { + return path.substr(pos + 1); + } + const char* home = std::getenv("KLAMMERTEXT_HOME"); + if (home != nullptr) { + std::string prefix = std::string(home) + "/"; + if (path.size() > prefix.size() && path.compare(0, prefix.size(), prefix) == 0) { + return path.substr(prefix.size()); + } + } + return path; +} + +} // namespace + +std::vector klammer_coverage(const Machine& machine) +{ + target_set all_targets {}; + for (const auto& t : machine.m_targets.user_defined()) { + all_targets.insert(t); + } + + // Pass 1: what each klammer's definitions say, without resolving anything. + std::map written {}; + std::map scans {}; + for (const auto& [name, klammer] : machine.m_klammers.m_klammers) { + written[name] = written_definitions(klammer); + if (written[name].m_general != nullptr) { + scans[name] = scan_body(written[name].m_general->body); + } + } + + // Pass 2: the greatest fixpoint. Every general klammer starts optimistic + // -- all targets -- and the intersection rule is applied until nothing + // shrinks. Starting optimistic is what makes a cycle terminate: two + // klammers calling each other simply keep each other's sets, and a set + // that only loses members cannot iterate forever. + std::map general {}; + for (const auto& [name, scan] : scans) { + general[name] = all_targets; + } + // A klammer's coverage, for use as an operand of the intersection: what + // its own definitions cover, plus whatever its general body covers. + auto coverage_of = [&](const std::string& name) -> target_set { + auto w = written.find(name); + if (w == written.end()) return {}; // not defined; contributes nothing + target_set result = w->second.m_targets; + auto g = general.find(name); + if (g != general.end()) { + result.insert(g->second.begin(), g->second.end()); + } + return result; + }; + bool changed = true; + while (changed) { + changed = false; + for (auto& [name, targets] : general) { + const Body_scan& scan = scans[name]; + if (scan.m_undecidable || scan.m_calls.empty()) continue; + target_set next = all_targets; + for (const auto& call : scan.m_calls) { + std::string called = trim_char(call, '@'); + if (called == name) continue; // self-reference constrains nothing + next = intersect(next, coverage_of(called)); + } + if (next != targets) { + targets = next; + changed = true; + } + } + } + + // Pass 3: classify and collect. + std::vector result {}; + for (const auto& [name, klammer] : machine.m_klammers.m_klammers) { + const Written& w = written.at(name); + Klammer_coverage kc {}; + kc.m_name = name; + kc.m_declared = w.m_declared; + kc.m_written = as_vector(w.m_targets); + // Written definitions only (m_defs), so this is where the klammer is + // WRITTEN. m_defloc would also carry the targets a general body was + // copied to, which name the same file again. + for (const auto& def : klammer.m_defs) { + std::string file = short_path(def.loc.m_filename); + if (!is_in(file, kc.m_files)) { + kc.m_files.push_back(file); + } + } + for (const auto& [target, loc] : klammer.m_defloc) { + if (target != Target_registry::declare_name && + target != Target_registry::general_name && + target != Target_registry::optionset_name) { + kc.m_effective.push_back(target); + } + } + if (klammer.m_general_declared) { + // The author has said "every target". That is a statement, not + // something to be re-derived from the body: the whole point of + // writing ".*" is to assert what an @eval body cannot be read to + // mean. + kc.m_kind = coverage_t::all_declared; + target_set covered = w.m_targets; + covered.insert(all_targets.begin(), all_targets.end()); + kc.m_targets = as_vector(covered); + } else if (w.m_general == nullptr) { + kc.m_kind = w.m_targets.empty() ? coverage_t::none : coverage_t::declared; + kc.m_targets = kc.m_written; + } else { + const Body_scan& scan = scans.at(name); + target_set covered = w.m_targets; + if (scan.m_undecidable) { + kc.m_kind = coverage_t::undecidable; + kc.m_reason = scan.m_reason; + // What it covers is not knowable here; report what is written. + kc.m_targets = kc.m_written; + } else if (scan.m_calls.empty()) { + kc.m_kind = coverage_t::all; + covered.insert(all_targets.begin(), all_targets.end()); + kc.m_targets = as_vector(covered); + } else { + kc.m_kind = coverage_t::derived; + kc.m_from = scan.m_calls; + const target_set& g = general.at(name); + covered.insert(g.begin(), g.end()); + kc.m_targets = as_vector(covered); + } + } + result.push_back(kc); + } + return result; +} + +namespace { + +std::string list_of(const std::vector& v) +{ + return v.empty() ? "--" : join(v, " "); +} + +void section(std::ostream& os, const std::string& title, size_t count, + const std::string& explanation) +{ + os << "\n" << title << " (" << count << ")\n"; + if (!explanation.empty()) { + os << explanation; + } +} + +} // namespace + +void report_coverage(const Machine& machine, + const std::vector& coverage, + bool full, std::ostream& os) +{ + strings_t targets = machine.m_targets.user_defined(); + os << "Klammer coverage\n" + << "================\n\n" + << coverage.size() << " klammers, " << targets.size() + << " targets: " << join(targets, " ") << "\n"; + + auto of_kind = [&coverage](coverage_t kind) { + std::vector result {}; + for (const auto& kc : coverage) { + if (kc.m_kind == kind) result.push_back(&kc); + } + return result; + }; + size_t width = 0; + for (const auto& kc : coverage) { + width = std::max(width, kc.m_name.size()); + } + // The indent of a continuation line, under the name column. + std::string continuation(2 + 1 + width, ' '); + + auto name_of = [&](const Klammer_coverage& kc) { + std::stringstream ss {}; + ss << " @" << std::left << std::setw(width) << kc.m_name; + return ss.str(); + }; + + // A section's rows, held until the whole section is built so the + // source-file column can be aligned. The file goes LAST because it is + // reference information: what the row says comes first, and the reader + // looks right only when they want to go and edit it. Only a row naming + // ONE klammer carries a file -- the "All targets" section lists many + // names on a line and has nothing to attach one to. + using Row = std::pair; // text, file + auto emit = [&os, full](const std::vector& rows) { + size_t text_width = 0; + if (full) { + for (const auto& [text, file] : rows) { + if (!file.empty()) text_width = std::max(text_width, text.size()); + } + } + for (const auto& [text, file] : rows) { + if (full && !file.empty()) { + os << std::left << std::setw(text_width) << text << " " << file << "\n"; + } else { + os << trim_right(text) << "\n"; + } + } + }; + + // ---- what works, first -------------------------------------------- + // + // The reporting categories come before the problems because a terminal is + // read from the BOTTOM: an eighty-klammer listing scrolls a three-line + // warning off the screen entirely, so the actionable part has to be last, + // where "| tail" finds it. + // + // The two halves of the report hide an empty category for different + // reasons, so they are two functions rather than one with a flag. + using Row_of = std::function; + // The explanation under a heading is for a reader learning the categories, + // so it appears only under "all" -- the same argument that shows the empty + // ones. A default report is headings and rows. + auto write = [&](const std::string& title, + const std::vector& group, + const std::string& explanation, const Row_of& row) { + section(os, title, group.size(), full ? explanation : ""); + std::vector rows {}; + for (const auto* kc : group) { + rows.push_back({row(*kc), join(kc->m_files, ", ")}); + } + emit(rows); + }; + + // A REPORTING category describes the shape of the klammer set, so an + // empty one still says something ("nothing here uses .*") and "all" shows + // it as a designer's checklist. + auto reporting = [&](const std::string& title, + const std::vector& group, + const std::string& explanation, const Row_of& row) { + if (group.empty() && !full) return; + write(title, group, explanation, row); + }; + + // A PROBLEM category is different: it sits under a banner that says + // "Needs attention", and an empty one does not. Printing "Covers no + // target (0)" there states the opposite of the heading above it, so it is + // hidden whether or not "all" was given -- "all" is for information that + // is missing, and a category with nothing in it is not missing anything. + auto problem = [&](const std::string& title, + const std::vector& group, + const std::string& explanation, const Row_of& row) { + if (group.empty()) return; + write(title, group, explanation, row); + }; + auto with_targets = [&](const Klammer_coverage& kc) { + return name_of(kc) + " " + list_of(kc.m_targets); + }; + + reporting("Defined per target", of_kind(coverage_t::declared), "", with_targets); + + reporting("All targets, declared", of_kind(coverage_t::all_declared), + " Written \".*\": the author states that these work for every target,\n" + " including targets that do not exist yet.\n", with_targets); + + // Many names on one line, so no file column: there is nothing for a file + // to attach to. + auto all = of_kind(coverage_t::all); + if (!all.empty() || full) { + section(os, "All targets, derived", all.size(), + full ? " No target suffix and a body of plain text, so nothing in them is\n" + " target-specific. This is the writer's macro form -- a repeated\n" + " phrase, not a klammer set -- and needs no declaration.\n" : ""); + strings_t names {}; + for (const auto* kc : all) { + names.push_back("@" + kc->m_name); + } + if (!names.empty()) { + os << " " << join(names, " ") << "\n"; + } + } + + // A derived klammer covering NOTHING is unusable, so it is reported with + // the problems rather than here. + std::vector derived {}; + std::vector uncoverable {}; + for (const auto* kc : of_kind(coverage_t::derived)) { + (kc->m_targets.empty() ? uncoverable : derived).push_back(kc); + } + reporting("Derived from the klammers the body calls", derived, + " No target suffix and a body of klammer calls, so the coverage is the\n" + " intersection of what those klammers cover.\n", + [&](const Klammer_coverage& kc) { + std::stringstream ss {}; + ss << name_of(kc) << " " << std::left << std::setw(22) << list_of(kc.m_targets) + << " from " << join(kc.m_from, " "); + return ss.str(); + }); + + // ---- then what needs doing ---------------------------------------- + // + // Four categories, ordered by severity: the first two mean the klammer + // cannot be used at all, the third that its coverage is a guess, the + // fourth that it is undocumented. Counted by DISTINCT klammer -- "no .k" + // is orthogonal to the others, so a klammer can be in two categories and + // summing the counts would overstate the work. + auto undecidable = of_kind(coverage_t::undecidable); + auto none = of_kind(coverage_t::none); + std::vector undescribed {}; + for (const auto& kc : coverage) { + if (!kc.m_declared) undescribed.push_back(&kc); + } + target_set needing {}; + for (const auto* group : { &none, &uncoverable, &undecidable, &undescribed }) { + for (const auto* kc : *group) needing.insert(kc->m_name); + } + // Nothing to attend to, nothing said -- the same rule as the categories + // below it. "Needs attention: 0" under "all" was the banner contradicting + // itself, exactly as an empty category under it would. + if (!needing.empty()) { + os << "\nNeeds attention: " << needing.size() << " " + << plural("klammer", static_cast(needing.size())) << "\n"; + } + + problem("Declared but never defined", none, + " A \".k\" declaration with no definition for any target, so the klammer\n" + " can never be applied.\n", + [&](const Klammer_coverage& kc) { return name_of(kc); }); + problem("Covers no target", uncoverable, + " The klammers this one calls have no target in common, so the\n" + " intersection is empty and it can never be applied. The klammers\n" + " named are the ones to look at.\n", + [&](const Klammer_coverage& kc) { + return name_of(kc) + " from " + join(kc.m_from, " "); + }); + problem("Must be declared", undecidable, + " A general definition whose body the engine cannot interpret, so it is\n" + " offered to EVERY target whether or not its code answers for that\n" + " target. Name the targets it does answer for -- @@name.html,tex :: --\n" + " or, if it works for any target at all, @@name.* ::\n", + [&](const Klammer_coverage& kc) { return name_of(kc) + " " + kc.m_reason; }); + problem("No \".k\" declaration", undescribed, + " These render, but nothing describes them: a klammer without a \".k\"\n" + " has no description, so kdesc can say nothing about what it does and\n" + " \"kdesc -k \" can only find it by name.\n", with_targets); + + // The progress number. "Undecided" is the accurate label, and the one + // that asserts no more than was measured: a klammer that does not cover a + // target has not been declared unavailable there -- no notation for that + // exists yet -- it simply has no definition. "Unsupported" or "excluded" + // would each claim a decision nobody made. + os << "\nBy target\n"; + for (const auto& target : targets) { + size_t covered = 0; + size_t offered = 0; + for (const auto& kc : coverage) { + if (is_in(target, kc.m_targets)) covered++; + if (is_in(target, kc.m_effective)) offered++; + } + os << " " << std::left << std::setw(10) << target + << std::right << std::setw(4) << covered << " covered" + << std::setw(6) << (coverage.size() - covered) << " undecided"; + if (offered > covered) { + os << " (" << offered - covered + << " more currently offered by an underivable general body)"; + } + os << "\n"; + } +} diff --git a/mac/coverage.h b/mac/coverage.h new file mode 100644 index 0000000..dcd5f0e --- /dev/null +++ b/mac/coverage.h @@ -0,0 +1,110 @@ +#pragma once + +#include +#include +#include + +class Machine; + +// Target coverage: which targets a klammer can actually render to. +// +// Coverage is a FACT about a klammer, distinct from a klammer set's claim +// about what it supports and from what happens when a document meets a +// target. This module computes the fact, and computes only what can be +// computed -- it changes nothing about how the Machine behaves. See +// notes/target_coverage.md for why the fact has to come first. +// +// Three rules, and the whole of the analysis is deciding which one applies: +// +// DERIVED where coverage is structurally determined. A general +// definition (no target suffix) whose body is text and nothing +// else covers every target. One whose body calls other klammers +// covers the INTERSECTION of what those klammers cover -- a +// klammer can only render where everything it is made of renders. +// +// DECLARED where coverage depends on something the engine cannot +// interpret. A general body holding an @eval is the main case: +// deciding which targets a Python function answers for is +// undecidable, so the targets have to be written down (which is +// what the comma-separated target list is for). A general body +// holding a ^'...'^ literal span is the same problem wearing +// different clothes -- the span exists precisely to carry raw +// target markup past the escaping pass, so a body containing one +// is target-specific with nothing for the intersection rule to +// see. @read is included: its content is not known statically. +// +// DECLARED-ALL a definition written ".*" asserts that the klammer works for +// EVERY target, including targets that do not exist yet. A list +// of the targets defined today cannot say that. It is the one +// target declaration a machine could later falsify: a ".*" +// klammer whose implementation branches per target is +// contradicting itself, which is a structural property. +// +// UNKNOWN where neither applies -- no definition at all. Absence means +// "not decided yet", never "deliberately unavailable": the SKS is +// incomplete on schedule rather than by design, so nothing may +// read a missing definition as a statement of intent. +// +// The intersection is computed as a GREATEST FIXPOINT rather than by +// recursion: general klammers may call each other, and a cycle would not +// terminate. Every general klammer starts at "all targets" and the rule is +// applied until nothing shrinks, which terminates because the sets only ever +// lose members. The pass runs over the whole registry AFTER loading, not at +// definition time -- definitions load in file order, so a body may call a +// klammer defined later. +enum class coverage_t { + all_declared, // written ".*": every target, including ones not yet defined + all, // general body, no klammer calls: every target + derived, // general body of klammer calls: their intersection + declared, // written per target, no general body to derive from + undecidable, // general body holding @eval, @read, or a literal span + none, // declared (.k) but never defined +}; + +struct Klammer_coverage +{ + std::string m_name {}; + coverage_t m_kind { coverage_t::none }; + // The targets this klammer can render to, as computed. + std::vector m_targets {}; + // Targets the Machine currently offers it for. These differ exactly + // where a general body is copied to targets it cannot really serve, which + // is the hazard the report exists to surface. + std::vector m_effective {}; + // Targets named in a written definition (the comma-list, or one per + // definition), empty for a purely general klammer. + std::vector m_written {}; + // For `derived`: the klammers the body calls. For `undecidable`: why. + std::vector m_from {}; + std::string m_reason {}; + // Whether a ".k" declaration exists. A klammer without one still works + // -- its parameters can be declared on the definition itself -- but it + // has no DESCRIPTION, so kdesc can say nothing about what it does and + // "kdesc -k " can only find it by name. + bool m_declared { false }; + // The file(s) the klammer is written in, in definition order. Usually + // one -- a klammer's targets are declared together -- but a klammer whose + // definitions are spread over several files lists them all. Shown by + // "--coverage -v", and only on a row that names ONE klammer. + std::vector m_files {}; +}; + +// 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); + +// The report behind "kdesc --coverage". `full` ("--coverage all") adds the +// source-file column and shows every "Needs attention" category, including +// the empty ones; without it those categories appear only when they have +// entries, so a klammer set with nothing wrong produces a short report. +// +// It is an argument rather than a verbosity level because the two are +// independent: -v says how much to show about the command's PROCESSING, and +// this says what the command's RESULT contains. +// +// Audiences, in the project's terms: an AUTHOR runs it to see what is +// available for a target; a DESIGNER runs "--coverage all" to be reminded of +// the full set of categories while building a klammer set. +void report_coverage(const Machine& machine, + const std::vector& coverage, + bool full, std::ostream& os); diff --git a/mac/klammer.cpp b/mac/klammer.cpp index abb03da..b57ecc5 100644 --- a/mac/klammer.cpp +++ b/mac/klammer.cpp @@ -10,34 +10,82 @@ using namespace std::literals; -std::regex Klammer::name_re = std::regex(R"((\w+)(?:\.(\w+))?)"); +std::regex Klammer::name_re = std::regex(R"((\w+)(?:\.((?:\w+|\*)(?:,(?:\w+|\*))*))?)"); -std::tuple +std::tuple parse_name(const Target_registry& targets, const Katom& name_katom) { std::string name_with_target = trim_char(name_katom.m_text, '@'); std::smatch match {}; - + if (!std::regex_match(name_with_target, match, Klammer::name_re)) { throw Parsing_error( "The klammer name \"" + name_with_target + "\" is not correctly defined. " "The form is \"\" for general klammers or \".\" " - "for a specialized target. The klammer defined as \".k\" specifies the " + "for a specialized target. Several targets that share one body are written as a " + "comma-separated list: \".,\". The klammer " + "defined as \".k\" specifies the " "arguments and contains a description of the klammer in the definition body.", name_katom.m_loc); } std::string klammer_name = match[1]; - std::string target_name = match[2]; - if (target_name.empty()) { - target_name = Target_registry::general_name; + std::string target_part = match[2]; + if (target_part.empty()) { + // No suffix at all: the general target, but not a STATEMENT about + // coverage. This is the writer's macro form -- someone defining a + // repeated phrase is not building a klammer set -- so the third + // result is false and the coverage analysis treats the body on its + // merits rather than as an assertion. + return { klammer_name, { Target_registry::general_name }, false }; } - if (!targets.has(target_name)) { + strings_t target_names = regex_split(target_part, std::regex(",")); + strings_t seen {}; + for (const auto& target_name : target_names) { + if (!targets.has(target_name)) { + throw Target_error( + "The target \"" + target_name + "\" in klammer definition \"" + name_with_target + "\" " + "is not defined. Enter \"kdesc -t\" to see the targets defined by the Standard Klammer Set.", + name_katom.m_loc); + } + if (is_in(target_name, seen)) { + throw Target_error( + "The target \"" + target_name + "\" is named more than once in klammer definition \"" + + name_with_target + "\".", + name_katom.m_loc); + } + seen.push_back(target_name); + } + // A ".k" declaration states ONE interface for every target, and a ".o" + // declares an option set; neither produces output, so neither has any + // meaning as a member of a list of output targets. + if (target_names.size() > 1) { + for (const auto& reserved : + { Target_registry::declare_name, Target_registry::optionset_name }) { + if (is_in(reserved, target_names)) { + throw Target_error( + "The klammer definition \"" + name_with_target + "\" names \"" + reserved + + "\" in a list of targets. A \"." + reserved + "\" definition declares an " + "interface for all targets rather than producing output for one, so it must " + "be written on its own.", + name_katom.m_loc); + } + } + } + // "*" written out is an ASSERTION: this klammer works for every target, + // including targets that do not exist yet. A list of "all the targets + // defined today" cannot say that, and the difference matters the moment a + // new target is added. It may not appear IN a list -- "all targets and + // also html" is either redundant or a misunderstanding. + bool general_declared = is_in(Target_registry::general_name, target_names); + if (general_declared && target_names.size() > 1) { throw Target_error( - "The target \"" + target_name + "\" in klammer definition \"" + name_with_target + "\" " - "is not defined. Enter \"kdesc -t\" to see the targets defined by the Standard Klammer Set.", + "The klammer definition \"" + name_with_target + "\" names \"" + + Target_registry::general_name + "\" in a list of targets. \"" + + Target_registry::general_name + "\" already means every target, so it " + "must be written on its own.", name_katom.m_loc); } - return { klammer_name, target_name }; + return { klammer_name, target_names, general_declared }; } std::tuple @@ -146,12 +194,50 @@ void Klammer::remove_target_definition(const std::string& target_name) // Rationalize multiple definitions +// ONE definition in the source can register more than once. A target that +// "provides" another registers both (the SKS's pdf includes tex, so +// "@@fraktur.tex : ..." becomes a tex definition and a pdf one), and so does +// every member of a comma-separated target list. Counting or listing those +// registrations reports work the author did not do: "2 definitions" for a +// single line, followed by that same line printed twice -- which sends the +// reader hunting for a second definition that does not exist. +// +// These two report what was WRITTEN. Registrations are grouped by source +// location, and a location that produced several targets names them, so the +// count and the listing agree with the file. +using location_group = std::pair; + +std::vector group_by_location(const auto& components) +{ + std::vector groups {}; + for (const auto& c : components) { + std::string loc = c.loc.desc(); + auto it = std::find_if(groups.begin(), groups.end(), + [&loc](const location_group& g) { return g.first == loc; }); + if (it == groups.end()) { + groups.push_back({loc, {c.target}}); + } else { + it->second.push_back(c.target); + } + } + return groups; +} + +int written_count(const auto& components) +{ + return static_cast(group_by_location(components).size()); +} + std::string error_list(const std::string& label, const auto& components, const std::string& after="") { std::stringstream ss {}; ss << label << ":\n"; - for (const auto& c : components) { - ss << " " << c.loc.desc() << "\n"; + for (const auto& [loc, targets] : group_by_location(components)) { + ss << " " << loc; + if (targets.size() > 1) { + ss << " (targets " << join(targets, ", ") << ")"; + } + ss << "\n"; } ss << after; return ss.str(); @@ -182,7 +268,7 @@ void Klammer::disallow_instances() //Klammer::components declaration) { auto instances = instance_defs(); if (!instances.empty()) { - int icount = instances.size(); + int icount = written_count(instances); std::stringstream ss {}; ss << "There " << to_be(icount) << " " << icount << " " << plural("instance", icount) << " (defined by \"::\"), but " @@ -201,9 +287,9 @@ bool Klammer::copy_to_instances(const Target_registry& targets) [] (const auto& def) { return def.deftype != katom_t::klammer_instance && def.deftype != katom_t::klammer_override; }); - int dcount = definitions.size(); + int dcount = written_count(definitions); if (dcount != 1) { - int icount = instances.size(); + int icount = written_count(instances); std::stringstream ss {}; ss << "There " << to_be(icount) << " " << icount << " " << plural("instance", icount) << " (defined by \"::\"), but " @@ -268,11 +354,13 @@ void Klammer::check_for_declaration_and_definitions() } } if (!definitions.empty()) { - auto defsize = definitions.size(); - std::string desc = defsize == 1 ? "a definition" : - std::to_string(defsize) + " definitions"; + int defsize = written_count(definitions); + std::string desc = defsize == 1 ? "a definition that declares its own parameters" + : std::to_string(defsize) + " definitions that declare their own parameters"; throw Definition_error( - error_list("A klammer has both a declaration (.k) as well as " + desc + "\n(instances are defined by \"::\")", + error_list("A klammer has both a \".k\" declaration and " + desc + + ".\nWrite \"::\" instead of \":\" so the definition takes its parameters " + "from the declaration", definitions), declares[0].loc, false); } diff --git a/mac/klammer.h b/mac/klammer.h index 0954e30..901703d 100644 --- a/mac/klammer.h +++ b/mac/klammer.h @@ -18,7 +18,9 @@ public: using variable_map_t = std::map>; using target_variable_map_t = std::map; - static std::regex name_re; // = std::regex(R"((\w+)(?:\.(\w+))?)"); + // [.[,...]] -- the target part is a comma-separated + // list so that one body can serve several targets (see parse_name). + static std::regex name_re; // = std::regex(R"((\w+)(?:\.(\w+(?:,\w+)*))?)"); struct components { std::string target; @@ -93,11 +95,26 @@ public: // target -> true if this target's body came from a general ("*") definition // (writer content, subject to target escaping) vs a target-specific one. std::map m_body_generic {}; + // True when a definition wrote the general target out as ".*" instead of + // omitting the suffix. Identical to the engine; to a reader it is the + // difference between "I did not say" and "I say: every target, whatever + // they turn out to be". Only the second is a claim the coverage report + // can repeat. + bool m_general_declared { false }; }; std::string klammer_name_from_katom(const std::string& s, const Locator& loc); -std::tuple +// Split "@@[.[,...]]" into the klammer name and the +// targets the definition is for. Always at least one name: an absent suffix +// is the general target. A comma list is surface syntax only -- the caller +// registers one definition per target, so nothing downstream of registration +// knows a list was written. +// The third result is true when the general target was written out as "*" +// rather than left off. Both mean the same to the engine; they mean +// different things to a reader and to the coverage analysis -- see +// Klammer::m_general_declared. +std::tuple parse_name(const Target_registry& targets, const Katom& name_katom); // Split a definition's katoms into its definition separator, parameters, diff --git a/mac/klammer_registry.cpp b/mac/klammer_registry.cpp index c09af22..cf97622 100644 --- a/mac/klammer_registry.cpp +++ b/mac/klammer_registry.cpp @@ -18,13 +18,11 @@ void Klammer_registry::add( { (void)K::log(3, *begin, *(end - 1)); restore_initial_type(begin, end); - auto [klammer_name, target_name] = parse_name(targets, *begin); - if (!targets.has(target_name)) { - throw Argument_error("The target \"" + target_name + "\" is not defined", begin->m_loc); - } - if (target_name == Target_registry::optionset_name) { + auto [klammer_name, target_names, general_declared] = parse_name(targets, *begin); + if (is_in(Target_registry::optionset_name, target_names)) { // The Machine routes an ".o" definition to the option set registry; - // reaching here means it did not. + // reaching here means it did not. parse_name has already rejected + // ".o" as a member of a list, so this is the bare ".o" form. throw Internal_error( "The option set declaration \"" + klammer_name + ".o\" reached the klammer registry", begin->m_loc); @@ -41,37 +39,47 @@ void Klammer_registry::add( if (m_klammers.count(klammer_name) == 0) { m_klammers[klammer_name] = Klammer(klammer_name); - } else if (m_klammers[klammer_name].m_defloc.count(target_name) > 0) { - defmode_t existing_mode = m_klammers[klammer_name].m_defmode[target_name]; - const auto& result = defmode_transition(existing_mode, incoming_mode); - std::string name_target = klammer_name + "." + target_name; - std::string at_desc = m_klammers[klammer_name].m_defloc[target_name].desc(); - if (!result.replace) { - if (result.message.empty()) { - // Silent ignore (e.g., create + default) - modify_type(katom_t::replaced, begin, end); - ignore_whitespace(end, katoms); - return; - } - std::string msg = result.message; - msg = string_replace(msg, "NAME", q_(name_target)); - msg = string_replace(msg, "AT", at_desc); - throw Definition_error(msg, begin->m_loc); - } - if (result.warn) { - 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); - } - m_klammers[klammer_name].remove_target_definition(target_name); } - m_klammers[klammer_name].add_target_definition( - target_name, argtypes, option_sets, begin + 1, end - 1); + // Sticky: once a definition has written ".*", the klammer carries the + // claim. A klammer with both a ".*" body and a target-specific one still + // asserts that the general body serves everything else. + if (general_declared) { + m_klammers[klammer_name].m_general_declared = true; + } + // One definition per target named. A comma-separated list is surface + // syntax: each target goes through the same registration, including the + // redefinition transition table, so a list that collides with an existing + // definition is decided per target -- one member may be silently ignored + // or rejected while the others are created. + for (const auto& target_name : target_names) { + if (m_klammers[klammer_name].m_defloc.count(target_name) > 0) { + defmode_t existing_mode = m_klammers[klammer_name].m_defmode[target_name]; + const auto& result = defmode_transition(existing_mode, incoming_mode); + std::string name_target = klammer_name + "." + target_name; + std::string at_desc = m_klammers[klammer_name].m_defloc[target_name].desc(); + if (!result.replace) { + if (result.message.empty()) { + // Silent ignore (e.g., create + default) + continue; + } + std::string msg = result.message; + msg = string_replace(msg, "NAME", q_(name_target)); + msg = string_replace(msg, "AT", at_desc); + throw Definition_error(msg, begin->m_loc); + } + if (result.warn) { + 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); + } + m_klammers[klammer_name].remove_target_definition(target_name); + } + m_klammers[klammer_name].add_target_definition( + target_name, argtypes, option_sets, begin + 1, end - 1); - // This add's target: - Target target = targets.get(target_name, begin->m_loc); - if (!target.m_provides.empty()) { + // This add's target: + Target target = targets.get(target_name, begin->m_loc); for (const auto& provide_name : target.m_provides) { if (m_klammers[klammer_name].m_defloc.count(provide_name) > 0) { m_klammers[klammer_name].remove_target_definition(provide_name); @@ -164,7 +172,7 @@ std::string Klammer_registry::instance_list(int margin) const return ss.str(); } -std::string Klammer_registry::describe(int margin) const +std::string Klammer_registry::describe(int margin, const std::string& search) const { /* strings_t names {}; @@ -172,7 +180,13 @@ std::string Klammer_registry::describe(int margin) const strings_t locations {}; */ std::string result; + std::string query = collapse_whitespace(search); for (const auto& [name, k] : m_klammers) { + if (!query.empty() && + !contains_fold(name, query) && + !contains_fold(collapse_whitespace(k.description_text()), query)) { + continue; + } result += k.describe(margin) + "\n"; /* names.push_back(name); diff --git a/mac/klammer_registry.h b/mac/klammer_registry.h index 1fb850a..dfcac70 100644 --- a/mac/klammer_registry.h +++ b/mac/klammer_registry.h @@ -14,7 +14,12 @@ public: void check_klammer(const std::string& name, const std::string& target, const Locator& loc) const; const std::vector* constant_body(const std::string& name) const; std::string instance_list(int margin) const; - std::string describe(int margin=0) const; + // With a search string, only the klammers whose NAME or DESCRIPTION + // contains it, case-insensitively and with whitespace collapsed on both + // 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; std::map m_klammers {}; }; diff --git a/mac/ktype.h b/mac/ktype.h index 8dcc135..093e10d 100644 --- a/mac/ktype.h +++ b/mac/ktype.h @@ -35,6 +35,16 @@ inline const std::string ws_newline_s { R"(#/\d*)" }; // const std::string k_name = R"([^^@#|\:]+)"; inline const std::string definition_name = R"([a-zA-Z][a-zA-Z0-9_.]*)"; +// The name at the START of a definition may carry a comma-separated list of +// targets ("@@table.html,tex"), so its katom runs through commas. This is +// deliberately looser than the grammar: the katomizer's job is to delimit the +// token, and parse_name() in klammer.cpp validates the list, where a +// malformed one can be reported with its location. The comma stays out of +// definition_name itself -- that pattern also delimits klammer APPLICATIONS, +// option names and "*arg*" variables, where a comma is ordinary writer text. +// "*" is here for the same reason: "@@date.*" declares a klammer for every +// target explicitly, and "*" is a Klammertext special everywhere else. +inline const std::string definition_begin_name = R"([a-zA-Z][a-zA-Z0-9_.,*]*)"; enum class katom_t { @@ -163,7 +173,7 @@ std::vector katom_types { Ktype(katom_t::apply_begin, "apply-begin", at_s + definition_name, "Beginning of a klammer call"), Ktype(katom_t::apply_end, "apply-end", "(" + definition_name + ")?" + at_s, "End of a klammer call"), Ktype(katom_t::option_name, "option-name", ":" + definition_name, "Optional argument name"), - Ktype(katom_t::define_begin, "define-begin", at2_s + definition_name, "Beginning of a klammer definition"), + Ktype(katom_t::define_begin, "define-begin", at2_s + definition_begin_name, "Beginning of a klammer definition"), Ktype(katom_t::define_end, "define-end", "(" + definition_name + ")?" + at2_s, "End of a klammer definition"), Ktype(katom_t::klammer_default, "klammer-default", "::::", "Define klammer default value for possible override"), Ktype(katom_t::klammer_override, "klammer-override", ":::", "Override existing klammer definition"), diff --git a/mac/machine.cpp b/mac/machine.cpp index 16b03d1..f16fdfb 100644 --- a/mac/machine.cpp +++ b/mac/machine.cpp @@ -528,8 +528,11 @@ void Machine::add_definition(katom_list& katoms, const Katom& op, const Katom& c { expand_constant_klammers(katoms, op, cl); auto [begin, end] = find_span_katoms(katoms, op, cl); - auto [name, target] = parse_name(m_targets, *begin); - if (target == Target_registry::optionset_name) { + auto [name, target_names, general_declared] = parse_name(m_targets, *begin); + (void)general_declared; // routing only cares whether this is a ".o" + // parse_name rejects ".o" as a member of a target list, so an option set + // declaration is always the single-target form. + if (target_names.size() == 1 && target_names[0] == Target_registry::optionset_name) { m_option_sets.add(m_argtypes, name, begin, end, katoms); } else { m_klammers.add(m_argtypes, m_targets, m_option_sets, begin, end, katoms); diff --git a/mac/target_registry.cpp b/mac/target_registry.cpp index 76bc6ff..628be48 100644 --- a/mac/target_registry.cpp +++ b/mac/target_registry.cpp @@ -17,7 +17,7 @@ Target_registry::Target_registry() : m_parameters(Parameter_set("name | desc :after_apply :after_write :includes :escape | transforms.rest")) { Target k(declare_name, "Description of parameters and klammer result", Locator()); - Target general(general_name, "General target, used when a target is not specified", 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()); add(k); add(general); diff --git a/mac/util.cpp b/mac/util.cpp index aa515e2..ac55319 100644 --- a/mac/util.cpp +++ b/mac/util.cpp @@ -134,6 +134,36 @@ strings_t word_split(const std::string& s) return regex_split(s, std::regex("\\s+")); } +std::string collapse_whitespace(const std::string& s) +{ + std::string result {}; + bool in_space = true; // leading whitespace is dropped + for (char c : s) { + if (std::isspace(static_cast(c)) != 0) { + in_space = true; + } else { + if (in_space && !result.empty()) { + result += ' '; + } + in_space = false; + result += c; + } + } + return result; +} + +bool contains_fold(const std::string& haystack, const std::string& needle) +{ + auto fold = [](const std::string& s) { + std::string result {}; + for (char c : s) { + result += static_cast(std::tolower(static_cast(c))); + } + return result; + }; + return fold(haystack).find(fold(needle)) != std::string::npos; +} + bool is_in(const std::string& s, const strings_t& v) { return find(v.begin(), v.end(), s) != v.end(); diff --git a/mac/util.h b/mac/util.h index 9cb263c..e65a542 100644 --- a/mac/util.h +++ b/mac/util.h @@ -38,6 +38,19 @@ bool contains(const std::string& str, const std::string& substr); bool contains(const std::vector& strings, const std::string& element); std::vector regex_split(const std::string& s, const std::regex& re, bool trim_parts=true); std::vector word_split(const std::string& s); + +// Every run of whitespace becomes one space, and the ends are trimmed. Needed +// wherever a phrase typed on one line is matched against text that was written +// across several: a klammer's description sits on its own lines in a ".k" file +// and may wrap, so "displayed verbatim" would otherwise match the klammer +// whose description happens to fit one line and miss the identically worded +// one that does not. +std::string collapse_whitespace(const std::string& s); + +// Case-insensitive substring test, ASCII folding only. The project takes no +// ICU or locale dependency (the same rule as the language tables and +// ":decimal"), so a non-ASCII query matches only exactly. +bool contains_fold(const std::string& haystack, const std::string& needle); bool is_in(const std::string& s, const std::vector& v); bool is_not_in(const std::string& s, const std::vector& v); std::vector find_all(const std::string& str, const std::regex& pattern, int match_group=0); diff --git a/sks/block/block.k b/sks/block/block.k index b5799a7..86f33cf 100644 --- a/sks/block/block.k +++ b/sks/block/block.k @@ -25,11 +25,12 @@ vertical mode is a no-op. @@sp.html :: &^#160; @@ @@sp.tex :: ~ @@ -@@footnote.k s : Footnote (TBD) @@ -@@footnote :: [*s*] @@ +# TODO: Easy in LaTeX; how to handle in HTML and plain text? +# @@footnote.k s : Footnote (TBD) @@ +# @@footnote :: [*s*] @@ @@indent.k s :w.int 3 :linebreak.bool false : Indented block @@ -@@indent :: @eval block.Indent(K) eval@ @@ +@@indent.html,tex,txt :: @eval block.Indent(K) eval@ @@ @@quote.k s :w.int 1 :source : Quotation block @@ @@ -40,18 +41,7 @@ vertical mode is a no-op. @@ @@quote.tex :: -ANDY: QUOTE: *s* -#[ -\hspace*{@{justify.length_mul("|margin|", 1, 'latex')}@} -\begin{minipage}{\textwidth- @{justify.length_mul("|margin|", 2, 'latex')}@ } -\raggedright -|text| -@? """|source|""" |? -\vspace*{6pt} -{\begin{spacing}{1.1}\footnotesize\raggedleft |source| \end{spacing}} -?@ -\end{minipage} -]# +\quoteblock{*s*}{*source*} @@ @@quote.txt :: @@ -61,7 +51,7 @@ ANDY: QUOTE: *s* @@note.k s :label Note :color 1.0,1.0,0.9 :bordercolor 0.2,0.2,0.2 :level.int 0 :width : Rectangular block for a special note @@ -@@note :: @eval block.Note(K) eval@ @@ +@@note.html,tex :: @eval block.Note(K) eval@ @@ @@center.k s : Center text @@ @@ -94,11 +84,6 @@ ANDY: QUOTE: *s* @@nl.tex :: \newline @@ @@nl.txt :: \n @@ -@@tnl.k : Table newline (deprecated; check) @@ -@@tnl.html ::
@@ -@@tnl.tex :: \\\\ @@ -@@tnl.txt :: \n @@ - @@newpage.k : Start new page @@ @@newpage.html :: @@ @@newpage.tex :: \newpage @@ @@ -128,7 +113,7 @@ only makes some vertical space. @@ @@qa.k question | answer : Question and answer formatting @@ -@@qa :: +@@qa.html,tex,txt :: @b Q: @ *question* @b A: @ *answer* @@ -139,12 +124,13 @@ only makes some vertical space. @@ @@block.k : to.coords | content :width.float .5 :point.coords 0.0 0.0 : Absolute positioning of text block @@ -@@block :: @eval block.Block(K) eval@ @@ +@@block.tex :: @eval block.Block(K) eval@ @@ @@lines.k s : Maintain line breaks @@ -@@lines :: @eval block.Lines(K) eval@ @@ +@@lines.html,tex,txt :: @eval block.Lines(K) eval@ @@ -@@twocolumns.tex s : +@@twocolumns.k s : Format *s* in two columns @@ +@@twocolumns.tex :: \begin{multicols}{2} *s* \end{multicols} diff --git a/sks/block/block.py b/sks/block/block.py index cf97fc0..50f63ef 100644 --- a/sks/block/block.py +++ b/sks/block/block.py @@ -76,10 +76,6 @@ COLOR\\setlength{\\fboxsep}{8pt} class Block(klammer_base.Klammer_base): def __init__(self, K): super().__init__(K) - #self.show() - - def html(self): - return "" def tex(self): # NOT latex_util.block(): a textblock is absolutely positioned and @@ -110,10 +106,6 @@ class Lines(klammer_base.Klammer_base): def tex(self): return "\\\\\n".join(self.lines) + "\n" - result = "" - for line in self.lines: - if line: - line += r" \\" - result += line + "\n" - result = re.sub(r"\\\\\n\n", "\n\n", result) - return result + + def txt(self): + return self.lines diff --git a/sks/block/sty/block.sty b/sks/block/sty/block.sty index 7866b87..ec8f77c 100644 --- a/sks/block/sty/block.sty +++ b/sks/block/sty/block.sty @@ -18,3 +18,10 @@ % No space if top of page: \newcommand{\topspace}[1]{\ifdim\pagetotal=0pt\else\vspace*{#1}\fi} + + +% Quote (to be improved) +\newcommand{\quoteblock}[2]{ +{\hspace*{24pt}\begin{minipage}{\textwidth - 48pt} + #1 +\end{minipage}}} diff --git a/sks/code/code.k b/sks/code/code.k index a9d1abe..1535b1d 100644 --- a/sks/code/code.k +++ b/sks/code/code.k @@ -6,15 +6,16 @@ A source file displayed verbatim @@ -@@code :: @eval code_block.Code(K) @ @@ +@@code.html,tex :: @eval code_block.Code(K) @ @@ @@c.k code_text : A word or phrase displayed verbatim in a line @@ -@@c :: @eval code_block.Code_fragment(K) eval@ +@@c.html,tex :: @eval code_block.Code_fragment(K) eval@ @@ # :cwd makes the filename resolve against the DOCUMENT's directory, not # the directory ktext happens to run in. -@@source_file filename : @eval :cwd *K_input_dir* code_block.Source(K) @ @@ +@@source_file.k filename : Display the contents of the file verbatim. @@ +@@source_file.html,tex :: @eval :cwd *K_input_dir* code_block.Source(K) @ @@ diff --git a/sks/color/color.k b/sks/color/color.k index e36456c..5c7ca57 100644 --- a/sks/color/color.k +++ b/sks/color/color.k @@ -5,4 +5,5 @@ @@@ @@color.k rgb :text : Color description as required by the target from @c ,, @ input @@ -@@color :: @eval color.Color(K) eval@ @@ + +@@color.html,tex :: @eval color.Color(K) eval@ @@ diff --git a/sks/date/date.k b/sks/date/date.k index 84b51fc..05dd724 100755 --- a/sks/date/date.k +++ b/sks/date/date.k @@ -7,9 +7,8 @@ order, separator, and padding: "6/16/1910" (en, month first), "16.06.1910" (de, day first, zero-padded per DIN 5008) @@ -@@date.html :: @eval date.date(K) eval@ @@ -@@date.tex :: @eval date.date(K) eval@ @@ -@@date.txt :: @eval date.date(K) eval@ @@ + +@@date.* :: @eval date.date(K) eval@ @@ @@datetime.k :days.int 0 :lang.language :number.bool false : Date and time formatted as "16 June 1910, 13:10" (^:lang de: @@ -17,21 +16,14 @@ tomorrow, ^:days -1 is yesterday). ^:lang and ^:number select the language and the numeric form as for ^@date @@ -@@datetime.html :: @eval date.datetime(K) eval@ @@ -@@datetime.tex :: @eval date.datetime(K) eval@ @@ -@@datetime.txt :: @eval date.datetime(K) eval@ @@ + +@@datetime.* :: @eval date.datetime(K) eval@ @@ @@timestamp.k : Date and time formatted as "1910.06.16-12:34" @@ -@@timestamp :: @eval time.strftime("%Y.%m.%d-%H:%M") eval@ @@ +@@timestamp.* :: @eval time.strftime("%Y.%m.%d-%H:%M") eval@ @@ @@serialdate.k : Date formatted as "YYMMDD" @@ -@@serialdate :: @eval time.strftime("%y%m%d") eval@ @@ +@@serialdate.* :: @eval time.strftime("%y%m%d") eval@ @@ @@year.k : Current year formatted as "20XX" @@ -@@year :: @eval time.strftime("%Y") eval@ @@ - -#[ -@@@category date -date datetime timestamp serialdate -:desc Time and date formatting @@@ -]# +@@year.* :: @eval time.strftime("%Y") eval@ @@ diff --git a/sks/document/document.k b/sks/document/document.k index e988c84..d511a45 100644 --- a/sks/document/document.k +++ b/sks/document/document.k @@ -62,60 +62,4 @@ : Top-level document structure @@ -@@document :: @eval :cpp *KLAMMERTEXT_HOME*/sks/document/document document @ @@ - -# @link https://cdn.britannica.com/09/152309-050-5E0B2A42/Sahara-Morocco.jpg :text Niagara @ - -@@niagara : -@link https://cdn.britannica.com/09/152309-050-5E0B2A42/Sahara-Morocco.jpg :text Niagra @ -@@ - -#[ -@@niagara.html : -Niagara -@@ - -@@niagara.tex : -@link https://cdn.britannica.com/09/152309-050-5E0B2A42/Sahara-Morocco.jpg :text Niagra @ -@@ -]# - - -# @@moby : Moby @@ -@@moby : -But here is an artist. He desires to paint you the dreamiest, -shadiest, quietest, most enchanting bit of romantic landscape in all -the valley of the Saco. What is the chief element he employs? There -stand his trees, each with a hollow trunk, as if a hermit and a -crucifix were within; and here sleeps his meadow, and there sleep his -cattle; and up from yonder cottage goes a sleepy smoke. Deep into -distant woodlands winds a mazy way, reaching to overlapping spurs of -mountains bathed in their hill-side blue. But though the picture lies -thus tranced, and though this pine-tree shakes down its sighs like -leaves upon this shepherd's head, yet all were vain, unless the -shepherd's eye were fixed upon the magic stream before him. Go visit -the Prairies in June, when for scores on scores of miles you wade -knee-deep among Tiger-lilies---what is the one charm wanting? Water ----there is not a drop of water there! Were @niagara@ but a cataract of -sand, would you travel your thousand miles to see it? -@@ - -@@smoby.html : -Go visit the Prairies in June, when for scores on scores of miles you -wade knee-deep among Tiger-lilies---what is the one charm wanting? -Water --- there is not a drop of water there! Were @niagara@ but a -cataract of sand, would you travel your thousand miles to see it? -@@ - -@@smoby.tex : -Go visit the Prairies in June, when for scores on scores of miles you -wade knee-deep among Tiger-lilies---what is the one charm wanting? -Water --- there is not a drop of water there! Were @niagara@ but a -cataract of sand, would you travel your thousand miles to see it? -@@ - -@@ssmoby : -Go visit the Prairies in June, when for scores on scores of miles you -wade knee-deep among Tiger-lilies---what is the one charm wanting? -Water---there is not a drop of water there! -@@ +@@document.html,tex :: @eval :cpp *KLAMMERTEXT_HOME*/sks/document/document document @ @@ diff --git a/sks/font/font.k b/sks/font/font.k index e805813..ff71afa 100644 --- a/sks/font/font.k +++ b/sks/font/font.k @@ -58,35 +58,6 @@ @@ri.tex :: \textrm{\textit{*text*}} @@ @@ri.txt :: *text* @@ - -#[ -@@small.k s :mode span : Smaller font size @@ -@@small.html :: < #- *mode* class="small"> #- *s* #- @@ -@@small.tex :: {\small *s*} @@ - -@@large.k s :mode span : Larger font size @@ -@@large.html :: <*mode* class="large">*s* @@ -@@large.tex :: {\Large *s*} @@ - -@@rfont.k size | leading | text : Scaled roman font @@ - -@@rfont.tex :: -%{\fontsize{*size*\basefontsize}{*leading* \basefontsize * \real{1.4}}\rmfamily -{\fontsize{ *size* }{ *leading* * \real{1.4}}\rmfamily - -*text* - -} -@@ - -@@sfont.k size | leading | text : Scaled sans-serif font @@ -@@sfont.tex :: -{\fontsize{ *size* \basefontsize}{ *leading* \basefontsize * \real{1.4}}\sffamily -*text*} -@@ -]# - - @@ts.k scale.float | text : Resize *text* by *scale* @@ # @@ts.tex :: {\scalefont{*scale*}\em *text*} @@ @@ts.tex :: \scalefont{*scale*}{*text*} @@ @@ -97,37 +68,32 @@ #@@leading.tex :: \renewcommand{\baselinestretch}{*n*} @@ @@leading.tex :: \setstretch{*n*} @@ -# @@default_font : @font EB Garamond @ @@ -# @@default_font : @font Bitstream Charter @ @@ -@@default_font : +@@default_font.k : Default font for documents @@ +@@default_font.tex :: # \setmainfont[Ligatures=TeX,Numbers=Lining]{Garamond Libre} \setmainfont{Garamond Libre} @@ -#[ -@font -# Bitstream Charter -# Liberation Serif -EB Garamond -@ -]# @@cjk.k text : Switch to font that supports CJK for *text* @@ @@cjk.html :: *cjk* @@ @@cjk.tex :: { #- @font Noto Sans CJK SC @ *cjk* #- } @@ @@cjk.txt :: *cjk* @@ -@@fraktur.tex s : \textfrak{*s*} @@ +@@fraktur.k s : Set text *s* in Fraktur @@ +@@fraktur.tex :: \textfrak{*s*} @@ -@@euro.html s : &^#x20AC;*s* @@ -@@euro.tex s : €\,*s* @@ +@@euro.k f : The Euro symbol, followed by the amount *f* @@ +@@euro.html :: &^#x20AC;*f* @@ +@@euro.tex,txt :: €\,*f* @@ -@@math s :center.bool false : @i *s* @ @@ # xxx +# TODO: This needs to use MathJax. +# @@math s :center.bool false : @i *s* @ @@ @@sub.k base | script : Subscript: @sub x 2 @ @@ -@@sub :: @eval font.Subsuper(K, "sub") eval@ @@ +@@sub.html,tex :: @eval font.Subsuper(K, "sub") eval@ @@ @@sup.k base | script : Superscript: @sup x 2 @ @@ -@@sup :: @eval font.Subsuper(K, "sup") eval@ @@ +@@sup.html,tex :: @eval font.Subsuper(K, "sup") eval@ @@ @@copyright.k : Copyright symbol: © @@ @@copyright.html :: © @@ diff --git a/sks/image/image.k b/sks/image/image.k index 030f790..73fb23c 100644 --- a/sks/image/image.k +++ b/sks/image/image.k @@ -39,7 +39,7 @@ for in the image search path and converted to a format the target can use. it there, and the caption arguments caption it. @@ -@@image :: @eval image.Image(K) eval@ @@ +@@image.html,tex :: @eval image.Image(K) eval@ @@ @@image_grid.k image_specs.rest(2) @@ -56,6 +56,7 @@ it there, and the caption arguments caption it. : A grid of images @@ -@@image_grid :: @eval image_grid.Image_grid(K) eval@ @@ +@@image_grid.html,tex :: @eval image_grid.Image_grid(K) eval@ @@ -@@fig spec.figure_id : @reference *spec* | Figure @ @@ +@@fig.k spec.figure_id : The caption title for a figure @@ +@@fig.html,tex :: @reference *spec* | Figure @ @@ diff --git a/sks/image/image.py b/sks/image/image.py index 5c5a3dd..6678ed9 100644 --- a/sks/image/image.py +++ b/sks/image/image.py @@ -147,5 +147,6 @@ class Image(klammer_base.Klammer_base): return latex_util.block(result) - def txt(self): - return f"[Image: {self.basename}\nCaption: {self.caption}]" +# Better to cause an error; there's no good text substitute for an image +# def txt(self): +# return f"[Image: {self.basename}\nCaption: {self.caption}]" diff --git a/sks/kutil/kutil.k b/sks/kutil/kutil.k index 658419b..b283518 100644 --- a/sks/kutil/kutil.k +++ b/sks/kutil/kutil.k @@ -172,9 +172,7 @@ other structures. The ^:offset is used only if the ^:hpos value is "left" or # Klammers @@reference.k spec | name : Reference marker for captioned elements @@ -@@reference :: - __REF__*spec*__*name*__ -@@ +@@reference.* :: __REF__*spec*__*name*__ @@ @@show.k s : Show the raw Klammertext and the result @@ -@@show :: @eval :cpp show show @ @@ +@@show.html,tex,txt :: @eval :cpp show show @ @@ diff --git a/sks/link/link.k b/sks/link/link.k index 7fde875..e6fa90d 100755 --- a/sks/link/link.k +++ b/sks/link/link.k @@ -1,79 +1,23 @@ -@@link target +@@link.k target :text :footnote.bool false :section.bool false :nq.bool false -:basename # xxx +:basename # xxx -- Deprecated? :code # xxx :phrase # xxx :dox # xxx :doxanchor # xxx :id # xxx -: - @eval link.Link(K) eval@ +: Links within documents and to external URLs. @@ -@@target id :text : - @eval link.Target(K) eval@ -@@ +@@link.html,tex,txt :: @eval link.Link(K) eval@ @@ -@@email name | domain :subject :body : - @eval link.Email(K) eval@ -@@ +@@target.k id :text : A link target within a document @@ -#[ -@@@category link -target link -:desc Links @@@ +@@target.html,tex :: @eval link.Target(K) eval@ @@ -@@target.doc id :text : -Link target in page is @i-name, with optional @i-text for target @@ +@@email.k name | domain :subject :body : An email with an mangled address for HTML @@ -@@target : @eval link.target(K) eval@ @@ - -@@link.doc url -:text -:download.bool false -:mail.bool false -:nopage.bool false -:section.bool false -:pageonly.bool false -:footnote.bool true -:in_footnote.bool false -:dox.bool false -:abs.bool false -:add_number.bool false -: -Link to @i-url using @i-text for display, or @i-url if @i-text is not -specified. If :section is true, the @i-url is the text of a section -title. -@@ - -@@link : @eval link.link(K) eval@ @@ - -@@subpage kt_basename -:pagetitle -:sectiontitle -:linktext -:index -:toc.bool true -:logo -:include -:prevlink -:nextlink -:uplink -:logo -:top_level.int -:href_base -:search.bool false -: -@eval link.subpage(K) eval@ @@ - -@@subpages.html s : - -*s* - -@@ - -@@subpages.latex s : *s* @@ -]# +@@email.html,tex :: @eval link.Email(K) eval@ @@ diff --git a/sks/link/link.py b/sks/link/link.py index cf5a688..b1ffb3d 100755 --- a/sks/link/link.py +++ b/sks/link/link.py @@ -88,8 +88,9 @@ class Target(klammer_base.Klammer_base): #result += '\\label{{{}-label}}'.format(id) return result - def txt(self): - return "" +# Better to fail than return nothing; not possible to have a target in a text file. +# def txt(self): +# return "" class Email(klammer_base.Klammer_base): diff --git a/sks/list/list.k b/sks/list/list.k index 39b3dc7..db331c9 100755 --- a/sks/list/list.k +++ b/sks/list/list.k @@ -4,62 +4,13 @@ @@@ @@ul.k list_items.rest :cmp.bool false :bullet : Unordered list, with list elements preceeded by the *bullet* character. @@ -@@ul :: @eval list.List(K, "ul") eval@ @@ +@@ul.html,tex,txt :: @eval list.List(K, "ul") eval@ @@ @@ol.k list_items.rest :cmp.bool false :initial.int 1 : Ordered list, with numbered list elements. @@ -@@ol :: @eval list.List(K, "ol") eval@ @@ +@@ol.html,tex,txt :: @eval list.List(K, "ol") eval@ @@ -@@define descriptions.rest(2) :font i : @eval list.Define(K) eval@ @@ +@@define.k descriptions.rest(2) :font i : A list of words or phrases and their definitions or descriptions @@ +@@define.html,tex :: @eval list.Define(K) eval@ @@ -@@columns items :n.int 2 : @eval list.columns(K) eval@ @@ -## - -#[ -@@@category list ul ulc ol li define item -:desc Ordered and unordered lists; definitions of terms @@@ -]# - - -#[ -@@describe descriptions.rest : @eval list.describe(K) eval@ @@ -@@entry item | description : @eval list.entry(K) eval@ @@ -]# - -#[ -@@ul.doc list_items.rest :cmp.bool false :bullet square :itemsep :uncover.bool :indent.bool true :parsep 6pt :margin 8pt :in_define.bool : Unordered list @@ -@@ul : @eval list.ul(K) @ @@ - -@@ol.doc list_items.rest :cmp.bool false :itemsep :uncover.bool :parsep 6pt :margin 8pt :in_define.bool : Ordered list @@ -@@ol : @eval list.ol(K) @ @@ - -@@li.doc s :keep.bool : List item @@ -@@li : @eval list.li(K) @ @@ - -@@dfont :s : @eval list.dfont(K) @ @@ - -@@define.doc s :font r :uncover.bool : List of definitions @@ - -@@define : @eval list.define(K) @ @@ - -@@item.doc term desc :c.bool false :inlist.bool false : Definition of @i-term is @i-desc @@ - -@@item term desc : -@eval list.definition_item(K) @ -@@ - -@@mli args.rest : @eval list.mli(K) @ @@ - -@@ncircle.latex n : \circled{*n*} @@ - -@@dblitem.latex a | b | desc : -\item[\parbox{\linewidth}{*a* \\ *b*}] -\leavevmode \\[-5pt] -*desc* -@@ - -@@dblitem.html a | b | desc : -
*a*
-*b*
-
*desc*
-@@ -]# +@@columns.k items :n.int 2 : Create two or more columns from the text @@ +@@columns.tex :: @eval list.columns(K) eval@ @@ diff --git a/sks/list/list.py b/sks/list/list.py index 96a8a60..56f12fc 100644 --- a/sks/list/list.py +++ b/sks/list/list.py @@ -49,7 +49,6 @@ class List(klammer_base.Klammer_base): if not self.compressed: result += "\n" n += 1 - print(result) return result @@ -87,8 +86,9 @@ class Define(klammer_base.Klammer_base): result = f"\\begin{{description}}[labelindent=12pt,nosep,itemsep=6pt]\n{result}\n\\end{{description}}" return result - def txt(self): - return "TXT DEFINITIONS" +# Needs to be implemented: +# def txt(self): +# return "TXT DEFINITIONS" class Lines(klammer_base.Klammer_base): diff --git a/sks/section/section.k b/sks/section/section.k index 791fd57..2d7549c 100644 --- a/sks/section/section.k +++ b/sks/section/section.k @@ -2,53 +2,53 @@ # the front and back matter of a book): @@preface.k :id :name Preface : Preface text @@ -@@preface :: @eval section.Section(K, "preface") eval@ @@ +@@preface.html,tex :: @eval section.Section(K, "preface") eval@ @@ # Numbered section headings: @@part.k title :id :name Part : Part heading @@ -@@part :: @eval section.Section(K, 0) eval@ @@ +@@part.html,tex :: @eval section.Section(K, 0) eval@ @@ @@s1.k title :id :n : Top level division, numbered @@ -@@s1 :: @eval section.Section(K, 1) eval@ @@ +@@s1.html,tex :: @eval section.Section(K, 1) eval@ @@ @@s2.k title :id :n : Second level division, numbered @@ -@@s2 :: @eval section.Section(K, 2) eval@ @@ +@@s2.html,tex :: @eval section.Section(K, 2) eval@ @@ @@s3.k title :id :n : Third level division, numbered @@ -@@s3 :: @eval section.Section(K, 3) eval@ @@ +@@s3.html,tex :: @eval section.Section(K, 3) eval@ @@ @@s4.k title :id :n : Fourth level division, numbered @@ -@@s4 :: @eval section.Section(K, 4) eval@ @@ +@@s4.html,tex :: @eval section.Section(K, 4) eval@ @@ @@s5.k title :id :n : Fifth level division, numbered @@ -@@s5 :: @eval section.Section(K, 5) eval@ @@ +@@s5.html,tex :: @eval section.Section(K, 5) eval@ @@ @@s6.k title :id :n : Sixth level division, numbered @@ -@@s6 :: @eval section.Section(K, 6) eval@ @@ +@@s6.html,tex :: @eval section.Section(K, 6) eval@ @@ @@s7.k title :id :n : Seventh level division, numbered @@ -@@s7 :: @eval section.Section(K, 7) eval@ @@ +@@s7.html,tex :: @eval section.Section(K, 7) eval@ @@ # Unnumbered section headings: @@h1.k title :id :n : Top level division, unnumbered @@ -@@h1 :: @eval section.Section(K, 1, numbered=False) eval@ @@ +@@h1.html,tex :: @eval section.Section(K, 1, numbered=False) eval@ @@ @@h2.k title :id : Second level division, unnumbered @@ -@@h2 :: @eval section.Section(K, 2, numbered=False) eval@ @@ +@@h2.html,tex :: @eval section.Section(K, 2, numbered=False) eval@ @@ @@h3.k title :id : Third level division, unnumbered @@ -@@h3 :: @eval section.Section(K, 3, numbered=False) eval@ @@ +@@h3.html,tex :: @eval section.Section(K, 3, numbered=False) eval@ @@ @@h4.k title :id : Fourth level division, unnumbered @@ -@@h4 :: @eval section.Section(K, 4, numbered=False) eval@ @@ +@@h4.html,tex :: @eval section.Section(K, 4, numbered=False) eval@ @@ @@h5.k title :id : Fifth level division, unnumbered @@ -@@h5 :: @eval section.Section(K, 5, numbered=False) eval@ @@ +@@h5.html,tex :: @eval section.Section(K, 5, numbered=False) eval@ @@ @@h6.k title :id : Sixth level division, unnumbered @@ -@@h6 :: @eval section.Section(K, 6, numbered=False) eval@ @@ +@@h6.html,tex :: @eval section.Section(K, 6, numbered=False) eval@ @@ @@h7.k title :id : Seventh level division, unnumbered @@ -@@h7 :: @eval section.Section(K, 7, numbered=False) eval@ @@ +@@h7.html,tex :: @eval section.Section(K, 7, numbered=False) eval@ @@ diff --git a/sks/section/section.py b/sks/section/section.py index 452b3b4..8ae62e0 100644 --- a/sks/section/section.py +++ b/sks/section/section.py @@ -72,5 +72,6 @@ class Section(klammer_base.Klammer_base): result = f"%__sectionlink__{id}__{self.title}__" + "\n" + result return result - def txt(self): - return self.title +# Need to implement the procedural numbering for the txt target +# def txt(self): +# return self.title diff --git a/sks/sks.k b/sks/sks.k index 7307f80..0c449f9 100644 --- a/sks/sks.k +++ b/sks/sks.k @@ -6,5 +6,5 @@ :files kutil/kutil.k target/target.k font/font.k section/section.k image/image.k code/code.k list/list.k link/link.k table/table.k date/date.k block/block.k color/color.k - document/document.k book/book.k + document/document.k @@@ diff --git a/sks/table/table.k b/sks/table/table.k index da93b93..074fd7a 100644 --- a/sks/table/table.k +++ b/sks/table/table.k @@ -191,7 +191,8 @@ :pattern \s*([^^\s;]+\s+[^^\s;]+\s*(;\s*^|\s*$))+ @@@ -@@rowcolor.tex s : \colorrow{*s*} @@ +@@rowcolor.k c : Change the color of a table row to color *c* @@ +@@rowcolor.tex :: \colorrow{*s*} @@ @@table.k rows.rest(2) :id @@ -227,8 +228,7 @@ formats them, and ^:hpos and ^:offset place the whole table in the text column. @@ -@@table :: @eval table.Table(K) eval@ @@ +@@table.html,tex :: @eval table.Table(K) eval@ @@ -@@tbl spec.figure_id : - @reference *spec* | Table @ -@@ +@@tbl.k spec.figure_id : The caption title for a table @@ +@@tbl.html,tex,txt :: @reference *spec* | Table @ @@ diff --git a/sks/table/table.py b/sks/table/table.py index bccfec3..e3533ea 100644 --- a/sks/table/table.py +++ b/sks/table/table.py @@ -864,6 +864,7 @@ class Table(klammer_base.Klammer_base): # there, so the rule stays unconditional.) return latex_util.block(result) - def txt(self): - return "Table in .txt format not implemented" +# This should signal that it has not be implemented by not being defined. +# def txt(self): +# return "Table in .txt format not implemented" diff --git a/tst/Makefile b/tst/Makefile index 2f645ba..0d16e54 100644 --- a/tst/Makefile +++ b/tst/Makefile @@ -19,4 +19,8 @@ test: ./klammerset_test.sh ./option_set_test.sh ./signature_test.sh + ./target_list_test.sh + ./coverage_test.sh + ./command_option_test.sh + ./kdesc_test.sh ./editor_test.sh diff --git a/tst/command_option_test.sh b/tst/command_option_test.sh new file mode 100755 index 0000000..f9d8ee8 --- /dev/null +++ b/tst/command_option_test.sh @@ -0,0 +1,120 @@ +#!/bin/bash +# +# command_option_test.sh — Command-line option parsing (mac/argv.cpp). +# +# The commands' own argument handling, which no suite covered: argv_test.cpp +# exercises Argv but asserts nothing, so two defects lived there unnoticed. +# +# * An option declared with opt() takes a value. Written LAST with nothing +# after it -- "kdesc -v", "ktext doc.kt -t" -- parse_optional() read one +# past the end of the word vector and the command died with SIGSEGV, +# naming nothing. Two bounds checks sat commented out at that spot; they +# would have returned a half-parsed option instead of reporting the +# mistake. It is now a located argument error. +# * kdesc and kdiag printed an error and then exited 0, so a script could +# not tell a failed run from a successful one. Both return 1 now, as +# ktext already did. +# +# Also here: two required positional arguments. parse_positional() consumes +# one per required name, but regex_split_prefix() requires its match at +# position 0 and returned the remainder with the separating space intact, so +# the second was always "not found". Latent, because no shipped command +# declares two -- tst/argv_test.cpp is the only program that does. +# +# Usage: ./command_option_test.sh (needs KLAMMERTEXT_HOME set; commands on PATH) +# Exit code: 0 if all tests pass, 1 otherwise. + +PASS=0 +FAIL=0 +K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set} +TSTDIR="$(cd "$(dirname "$0")" && pwd)" + +red=$'\033[31m' +green=$'\033[32m' +bold=$'\033[1m' +reset=$'\033[0m' + +# A signal death is not an error exit: 128+n, and the point of these tests is +# the difference. Report it distinctly so a crash can never read as a pass. +died_by_signal() { [ "$1" -gt 128 ]; } + +# error NAME PATTERN CMD... — nonzero exit, no signal, PATTERN in the output. +error() { + local name="$1" pattern="$2"; shift 2 + local out status + out=$("$@" 2>&1); status=$? + if died_by_signal $status; then + echo "${red}FAIL${reset} $name — died by signal $((status-128))"; FAIL=$((FAIL+1)); return + fi + if [ $status -eq 0 ]; then + echo "${red}FAIL${reset} $name — reported an error but exited 0"; FAIL=$((FAIL+1)); return + fi + if printf '%s' "$out" | grep -qF -- "$pattern"; then + echo "${green}PASS${reset} $name"; PASS=$((PASS+1)) + else + echo "${red}FAIL${reset} $name — expected [$pattern]" + echo " got: $(printf '%s' "$out" | head -2)"; FAIL=$((FAIL+1)) + fi +} + +# ok NAME CMD... — exits 0 and does not die by signal. +ok() { + local name="$1"; shift + local status + "$@" >/dev/null 2>&1; status=$? + if died_by_signal $status; then + echo "${red}FAIL${reset} $name — died by signal $((status-128))"; FAIL=$((FAIL+1)) + elif [ $status -ne 0 ]; then + echo "${red}FAIL${reset} $name — exit $status"; FAIL=$((FAIL+1)) + else + echo "${green}PASS${reset} $name"; PASS=$((PASS+1)) + fi +} + +echo "${bold}Command option tests${reset}" +echo "====================" +echo + +echo "-- an option written last, with no value --" +error " 1. kdesc -v" "needs a value" kdesc -v +error " 2. kdesc with a flag first" "needs a value" kdesc --katoms -v +error " 3. kdiag -v" "needs a value" kdiag -v +error " 4. ktext -t" "needs a value" ktext -s '@i-x' -t +error " 5. the message names the option" "-v " kdesc -v +error " 6. ... and how to get the usage" "for the list of arguments" kdesc -v + +echo +echo "-- the same options WITH a value still work --" +# NOT "kdesc -v 1": show_usage() treats exactly " -v " as a +# request for the usage text, which exits 1 by design. Give it another flag. +ok " 7. kdesc -c -v 1" kdesc -c -v 1 +ok " 8. kdesc --katoms -v 1" kdesc --katoms -v 1 +ok " 9. kdiag -v 1 '@i-x'" kdiag -v 1 '@i-x' +ok "10. ktext -t html" ktext -s '@i-x' -t html -d + +echo +echo "-- an error exit is nonzero, not a printed message and exit 0 --" +error "11. kdesc reports failure" "needs a value" kdesc -v +error "12. kdiag reports failure" "needs a value" kdiag -v + +echo +echo "-- two required positional arguments --" +# argv_test is the only program declaring two; it prints what it parsed. +if [ -x "$TSTDIR/argv_test" ]; then + out=$("$TSTDIR/argv_test" first second --bool 2>&1 | sed 's/\x1b\[[0-9;]*m//g') + for expect in " : first" " : second"; do + if printf '%s' "$out" | grep -qF "$expect"; then + echo "${green}PASS${reset} 13/14. positional [$expect]"; PASS=$((PASS+1)) + else + echo "${red}FAIL${reset} 13/14. positional [$expect] not parsed"; FAIL=$((FAIL+1)) + fi + done +else + echo "SKIP 13/14. two positionals (argv_test not built; run make -C tst)" +fi + +echo +echo "====================" +echo "Results: ${PASS} passed, ${FAIL} failed" +[ "$FAIL" -eq 0 ] || exit 1 +exit 0 diff --git a/tst/coverage/basic.k b/tst/coverage/basic.k new file mode 100644 index 0000000..396bd59 --- /dev/null +++ b/tst/coverage/basic.k @@ -0,0 +1,29 @@ +# Fixture for coverage_test.sh. Engine tier: it declares its own targets, +# so the suite runs with no klammer set. +@@@target ta | Target A @@@ +@@@target tb | Target B @@@ +@@@target tc | Target C @@@ + +# Defined per target: coverage is what the definitions say. +@@ab.k s : defined for ta and tb @@ +@@ab.ta :: A*s* @@ +@@ab.tb :: B*s* @@ + +# A comma list is the same statement written once (step 1). +@@cd.k s : defined for ta and tc by a list @@ +@@cd.ta,tc :: C*s* @@ + +# General body of plain text: every target. +@@plain : just text @@ + +# General body calling one klammer: that klammer's coverage. +@@calls_ab s : @ab *s* @ @@ + +# Intersection of two klammers with different coverage: only ta is in both. +@@calls_both s : @ab *s* @ @cd *s* @ @@ + +# ".*" is an assertion: every target, including targets not yet defined. +# Distinct from a bare definition, which asserts nothing -- that is the +# writer's macro form. +@@universal.k s : works for any target @@ +@@universal.* :: U*s* @@ diff --git a/tst/coverage/clean.k b/tst/coverage/clean.k new file mode 100644 index 0000000..4b70f04 --- /dev/null +++ b/tst/coverage/clean.k @@ -0,0 +1,8 @@ +# A klammerset with nothing outstanding: every klammer declared and defined. +# Used to check that a report with no problems says nothing about problems -- +# no banner, no categories. +@@@target ta | Target A @@@ +@@@target tb | Target B @@@ + +@@x.k s : declared and defined @@ +@@x.ta,tb :: [*s*] @@ diff --git a/tst/coverage/cycle.k b/tst/coverage/cycle.k new file mode 100644 index 0000000..2d1b6a3 --- /dev/null +++ b/tst/coverage/cycle.k @@ -0,0 +1,22 @@ +@@@target ta | Target A @@@ +@@@target tb | Target B @@@ + +# Mutual reference between two general klammers. A recursive analysis would +# not terminate; the greatest fixpoint does. +# +# These take a parameter deliberately. A general klammer with NO parameters +# is a CONSTANT, and a constant's body is spliced into later definitions at +# definition time -- so a parameterless pair never reaches the fixpoint as a +# cycle: the expansion has already turned the second body into a +# self-reference. Parameters keep the calls in the stored body. +@@ping s : @pong *s* @ @@ +@@pong s : @ping *s* @ @@ + +# A cycle that reaches a constrained klammer keeps the constraint. +@@only_ta.ta s : A*s* @@ +@@loop_a s : @loop_b *s* @ @only_ta *s* @ @@ +@@loop_b s : @loop_a *s* @ @@ + +# Disjoint coverage: nothing is in both, so this can never be applied. +@@only_tb.tb s : B*s* @@ +@@impossible s : @only_ta *s* @ @only_tb *s* @ @@ diff --git a/tst/coverage/split.k b/tst/coverage/split.k new file mode 100644 index 0000000..c10916b --- /dev/null +++ b/tst/coverage/split.k @@ -0,0 +1,6 @@ +@@@target ta | Target A @@@ +@@@target tb | Target B @@@ + +@@x.k s : declared here, defined in two files @@ +@@x.ta :: A*s* @@ +@read split_more.k @ diff --git a/tst/coverage/split_more.k b/tst/coverage/split_more.k new file mode 100644 index 0000000..2551689 --- /dev/null +++ b/tst/coverage/split_more.k @@ -0,0 +1,5 @@ +# The second half of the split fixture: one klammer's targets, written in a +# different file from its declaration. Andy's policy keeps a klammer's +# targets together, so this is the exception the file column exists to make +# visible. +@@x.tb :: B*s* @@ diff --git a/tst/coverage/undecidable.k b/tst/coverage/undecidable.k new file mode 100644 index 0000000..d038a99 --- /dev/null +++ b/tst/coverage/undecidable.k @@ -0,0 +1,10 @@ +@@@target ta | Target A @@@ +@@@target tb | Target B @@@ + +# The three bodies whose coverage the engine cannot work out. +@@evaluated : @eval 1 + 1 @ @@ +@@literally : ^'\raw{markup}'^ @@ +@@included : @read /dev/null @ @@ + +# A declaration with no definition anywhere. +@@orphan.k s : declared and never defined @@ diff --git a/tst/coverage_test.sh b/tst/coverage_test.sh new file mode 100755 index 0000000..2021d55 --- /dev/null +++ b/tst/coverage_test.sh @@ -0,0 +1,258 @@ +#!/bin/bash +# +# coverage_test.sh — Target coverage: which targets a klammer can render to. +# +# "kdesc --coverage" computes the coverage FACT and reports it. Three rules +# (mac/coverage.{h,cpp}, notes/target_coverage.md): +# +# DERIVED a general body of plain text covers every target; a general body +# of klammer calls covers the INTERSECTION of what those klammers +# cover, computed as a greatest fixpoint after loading. +# DECLARED a general body holding @eval, @read or a ^'...'^ literal span +# cannot be interpreted, so its targets must be written down. +# UNKNOWN no definition at all. Absence never means "deliberately +# unavailable" -- the SKS is incomplete on schedule, not by design. +# +# The analysis modifies nothing. These tests therefore assert only what is +# 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. +# +# Usage: ./coverage_test.sh (needs KLAMMERTEXT_HOME set; kdesc on PATH) +# Exit code: 0 if all tests pass, 1 otherwise. + +PASS=0 +FAIL=0 +KDESC=kdesc +KTEXT=ktext +K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set} +DIR="$(cd "$(dirname "$0")" && pwd)/coverage" + +red=$'\033[31m' +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 +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') + # "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 + echo "${red}FAIL${reset} $f.k produced no report"; FAIL=$((FAIL+1)) + fi +done + +# 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 + echo "${red}FAIL${reset} $name — unexpected match: $rgx"; FAIL=$((FAIL+1)) + else + echo "${green}PASS${reset} $name"; PASS=$((PASS+1)) + fi +} + +# 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 + echo "${green}PASS${reset} $name"; PASS=$((PASS+1)) + else + echo "${red}FAIL${reset} $name" + echo " no line matching: $rgx"; FAIL=$((FAIL+1)) + fi +} + +# 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 + echo "${green}PASS${reset} $name"; PASS=$((PASS+1)) + else + echo "${red}FAIL${reset} $name" + echo " no line matching: $rgx"; FAIL=$((FAIL+1)) + fi +} + +# section_lacks NAME FIXTURE HEADER REGEX — REGEX does not appear within the +# named section. Needed wherever the same klammer legitimately appears in a +# LATER section: a whole-report "lacks" would match there and report a failure +# that is not one. A section runs from its header to the next blank line. +section_lacks() { + local name="$1" fixture="$2" header="$3" rgx="$4" + local body + body=$(printf '%s\n' "${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 + fi + if printf '%s\n' "$body" | grep -Eq "$rgx"; then + echo "${red}FAIL${reset} $name — unexpected match in [$header]: $rgx"; FAIL=$((FAIL+1)) + else + echo "${green}PASS${reset} $name"; PASS=$((PASS+1)) + fi +} + +# 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 + echo "${red}FAIL${reset} $name — unexpected match: $rgx"; FAIL=$((FAIL+1)) + else + echo "${green}PASS${reset} $name"; PASS=$((PASS+1)) + fi +} + +echo "${bold}Target coverage tests${reset}" +echo "=====================" +echo + +echo "-- derived coverage --" +has " 1. a general text body covers every target" basic '^All targets, derived \(1\)' +has " 2. ... and that body is @plain" basic '@plain' +has " 3. a body calling one klammer takes its targets" basic '@calls_ab +ta tb +from @ab' +has " 4. two klammers intersect" basic '@calls_both +ta +from @ab @cd' +lacks " 5. the intersection drops the target only one has" basic '@calls_both +ta tb' + +echo +echo "-- what the definitions themselves say --" +has " 6. per-target definitions are not derived" basic 'Defined per target \(2\)' +has " 7. a comma list covers each target it names" basic '^ ta +6 covered' +has " 8. a target named by no definition is undecided" basic '^ tc +3 covered +3 undecided' + +echo +echo "-- coverage that cannot be derived --" +has " 9. an @eval general body must be declared" undecidable '@evaluated +@eval body' +has "10. a @read general body must be declared" undecidable '@included +@read body' +has "11. a literal span must be declared" undecidable "@literally +\\^'\\.\\.\\.'\\^ literal span" +has "12. all three are counted together" undecidable '^Must be declared \(3\)' +has "13. the report says they are offered everywhere" undecidable 'currently offered by an underivable general body' + +echo +echo "-- declared and never defined --" +has "14. a .k with no definitions is reported" undecidable '^Declared but never defined \(1\)' +has "15. ... and named" undecidable '^ @orphan$' + +echo +echo "-- \".*\": every target, asserted --" +# The distinction the report exists to keep: a bare definition asserts +# nothing (the writer's macro form), while ".*" states that the klammer works +# for any target, including targets that do not exist yet. A list of the +# targets defined today cannot say that. +has "37. an explicit .* is its own category" basic '^All targets, declared \(1\)' +has "38. ... and covers every target" basic '^ @universal +ta tb tc' +lacks "39. it is not counted as derived" basic '^ @universal +ta tb tc$(.*)from' +section_lacks "40. nor as a bare general body" basic 'All targets, derived' '@universal' +# ".*" outranks the body scan: the point of writing it is to assert what an +# @eval body cannot be read to mean. +has "41. an @eval body under .* is not flagged" basic '^All targets, declared' + +echo +echo "-- klammers with no \".k\" declaration --" +# The mirror image of the section above: that one has a declaration and no +# definitions, this one definitions and no declaration. Such a klammer works +# but has no DESCRIPTION, so kdesc can say nothing about it and "-k " +# can only find it by name. Orthogonal to the coverage kinds -- a klammer can +# be defined for every target and still have none. +has "32. the section counts them" basic '^No "\.k" declaration \(3\)' +has "33. a general klammer has none" basic '^ @plain +ta tb tc$' +has "34. ... nor one defined without .k" basic '^ @calls_ab +ta tb$' +section_lacks "35. a declared klammer is not listed" basic 'No ".k" declaration' '^ @ab ' +# @orphan has a .k and no definitions, so it belongs to the OTHER section. +lacks "36. the two sections do not overlap" undecidable '^No "\.k" declaration \(1\)\n @orphan' + +echo +echo "-- the fixpoint --" +# A recursive analysis would not terminate on these; the report existing at +# all is most of the assertion. +has "16. mutual reference terminates" cycle '@ping +ta tb +from @pong' +has "17. ... in both directions" cycle '@pong +ta tb +from @ping' +has "18. a constraint propagates around a cycle" cycle '@loop_a +ta +from @loop_b @only_ta' +has "19. ... to the klammer that does not name it" cycle '@loop_b +ta +from @loop_a' +has "20. disjoint coverage is its own category" cycle '^Covers no target \(1\)' +has "21. ... naming the klammers to look at" cycle '^ @impossible +from @only_ta @only_tb' +section_lacks "42. and it is not listed as derived" cycle 'Derived from the klammers' '@impossible' + +echo +echo "-- the source-file column (-v only) --" +# The file is the LAST column -- anchored, so a change of position is caught. +vhas "23. a klammer's file is shown, last" undecidable '@evaluated +@eval body +tst/coverage/undecidable\.k$' +vhas "24. ... in the derived section too" basic '@calls_ab +ta tb +from @ab +tst/coverage/basic\.k$' +vhas "25. ... and in the per-target listing" basic '@ab +ta tb +tst/coverage/basic\.k$' +vhas "26. a declaration with no definition" undecidable '^ @orphan +tst/coverage/undecidable\.k$' +lacks "27. no file column without -v" basic '@calls_ab +tst/coverage' +# Andy's policy keeps a klammer's targets together, so several files is the +# exception -- which is the case the column exists to make visible. +vhas "28. definitions in two files, comma-separated" split \ + '@x +ta tb +tst/coverage/split\.k, tst/coverage/split_more\.k$' +# The "All targets" section lists many names on one line, so there is nothing +# for a file to attach to; it must not sprout a column. +vhas "29. the many-names row keeps its shape" basic '^ @plain$' +# A continuation line belongs to the row above it and takes no file. +lacks "30. the old continuation line is gone" cycle '^ +\^ covers no target' + +echo +echo "-- the problems come last, and are grouped --" +# A terminal is read from the bottom: an 80-klammer listing scrolls a +# three-line warning off the screen, so the actionable part must be last. +has "43. a banner counts the distinct klammers" basic '^Needs attention: [0-9]+ klammers?$' +# An empty category is hidden, but the two halves hide for different reasons. +# +# A REPORTING category describes the shape of the klammer set, so an empty one +# still says something and "all" shows it as a designer's checklist. +lacks "44. an empty reporting category is hidden" cycle '^All targets, declared \(0\)' +vhas "45. ... and shown by \"all\"" cycle '^All targets, declared \(0\)' +# A PROBLEM category sits under a banner reading "Needs attention", and an +# empty one does not. Printing it there would state the opposite of the +# heading above it, so it stays hidden even under "all". +lacks "47. an empty problem category is hidden" basic '^Covers no target \(0\)' +vlacks "48. ... and stays hidden under \"all\"" basic '^Covers no target \(0\)' +# A category WITH entries is always shown, with or without "all". +has "49. a non-empty category needs no \"all\"" cycle '^Derived from the klammers the body calls \(4\)' +# The banner still counts, so "all" is not silent about the problems. +vhas "50. the banner survives under \"all\"" basic '^Needs attention: 3 klammers$' +# "No .k" is orthogonal to the others, so summing the counts would overstate. +has "46. the banner counts distinct klammers" basic '^Needs attention: 3 klammers$' + +echo +echo "-- the category explanations --" +# The prose under a heading teaches the categories; a reader who knows them +# wants headings and rows. So it appears only under "all", with the empty +# categories and the file column. +lacks "51. no explanation by default" basic 'the author states that these work' +vhas "52. ... and one under \"all\"" basic 'the author states that these work' +has "53. the heading is always there" basic '^All targets, declared \(1\)' + +echo +echo "-- a klammer set with nothing outstanding --" +# Nothing to attend to, nothing said: no banner and no problem categories, +# with or without "all". A banner reading "0" would contradict itself in the +# same way an empty category under it would. +lacks "54. no banner when nothing needs attention" clean '^Needs attention' +vlacks "55. ... not even under \"all\"" clean '^Needs attention' +vlacks "56. ... and no problem categories" clean '^No "\.k" declaration' +# The reporting categories still describe the set. +vhas "57. the reporting categories remain" clean '^Defined per target \(1\)' + +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 @@@ +@@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)) +else + echo "${red}FAIL${reset} 22. an underivable klammer still renders — got [$out]"; FAIL=$((FAIL+1)) +fi + +echo +echo "=====================" +echo "Results: ${PASS} passed, ${FAIL} failed" +[ "$FAIL" -eq 0 ] || exit 1 +exit 0 diff --git a/tst/kdesc_test.sh b/tst/kdesc_test.sh new file mode 100755 index 0000000..b17c3e4 --- /dev/null +++ b/tst/kdesc_test.sh @@ -0,0 +1,158 @@ +#!/bin/bash +# +# kdesc_test.sh — the kdesc command's interface. +# +# The flag structure was reorganised 2026-08-11 +# (notes/modifying_the_kdesc_arguments.md) on two rules: +# +# * 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); +# * -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. +# +# "-k" is the one rename that changes a name's MEANING rather than retiring +# it: it used to show katom types (now --katoms). A name-based guard cannot +# catch that -- the old spelling still works and does something else -- so +# these tests pin the new meaning down. +# +# Usage: ./kdesc_test.sh (needs KLAMMERTEXT_HOME set; kdesc on PATH) +# Exit code: 0 if all tests pass, 1 otherwise. + +PASS=0 +FAIL=0 +KDESC=kdesc +K=${KLAMMERTEXT_HOME:?KLAMMERTEXT_HOME must be set} +DIR="$(cd "$(dirname "$0")" && pwd)/coverage" + +red=$'\033[31m' +green=$'\033[32m' +bold=$'\033[1m' +reset=$'\033[0m' + +plain() { sed 's/\x1b\[[0-9;]*m//g'; } + +# 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=$? + if [ $status -gt 128 ]; then + echo "${red}FAIL${reset} $name — died by signal $((status-128))"; FAIL=$((FAIL+1)); return + fi + if printf '%s' "$out" | grep -Eq -- "$pattern"; then + echo "${green}PASS${reset} $name"; PASS=$((PASS+1)) + else + echo "${red}FAIL${reset} $name — no line matching [$pattern]" + echo " got: $(printf '%s' "$out" | head -2)"; FAIL=$((FAIL+1)) + fi +} + +# 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 + echo "${red}FAIL${reset} $name — unexpected match [$pattern]"; FAIL=$((FAIL+1)) + else + echo "${green}PASS${reset} $name"; PASS=$((PASS+1)) + fi +} + +echo "${bold}kdesc interface tests${reset}" +echo "=====================" +echo + +echo "-- the single-letter flags --" +shows " 1. -k lists klammers" '@table' "$KDESC" -k +shows " 2. -t lists targets" 'html' "$KDESC" -t +shows " 3. -c lists character codes" '.' "$KDESC" -c +shows " 4. -i reads the named input" '@x' "$KDESC" -i "$DIR/split.k" -k + +echo +echo "-- the multi-letter topics --" +shows " 5. --katoms lists katom types" 'katom' "$KDESC" --katoms +shows " 6. --argtypes lists argtypes" 'fraction' "$KDESC" --argtypes +shows " 7. --rewrite lists rewrites" '.' "$KDESC" --rewrite +shows " 8. --optionsets lists sets" 'caption_args' "$KDESC" --optionsets + +echo +echo "-- result detail is a word, not a verbosity level --" +shows " 9. --katoms full adds the regex column" 'Regex' "$KDESC" --katoms full +absent "10. --katoms alone omits it" 'Regex' "$KDESC" --katoms +shows "11. --coverage all adds the file column" 'sks/table/table\.k' "$KDESC" --coverage all +absent "12. --coverage alone omits it" 'sks/table/table\.k' "$KDESC" --coverage +# "all" is one word for both kinds of missing information: if you want some of +# it you probably want all of it. It shows an empty REPORTING category -- a +# designer's checklist of the shapes a klammer set can have -- but not an +# empty PROBLEM one, which would state the opposite of the "Needs attention" +# banner above it. +# Against a FIXTURE, not the SKS: which categories are empty there changes as +# the klammer set is worked on, and a test pinned to that state fails for a +# 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 +absent "12b. ... hidden without it" \ + 'All targets, declared \(0\)' "$KDESC" -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 +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 + +echo +echo "-- the klammer search --" +shows "16. a name matches" '@table' "$KDESC" -k table +absent "17. ... and others are excluded" '@document' "$KDESC" -k table +shows "18. matching is case-insensitive" '@table' "$KDESC" -k TABLE +shows "19. a description word matches" '@' "$KDESC" -k verbatim +# The words after -k are one phrase, and whitespace is collapsed on both +# sides -- a description written across several lines in a .k file must still +# match a phrase typed on one. +shows "20. several words are one phrase" '@' "$KDESC" -k displayed verbatim +shows "21. ... with runs of space collapsed" '@' "$KDESC" -k displayed verbatim +shows "22. no match says so" 'No klammer names or descriptions contained "zzqq"\.' "$KDESC" -k zzqq +absent "23. ... and lists nothing" '^ @' "$KDESC" -k zzqq + +echo +echo "-- the subcommand words --" +shows "24. --coverage help explains" 'Coverage commands' "$KDESC" --coverage help +shows "25. --katoms help explains" 'Katom commands' "$KDESC" --katoms help +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 "-- 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) +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 + echo "${green}PASS${reset} 30. -k is listed before --katoms"; PASS=$((PASS+1)) +else + echo "${red}FAIL${reset} 30. usage order: -k at line $k_line, --katoms at $katoms_line" + FAIL=$((FAIL+1)) +fi + +echo +echo "-- a no-result search is not an error --" +timeout 60 "$KDESC" -k zzqq >/dev/null 2>&1 +if [ $? -eq 0 ]; then + echo "${green}PASS${reset} 31. finding nothing exits 0"; PASS=$((PASS+1)) +else + echo "${red}FAIL${reset} 31. finding nothing exits nonzero"; FAIL=$((FAIL+1)) +fi + +echo +echo "=====================" +echo "Results: ${PASS} passed, ${FAIL} failed" +[ "$FAIL" -eq 0 ] || exit 1 +exit 0 diff --git a/tst/signature_test.sh b/tst/signature_test.sh index 5f7b99f..566744e 100755 --- a/tst/signature_test.sh +++ b/tst/signature_test.sh @@ -135,7 +135,7 @@ accepted " 8. a .k declaration with :: instances" \ rejected " 9. a .k declaration plus a parameterized definition" \ '@@k9.k s : a declaration @@ @@k9.ta s2 :other : [*s2*] @@ -@k9 x @' 'both a declaration' +@k9 x @' 'both a ".k" declaration' echo echo "=============================" diff --git a/tst/target_list_test.sh b/tst/target_list_test.sh new file mode 100755 index 0000000..6ef6753 --- /dev/null +++ b/tst/target_list_test.sh @@ -0,0 +1,215 @@ +#!/bin/bash +# +# target_list_test.sh — A klammer definition may name several targets. +# +# @@table.html,tex :: @@ +# +# One body, several targets. The list is SURFACE SYNTAX: the registry makes +# one definition per target named, so nothing downstream of registration knows +# a list was written. That is what these tests pin down — in particular that +# each member goes through the redefinition transition table on its own, so a +# list overlapping an existing definition is decided per target rather than +# all-or-nothing. +# +# Why the feature exists: a klammer whose body is an `@eval` cannot have its +# coverage derived (deciding which targets a Python function answers for is +# undecidable), so its targets must be DECLARED. Writing one definition per +# target would then duplicate the body. See notes/target_coverage.md. +# +# Two patterns are involved and must not be confused (mac/ktype.h): +# definition_begin_name — runs through commas; used ONLY for "@@" +# definition_name — no comma; klammer applications, option names, +# "*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 +# inline: a target is a Machine construct (@@@target), not owned by any +# klammer set. +# +# Usage: ./target_list_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} +ERR=/tmp/target_list_test_err.$$ + +red=$'\033[31m' +green=$'\033[32m' +bold=$'\033[1m' +reset=$'\033[0m' + +# ta, tb, tc are independent; td INCLUDES tc, so tc "provides" td and a +# definition for tc is copied to td (mac/klammer_registry.cpp). +TARGETS='@@@target ta | Target A @@@ +@@@target tb | Target B @@@ +@@@target tc | Target C @@@ +@@@target td | Target D :includes tc @@@ +' + +trim() { awk '{ sub(/[ \t\r]+$/, "") } { line[NR]=$0 } END { f=1; while (f<=NR && line[f]=="") f++; l=NR; while (l>=1 && line[l]=="") l--; for (i=f;i<=l;i++) print line[i] }'; } + +# check_eq NAME TARGET EXPECTED SRC — exit 0, stdout==EXPECTED, no warning. +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=$? + err=$(cat "$ERR") + out=$(printf '%s' "$out" | trim) + if [ $status -ne 0 ]; then + echo "${red}FAIL${reset} $name — ktext exited $status" + echo " stderr: $(echo "$err" | head -2)"; FAIL=$((FAIL+1)); return + fi + if printf '%s' "$err" | grep -qiF "warning"; then + echo "${red}FAIL${reset} $name — unexpected warning" + echo " stderr: $(echo "$err" | head -2)"; FAIL=$((FAIL+1)); return + fi + if [ "$out" = "$expected" ]; then + echo "${green}PASS${reset} $name"; PASS=$((PASS+1)) + else + echo "${red}FAIL${reset} $name" + echo " expected: [$expected]"; echo " got: [$out]"; FAIL=$((FAIL+1)) + fi +} + +# check_warn NAME TARGET EXPECTED SRC — exit 0, stdout==EXPECTED, AND 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=$(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 + fi + if [ "$out" = "$expected" ]; then + echo "${green}PASS${reset} $name"; PASS=$((PASS+1)) + else + echo "${red}FAIL${reset} $name" + echo " expected: [$expected]"; echo " got: [$out]"; FAIL=$((FAIL+1)) + fi +} + +# check_error NAME TARGET PATTERN SRC — nonzero exit and PATTERN in the message. +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=$? + if [ $status -eq 0 ]; then + echo "${red}FAIL${reset} $name — expected an error but ktext succeeded"; FAIL=$((FAIL+1)); return + fi + if echo "$out" | grep -qF "$pattern"; then + echo "${green}PASS${reset} $name"; PASS=$((PASS+1)) + else + echo "${red}FAIL${reset} $name — expected error to contain [$pattern]" + echo " output: $(echo "$out" | head -3)"; FAIL=$((FAIL+1)) + fi +} + +echo "${bold}Klammer definition target-list tests${reset}" +echo "====================================" +echo + +# --- One body, several targets --- +echo "-- the list defines each target named --" +check_eq " 1. first member gets the body" ta "x B y" \ + '@@f.k : a test @@ @@f.ta,tb :: B @@ x @f@ y' +check_eq " 2. second member gets the body" tb "x B y" \ + '@@f.k : a test @@ @@f.ta,tb :: B @@ x @f@ y' +check_error " 3. a target NOT in the list is undefined" tc "not defined for target" \ + '@@f.k : a test @@ @@f.ta,tb :: B @@ x @f@ y' +check_eq " 4. three members, last one" tc "x B y" \ + '@@f.k : a test @@ @@f.ta,tb,tc :: B @@ x @f@ y' +check_eq " 5. parameters are inherited from .k for every member" tb "x [q] y" \ + '@@f.k s : a test @@ @@f.ta,tb :: [*s*] @@ x @f q @ y' +check_eq " 6. a list member propagates to a target that includes it" td "x B y" \ + '@@f.k : a test @@ @@f.ta,tc :: B @@ x @f@ y' + +# --- Malformed lists --- +echo +echo "-- malformed lists --" +check_error " 7. an unknown target in the list" ta "is not defined" \ + '@@f.ta,nosuch : B @@' +check_error " 8. a target named twice" ta "more than once" \ + '@@f.ta,ta : B @@' +check_error " 9. \".k\" in a list" ta "in a list of targets" \ + '@@f.k,ta : a test @@' +check_error "10. \".o\" in a list" ta "in a list of targets" \ + '@@f.o,ta : an option set @@' +# "*" is the general target written out: an assertion that the klammer works +# for EVERY target, including ones not yet defined. In a list it is either +# redundant or a misunderstanding. +check_error "10a. \"*\" in a list" ta 'names "*" in a list of targets' \ + '@@f.*,ta : b @@' +check_eq "10b. \"*\" alone defines every target" tb "x body" \ + '@@f.* : body @@ x @f@' +check_eq "10c. ... including one it does not name" ta "x body" \ + '@@f.* : body @@ x @f@' +check_error "11. a trailing comma" ta "is not correctly defined" \ + '@@f.ta, : B @@' +check_error "12. an empty member" ta "is not correctly defined" \ + '@@f.ta,,tb : B @@' + +# --- The transition table applies per member, not to the list --- +echo +echo "-- redefinition, decided per member --" +check_error "13. create + list create collides on the shared member" ta "already defined" \ + '@@f.ta : one @@ @@f.ta,tb : two @@ x @f@' +# The list's default is silently ignored for ta (which already has a create) +# and creates tb. This is the case a whole-list decision would get wrong. +check_eq "14. existing create + list default: existing kept" ta "x one" \ + '@@f.ta : one @@ @@f.ta,tb :::: two @@ x @f@' +check_eq "15. existing create + list default: other member defined" tb "x two" \ + '@@f.ta : one @@ @@f.ta,tb :::: two @@ x @f@' +check_eq "16. list default + create for one member: replaced" ta "x real" \ + '@@f.ta,tb :::: def @@ @@f.ta : real @@ x @f@' +check_eq "17. list default + create for one member: other keeps default" tb "x def" \ + '@@f.ta,tb :::: def @@ @@f.ta : real @@ x @f@' +check_warn "18. list override warns and replaces (first member)" ta "x over" \ + '@@f.k : a test @@ @@f.ta :: one @@ @@f.tb :: two @@ @@f.ta,tb ::: over @@ x @f@' +check_warn "19. list override warns and replaces (second member)" tb "x over" \ + '@@f.k : a test @@ @@f.ta :: one @@ @@f.tb :: two @@ @@f.ta,tb ::: over @@ x @f@' + +# --- The comma is a target separator ONLY after "@@" --- +echo +echo "-- a comma elsewhere is writer text --" +check_eq "20. a comma in a body is text" ta "x a,b" \ + '@@f.ta : a,b @@ x @f@' +check_eq "21. a comma after an argument substitution" ta "x q,tail" \ + '@@f.ta s : *s*,tail @@ x @f q @' +check_eq "22. a comma after an application" ta "x q, y" \ + '@@f.ta s : *s* @@ x @f q @, y' +check_eq "23. a general definition is unaffected" ta "x B" \ + '@@f : B @@ x @f@' + +# --- Diagnostics count what was WRITTEN, not how often it registered --- +echo +echo "-- one definition, several registrations --" +# tc provides td, so "@@f.tc : ..." registers twice from one line. Before +# this was grouped, the message said "2 definitions" and then printed the one +# line the author wrote twice, sending them to look for a second. +check_error "24. the count is of written definitions" ta \ + "and a definition that declares its own parameters" \ + '@@f.k s : d @@ @@f.tc : [*s*] @@' +check_error "25. ... and the location names its targets" ta "(targets tc, td)" \ + '@@f.k s : d @@ @@f.tc : [*s*] @@' +# Two definitions in two places are still two. +check_error "26. distinct places still count separately" ta \ + "2 definitions that declare their own parameters" \ + '@@f.k s : d @@ @@f.ta : a*s* @@ @@f.tb : b*s* @@' +# The remedy, named in the message rather than implied. +check_error "27. the message says what to write instead" ta \ + 'Write "::" instead of ":"' \ + '@@f.k s : d @@ @@f.tc : [*s*] @@' + +rm -f "$ERR" + +echo +echo "====================================" +echo "Results: ${PASS} passed, ${FAIL} failed" +[ "$FAIL" -eq 0 ] || exit 1 +exit 0