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

13 KiB
Raw Permalink Blame History

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

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 tripleoperand 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 dirFWD,BACK; speed 0..100; dur ms, 0 = until countermanded
TURN $dir $deg dirLEFT,RIGHT. Open-loop until odometry exists
DRIVE $left $right $dur=0 tank-style, 100..100 per side
STOP
WAIT $dur ms
CAM $action actionSNAP

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.