Files

300 lines
12 KiB
C++
Raw Permalink Normal View History

Recursion guard and static klammer checking A klammer that reaches itself, directly or through a cycle, expanded until the C++ stack was exhausted: the process died from SIGSEGV with no message and no location. The former limit guarded only the top-level fixed-point iteration, never the descent through klammer application. A depth guard now raises a recursion error naming the klammer and where it was applied. The same loop's termination test moves from "the katom list stopped growing" to "a pass applied no klammer", since a klammer whose body expands to nothing is a reduction that adds no katoms; exceeding the round limit is now an error rather than a message followed by rendering a document with live klammers still in it. ktext --check locates every klammer application written in a document or in a klammer body and checks name existence, argument count, option names, and target coverage without applying anything, reporting all problems at once. This is possible because Klammertext has no catcodes: katom structure is fixed when a file is read, so a klammer body has a determinate shape before it is expanded. The check therefore reaches what the engine cannot -- the branch of a @cond that is not selected, and bodies a given render never enters. @cond's set of truth values is an open language question, so its meaning is unchanged here; an unrecognized predicate now warns, giving its value and location. tst/ gains recursion_test.sh (7 cases) and check_test.sh (19 cases), and this snapshot's test Makefile is generated from the shipped suite list so the two cannot drift apart. (from dev c27e63802406) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:41:43 +02:00
#include <algorithm>
#include <iostream>
#include <optional>
#include <sstream>
#include "check.h"
#include "machine.h"
#include "katom.h"
#include "katom_list.h"
#include "util.h"
namespace {
// One application's argument shape, as written: how many positional parts it
// supplies and which option names it names. Both are counted at nesting
// depth 0 within the application's span, so a bar or an option name belonging
// to a nested klammer is not miscounted as this one's.
//
// This is the same rule the engine uses at run time, but it has to be stated
// again here rather than reused: argument_split() walks the range flatly,
// which is correct THERE because application is post-order -- by the time a
// klammer is applied its nested spans have already been reduced to text. At
// check time nothing has been reduced, so the nesting is still present and
// must be tracked. (The depth-0 rule is the same one cond_separator_bars()
// applies for @cond; see doc/cond_evaluation_order.md.)
struct Application_shape
{
int m_positional { 0 };
std::vector<std::string> m_options {};
};
bool is_boundary_katom(const Katom& k)
{
return k.m_type == katom_t::bar || k.m_type == katom_t::option_name;
}
Application_shape application_shape(katom_list::const_iterator begin, katom_list::const_iterator end)
{
Application_shape shape {};
auto first = begin;
while (first != end && first->is_whitespace()) ++first;
if (first == end) return shape;
// function_symbol_parts() prepends a synthetic bar when the argument list
// does not open with an option name, so that content before the first bar
// counts as a positional part. Mirror that, or "@f a @" would count zero
// positional arguments.
bool in_positional = first->m_type != katom_t::option_name;
if (in_positional) shape.m_positional = 1;
int depth = 0;
for (auto k = first; k != end; ++k) {
if (depth == 0 && is_boundary_katom(*k)) {
if (k->m_type == katom_t::bar) {
++shape.m_positional;
} else {
shape.m_options.push_back(k->m_text.substr(1));
}
}
if (level_increase(*k)) {
++depth;
} else if (level_decrease(*k)) {
--depth;
}
}
return shape;
}
// The span of the application opening at `begin`, as [begin, end): end is one
// past the matching close. Empty when the span is unclosed -- which the
// engine reports separately, so the checker just stops descending.
//
// The result must be an optional rather than "list_end means unclosed": a
// span that closes on the very last katom of the list -- a klammer body that
// is nothing but one application, "@@u : @nosuch x @ @@" -- ends exactly AT
// list_end while being perfectly well formed, and conflating the two made the
// checker silently skip every such body.
std::optional<katom_list::const_iterator> span_end(
katom_list::const_iterator begin, katom_list::const_iterator list_end)
{
int depth = 0;
for (auto k = begin; k != list_end; ++k) {
if (level_increase(*k)) {
++depth;
} else if (level_decrease(*k)) {
if (--depth == 0) return k + 1;
}
}
return {};
}
bool skip_katom(const Katom& k)
{
return k.m_type == katom_t::replaced
|| k.m_type == katom_t::ignored
|| k.m_type == katom_t::literal;
}
// Argument spans of the primitives whose contents are not Klammertext: @eval
// receives code, @read a filename. @cond is NOT in this set -- its branches
// are Klammertext, and checking the branch that is not selected is the main
// thing the checker is for.
bool opens_uncheckable_span(const Katom& k)
{
return k.m_type == katom_t::eval_begin || k.m_type == katom_t::read_begin;
}
class Checker
{
public:
Checker(Machine& machine, std::vector<Diagnostic>& diagnostics)
: m_machine(machine)
, m_diagnostics(diagnostics)
{}
void check_list(const katom_list& katoms, const std::string& target,
const std::string& context);
private:
void check_application(
const std::string& name, const Klammer& klammer,
katom_list::const_iterator begin, katom_list::const_iterator end,
const std::string& target, const std::string& context);
void error(const std::string& message, const std::string& context, const Locator& loc)
{
m_diagnostics.emplace_back("error", message, context, loc);
}
Machine& m_machine;
std::vector<Diagnostic>& m_diagnostics;
};
void Checker::check_application(
const std::string& name, const Klammer& klammer,
katom_list::const_iterator begin, katom_list::const_iterator end,
const std::string& target, const std::string& context)
{
// Arity is a property of the klammer's rationalized parameter set, which
// is shared by all of its target definitions, so it is checked once here
// rather than per target.
const Parameter_set& parameters = klammer.m_parameters;
Application_shape shape = application_shape(begin + 1, end - 1);
auto required = static_cast<int>(parameters.m_positional.size());
bool has_rest = !parameters.m_rest.empty();
if (shape.m_positional < required) {
std::stringstream ss {};
ss << "@" << name << " needs " << required << " positional "
<< plural("argument", required) << " but is given " << shape.m_positional
<< ". Positional arguments are separated by \"|\".";
error(ss.str(), context, begin->m_loc);
} else if (shape.m_positional > required && !has_rest) {
std::stringstream ss {};
ss << "@" << name << " takes " << required << " positional "
<< plural("argument", required) << " but is given " << shape.m_positional << ".";
error(ss.str(), context, begin->m_loc);
}
std::vector<std::string> seen {};
for (const auto& option : shape.m_options) {
if (std::ranges::count(parameters.m_optional_names, option) == 0) {
std::stringstream ss {};
ss << "@" << name << " has no optional argument \":" << option << "\".";
if (!parameters.m_optional_names.empty()) {
ss << " It accepts: :" << join(parameters.m_optional_names, " :") << ".";
}
error(ss.str(), context, begin->m_loc);
} else if (std::ranges::count(seen, option) > 0) {
error("@" + name + " is given \":" + option + "\" more than once.",
context, begin->m_loc);
}
seen.push_back(option);
}
// Target coverage. A klammer may be declared (.k) and defined for some
// targets but not the one being built; run time only discovers this if the
// application is actually reached.
//
// Not checked under the general target: a general body is not applied
// under "*", it is copied to every target that lacks its own definition
// and applied under whichever of those is in force (copy_general_klammer_
// to_undefined() in klammer.cpp). So an application inside it resolves
// against a real target, and the per-target passes are where coverage is
// decided. Checking it here reported @b -- defined for html/tex/pdf/txt
// but not for "*" -- as missing from a general body that in fact works.
if (target != Target_registry::general_name && klammer.m_defloc.count(target) == 0) {
std::stringstream ss {};
ss << "@" << name << " is not defined for the target \"" << target << "\".";
strings_t targets = klammer.get_target_names();
if (!targets.empty()) {
ss << " It is defined for: " << join(targets, ", ") << ".";
}
error(ss.str(), context, begin->m_loc);
}
}
void Checker::check_list(
const katom_list& katoms, const std::string& target, const std::string& context)
{
for (auto k = katoms.begin(); k != katoms.end(); ++k) {
if (k->m_type == katom_t::ignore_rest) break;
if (skip_katom(*k)) continue;
// Code and filenames, not applications: skip the whole span.
if (opens_uncheckable_span(*k)) {
auto skip_to = span_end(k, katoms.end());
if (!skip_to) return;
k = *skip_to - 1;
continue;
}
if (k->m_type != katom_t::apply_begin) continue;
auto closed = span_end(k, katoms.end());
if (!closed) return; // unclosed; the engine reports it
auto end = *closed;
std::string name = trim_char(k->m_text, '@');
auto found = m_machine.m_klammers.m_klammers.find(name);
if (found == m_machine.m_klammers.m_klammers.end()) {
error("The klammer @" + name + " is not defined.", context, k->m_loc);
continue;
}
check_application(name, found->second, k, end, target, context);
// A literal parameter's content is raw text -- a "@" inside it is not
// an application -- so do not descend into it.
if (found->second.has_literal_param()) {
k = end - 1;
}
}
}
} // namespace
An output policy for the three commands, and @cond as a true special form A snapshot of the development tree. The substantial changes since the last one: COMMAND OUTPUT POLICY. The three commands display text in exactly three cases, and each owns a stream: LOGGING under "-v" greater than 0 and an ERROR before termination go to STDERR; OUTPUT THE USER ASKED FOR goes to STDOUT. For ktext that output is a document, so "ktext doc.kt -d | ..." is now safe -- logging used to share the stream and land inside the document. A bare command prints its usage and succeeds rather than failing. Colour is emitted only to a terminal, per stream, and NO_COLOR is honoured. "-v 1" reports every decision whose outcome you could not have read off your own input: the klammerset that was loaded and from which file, a font's directory, a ":files" name's file, how "-o" was expanded. Higher levels are the trace. The commands no longer warn and continue: an anomaly is an error, described with its location. Two exceptions remain, each for a stated reason -- a condition that is expected and temporary by design, and a judgment that is a heuristic rather than exact. @cond IS NOW A TRUE SPECIAL FORM, resolved at APPLICATION time rather than when the file is read. Two consequences for a writer: * a state variable reaches the predicate. "@@@state Flag :value true @@@ @cond *Flag* | T | F @" renders "T"; it used to see the literal "*Flag*" and silently take the false branch. The document now behaves like a klammer body, whose arguments are bound before its conditionals are decided. * nothing in a discarded branch happens -- it is not read, not evaluated, not expanded. An @eval in the branch not taken used to run anyway. Its predicate relation is total and strict: true, True, 1; false, False, 0, and empty; anything else is an error at the @cond rather than silently false. @eval REACHING OUTSIDE. ":shell" and ":haskell" now keep the command's standard error out of the document (it appears under "-v 1") and treat a nonzero exit as an error naming what the command reported. A command that exits nonzero on purpose -- "grep" finding no match -- says so with "|| true". KLAMMER SETS. Several combine: "--klammersets a b c" loads all three in the order given, sharing one namespace, with the definition modes deciding collisions. "none" means none and may not be combined with other symbols. A klammerset with symbol X is declared in a file X/X.k, which is what lets two sets require the same third set without loading it twice. TESTS. Four new suites: the kdiag command's interface, the @eval primitive's contract with the outside world, and verbosity at both tiers. Three suites that could not run on macOS at all now do. Assembled from dev commit 6c8ee6c22fca.
2026-08-16 01:37:59 +02:00
std::vector<Diagnostic> check_machine(Machine& machine, const katom_list& document,
const std::string& target)
Recursion guard and static klammer checking A klammer that reaches itself, directly or through a cycle, expanded until the C++ stack was exhausted: the process died from SIGSEGV with no message and no location. The former limit guarded only the top-level fixed-point iteration, never the descent through klammer application. A depth guard now raises a recursion error naming the klammer and where it was applied. The same loop's termination test moves from "the katom list stopped growing" to "a pass applied no klammer", since a klammer whose body expands to nothing is a reduction that adds no katoms; exceeding the round limit is now an error rather than a message followed by rendering a document with live klammers still in it. ktext --check locates every klammer application written in a document or in a klammer body and checks name existence, argument count, option names, and target coverage without applying anything, reporting all problems at once. This is possible because Klammertext has no catcodes: katom structure is fixed when a file is read, so a klammer body has a determinate shape before it is expanded. The check therefore reaches what the engine cannot -- the branch of a @cond that is not selected, and bodies a given render never enters. @cond's set of truth values is an open language question, so its meaning is unchanged here; an unrecognized predicate now warns, giving its value and location. tst/ gains recursion_test.sh (7 cases) and check_test.sh (19 cases), and this snapshot's test Makefile is generated from the shipped suite list so the two cannot drift apart. (from dev c27e63802406) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:41:43 +02:00
{
std::vector<Diagnostic> diagnostics {};
Checker checker(machine, diagnostics);
// With no target named, check every target the machine defines, plus the
// general one -- a klammer defined without a target suffix has its body
// filed under the general name, and with no klammer set loaded that is the
// only target there is.
strings_t targets {};
if (target == Target_registry::general_name) {
targets = machine.m_targets.user_defined();
targets.push_back(Target_registry::general_name);
} else {
targets.push_back(target);
}
for (const auto& t : targets) {
An output policy for the three commands, and @cond as a true special form A snapshot of the development tree. The substantial changes since the last one: COMMAND OUTPUT POLICY. The three commands display text in exactly three cases, and each owns a stream: LOGGING under "-v" greater than 0 and an ERROR before termination go to STDERR; OUTPUT THE USER ASKED FOR goes to STDOUT. For ktext that output is a document, so "ktext doc.kt -d | ..." is now safe -- logging used to share the stream and land inside the document. A bare command prints its usage and succeeds rather than failing. Colour is emitted only to a terminal, per stream, and NO_COLOR is honoured. "-v 1" reports every decision whose outcome you could not have read off your own input: the klammerset that was loaded and from which file, a font's directory, a ":files" name's file, how "-o" was expanded. Higher levels are the trace. The commands no longer warn and continue: an anomaly is an error, described with its location. Two exceptions remain, each for a stated reason -- a condition that is expected and temporary by design, and a judgment that is a heuristic rather than exact. @cond IS NOW A TRUE SPECIAL FORM, resolved at APPLICATION time rather than when the file is read. Two consequences for a writer: * a state variable reaches the predicate. "@@@state Flag :value true @@@ @cond *Flag* | T | F @" renders "T"; it used to see the literal "*Flag*" and silently take the false branch. The document now behaves like a klammer body, whose arguments are bound before its conditionals are decided. * nothing in a discarded branch happens -- it is not read, not evaluated, not expanded. An @eval in the branch not taken used to run anyway. Its predicate relation is total and strict: true, True, 1; false, False, 0, and empty; anything else is an error at the @cond rather than silently false. @eval REACHING OUTSIDE. ":shell" and ":haskell" now keep the command's standard error out of the document (it appears under "-v 1") and treat a nonzero exit as an error naming what the command reported. A command that exits nonzero on purpose -- "grep" finding no match -- says so with "|| true". KLAMMER SETS. Several combine: "--klammersets a b c" loads all three in the order given, sharing one namespace, with the definition modes deciding collisions. "none" means none and may not be combined with other symbols. A klammerset with symbol X is declared in a file X/X.k, which is what lets two sets require the same third set without loading it twice. TESTS. Four new suites: the kdiag command's interface, the @eval primitive's contract with the outside world, and verbosity at both tiers. Three suites that could not run on macOS at all now do. Assembled from dev commit 6c8ee6c22fca.
2026-08-16 01:37:59 +02:00
checker.check_list(document, t, "document");
Recursion guard and static klammer checking A klammer that reaches itself, directly or through a cycle, expanded until the C++ stack was exhausted: the process died from SIGSEGV with no message and no location. The former limit guarded only the top-level fixed-point iteration, never the descent through klammer application. A depth guard now raises a recursion error naming the klammer and where it was applied. The same loop's termination test moves from "the katom list stopped growing" to "a pass applied no klammer", since a klammer whose body expands to nothing is a reduction that adds no katoms; exceeding the round limit is now an error rather than a message followed by rendering a document with live klammers still in it. ktext --check locates every klammer application written in a document or in a klammer body and checks name existence, argument count, option names, and target coverage without applying anything, reporting all problems at once. This is possible because Klammertext has no catcodes: katom structure is fixed when a file is read, so a klammer body has a determinate shape before it is expanded. The check therefore reaches what the engine cannot -- the branch of a @cond that is not selected, and bodies a given render never enters. @cond's set of truth values is an open language question, so its meaning is unchanged here; an unrecognized predicate now warns, giving its value and location. tst/ gains recursion_test.sh (7 cases) and check_test.sh (19 cases), and this snapshot's test Makefile is generated from the shipped suite list so the two cannot drift apart. (from dev c27e63802406) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:41:43 +02:00
for (const auto& [name, klammer] : machine.m_klammers.m_klammers) {
auto body = klammer.m_body.find(t);
if (body == klammer.m_body.end()) continue;
checker.check_list(body->second, t, "body of @" + name);
}
}
// The same text is checked once per target, so a fault that does not
// depend on the target -- an undefined name, a wrong argument count --
// is found once per target and must be reported once. Target coverage
// names its target in the message, so those stay distinct. Hence the
// context deliberately does NOT carry the target: it is what makes the
// target-independent duplicates compare equal.
std::vector<Diagnostic> unique {};
for (const auto& d : diagnostics) {
bool seen = std::any_of(
unique.begin(), unique.end(), [&d](const Diagnostic& u) {
return u.m_severity == d.m_severity && u.m_message == d.m_message
&& u.m_context == d.m_context && u.m_loc.str() == d.m_loc.str(); });
if (!seen) unique.push_back(d);
}
return unique;
}
int report_diagnostics(const std::vector<Diagnostic>& diagnostics, std::ostream& os)
{
int errors = 0;
for (const auto& d : diagnostics) {
if (d.m_severity == "error") ++errors;
os << d.m_severity << ": " << d.m_message << "\n";
if (!d.m_context.empty()) {
os << " in " << d.m_context << "\n";
}
if (!d.m_loc.m_filename.empty()) {
os << " " << d.m_loc.desc() << "\n";
}
os << "\n";
}
os << diagnostics.size() << " " << plural("diagnostic", diagnostics.size())
<< ", " << errors << " " << plural("error", errors) << "\n";
return errors;
}