A character written in the ^...^ mnemonic form -- ^oe^, ^zz^, ^hello^ --
used to kill ktext outright: no message, no output, and an exit status
that looked like a crash because it was one. Only the forms that happen
to spell hexadecimal digits escaped, and they were right by luck rather
than by reading. Any caret form that names no character is now a located
error that quotes what was written.
A klammer may now sit inside square brackets and braces as it already
could inside parentheses, and may be followed by the punctuation that
ends a sentence: [@i word@], {@b word@}, @i word@'s. The rule is a fixed
list of characters, because none of them can occur inside a klammer name
and so none of them is ambiguous next to a delimiter. A letter still
cannot: "x@" is the named closing delimiter, so a letter before "@" has
two readings and no rule can separate them.
The abbreviations an author writes -- the hyphen form @i-word, a klammer
glued to its neighbouring punctuation -- are now recognised as author
conveniences only. They are not applied to @@ definitions or @@@ system
commands, which are written by someone who needs no such shorthand and
whose forms are already whole.
Also fixed, each of them silent: an exported shell function in the
environment broke every @eval in the document; a line break written with
@nl was destroyed when it passed through a klammer that generates a
table, printing as \textbackslash{}newline in the finished PDF; and
"kdesc --coverage" shortened its file column by comparing the spelling of
a path rather than the path, so a klammer set reached by a symbolic link
showed a full path where its neighbours showed a short one.
New in the distribution: ref/, a small klammerset that describes the
Klammermachine's own tables in Klammertext. It currently provides the
character tables -- the diacritics and the extended Latin letters -- as
klammers that render to any target, so the documentation of what can be
written cannot drift from what the machine accepts. It is early, and
will grow.
The Klammertext taxonomy now has a section on targets, describes the
@@@klammerset system command alongside the other three, and states the
precedence of "^" -- what a caret means before a letter, a digit, a
punctuation character, and a quote, in one ordered list.
(from dev 6594c7ff8c1f)
263 lines
12 KiB
C++
263 lines
12 KiB
C++
// katom_matching.cpp -- finding katoms in text by matching. See
|
|
// katom_matching.h for what this is and is not. The whole of it is the
|
|
// three functions below.
|
|
//
|
|
// THE THREE RULES. Everything the direct katomizer does follows from these; there
|
|
// is nothing else to know, and no rule ordering to reconstruct.
|
|
//
|
|
// 1. LONGEST MATCH. At each position, every source katom type is tried
|
|
// anchored at that position, and the one matching the most characters
|
|
// wins.
|
|
// 2. DECLARATION ORDER BREAKS TIES. Two types matching the same length
|
|
// at the same position: the one written earlier in katom_types wins.
|
|
// This is the same precedence katomization by rewriting already uses, where
|
|
// std::find_if takes the first type that matches the whole text -- so
|
|
// the table's order keeps exactly the meaning it has today.
|
|
// 3. ALWAYS ADVANCE. Every step consumes at least one character. A
|
|
// position where nothing matches emits one character as an unparsed
|
|
// katom rather than stalling.
|
|
//
|
|
// Rule 3 is why the direct katomizer cannot have the failure that killed ktext on
|
|
// "^oe^" (2026-08-29): a rewrite fixed point can fire a rule, change the
|
|
// string, and still hand back the identical text. Here, non-termination is
|
|
// not something to guard against -- it is unavailable.
|
|
//
|
|
// WHAT IS NOT HERE, and deliberately. There is no whitespace splitting, so
|
|
// there are no rules to repair it. Roughly thirty of the thirty-four
|
|
// entries in katom_rewrite_rules say "these two katoms may abut without a
|
|
// space between them" -- "bar separator", "embedded klammer to the left",
|
|
// "trailing punctuation", "left parenthesis", "literal start to the right".
|
|
// A direct katomizer never joins two katoms into one piece of text,
|
|
// so it never needs to be told how to take one apart. Those thirty rules have no counterpart in
|
|
// this file; the four that remain are the abbreviated form, which is a
|
|
// separate pass at the bottom.
|
|
|
|
#include <map>
|
|
#include <regex>
|
|
|
|
#include "katom_matching.h"
|
|
#include "katomize.h"
|
|
#include "ktype.h"
|
|
#include "util.h"
|
|
|
|
namespace {
|
|
|
|
// The abbreviated application @name-arg-arg. The one katom the katom_types
|
|
// table does not carry: today it lives as a special case at the top of
|
|
// expand_compound_katom() rather than as a type, which is why a trailing
|
|
// period defeats it (that regex_match is a WHOLE-text match, so "@i-arg."
|
|
// fails it) and why katom_rewrite_rules needs a rule to strip the period
|
|
// first. Here it is one pattern among the rest, and longest match keeps
|
|
// the period out of it with no rule at all. Its parts are \w+ rather than
|
|
// definition_name, matching what expand_compound_katom() accepts today.
|
|
const std::regex abbreviated_apply_rgx { R"(@\w+(?:-\w+)+)" };
|
|
|
|
// Processing states, not source categories -- see appears_in_source() in
|
|
// katom_matching.h. Listed by name rather than detected by an empty pattern,
|
|
// because ws_added's "pattern" is the DESCRIPTION "<space> or \n" and would
|
|
// pass an emptiness test while matching nothing a document contains.
|
|
const std::set<katom_t> state_only_types {
|
|
katom_t::ws_added,
|
|
katom_t::literal,
|
|
katom_t::eval_result,
|
|
katom_t::replaced,
|
|
katom_t::ignored,
|
|
};
|
|
|
|
// PATTERNS THAT ASSUME A WHOLE-WORD MATCHER. This is the experiment's
|
|
// first real finding, and the only entry so far.
|
|
//
|
|
// katom_types is asked one question today: "does this WHOLE piece of text match?"
|
|
// A pattern written for that question may be sloppy about characters a
|
|
// piece of text cannot contain, because the whitespace split removed them. The
|
|
// "text" type is written [^^@#|]+ -- everything except the four structural
|
|
// characters -- and whitespace is not excluded because the whitespace
|
|
// split had already removed any. Under longest match that pattern eats spaces and newlines whole,
|
|
// and "@i arg @." is katomized as one text katom from the space to the period.
|
|
//
|
|
// So a direct katomizer needs [^^@#|\s]+ there. That is not a workaround: it is the
|
|
// pattern saying what it always meant, now that something asks it at every
|
|
// position instead of once per piece. Adopting the direct katomizer means making
|
|
// this edit in ktype.h; the override lives here so the experiment changes
|
|
// no shipped file. If more of these turn up, this table is the honest
|
|
// measure of what adoption costs.
|
|
const std::map<katom_t, std::regex> matching_patterns {
|
|
// THIRD FINDING -- recorded, not fixed, because at this level it cannot
|
|
// be. The literal span "^'...'^" written in PROSE is katomized wrong: after
|
|
// the opening delimiter, text runs greedily to "...'", eating the
|
|
// apostrophe that literal-end ('\^) needed, and the lone caret then
|
|
// matches nothing. A lookahead stopping text before "'\^" repairs that
|
|
// one line and BREAKS SIX -- every argtype :pattern written
|
|
// "('style_code'^|\s)+", where the same two characters are an
|
|
// apostrophe ending a type reference followed by a quoted bar.
|
|
// Measured: sks/ went from 16 divergences to 22, so the lookahead is
|
|
// not here.
|
|
//
|
|
// "'^" is genuinely ambiguous and no amount of longest match decides
|
|
// it: what it means depends on whether a "^'" is OPEN, which is state,
|
|
// not lookahead. The standard answer is a katomizer MODE (flex calls them
|
|
// start conditions) -- one bool, set by literal-begin, cleared by
|
|
// literal-end, consulted here. That is a real fourth rule on top of
|
|
// the three at the top of this file, and it is left undone
|
|
// deliberately: whether the direct katomizer may carry state is a decision about
|
|
// the experiment, not a detail of it. The rewrite katomizer never meets
|
|
// the question, because word_split has already cut "^'...'^" out as its
|
|
// own piece before anything is asked.
|
|
{ katom_t::text, std::regex(R"([^^@#|\s]+)") },
|
|
|
|
// Second finding, and a subtler one: within ONE pattern, ECMAScript
|
|
// alternation is ORDERED, not longest. The nonascii type is written
|
|
// (^Xy) | (^<alnum>{1,6}^), and regex_search anchored at a caret takes
|
|
// the first alternative that succeeds -- so "^4E2D^" matched as "^4E",
|
|
// three characters, and the rest of the code point fell out as text.
|
|
// regex_match never sees this, because there both alternatives must
|
|
// consume the whole text and only the right one can. Longest match
|
|
// BETWEEN types does not rescue a pattern that is not longest-match
|
|
// WITHIN itself. The repair is to order the alternatives longest
|
|
// first; the pattern is otherwise identical.
|
|
{ katom_t::nonascii, std::regex(R"((\^[A-Za-z0-9]{1,6}\^)|(\^(\w(?:.|\^))))") },
|
|
};
|
|
|
|
// "@sup-2-3" -> { "@sup", "2", "3" }. Local rather than util.h's
|
|
// regex_split(), which trims its parts -- an argument's own whitespace is
|
|
// not this pass's to remove.
|
|
strings_t hyphen_split(const std::string& s)
|
|
{
|
|
strings_t parts {};
|
|
size_t start = 0;
|
|
for (size_t i = s.find('-'); i != std::string::npos; i = s.find('-', start)) {
|
|
parts.push_back(s.substr(start, i - start));
|
|
start = i + 1;
|
|
}
|
|
parts.push_back(s.substr(start));
|
|
return parts;
|
|
}
|
|
|
|
// How many characters of s, starting at pos, this katom type matches; 0 if
|
|
// it does not match there. match_continuous is what anchors the match to
|
|
// pos -- without it regex_search would find the pattern anywhere later in
|
|
// the string, which is the search the rewrite rules do and the reason they
|
|
// need those (.*?) context groups to say where the match landed.
|
|
size_t match_length(const Ktype& ktype, const std::string& s, size_t pos)
|
|
{
|
|
if (ktype.m_use_equal) {
|
|
return s.compare(pos, ktype.m_pattern.size(), ktype.m_pattern) == 0
|
|
? ktype.m_pattern.size() : 0;
|
|
}
|
|
auto override_pattern = matching_patterns.find(ktype.m_type);
|
|
const std::regex& rgx = override_pattern == matching_patterns.end()
|
|
? ktype.m_rgx : override_pattern->second;
|
|
|
|
std::smatch hit {};
|
|
auto from = s.cbegin() + static_cast<long>(pos);
|
|
if (std::regex_search(from, s.cend(), hit, rgx,
|
|
std::regex_constants::match_continuous)) {
|
|
return static_cast<size_t>(hit.length(0));
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// Rules 1 and 2: the longest match at pos, earliest declaration winning a
|
|
// tie. Returns a length of 0 when no type matches.
|
|
std::pair<katom_t, size_t> longest_match(const std::string& s, size_t pos)
|
|
{
|
|
katom_t best_type { katom_t::word };
|
|
size_t best_length { 0 };
|
|
|
|
size_t abbreviated = 0;
|
|
std::smatch hit {};
|
|
auto from = s.cbegin() + static_cast<long>(pos);
|
|
if (std::regex_search(from, s.cend(), hit, abbreviated_apply_rgx,
|
|
std::regex_constants::match_continuous)) {
|
|
abbreviated = static_cast<size_t>(hit.length(0));
|
|
best_type = katom_t::apply_begin;
|
|
best_length = abbreviated;
|
|
}
|
|
|
|
for (const Ktype& ktype : katom_types) {
|
|
if (!appears_in_source(ktype.m_type))
|
|
continue;
|
|
size_t length = match_length(ktype, s, pos);
|
|
// Strictly greater: a tie leaves the earlier winner in place, which
|
|
// is rule 2. The abbreviated form is seeded above so that it wins
|
|
// a tie against apply_begin -- they can only tie when there is no
|
|
// hyphen, and then they are the same answer anyway.
|
|
if (length > best_length) {
|
|
best_length = length;
|
|
best_type = ktype.m_type;
|
|
}
|
|
}
|
|
return { best_type, best_length };
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool appears_in_source(katom_t type)
|
|
{
|
|
return !state_only_types.contains(type);
|
|
}
|
|
|
|
katom_list katomize_by_matching(const std::string& text, const std::string& source, int source_line)
|
|
{
|
|
// The same normalization split_into_katoms() does, so the two are
|
|
// compared on identical input rather than on a difference in what a tab
|
|
// means.
|
|
std::string s = string_replace(text, "\r", "");
|
|
s = string_replace(s, "\t", " ");
|
|
|
|
katom_list result {};
|
|
size_t pos = 0;
|
|
while (pos < s.size()) {
|
|
auto [type, length] = longest_match(s, pos);
|
|
if (length == 0) {
|
|
// Rule 3. Nothing matched here, so take one character and say
|
|
// so. The rewrite katomizer reaches the same outcome by falling
|
|
// off the end of its rule list; the difference is that it can
|
|
// only do so for a whole whitespace-delimited piece, so "x@i" arrives as one
|
|
// unparsed katom where this reports the one character that is
|
|
// actually unaccounted for.
|
|
Katom k(s.substr(pos, 1), katom_t::word,
|
|
Locator(source, source_line, static_cast<int>(pos)));
|
|
k.m_unparsed = true;
|
|
result.push_back(k);
|
|
pos += 1;
|
|
continue;
|
|
}
|
|
result.push_back(source_katom(s.substr(pos, length), type,
|
|
Locator(source, source_line, static_cast<int>(pos))));
|
|
pos += length;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
katom_list expand_abbreviations(const katom_list& katoms)
|
|
{
|
|
katom_list result {};
|
|
for (const Katom& k : katoms) {
|
|
if (k.m_type != katom_t::apply_begin
|
|
|| k.m_src.find('-') == std::string::npos) {
|
|
result.push_back(k);
|
|
continue;
|
|
}
|
|
// "@sup-2-3" -> "@sup" "2" "|" "3" "@". The arguments are re-run
|
|
// through katomize_by_matching() rather than typed here, so there is one answer to
|
|
// "what is this text" and it lives in one place: an argument of
|
|
// letters becomes a word katom and an argument of digits a text
|
|
// katom, exactly as it does when the same characters are written
|
|
// out longhand. No whitespace is emitted between them, matching
|
|
// what katomization by rewriting produces today (word_split() discards the
|
|
// spaces its own rewrite inserted).
|
|
strings_t parts = hyphen_split(k.m_src);
|
|
result.push_back(Katom(parts.front(), katom_t::apply_begin, k.m_loc));
|
|
for (size_t i = 1; i < parts.size(); ++i) {
|
|
if (i > 1)
|
|
result.push_back(Katom("|", katom_t::bar, k.m_loc));
|
|
katom_list arg = katomize_by_matching(parts[i], k.m_loc.m_filename, k.m_loc.m_line);
|
|
result.insert(result.end(), arg.begin(), arg.end());
|
|
}
|
|
result.push_back(Katom("@", katom_t::apply_end, k.m_loc));
|
|
}
|
|
return result;
|
|
}
|
|
|