Add 2.13-compatible noeval branch evaluation

ast_eval_branch now uses a two-pass path matching 2.13 behavior:
pass 1 strips backslashes while copying % substitutions literally,
pass 2 re-tokenizes the result through mux_exec for evaluation.

The noeval pass folds Esc("\\") + Sub("%...") sequences so that
the reserialized text exposes the raw % form to pass 2, matching
how 2.13's character scanner processes these in a single stream.

Also adds has_close_bracket/has_close_brace tracking to ASTNode
so the noeval pass preserves malformed (unclosed) bracket/brace
structure instead of normalizing it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stephen Dennis 2026-03-21 12:55:19 -06:00
parent 688913ed96
commit aa7f476598
2 changed files with 170 additions and 4 deletions

View file

@ -44,9 +44,12 @@ struct ASTNode {
std::string text;
std::vector<std::unique_ptr<ASTNode>> children;
bool has_close_paren; // FUNCCALL: true if ')' was found
bool has_close_bracket; // EVALBRACKET: true if ']' was found
bool has_close_brace; // BRACEGROUP: true if '}' was found
ASTNode(ASTNodeType t, std::string_view s = "")
: type(t), text(s), has_close_paren(true) {}
: type(t), text(s), has_close_paren(true),
has_close_bracket(true), has_close_brace(true) {}
void addChild(std::unique_ptr<ASTNode> child) {
children.push_back(std::move(child));

View file

@ -210,6 +210,10 @@ private:
{
advance();
}
else
{
bracket->has_close_bracket = false;
}
m_bracketDepth--;
return bracket;
}
@ -227,6 +231,10 @@ private:
{
advance();
}
else
{
group->has_close_brace = false;
}
m_braceDepth--;
return group;
}
@ -374,6 +382,162 @@ void ast_dump(const ASTNode *node, int indent)
// internally as before.
//
// Replicate 2.13's noeval pass on an AST subtree.
//
// In 2.13, mux_exec with EV_EVAL off still processes backslash
// escapes (the handler has no EV_EVAL guard). Percent substitutions
// are copied literally (guarded by EV_EVAL).
//
// This produces a string with one layer of backslash stripping,
// which can then be re-tokenized and evaluated.
//
static std::string ast_noeval_pass(const ASTNode *node)
{
if (!node)
{
return "";
}
switch (node->type)
{
case AST_LITERAL:
case AST_SPACE:
case AST_SEMICOLON:
return node->text;
case AST_SUBST:
// Percent handler IS guarded by EV_EVAL in 2.13.
// With EV_EVAL off, it copies % and following char literally.
//
return node->text;
case AST_ESCAPE:
// Backslash handler is NOT guarded by EV_EVAL in 2.13.
// It unconditionally consumes the backslash and emits the
// next character.
//
if (node->text.size() >= 2)
{
return node->text.substr(1);
}
return "\\";
case AST_FUNCCALL:
// In noeval, function calls are not dispatched.
// Copy the name and parens literally.
//
{
std::string r = node->text + "(";
for (size_t i = 0; i < node->children.size(); i++)
{
if (i > 0) r += ",";
r += ast_noeval_pass(node->children[i].get());
}
if (node->has_close_paren)
{
r += ")";
}
return r;
}
case AST_EVALBRACKET:
// In noeval, [...] is not evaluated.
// Copy brackets and contents literally.
//
{
std::string r = "[";
for (const auto &c : node->children)
{
r += ast_noeval_pass(c.get());
}
if (node->has_close_bracket)
{
r += "]";
}
return r;
}
case AST_BRACEGROUP:
// Nested brace groups: copy braces and recurse.
//
{
std::string r = "{";
for (const auto &c : node->children)
{
r += ast_noeval_pass(c.get());
}
if (node->has_close_brace)
{
r += "}";
}
return r;
}
case AST_SEQUENCE:
{
std::string r;
for (size_t i = 0; i < node->children.size(); i++)
{
const auto &c = node->children[i];
// In 2.13's noeval pass, backslash still consumes
// the next character. For an AST split like
// Esc("\\") + Sub("%..."), that means the replay
// text should keep the raw %... sequence, not a
// literal backslash followed by the substitution.
//
if ( c->type == AST_ESCAPE
&& c->text == "\\\\"
&& i + 1 < node->children.size()
&& node->children[i + 1]->type == AST_SUBST)
{
r += node->children[i + 1]->text;
i++;
continue;
}
r += ast_noeval_pass(c.get());
}
return r;
}
}
return "";
}
// Evaluate a selected argument from a FN_NOEVAL function using the
// 2.13-style noeval pass followed by reparse/re-eval.
//
// This replicates the two-pass behavior observed in 2.13:
// Pass 1: noeval -- strip one layer of backslash (ast_noeval_pass)
// Pass 2: eval -- re-tokenize the result and evaluate it
//
static void ast_eval_noeval_legacy_arg(const ASTNode *node, UTF8 *buff,
UTF8 **bufc, dbref executor, dbref caller, dbref enactor,
int eval, const UTF8 *cargs[], int ncargs)
{
if (!node)
{
return;
}
const ASTNode *inner = node;
if (node->type == AST_BRACEGROUP && !node->children.empty())
{
inner = node->children[0].get();
}
// Pass 1: produce text with one backslash layer stripped.
//
std::string text = ast_noeval_pass(inner);
// Pass 2: re-tokenize and evaluate.
//
mux_exec(reinterpret_cast<const UTF8 *>(text.c_str()), text.size(),
buff, bufc, executor, caller, enactor,
(eval & ~(EV_TOP | EV_FMAND | EV_STRIP_CURLY))
| EV_EVAL | EV_FCHECK,
cargs, ncargs);
}
// Forward declaration.
//
static void ast_eval_node(const ASTNode *node, UTF8 *buff, UTF8 **bufc,
@ -964,9 +1128,8 @@ static void ast_eval_branch(const ASTNode *child, UTF8 *buff, UTF8 **bufc,
dbref executor, dbref caller, dbref enactor,
int eval, const UTF8 *cargs[], int ncargs)
{
ast_eval_node(child, buff, bufc,
executor, caller, enactor,
eval|EV_STRIP_CURLY|EV_FCHECK|EV_EVAL, cargs, ncargs);
ast_eval_noeval_legacy_arg(child, buff, bufc,
executor, caller, enactor, eval, cargs, ncargs);
}
// Native cand/candbool: short-circuit AND.