Rakudo Switched Frontends, and My Test Framework Stopped Loading

Today I built Rakudo from the current source, something I do every now and then. But when I ran the test suite for BDD::Behave, nothing passed. Every spec file died before running a single example:

===SORRY!===
Incompatible MROs in P6opaque rebless for types Raku::Grammar and Failure+{LetSlangActions} mixin

The version banner had not changed in any way that would warn me:

$ raku --version
Welcome to Rakudo™ v2026.09-216-gbba73cdd7b.
Implementing the Raku® Programming Language v6.d.
Built on MoarVM version 2026.09-38-g597c5351b.

The language is still v6.d, so I didn't expect a source build of the compiler to stop loading a module that had been working a few days ago. I was surprised that the Raku internals my module used changed without the language version changing.

This post covers what changed and what I changed in BDD::Behave to get things working again.

What BDD::Behave does at compile time#

BDD::Behave lets a spec declare a let and then refer to it by name:

describe 'a cart', {
  let(:cart, { Cart.new });

  it 'starts empty', {
    expect(cart.items.elems).to.eq(0);
  }
}

cart is not a variable. For it to parse as a call, BDD::Behave installs a lexical sub named &cart in the enclosing scope while the spec is compiled. It does so using a Raku slang: a role mixed into the compiler's actions class that provides a statement method that runs after each statement is parsed. When the statement is a let(...), it installs the sub.

I ❤️ meta-programming! It's something I always look for very early when picking up a new programming language.

My Raku slang reaches into three different compiler internals:

  • %*LANG<MAIN-actions>, to get the actions class that mixes the role in
  • $*W, the compiler's World object, to install the lexical sub
  • Code.line, to record the line where each describe and it was declared

All three now behave differently in my new, freshly compiled raku binary.

Rakudo now compiles with RakuAST by default#

The change is at src/main.nqp:18. The compiler now uses the RakuAST grammar and actions unless RAKUDO_RAKUAST is set to 0:

# We want legacy
if nqp::existskey(nqp::getenvhash,"RAKUDO_RAKUAST")
  && nqp::iseq_i(nqp::atkey(nqp::getenvhash,"RAKUDO_RAKUAST"),0) {
    nqp::bindhllsym('Raku', 'COMPILER-FRONTEND', 'legacy');
    $comp.parsegrammar(Perl6::Grammar);
    $comp.parseactions(Perl6::Actions);
    ...
}
else {
    nqp::bindhllsym('Raku', 'COMPILER-FRONTEND', 'rakuast');
    $comp.parsegrammar(Raku::Grammar);
    $comp.parseactions(Raku::Actions);
    ...
}

I am not going to ask the users of my testing framework to set an environment variable before they run their specs, so I decided BDD::Behave would have to work with whichever frontend the compiler selects, at least for now. Onward and upward!

%*LANG<MAIN-actions> is no longer set#

The legacy grammar, src/Perl6/Grammar.nqp:528, populates %*LANG at the start of TOP:

%*LANG<MAIN>            := self.WHAT;
%*LANG<MAIN-actions>    := self.actions;

The RakuAST grammar, src/Raku/Grammar.nqp:1246, sets up the same language braid in its TOP via define_slang but does not write to %*LANG<MAIN-actions>:

