262 lines
12 KiB
C++
262 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;
|
||
|
|
}
|
||
|
|
|