Caveats, limitations, and the gap between the pitch and the substrate — for readers of an introductory Rebol3/Red book — by request from @gchiu.
Compiled by Claude Opus 5 from a set of examples and rants given by @hostilefork, with the contempt removed. (Plus a chorus of feedback from other AI.)
Why this document exists
Rebol makes a specific promise: because code is data, and control constructs are functions rather than keywords, you can build your own languages inside it cheaply.
Dialects. Little DSLs. No parser generator, no toolchain, no dependencies.
That promise is genuinely visible at the REPL and in small, top-level examples. The problem begins when an abstraction must be factored into reusable pieces—wrapped, nested, recursively invoked, or combined with independently written code.
The rest of this document identifies the mechanisms behind that gap.
Two roots
Every failure below is one of two decisions wearing a different costume.
1. A function's return channel is a single untyped slot. One value comes back, with no way to attach a second meaning. So anything that needs to say something other than here is a value — "I broke out of a loop," "I found nothing," "this failed," "here are two results" — must overload an ordinary value (usually none!) or invent a private side channel. Two constructs that invent different side channels cannot be composed.
2. Binding happens at load time, to the scope the text came from. Code lifted from a file or built at runtime carries bindings you didn't ask for. So the one thing dialecting requires — taking unevaluated material and giving it meaning in your context — is the thing the language won't reliably let you do.
Neither is exotic. Other language designs have long provided explicit representations for several results, ordinary versus exceptional failure, nonlocal control transfer, and composable environments. The relevant question is not whether one particular alternative is universally best, but whether the distinctions required by reusable abstractions are representable at all.
Scope: unless marked otherwise, every claim below applies to Rebol2, R3-Alpha, and Red alike. Where they diverge, it's noted — and where Red diverged, it mostly diverged into a different failure rather than out of one.
1. Binding: their own advertisement doesn't run
In 2008, Nenad Rakocevic — later the creator of Red — posted this as the "REBOL master way" to do rules-driven dispatch:
foreach [condition msg] load http://site.com/facts.r [
if do condition [return msg]
]
A perfect miniature of the pitch. Data is code, no parser, three lines.
Put it where you'd actually want it:
checker: func [a b] [
foreach [condition msg] load %facts.red [
if do condition [return msg]
]
]
probe checker 10 20
*** Script Error: a has no value
load binds the block to the scope of %facts.red. The function's a and b are invisible to it. So are any local redefinitions of > or <. (Red's load also won't skip the file header — red#2431 — so you need a headerless file to get even this far.)
There is a fix for this case. There is no fix that composes. You can bind the loaded block to a single context:
foreach [condition msg] bind load %facts.red context? 'a [...]
That works, and then:
-
One context, no chaining.
bindattaches to one context. If your material needs words from the function frame and a module and an object, the second bind overwrites the first wherever they overlap. Real dialects need layered environments. This gives you one layer, and silently leaves the rest of your words whereverloadput them. -
It's destructive.
bindrewrites cells in place. Cache the rule block to avoid re-reading the file and every caller fights over the same cells.bind/copyfixes that by deep-copying the entire rule set per call. -
It doesn't survive recursion. Each call has a distinct frame. Bind, recurse, bind again, and the outer frame's bindings are gone underneath it.
The right thing here is an environment you compose. What's available is a mutation you apply. That's not an ergonomics gap, it's a missing operation.
Ren-C: material loads unbound. Binding propagates down from the tip of the enclosing structure during evaluation, so unbound code takes meaning from where it's used.
checker: func [a b] [
for-each [condition msg] bind-here (transcode read %facts.r) [
if eval condition [return msg]
]
]
2. return is a keyword in a function costume
The headline claim is that control constructs are ordinary functions, so you can write your own. It stops being true exactly where you'd want it.
return climbs the stack until something catches it. Natives don't catch. Any func you write does. So wrapping a native puts return in the wrapper:
rebol2>> unless: func [cond block] [if not cond block]
rebol2>> bar: func [x] [unless x = 10 [return "not 10"] return "it's 10!"]
rebol2>> bar 20
== "it's 10!" ; the UNLESS swallowed the RETURN
You cannot implement unless in terms of if. That is the simplest possible test of "control constructs are composable functions," and it fails silently — no error, just a wrong answer.
For a book this is load-bearing: any chapter teaching readers to build their own control constructs is teaching them to write code that quietly does the wrong thing.
Ren-C: return is definitional — bound to the frame that created it, so an intervening user function can't intercept it. Same for break, continue, throw.
(Worth noting: definitional return was argued for in the R3-Alpha era, by people who had the diagnosis right. It didn't ship. See the history section below.)
3. Loops don't compose into loops
Build for-both out of two foreach loops:
rebol2>> for-both-naive: func ['var blk1 blk2 body] [
foreach (var) blk1 body
foreach (var) blk2 body
]
break is undetectable from outside. A broken foreach returns none!. A foreach whose body evaluated to none also returns none!. The wrapper can't tell, so it runs the second loop:
rebol2>> for-both-naive x [1 2] [3 4] [
if x = 2 [break]
print [x]
x = 5
]
1
3 ; the BREAK only broke the first FOREACH
4
Red made this worse by adding break/return, so a broken loop can now return anything at all.
The last body result is lost. With an empty second block, the fallout from the first is discarded:
rebol2>> for-both-naive x [1 2] [] [print [x] x * 10]
1
2
== #[none!] ; wanted 20
Root cause #1, twice. One slot is carrying "the loop's value," "whether it broke," and "whether it ran," and it has room for one.
Ren-C: those signals get distinguishable representations that can't collide with ordinary data. for-both then falls out of existing combinators:
for-both: lambda [@(var) blk1 blk2 body] [
unlift* all [
^ lift* for-each (var) blk1 body
^ lift* for-each (var) blk2 body
]
]
No loop-aware break, no special status for for-both. Break, empty block, and a body returning null all handled by the same mechanism.
4. none! is asked to mean too many things
none! is routinely used—by language constructs, APIs, and idiom—to stand for several different conditions: absence of a value, false-like result, failure to find something, loop termination or break status, an empty body result, and end of input. Because it is an ordinary storable value, these conventions can collide with legitimate data.
This is why §2 and §3 can't be fixed in userspace. The authors of foreach didn't forget to signal break. There was nothing to signal it with.
It compounds with a thirty-year-old wart. Rebol decided everything that looks like a word is a word, and all words are truthy — so true and false stayed reassignable words while the real LOGIC! values were #[true] and #[false], which then render as the words anyway:
rebol2>> code: [flag: #[false]]
rebol2>> code
== [flag: false] ; renders as the word
rebol2>> type? second code
== logic! ; but isn't one
rebol2>> word? true
== false ; and `true` isn't a word either
The practical damage: in Red, "is there an element here" and "is the element none" are the same check.
red>> alpha: [1 2 #[none]]
red>> beta: [1 2]
red>> none? third alpha
== true
red>> none? third beta
== true
So the canonical Rebol idiom value: any [pick block index, <fallback>] has never actually been sound. It works until your data contains a none!.
Ren-C: the control signals become antiforms — unstable states that cannot be put in a block, and in some cases not in a variable. Because they can't appear in ordinary data, they can carry control meaning without ever being confused for a value you put there. IF then tests one thing: nullity.
The consequence runs opposite to what you'd expect. Count the falsey forms a beginner must learn:
-
Ren-C: one. Null. That's the rule. And null can't be in a block, so you'll never trip over one in your data.
-
Rebol2/Red: two —
none!and LOGIC! false — plus a rendering ambiguity wherefalseand#[false]print identically and behave oppositely.
Whatever the upfront cost of a principled system is, it isn't at the conditional. That's where Ren-C has strictly fewer rules.
5. Multi-return has no coherent story
Two options: return a block!, or pass in variable names to be written.
A block! means a caller who doesn't know the function is multi-return silently gets a block instead of a value. The result is always truthy, so you can't branch on it. And a block can't carry a null or an unset without distorting it.
Passing variable names in (the do/next [...] 'pos style) breaks composition outright. An enclose-style wrapper can only influence the primary output, because the variable name is an input the callee consumes. It's also non-atomic: a function that fails partway may already have written some of the caller's variables. Every such function is either broken or carrying its own transaction management, which usually means broken.
Ren-C shipped that design, used it for two years, and abandoned it on exactly these grounds. The historical multi-return idiom isn't a style choice. It's a composition defect.
Ren-C: functions return an antiform "pack." A plain set-word! decays it to the first value, so callers who don't care never know. A set-block! unpacks it. Packs can't be stored, so they can't leak into data.
>> x: multi-return ; just the first
>> [x y]: multi-return ; both
6. A typo and a contract failure arrive through the same door
Rebol2 had "hot errors": an error! returned from a function escalated to an exception unless triaged at the callsite. The instinct was right — it's roughly Rust's Result. It had real ergonomic problems (try intercepted errors without disarming them, so the caught value blew up when touched).
R3-Alpha and Red "fixed" that
by making everything an exception. Which produces this:
r3-alpha>> transcode: func [input] [
prnit "My Transcode Wrapper" ; typo
return transcode input
]
r3-alpha>> if not attempt [transcode to binary! "abc def"] [print "Bad input"]
Bad input
The input was fine. A misspelled word five frames down is indistinguishable from the function reporting a legitimate failure, because both arrive through the same channel. Every codebase past toy size accumulates these.
Exceptions are for things that should essentially never happen. Expected failures need a contract between caller and callee.
Ren-C: two mechanisms. A definitional error comes back from the function you actually called, escalating to a panic if unhandled at the callsite — so catching it can't accidentally catch a typo from four frames down. A panic is the abrupt case and is deliberately hard to intercept.
>> transcode "a bc 1&x def" except e -> [print ["Error:" e.id]]
Error: scan-invalid
7. The scanner interface can't handle a comment
Reading source text one item at a time is about as basic as language-workbench tasks get.
R3-Alpha returns a block with the remainder as the last element whether you asked or not, so you're indexing by position with a length-1 special case.
Red's looks tidier — remainder always in slot two — and is broken:
red>> transcode/next to binary! ""
== [[] #{}]
Nothing distinguishes "I scanned an empty block" from "there was only a comment here." So a scanning loop invents an item:
red>> utf8: to binary! "; I'm just a comment"
red>> while [not tail? utf8] [
set [item utf8] transcode/next utf8
print ["Item is:" mold item]
]
Item is: []
Neither accepts a string, either — the scanner reads UTF-8 and both store strings fixed-width, so you pay for a to binary! copy first.
Root cause #1 again. "No item was produced" is a different kind of answer from "here is an item," and there's nowhere to put it.
Ren-C: transcode returns a pack of remainder and value, or null when nothing was synthesized. The scanning loop is a while over the call. Syntax errors come back as definitional errors.
8. The flagship dialect is not dialectable
If dialecting were as cheap as advertised, parse would be written in Rebol.
It isn't. In Rebol2, R3-Alpha, and Red, parse is a monolithic native. You cannot add a keyword, tweak one, or hook it. To change anything you recompile the interpreter in C or Red/System.
Consider what that implies. The language whose entire value proposition is building custom languages here is easy ships exactly one custom language, written in C, by the language's author, closed to you.
The usual defense is performance. That would land if there were a usermode reference implementation the native was optimizing against. There isn't — and here's what that absence looks like from outside:
r3-alpha>> parse ["aaaa"] [and any-string! into [some 2 "a"]]
== false ; it does not know what it cannot do
r3-alpha>> parse ["aaaa"] [and any-string! into [some [2 "a"]]]
== true
Semantics that were never specified independently of the C. Rules also can't produce results, so anything a rule computes has to be stashed in a global.
The objection that Parsec and nom are libraries, not dialects, is correct — and it's the indictment, not a mismatch. In Haskell there are several, they compete, you can fork one, and a combinator you write yourself interoperates with the rest. That's what being a library means. A builtin means there is exactly one and your recourse for a missing feature is to file an issue and wait.
This does not mean parse is useless; it means the core example of a Rebol-family dialect is not itself a user-extensible language implementation. Users can write surrounding DSLs, preprocess inputs, or invoke parse in creative ways. But they cannot generally add, replace, or redefine the parser’s own operators and semantics in usermode.
Ren-C: parse is written in usermode as a combinator system. Every keyword is a function. Every datatype has a combinator. You can pass your own combinator map, override group! or block! itself. Rules synthesize values, so they compose:
>> parse [x 10 y 20] [gather [
emit reaction: (join "WOW" "!!!")
some [name: word!, emit (name): integer!]
]]
== make object! [reaction: "WOW!!!" x: 10 y: 20]
collect isn't built in — it's a combinator anyone could have written, and it backtracks correctly, which Red's doesn't. A Rebol2-compatible parse2 runs on the same machinery.
The first working draft took about a day. That is the actual demonstration of the original pitch, and it depends on every mechanism in §1–§6. Without definitional returns, distinguishable failure states, and controllable binding, you can't write it.
What the alternative costs
A critique that won't price its own remedy isn't worth much, so:
Ren-C is an experimental fork. No stable release. Breaking changes ongoing. It asks you to learn antiforms, definitional errors, packs, and a binding model with no counterpart in any Redbol you've used.
Some of that weight is unavoidable and some of it is unfinished. The useful test:
A distinction is forced if the problem has it. It's invented if only the solution has it.
"Did the loop break, or did the body return none?" is forced. It exists in Rebol2, in Red, in Ren-C, and in a language nobody has written yet. Declining to represent it doesn't remove it — it relocates it into the programmer's head, off the books, to be paid at runtime.
The notation for handling it is invented, and it's still moving. The lifting operator has been spelled several different ways in public. Negation across word domains is awkward: not negates nullity, so not 'true doesn't give you false — you write boolean not true? var, and the YES/NO equivalent still doesn't have a good name.
You can't remove complexity from a design. You can only move it to where it isn't biting you during the things you do most. It's noise shaping. The question is never whether there's a cost — it's who pays it, and when.
Ren-C puts it on the metaprogrammer, deliberately. Rebol2 and Red put it on whoever eventually enumerates a block containing a none!, which is everyone, silently, later.
Two honest risks, and they are not the same kind:
-
Unstable but coherent is a bet on a moving target.
-
Stable but non-composable is a bet that you will never need to factor your code.
What Rebol genuinely has
The critique is bounded, and it's worth saying where it stops.
Writing a small dialect in Rebol or Red is genuinely pleasant, and there is no close substitute. The pleasant part isn't homoiconicity — Lisp has had that since 1958 and nobody describes writing a Lisp DSL this way.
It's the token space. Dates, money, tuples, pairs, emails, URLs, files, tags, issues, binaries — all lexical, all first-class, all free. When your config dialect needs a date you write 12-Sep-2026 and it is a date. Racket has the theory. Clojure has the ecosystem. Neither hands you a literal syntax rich enough that your dialect reads like the domain instead of like the host language.
That claim survives everything above. It's also much smaller than the one on the box.
A rich token space gets your dialect's surface for free. It does nothing for its semantics — and semantics is where all eight sections live. "Your DSL will look right immediately and stop composing at the second level of abstraction" is an honest pitch. It just isn't the one being made.
A note on the history
The tempting story is that nobody in this world was serious enough to see these problems. That story is wrong, and the true one is worse.
The problems were identified. They were argued well. They didn't land. Definitional return was an R3-era argument, made by people who had the diagnosis right years before Ren-C existed. The shipped semantics went the other way. Red then forked from that design and carried the defects forward without revisiting them — and added break/return, which is an elaboration on top of an unresolved ambiguity, meaning someone looked directly at the ambiguity and decorated it.
So this isn't a failure of intelligence or attention. It's a failure of transmission: the analysis existed, and the community didn't organize around it.
That also answers the obvious objection to everything above. This isn't a novel complaint from an outsider. It's a decade-old internal one that somebody finally built.
Bottom line
-
Small, top-level dialect examples can be delightful. They demonstrate real strengths in Rebol-family syntax and literal notation.
-
The crucial test comes later: whether behavior survives wrapping, nesting, recursion, reuse, and composition with independently authored abstractions.
-
The examples here identify recurring failure shapes. A local workaround is not a general solution unless it preserves the relevant distinctions—value, absence, control transfer, failure, remainder, and binding—through arbitrary composition.
-
Prospective users should treat broad capability claims as verification tasks. Test the exact implementation, release, platforms, deployment path, libraries, and abstractions their own project requires.
-
Ren-C demonstrates that a Rebol-shaped system can make different choices. It is not presented as a risk-free replacement: it is experimental, evolving, and asks users to learn unfamiliar concepts. Its relevance here is narrower but significant—it makes the alleged limitations concrete design choices rather than laws of nature.
Further reading
| Topic | Link |
|---|---|
| Binding model | Ren-C Binding In A Nutshell |
| Antiforms / isotopes | A Justification of Generalized Isotopes |
| Definitional returns | What Are Definitional Returns? |
| Loop composability | FOR-BOTH |
| Value states | Clarity & Brevity vs. NULL, BLANK!, TRASH, VOID |
| Why nothing in a block is falsey | Why Can't LOGIC Be In A BLOCK!? |
| Flexible logic | Flexible Logic System: Terminology |
| Error model | Rebol2's "Hot Errors" |
| Multi-return | The History of Multi-Return |
| Scanner interface | Incomplete TRANSCODEs |
| Dialecting | The Hackable Usermode PARSE |
| Target audience | Rebol's Target Market |