method TOP() {
    # Set up the language braid.
    my $*LANG := self;
    my $*MAIN := 'MAIN';
    self.define_slang('MAIN', self.WHAT, self.actions);
    ...

My slang read %*LANG<MAIN-actions>, got a Failure, and mixed the role into it. That's what produced the Failure+{LetSlangActions} in the error messages.

The braid methods slang_grammar and slang_actions, defined on the cursor in NQP's src/QRegex/Cursor.nqp:227, work with both frontends, so my slang now reads the braid through them:

my $original-actions := $*LANG.slang_actions('MAIN');
...
my $grammar := $*LANG.slang_grammar('MAIN');
my $actions := $original-actions.^mixin(LetSlangActions);
$*LANG.define_slang('MAIN', $grammar, $actions);

$*W does not exist under RakuAST#

The legacy compiler tracks declarations in a World object, $*W, and my slang installed each let name using $*W.install_lexical_symbol. The RakuAST compiler has no such World. Instead, it has a resolver, $*R, with each scope holding the declarations generated for it. The RakuAST grammar uses merge-generated-lexical-declaration for the same purpose, so my slang does the same.

sub install-lexical(Str $symbol, $value) {
  with $*R {
    my $declaration := ::('RakuAST::VarDeclaration::Implicit::Constant').new(:name($symbol), :$value);
    $*R.current-scope.merge-generated-lexical-declaration($declaration, :resolver($*R), :force);
    return;
  }

  $*W.install_lexical_symbol($*W.cur_lexpad, $symbol, $value);
}

The check for an existing name in the same scope returned the same two branches, with $*R.resolve-lexical under RakuAST and $*W.cur_lexpad.symbol under the legacy frontend.

With those two changes, Behave specs loaded again. Most of the specs passed, but these location-specific CLI features still failed:

  • behave spec.raku:4
  • behave --bisect
  • behave --only-failures
  • behave --list-examples

Code.line reports a different line#

BDD::Behave records each group and example at the line where its block appears, using &block.line. That method is in src/core.c/Code.rakumod:61:

method line(Code:D:) {
    nqp::getcodelocation($!do)<line>;
}

nqp::getcodelocation returns the first line annotation in the compiled block. The two frontends annotate blocks differently. Given this spec:

describe 'a cart', {
  it 'starts empty', {
    expect(Cart.new.items.elems).to.eq(0);
  }
}

The legacy frontend reported line 1 for the describe block and line 2 for the it block, the lines where the blocks open. The RakuAST frontend reported lines 2 and 3, the lines of each block's first statement. So behave spec.raku:1 matched nothing, and spec.raku:2 matched the group, selecting every example in it. That's wrong.

I first tried taking the line from the caller's frame. That broke a lot of stuff when code coverage was enabled. With MVM_COVERAGE_LOG set, the caller frame reported wrong lines under both frontends. Under the legacy frontend, every example got the line of its enclosing describe, and under RakuAST the second example in a group got the line of the first. Very wrong.

But RakuAST gave me a better option. My slang already saw each statement after it was parsed, and under RakuAST statements are a syntax tree my slang can change. When the statement is a direct call to describe, context, it, pending, let, shared-examples, or one of their variants, my slang adds a named argument with the line where the call starts:

my $start = nqp::findmethod($match, 'from')($match) + ($text ~~ /^ \s*/).chars;
my $line = nqp::getcomp('Raku').lineof(nqp::findmethod($match, 'orig')($match), $start, :cache(1));

$call.args.push: ::('RakuAST::ColonPair::Value').new(
  :key(DECLARED-LINE),
  :value(::('RakuAST::IntLiteral').new($line)),
);

The DSL uses that argument when it is present and falls back to &block.line, which is correct under the legacy frontend:

sub declared-line(%meta, &block --> Int) {
  %meta{DECLARED-LINE}:delete // &block.line;
}

That change broke another spec. My slang determines which calls receive the line argument based on name alone, and example is on that list because it is an alias for it. The spec declares a let with the same name and calls it on a line by itself:

describe 'a group', {
  let(:example, { 42 });

  it 'calls a let named after a DSL sub', {
    example();

    expect(example()).to.eq(42);
  }
}

The let installs a sub named &example in that scope, so example() calls the let, not the alias for it. My slang still saw a statement that was a call named example and added the line argument to it. The sub the let installs takes no arguments, so the call failed.

My slang now looks up what the name resolves to before adding the argument, and adds it only when the name resolves to BDD::Behave's own routine. A let or a spec's own sub with the same name gets no extra argument.

The fixes#

The changes are in the BDD::Behave repository:

The full suite now passes on both frontends with the same counts, 4582 specs and 3240 examples, and no one has to set RAKUDO_RAKUAST.

What the language version covers#

The language version was v6.d before and after the recent frontend switch. v6.d covers the behavior that the specification tests check, not the things my slang used to make a let work. 🙁 %*LANG<MAIN-actions>, $*W, and the line Code.line belong to Rakudo, not Behave.

Even so, my fixes do not move Behave's slang onto a documented interface. They move to different internals: the braid methods, $*R and merge-generated-lexical-declaration, lineof, and the RakuAST:: node classes. RakuAST is part of language version v6.e, but its documentation says its development is still ongoing, so none of the classes my slang builds upon have documentation pages yet, and these fixes may require further work down the road. 🙁

raku rakudo rakuast bdd-behave testing