A C framework for commanding a Pico WH robot over USB, WiFi or BLE with a
small text DSL. No hardware attached yet, so the whole stack runs on the
host against a simulated HAL and is covered by tests.
Two specifications drive the code:
docs/grammar.md the language — one namespace of callables (native,
firmware-resident, and defined over the wire), procedures
with named arguments and defaults, REPEAT, and the rules
that keep it safe on a microcontroller.
docs/flow.md transport, bracket-balance framing, the envelope,
admission, execution, abort and failsafe.
Three properties the design leans on:
- Bodies may only reference callables that already exist, so the call
graph is acyclic by construction, recursion is unrepresentable, and
interpreter depth is checked at definition time rather than discovered
at runtime.
- Nothing blocks. The interpreter is a resumable state machine over an
explicit frame stack, so ABORT is honoured within one tick even in the
middle of a long move, and no program can overflow the MCU stack.
- No queue. One outstanding statement at a time, which keeps abort to a
single unambiguous victim and avoids inventing an answer to "the
running program failed, does the queued one still go?".
Named arguments are resolved to the callee's parameter order at definition
time, so they cost nothing at execution.
rubo_core is target-independent. Porting to the Pico means providing a
non-blocking rubo_transport_t and a rubo_hal_t, and nothing else.
Not built yet: the Pico transport and HAL, the camera DAT/DEND bulk path,
and IF/ELIF, which is reserved in the grammar and lands with the sensors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hfu1EFVbpee92zvzQunsKb
344 lines
13 KiB
Markdown
344 lines
13 KiB
Markdown
# Rubo Command Language — Grammar
|
||
|
||
Status: **draft, v1**. Covers the language only. Wire framing, session flow,
|
||
acknowledgements and events are specified separately in `docs/flow.md` (TBD).
|
||
|
||
## 1. Design principles
|
||
|
||
1. **One namespace of callables.** A call site cannot tell whether it is
|
||
invoking a C function, a procedure baked into firmware, or a procedure
|
||
defined over the wire five seconds ago. Primitives are simply callables
|
||
with a native body.
|
||
2. **Bounded execution.** Every program's call depth and iteration count are
|
||
statically bounded. Recursion is impossible by construction (§5.1), and the
|
||
only loop takes an explicit, capped count.
|
||
3. **No dynamic allocation at execution time.** Definitions are parsed once
|
||
into a compact form in a fixed arena. Execution allocates nothing.
|
||
4. **One parser.** Firmware-resident procedures are written in this same
|
||
language and parsed at boot, so there is no second representation to keep in
|
||
sync and the standard library is testable on the host.
|
||
5. **Two value types only:** integers and words. No strings, no floats, no
|
||
collections.
|
||
|
||
## 2. Kinds of callable
|
||
|
||
| Kind | Body lives in | Defined at | Deletable | Shadowable |
|
||
|-----------|---------------|-------------------|-----------|------------|
|
||
| `NATIVE` | C function | compile time | no | no |
|
||
| `STATIC` | flash (const) | compile time, in this language | no | no |
|
||
| `DYNAMIC` | RAM arena | runtime, over the wire | yes | yes (redefinition) |
|
||
|
||
`STATIC` procedures exist so a useful vocabulary survives a power cycle without
|
||
a filesystem. A `DYNAMIC` procedure is promoted to `STATIC` by pasting its text
|
||
into the firmware's standard library — no translation step.
|
||
|
||
## 3. Lexical structure
|
||
|
||
### 3.1 Tokens
|
||
|
||
| Token | Form | Notes |
|
||
|-------------|---------------------------------------|-------|
|
||
| `IDENT` | `letter (letter \| digit \| "_")*` | ≤ 15 chars, canonicalised to uppercase |
|
||
| `NUMBER` | `["-"] digit+` | signed 32-bit |
|
||
| `LBRACKET` | `[` | |
|
||
| `RBRACKET` | `]` | |
|
||
| `SEMI` | `;` | statement separator |
|
||
| `EQ` | `=` | named argument / default value |
|
||
| `DOLLAR` | `$` | parameter reference |
|
||
| `RELOP` | `<` `>` `<=` `>=` `==` `!=` | reserved, §7 |
|
||
|
||
### 3.2 Keywords
|
||
|
||
`DEF` `DEL` `REPEAT` `IF` `ELIF` `ELSE`
|
||
|
||
Keywords are case-insensitive and reserved: they may not be used as callable
|
||
or parameter names. `IF`/`ELIF`/`ELSE` are reserved but not yet implemented.
|
||
|
||
### 3.3 Whitespace and comments
|
||
|
||
Whitespace (including newlines) separates tokens and is otherwise
|
||
insignificant — a definition may span as many lines as it likes. `#` begins a
|
||
comment that runs to the end of the line.
|
||
|
||
Statement boundaries are therefore determined by **bracket balance**, not by
|
||
newlines. This has a direct consequence for the wire protocol, addressed in
|
||
`docs/flow.md`.
|
||
|
||
### 3.4 Case
|
||
|
||
All identifiers — callable names, parameter names, and word values — are
|
||
case-insensitive and canonicalised to uppercase on ingest. `move fwd`,
|
||
`MOVE FWD` and `Move Fwd` are the same statement.
|
||
|
||
## 4. Syntax
|
||
|
||
```ebnf
|
||
statement ::= def | del | repeat | if | sequence | call
|
||
|
||
def ::= "DEF" name paramdecl* sequence
|
||
paramdecl ::= "$" name [ "=" literal ]
|
||
del ::= "DEL" name
|
||
|
||
repeat ::= "REPEAT" arg sequence
|
||
|
||
(* reserved — see §7, not implemented in v1 *)
|
||
if ::= "IF" cond sequence { "ELIF" cond sequence } [ "ELSE" sequence ]
|
||
cond ::= operand relop operand
|
||
operand ::= literal | param | query
|
||
relop ::= "<" | ">" | "<=" | ">=" | "==" | "!="
|
||
|
||
sequence ::= "[" [ statement { ";" statement } [";"] ] "]"
|
||
|
||
call ::= name { positional } { named }
|
||
positional ::= arg
|
||
named ::= name "=" arg
|
||
|
||
arg ::= literal | param
|
||
literal ::= number | word
|
||
param ::= "$" name
|
||
|
||
name ::= IDENT
|
||
word ::= IDENT
|
||
number ::= NUMBER
|
||
```
|
||
|
||
The grammar is LL(1): one token of lookahead distinguishes every production,
|
||
including `positional` from `named`. A hand-written recursive-descent parser is
|
||
sufficient; no parser generator is needed.
|
||
|
||
### 4.1 The `$` invariant
|
||
|
||
`$name` means exactly one thing everywhere it appears: **the value of a
|
||
parameter of the enclosing procedure**. It never appears at a call site as a
|
||
binding target.
|
||
|
||
```
|
||
DEF WIGGLE $deg [ TURN dir=LEFT deg=$deg; TURN dir=RIGHT deg=$deg ]
|
||
# ^^^^ binding target: bare
|
||
# ^^^^ parameter read: $
|
||
```
|
||
|
||
Parameter scope is strictly the body of the procedure that declares it. There
|
||
are no globals, no closures, and no inheritance of a caller's parameters.
|
||
|
||
### 4.2 Arguments
|
||
|
||
Arguments may be given positionally, by name, or both — with all positional
|
||
arguments preceding all named ones, which is the familiar rule. Named form is
|
||
the documented style; positional is shorthand for short, frequent commands.
|
||
|
||
```
|
||
MOVE FWD 50 1000 # positional
|
||
MOVE dir=FWD speed=50 dur=1000 # named
|
||
MOVE FWD speed=50 dur=1000 # mixed
|
||
MOVE dur=1000 speed=50 dir=FWD # named args are order-independent
|
||
```
|
||
|
||
This requires every callable — natives included — to declare parameter *names*,
|
||
not just an arity. See §8.
|
||
|
||
### 4.3 Defaults
|
||
|
||
A parameter may declare a default, which must be a literal. Parameters with
|
||
defaults are optional at the call site; those without are required.
|
||
|
||
```
|
||
DEF SQUARE $side $speed=50 [ ... ]
|
||
|
||
SQUARE side=1000 # speed defaults to 50
|
||
SQUARE side=1000 speed=80
|
||
SQUARE # E_ARITY: required parameter 'side' missing
|
||
```
|
||
|
||
Defaults are evaluated at definition time and stored as literals; they cannot
|
||
reference other parameters.
|
||
|
||
**Parameters with defaults must come last.** Without that rule
|
||
`DEF F $a=1 $b` would accept `F 5`, bind `a`, and then fail on the missing
|
||
`b` — behaviour nobody can predict from reading the signature. Violation is
|
||
`E_SYNTAX`.
|
||
|
||
## 5. Static semantics
|
||
|
||
Checks performed at **definition** time, so that a malformed procedure is
|
||
rejected once at `DEF` rather than repeatedly at each call.
|
||
|
||
### 5.1 Forward references are forbidden
|
||
|
||
> A body may only reference callables that already exist.
|
||
|
||
This single rule is what makes the language safe to run on a microcontroller
|
||
with no supervision. Because a callable can only reference callables defined
|
||
strictly before it, the call graph is acyclic by construction, recursion is
|
||
unrepresentable, and maximum interpreter stack depth is computable at
|
||
definition time (and checked against `RUBO_MAX_DEPTH`).
|
||
|
||
Violation: `E_UNDEFINED`.
|
||
|
||
### 5.2 Redefinition
|
||
|
||
Redefining a `DYNAMIC` name is allowed and may change its arity and parameter
|
||
names. It is rejected if the new body transitively references the name being
|
||
redefined, which is the only way a cycle could otherwise reappear:
|
||
|
||
```
|
||
DEF A [ MOVE FWD 50 100 ]
|
||
DEF B [ A ]
|
||
DEF A [ B ] # E_CYCLE
|
||
```
|
||
|
||
The check is a depth-first walk of the new body's reference set. On success,
|
||
every existing caller is re-validated against the new signature; if any caller
|
||
would break, the redefinition is rejected atomically (`E_ARITY` / `E_ARGNAME`)
|
||
and the old body is retained.
|
||
|
||
`NATIVE` and `STATIC` names cannot be redefined or deleted: `E_READONLY`.
|
||
|
||
### 5.3 Argument checking
|
||
|
||
Resolved at definition time for calls inside a body, and at parse time for
|
||
top-level calls: unknown parameter name (`E_ARGNAME`), duplicate parameter name
|
||
(`E_ARGNAME`), a named argument colliding with one already given positionally
|
||
(`E_ARGNAME`), missing required parameter (`E_ARITY`), too many positionals
|
||
(`E_ARITY`), and value type mismatch where the callable declares a type
|
||
(`E_ARGTYPE`).
|
||
|
||
Range checks that depend on a runtime value (`E_RANGE`) are necessarily
|
||
deferred to execution. A word argument is one of these: `MOVE dir=SIDEWAYS`
|
||
is well-typed — `dir` takes a word and `SIDEWAYS` is a word — and is caught
|
||
as `E_RANGE` when `MOVE` runs and finds it is neither `FWD` nor `BACK`.
|
||
|
||
### 5.4 Deletion
|
||
|
||
`DEL` is refused with `E_READONLY` if any other callable references the name,
|
||
because deleting it would leave a dangling callee in a body that already
|
||
passed validation. Deletion is refused rather than cascaded: removing
|
||
procedures the operator did not ask about is a worse surprise than being told
|
||
to delete the caller first.
|
||
|
||
## 6. `REPEAT`
|
||
|
||
```
|
||
REPEAT 4 [ MOVE FWD 50 500; TURN RIGHT 90 ]
|
||
REPEAT $n [ ... ]
|
||
```
|
||
|
||
The count is an integer literal or parameter. `n <= 0` executes the body zero
|
||
times and is not an error; `n < 0` is `E_RANGE`. The count is clamped to
|
||
`RUBO_MAX_ITER`, exceeding which is `E_RANGE` rather than a silent truncation.
|
||
|
||
`REPEAT` is the only loop. There is deliberately no `WHILE`: an unbounded loop
|
||
would forfeit the termination guarantee of §5.1, and the failure mode of a
|
||
non-terminating program on a robot with motors is a physical one.
|
||
|
||
## 7. Reserved: conditionals
|
||
|
||
Not implemented in v1. Specified here so that adding it later is not a breaking
|
||
change to the grammar.
|
||
|
||
```
|
||
IF DIST < 30 [ STOP ]
|
||
ELIF DIST < 100 [ MOVE FWD 20 500 ]
|
||
ELSE [ MOVE FWD 50 500 ]
|
||
```
|
||
|
||
A condition is a **flat triple** — `operand relop operand`. There is no
|
||
nesting, no parenthesisation, and no `AND`/`OR`. This keeps it a fixed-size
|
||
struct rather than an expression tree, consistent with the decision to omit
|
||
arithmetic entirely.
|
||
|
||
`IF` is bounded and so does not threaten §5.1 — it selects among branches and
|
||
never loops.
|
||
|
||
The blocking dependency is not syntactic. Conditions require **queries**:
|
||
callables that yield a value rather than a status. Introducing them changes the
|
||
value model, the dispatch table and the HAL contract, and there is nothing
|
||
meaningful to branch on until real sensors exist. `IF` therefore lands with the
|
||
sensor layer.
|
||
|
||
## 8. Native signatures
|
||
|
||
Illustrative, to be finalised alongside the HAL. Listed here because named
|
||
arguments require natives to declare parameter names.
|
||
|
||
| Callable | Parameters | Notes |
|
||
|----------|-----------------------------------------|-------|
|
||
| `MOVE` | `$dir` `$speed` `$dur=0` | `dir` ∈ `FWD`,`BACK`; `speed` 0..100; `dur` ms, 0 = until countermanded |
|
||
| `TURN` | `$dir` `$deg` | `dir` ∈ `LEFT`,`RIGHT`. Open-loop until odometry exists |
|
||
| `DRIVE` | `$left` `$right` `$dur=0` | tank-style, −100..100 per side |
|
||
| `STOP` | — | |
|
||
| `WAIT` | `$dur` | ms |
|
||
| `CAM` | `$action` | `action` ∈ `SNAP` |
|
||
|
||
Units are open until the motion semantics are settled: `TURN deg=90` implies
|
||
odometry or an IMU we do not have. v1 is open-loop and the unit slot is
|
||
reserved for when encoders arrive.
|
||
|
||
## 9. Limits
|
||
|
||
All limits are compile-time constants. Exceeding any is `E_LIMIT` unless a more
|
||
specific code applies.
|
||
|
||
| Constant | Suggested | Meaning |
|
||
|----------------------|-----------|---------|
|
||
| `RUBO_MAX_NAME` | 15 | identifier length, excluding terminator |
|
||
| `RUBO_MAX_PARAMS` | 4 | parameters per callable |
|
||
| `RUBO_MAX_DEPTH` | 8 | interpreter call depth |
|
||
| `RUBO_MAX_NEST` | 8 | syntactic nesting of sequences |
|
||
| `RUBO_MAX_ITER` | 65535 | `REPEAT` count |
|
||
| `RUBO_MAX_CALLABLES` | 32 | `DYNAMIC` procedures resident |
|
||
| `RUBO_ARENA_INSTRS` | 256 | `STATIC` + `DYNAMIC` procedure storage, in fixed-width instructions |
|
||
| `RUBO_MAX_STMT` | 1024 | source bytes in one top-level statement |
|
||
|
||
## 10. Error codes
|
||
|
||
| Code | Name | Meaning |
|
||
|-----:|---------------|---------|
|
||
| 0 | `OK` | success |
|
||
| 1 | `E_SYNTAX` | malformed input |
|
||
| 2 | `E_UNKNOWN` | no such callable |
|
||
| 3 | `E_ARITY` | missing required argument, or too many positionals |
|
||
| 4 | `E_ARGNAME` | unknown, duplicate, or conflicting parameter name |
|
||
| 5 | `E_ARGTYPE` | wrong value type |
|
||
| 6 | `E_RANGE` | value outside accepted range |
|
||
| 7 | `E_UNDEFINED` | body references a callable that does not exist |
|
||
| 8 | `E_CYCLE` | redefinition would create a cycle |
|
||
| 9 | `E_NOMEM` | procedure arena exhausted |
|
||
| 10 | `E_LIMIT` | a static limit exceeded (§9) |
|
||
| 11 | `E_READONLY` | attempt to delete or redefine a `NATIVE`/`STATIC` callable |
|
||
| 12 | `E_BUSY` | reserved — flow layer |
|
||
| 13 | `E_ABORT` | execution interrupted |
|
||
| 14 | `E_FAULT` | HAL or hardware error |
|
||
|
||
## 11. Worked example
|
||
|
||
```
|
||
# --- firmware standard library, parsed at boot -------------------
|
||
DEF SQUARE $side $speed=50 [
|
||
REPEAT 4 [
|
||
MOVE dir=FWD speed=$speed dur=$side;
|
||
TURN dir=RIGHT deg=90
|
||
]
|
||
]
|
||
|
||
DEF PATROL [
|
||
SQUARE side=1000;
|
||
STOP
|
||
]
|
||
|
||
# --- sent over the wire at runtime -------------------------------
|
||
DEF ZIGZAG $deg=45 $reps=3 [
|
||
REPEAT $reps [
|
||
TURN dir=LEFT deg=$deg;
|
||
MOVE dir=FWD speed=40 dur=300;
|
||
TURN dir=RIGHT deg=$deg;
|
||
MOVE dir=FWD speed=40 dur=300
|
||
]
|
||
]
|
||
|
||
[ PATROL; ZIGZAG deg=30 reps=5; SQUARE side=500 speed=30; STOP ]
|
||
```
|
||
|
||
The last line is a top-level sequence: it is submitted, validated and executed
|
||
as a single abortable unit.
|