A snapshot of the development tree. The substantial changes since the last one:
COMMAND OUTPUT POLICY. The three commands display text in exactly three cases,
and each owns a stream: LOGGING under "-v" greater than 0 and an ERROR before
termination go to STDERR; OUTPUT THE USER ASKED FOR goes to STDOUT. For ktext
that output is a document, so "ktext doc.kt -d | ..." is now safe -- logging
used to share the stream and land inside the document. A bare command prints
its usage and succeeds rather than failing. Colour is emitted only to a
terminal, per stream, and NO_COLOR is honoured.
"-v 1" reports every decision whose outcome you could not have read off your own
input: the klammerset that was loaded and from which file, a font's directory, a
":files" name's file, how "-o" was expanded. Higher levels are the trace.
The commands no longer warn and continue: an anomaly is an error, described with
its location. Two exceptions remain, each for a stated reason -- a condition
that is expected and temporary by design, and a judgment that is a heuristic
rather than exact.
@cond IS NOW A TRUE SPECIAL FORM, resolved at APPLICATION time rather than when
the file is read. Two consequences for a writer:
* a state variable reaches the predicate. "@@@state Flag :value true @@@
@cond *Flag* | T | F @" renders "T"; it used to see the literal "*Flag*" and
silently take the false branch. The document now behaves like a klammer
body, whose arguments are bound before its conditionals are decided.
* nothing in a discarded branch happens -- it is not read, not evaluated, not
expanded. An @eval in the branch not taken used to run anyway.
Its predicate relation is total and strict: true, True, 1; false, False, 0, and
empty; anything else is an error at the @cond rather than silently false.
@eval REACHING OUTSIDE. ":shell" and ":haskell" now keep the command's standard
error out of the document (it appears under "-v 1") and treat a nonzero exit as
an error naming what the command reported. A command that exits nonzero on
purpose -- "grep" finding no match -- says so with "|| true".
KLAMMER SETS. Several combine: "--klammersets a b c" loads all three in the
order given, sharing one namespace, with the definition modes deciding
collisions. "none" means none and may not be combined with other symbols. A
klammerset with symbol X is declared in a file X/X.k, which is what lets two
sets require the same third set without loading it twice.
TESTS. Four new suites: the kdiag command's interface, the @eval primitive's
contract with the outside world, and verbosity at both tiers. Three suites
that could not run on macOS at all now do.
Assembled from dev commit 6c8ee6c22fca.
490 lines
20 KiB
C++
490 lines
20 KiB
C++
#include <algorithm>
|
|
#include <functional>
|
|
#include <iomanip>
|
|
#include <ostream>
|
|
#include <set>
|
|
|
|
#include "coverage.h"
|
|
#include "machine.h"
|
|
#include "katom.h"
|
|
#include "util.h"
|
|
|
|
namespace {
|
|
|
|
using target_set = std::set<std::string>;
|
|
|
|
// 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<std::string> 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<std::string> 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> klammer_coverage(const Machine& machine,
|
|
const strings_t& defined_outside)
|
|
{
|
|
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<std::string, Written> written {};
|
|
std::map<std::string, Body_scan> 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<std::string, target_set> 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. The filter applies HERE and not earlier:
|
|
// passes 1 and 2 must see every klammer, because a derived coverage is the
|
|
// intersection of what the called klammers cover and those are mostly the
|
|
// klammerset's.
|
|
std::vector<Klammer_coverage> result {};
|
|
for (const auto& [name, klammer] : machine.m_klammers.m_klammers) {
|
|
if (!defined_outside.empty() && !klammer.defined_outside(defined_outside)) {
|
|
continue;
|
|
}
|
|
const Written& w = written.at(name);
|
|
Klammer_coverage kc {};
|
|
kc.m_name = name;
|
|
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<std::string>& 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<Klammer_coverage>& coverage,
|
|
bool full, std::ostream& os)
|
|
{
|
|
strings_t targets = machine.m_targets.user_defined();
|
|
os << "Klammer coverage\n"
|
|
<< "================\n\n"
|
|
<< coverage.size() << " " << plural("klammer", static_cast<int>(coverage.size()))
|
|
<< ", " << targets.size() << " " << plural("target", static_cast<int>(targets.size()))
|
|
<< ": " << join(targets, " ") << "\n";
|
|
|
|
auto of_kind = [&coverage](coverage_t kind) {
|
|
std::vector<const Klammer_coverage*> result {};
|
|
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<std::string, std::string>; // text, file
|
|
auto emit = [&os, full](const std::vector<Row>& 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<std::string(const Klammer_coverage&)>;
|
|
// 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<const Klammer_coverage*>& group,
|
|
const std::string& explanation, const Row_of& row) {
|
|
section(os, title, group.size(), full ? explanation : "");
|
|
std::vector<Row> 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<const Klammer_coverage*>& 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<const Klammer_coverage*>& 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<const Klammer_coverage*> derived {};
|
|
std::vector<const Klammer_coverage*> 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<const Klammer_coverage*> 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<int>(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 <text>\" 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";
|
|
}
|
|
}
|