The Slow Road

Created 2026-05-13, last modified 2026-05-22. Visibility: public

Lately, I'm really active on Mastodon, more so than anywhere else by a decent margin. Not everything I post there is very dev-blog-like - I try to post a music video by the band Gunship once a day, I post about weird food and beverage combos I'm trying, I do a little clowning around, I talk a lot more about my stance on AI (which is to say: "no"), even a bit of politics. I'm trying out using Dillo for more of my browsing lately, and while I doubt I could daily drive it with my current needs, I'm pleased to see how close to correctly my website renders in such a feature-lite browser, and it should be totally possible to tweak my site to look better in that kind of context.

In addition to this, I post a lot about Prone development, in a more microblogging format. Recently, I made a thread that was snippety enough to really benefit from sane formatting (Mastodon can render formatted posts from other Fediverse clients, but can only create posts in plain text, which is a baffling and technically arbitrary limitation), so between formatting and length, it really deserved to be a blog post. So I'm going to clean it up for y'all here.


This thread might take awhile. I'm full in medical struggle. But I'm pretty proud of the work on #pronelang that I managed to squeeze in between the disruptions and chaos in my day, and I want to talk about it.

It doesn't look like a ton of progress. It's just one item on my TODO.md, really half of one (I did the other half yesterday). But it required a lot of side quests that will have long-term value beyond just unlocking the "eval" ability on prn_assign_t objects.

A little context: we need to represent "things you can say in Prone source code" as C objects, and the interpreter is able to process those objects in order to execute the code. I used to have a Construct type that handled a big random grab bag of these responsibilities, but that turned out to be a bad decision, so I've been slowly adding dedicated standalone types for each of these jobs, and paring down the Construct type. Assignments (x = 3) are the last piece of functionality left in these legacy Construct objects. Once I've finished making the prn_assign_t type, the old Construct type can just be straight up deleted. There will be rejoicing in the streets! Ding dong, the witch is dead.

In C, types can't actually perform actions, and I've praised this as a good thing so many times. What you can have are, separately, types and functions.

// A type.
typedef struct {
  prn_rc_t rc;
  prn_ltext_t lvalue;
  prn_dv_t rvalue;
} prn_assign_t;

// A function that uses it.
prn_istate_t *prn_eval_assign(prn_istate_t *, prn_assign_t *) {
  // do stuff
}

This means you actually have a decision point here in how you organize your code, both in terms of function names and which files they live in.

// Option A: Noun-centric.
// This function is defined in src/istate.c.
prn_istate_t *prn_istate_eval_list(prn_istate_t *state, prn_vvec_t *list);

// Option B: Verb-centric.
// This function is defined in src/eval.c
prn_istate_t *prn_eval_assign(prn_istate_t *state, prn_assign_t *assign);

Once upon a time, I wrote a bunch of code in Option A form, and that was a mistake borne of old object-oriented habits, even when I knew better. Now I'm cleaning up my mess.

So you'll notice I'm actually cleaning up two messes at the same time: legacy constructs, and noun-oriented code organization. This means every step of the way, I'm doing more work in more uncharted territory, and creating a schism between noun-oriented and verb-oriented code coexisting for awhile, but I feel okay with that trade. Even if I'm going slowly, I'm making the new stuff right, rather than making code I'm going to immediately need to fix (where the fixes are, essentially, rewrites). This isn't a dig on more incremental refactoring - that's saved my bacon before - but deciding when to be a headstrong purist vs a cautious improver-by-inches is an art, one I'm still refining with practice, and in this case, a war on two fronts feels worth it to me.

This is why creating the prn_assign_t type has meant creating so many new header/source/test combos. Before, I might create include/assign.h, src/assign.c, and tests/assign.c respectively.

And in that little slice of the codebase, there'd be a mix of reference counting logic, object construction, how to produce a "repr" string, equality testing, how to eval an assignment, how to convert back and forth between "thunk" form, make the parser produce prn_assign_t instead of Construct when it sees x = 3, etc.

Now, when I want to support object construction, I need to create a new include/mk.h, src/mk.c, and tests/mk.c. If those files already existed, I could just make them a little bigger by copying and pasting existing code that's kinda close to what I need. But now, I need to make a bunch of novel judgment calls about the conventions of the prn_mk_foo functions.

So that's what I've been dealing with lately regarding evaluation, too.

Let's talk about what evaluation is, because one of the things that can get you a little lost is that evaluation is different from execution. Execution is when you call something with parentheses, like plus(1, 2) is execution. Objects are executable if you can use the parentheses operator on them, so to speak - in that example, the plus function is an object that can be executed.

