Files
rubo/docs/grammar.md
khannurien 51c4fb1089 Initial framework: command language, flow layer, host build
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
2026-08-21 14:49:29 +00:00

344 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.