Evaluation is a little zoomed out, and talking about it is like talking about water to a fish. Simple objects always evaluate to themselves. Evaluate a string like "foo", you'll get "foo" back out. But some objects, when you evaluate them, do some processing, some simplification work. A function call for example. plus is executable, but plus(1, 2) is evaluable, and when you evaluate it, it causes plus to be executed with args 1 and 2. In fact, when you call a function, each argument is evaluated first before passing it in, which is why plus(1, plus(2, 3)) turns into plus(1, 5) before running.

So we're sprawling over a lot of topics here, but yesterday's work involved making prn_eval_assign(...) work, tests and all. Now here's the problem: we need to hook it into the bigger system of "here's a dynamic value, it could be any type. Look at the tag and decide how to evaluate it." We already have infrastructure to do this, it's just in the old world, the noun world. It looks like this.

IState *istate_eval(IState *state, DV input) {
  switch (dv_typecode(input)) {
  case DV_TC_IDENT:
    return istate_scope_get(state, dv_to_ltext(input));
  case DV_TC_LIST:
    return istate_eval_list(state, dv_to_list(input));
  case DV_TC_CONSTRUCT:
    return istate_eval_construct(state, dv_to_construct(input));
  case DV_TC_CALL:
    return prn_call_eval(state, dv_to_call(input));
  default:
    return istate_set_value(state, input); // pass through unchanged
  }
}

Ignore the IState vs prn_istate_t discrepancy, they're the same type with aliases. Different mess to clean up later.

Looking at that last snippet, you can see, we handle a couple different special objects that actually evaluate in interesting ways, and then have a catch-all for simple objects at the end. And this, somehow, someway, needs to call prn_eval_assign, our fancy new function with a verb-forward naming convention.

There's a couple different approaches I could use to incrementally migrate from the noun style to the verb style, but what I eventually landed on was creating a prn_eval_dv function that looks like this:

prn_istate_t *prn_eval_dv(prn_istate_t *s, prn_dv_t dv) {
  switch (prn_dv_typecode(dv)) {
  case PRN_DV_TC_ASSIGN:
    return prn_eval_assign(s, prn_conv(dv, dv, assign));
  default:
    return prn_istate_set_value(s, dv);
  }
}

It doesn't handle everything yet, but then I can turn the prn_istate_eval fallback logic into return prn_eval_dv(state, input), and then incrementally move logic from the old to the new, and eventually kill off prn_istate_eval, changing the call sites to point to the (now-complete) prn_eval_dv instead.

So, writing prn_eval_dv is actually really easy, if you have the mental context. It's almost turn-your-brain-off shit. The hard part is TESTING it.

What I figured out is that you can have the tests for prn_eval_assign, prn_eval_list etc. be really detailed and comprehensive. All you need for testing prn_eval_dv is making sure it dispatches to the right specific function, because that's all prn_eval_dv is: dispatch. Which means, what you really want to test is that, if you do these two things:

s = prn_eval_dv(s, assignment_wrapped_in_dv);
// versus
s = prn_eval_assign(s, raw_assignment_ptr);

You should get the same answer. We can handwave what that right answer is: as long as we do the same thing as prn_eval_assign would have, and THAT has good tests to confirm IT'S right, we're golden. This scales really easily to a variety of complex objects.

There's just one catch that was the lion's share of the timesuck: how do we test if two interpreter states are equal or not?

That's right. Up until this point, the test assertion framework has never needed to check if two IStates are equal or not. Which meant, side quest time.

The new test assertion framework is a massive improvement over its predecessor. You just have to implement a few things to allow EQ(istate, a, b) tests.

  1. You need to have a prn_drepr_istate function, for debug-representing IState objects.
  2. You need to have a prn_eq_istate_istate function, and maybe one for not-equals (ne) too, which is just "do the equals function and then flip the answer".
  3. You need to tell the framework how to turn the first assertion arg (istate) into a real type name (prn_istate_t).
  4. You need to tell the framework which memory management scheme istate uses. In this case, rc_release/rc_lend, as opposed to dv_release/dv_lend or unmanaged value types.

These all need tests, so a lot of my day was just implementing this crap ^^ with test logic to prove stuff correct.

All that setup work just to be able to write this:

prn_assign_t *assign = prn_mk_assign(PRN_ITEXT("foo"), PRN_DV_HTEXT("bar"));
prn_istate_t *as_rc = prn_eval_assign(prn_rc_lend(orig), prn_rc_lend(assign));
prn_istate_t *as_dv =
  prn_eval_dv(prn_rc_lend(orig), prn_conv(assign, assign, dv));
EQ(istate, as_rc, as_dv); // THE MONEY LINE

https://git.sr.ht/~maddiem4/prone/tree/main/item/tests/eval.c

The good news is, it's highly reusable, and I'll probably do test assertions on IStates all over the place now that it's convenient to do so. It was slow today because I was laying a solid foundation that will, eventually, make this project consistent in structure and easy to work on.

Thunk support is on the docket for tomorrow, and I expect to do all that and more for #WorkWednesday.