From 51c4fb108948a368cd6b7a6339376c2385059907 Mon Sep 17 00:00:00 2001 From: khannurien Date: Fri, 21 Aug 2026 14:49:29 +0000 Subject: [PATCH] Initial framework: command language, flow layer, host build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Hfu1EFVbpee92zvzQunsKb --- .devcontainer/Dockerfile | 18 + .devcontainer/devcontainer-lock.json | 9 + .devcontainer/devcontainer.json | 23 + .devcontainer/reinstall-cmake.sh | 62 +++ .gitignore | 11 + CMakeLists.txt | 47 ++ README.md | 102 +++++ docs/flow.md | 429 ++++++++++++++++++ docs/grammar.md | 343 ++++++++++++++ host/main.c | 129 ++++++ host/transport_stdio.c | 61 +++ include/rubo/hal_sim.h | 28 ++ include/rubo/rubo.h | 172 ++++++++ src/exec.c | 276 ++++++++++++ src/framer.c | 92 ++++ src/hal_sim.c | 66 +++ src/internal.h | 201 +++++++++ src/lexer.c | 138 ++++++ src/lexer.h | 54 +++ src/natives.c | 176 ++++++++ src/parser.c | 638 +++++++++++++++++++++++++++ src/registry.c | 152 +++++++ src/rubo.c | 109 +++++ src/session.c | 117 +++++ src/stdlib.c | 30 ++ src/sym.c | 38 ++ tests/test_rubo.c | 560 +++++++++++++++++++++++ 27 files changed, 4081 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer-lock.json create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/reinstall-cmake.sh create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 docs/flow.md create mode 100644 docs/grammar.md create mode 100644 host/main.c create mode 100644 host/transport_stdio.c create mode 100644 include/rubo/hal_sim.h create mode 100644 include/rubo/rubo.h create mode 100644 src/exec.c create mode 100644 src/framer.c create mode 100644 src/hal_sim.c create mode 100644 src/internal.h create mode 100644 src/lexer.c create mode 100644 src/lexer.h create mode 100644 src/natives.c create mode 100644 src/parser.c create mode 100644 src/registry.c create mode 100644 src/rubo.c create mode 100644 src/session.c create mode 100644 src/stdlib.c create mode 100644 src/sym.c create mode 100644 tests/test_rubo.c diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..8167050 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,18 @@ +FROM mcr.microsoft.com/devcontainers/cpp:3-debian13 + +ARG REINSTALL_CMAKE_VERSION_FROM_SOURCE="none" + +# Optionally install the cmake for vcpkg +COPY ./reinstall-cmake.sh /tmp/ + +RUN if [ "${REINSTALL_CMAKE_VERSION_FROM_SOURCE}" != "none" ]; then \ + chmod +x /tmp/reinstall-cmake.sh && /tmp/reinstall-cmake.sh ${REINSTALL_CMAKE_VERSION_FROM_SOURCE}; \ + fi \ + && rm -f /tmp/reinstall-cmake.sh + +# [Optional] Uncomment this section to install additional vcpkg ports. +# RUN su vscode -c "${VCPKG_ROOT}/vcpkg install " + +# [Optional] Uncomment this section to install additional packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..52fdf2e --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/khannurien/devcontainer-features/claude-code-rtk:1": { + "version": "1.0.0", + "resolved": "ghcr.io/khannurien/devcontainer-features/claude-code-rtk@sha256:12b59207a735aa0122d7c46dfc9ceee0eb6dfd5635c13037779a99ede1f87dd4", + "integrity": "sha256:12b59207a735aa0122d7c46dfc9ceee0eb6dfd5635c13037779a99ede1f87dd4" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..b519e25 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,23 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/cpp +{ + "name": "C++", + "build": { + "dockerfile": "Dockerfile" + } + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "gcc -v", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.devcontainer/reinstall-cmake.sh b/.devcontainer/reinstall-cmake.sh new file mode 100644 index 0000000..a37a3c2 --- /dev/null +++ b/.devcontainer/reinstall-cmake.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- +# +set -e + +CMAKE_VERSION=${1:-"none"} + +if [ "${CMAKE_VERSION}" = "none" ]; then + echo "No CMake version specified, skipping CMake reinstallation" + exit 0 +fi + +# Cleanup temporary directory and associated files when exiting the script. +cleanup() { + EXIT_CODE=$? + set +e + if [[ -n "${TMP_DIR}" ]]; then + echo "Executing cleanup of tmp files" + rm -Rf "${TMP_DIR}" + fi + exit $EXIT_CODE +} +trap cleanup EXIT + + +echo "Installing CMake..." +apt-get -y purge --auto-remove cmake +mkdir -p /opt/cmake + +architecture=$(dpkg --print-architecture) +case "${architecture}" in + arm64) + ARCH=aarch64 ;; + amd64) + ARCH=x86_64 ;; + *) + echo "Unsupported architecture ${architecture}." + exit 1 + ;; +esac + +CMAKE_BINARY_NAME="cmake-${CMAKE_VERSION}-linux-${ARCH}.sh" +CMAKE_CHECKSUM_NAME="cmake-${CMAKE_VERSION}-SHA-256.txt" +TMP_DIR=$(mktemp -d -t cmake-XXXXXXXXXX) + +echo "${TMP_DIR}" +cd "${TMP_DIR}" + +curl -sSL "https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/${CMAKE_BINARY_NAME}" -O +curl -sSL "https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/${CMAKE_CHECKSUM_NAME}" -O + +sha256sum -c --ignore-missing "${CMAKE_CHECKSUM_NAME}" +sh "${TMP_DIR}/${CMAKE_BINARY_NAME}" --prefix=/opt/cmake --skip-license + +ln -s /opt/cmake/bin/ccmake /usr/local/bin/ccmake +ln -s /opt/cmake/bin/cmake /usr/local/bin/cmake +ln -s /opt/cmake/bin/cmake-gui /usr/local/bin/cmake-gui +ln -s /opt/cmake/bin/cpack /usr/local/bin/cpack +ln -s /opt/cmake/bin/ctest /usr/local/bin/ctest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..34dad8d --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# build output +build/ +*.o +*.a +*.elf +*.uf2 +*.bin +*.map + +# machine-local Claude Code permissions +.claude/settings.local.json diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..2743f44 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,47 @@ +cmake_minimum_required(VERSION 3.16) +project(rubo C) + +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) + +# The core is target-independent: everything between the framer and the +# interpreter is shared by the host and Pico builds (docs/flow.md §2). +add_library(rubo_core STATIC + src/sym.c + src/lexer.c + src/registry.c + src/parser.c + src/exec.c + src/framer.c + src/session.c + src/rubo.c + src/natives.c + src/stdlib.c +) +target_include_directories(rubo_core PUBLIC include) +target_compile_options(rubo_core PRIVATE + -Wall -Wextra -Wpedantic -Wshadow -Wstrict-prototypes +) + +# ----------------------------------------------------------------- host build +# Simulated HAL + stdio transport: the whole stack, no hardware. +add_library(rubo_sim STATIC src/hal_sim.c) +target_link_libraries(rubo_sim PUBLIC rubo_core) + +add_executable(rubo_host host/main.c host/transport_stdio.c) +target_link_libraries(rubo_host PRIVATE rubo_core rubo_sim) + +# ---------------------------------------------------------------------- tests +enable_testing() +add_executable(test_rubo tests/test_rubo.c) +target_link_libraries(test_rubo PRIVATE rubo_core rubo_sim) +target_include_directories(test_rubo PRIVATE src) +add_test(NAME rubo COMMAND test_rubo) + +# ----------------------------------------------------------------- pico build +# Swapping in the real hardware means providing exactly two things: a +# rubo_transport_t over USB CDC / lwIP / BLE NUS, and a rubo_hal_t driving +# the motors. Nothing in rubo_core changes. +if(DEFINED ENV{PICO_SDK_PATH} OR DEFINED PICO_SDK_PATH) + message(STATUS "rubo: PICO_SDK_PATH set, but pico/ is not implemented yet") +endif() diff --git a/README.md b/README.md new file mode 100644 index 0000000..c82319f --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# rubo + +A C framework for commanding a small robot — Raspberry Pi Pico WH, two +motors driving four wheels, a black-and-white camera — over USB, WiFi or +BLE, using a small command language. + +The hardware isn't attached yet, so the whole stack runs on the host against +a simulated HAL. The language, the framer and the interpreter are exercised +by tests with no Pico in the loop. + +## Specifications + +| Document | Covers | +|----------|--------| +| [`docs/grammar.md`](docs/grammar.md) | the language: callables, procedures, named arguments, `REPEAT`, the rules that keep it safe | +| [`docs/flow.md`](docs/flow.md) | transport, framing, the envelope, admission, execution, abort and failsafe | + +Read those before changing anything here. Most of what looks like an odd +decision in the code is a decision recorded in one of them. + +## Build and test + +```sh +cmake -S . -B build +cmake --build build +./build/test_rubo # or: ctest --test-dir build +``` + +## Drive it + +```sh +./build/rubo_host --trace +``` + +`--trace` prints motor state changes. Try: + +``` +PING +STAT +SQUARE side=400 +DEF ZIGZAG $deg=20 $reps=3 [ + REPEAT $reps [ + TURN dir=LEFT deg=$deg; + TURN dir=RIGHT deg=$deg + ] +] +ZIGZAG deg=15 +ABORT +``` + +`--script` makes it read piped input as a well-behaved client would: one +outstanding statement at a time, waiting for `DONE` or `ERR` before sending +the next (docs/flow.md §9). + +```sh +./build/rubo_host --script < commands.txt +``` + +## Layout + +``` +include/rubo/rubo.h public interface: limits, error codes, framer, engine +include/rubo/hal_sim.h simulated hardware, host only + +src/lexer.c tokeniser (grammar.md §3) +src/parser.c recursive descent, validation (grammar.md §4, §5) +src/registry.c callable table and arena (grammar.md §2) +src/sym.c identifier interning +src/exec.c resumable interpreter (flow.md §8) +src/framer.c bracket-balance framing (flow.md §4) +src/session.c envelope and admission (flow.md §5, §7, §9) +src/natives.c built-in callables (grammar.md §8) +src/stdlib.c firmware procedures, in the language itself +src/hal_sim.c simulated motors and camera + +host/main.c the main loop from flow.md §8.3 +host/transport_stdio.c non-blocking stdio transport + +tests/test_rubo.c host tests +``` + +## Porting to the Pico + +Two things, and nothing else: + +1. a `rubo_transport_t` over USB CDC, lwIP or BLE NUS — `read()` must never + block; +2. a `rubo_hal_t` driving the real motors. + +`rubo_core` is target-independent and does not change. That is the point of +the layering in docs/flow.md §2, and it is why the language is testable +today. + +## Status + +Working: the language end to end (definition, named arguments, defaults, +`REPEAT`, procedures calling procedures), framing, the envelope, admission, +abort, the deadman, and a simulated HAL. + +Not built yet: the Pico transport and HAL, the camera `DAT`/`DEND` bulk path +(docs/flow.md §11), and `IF`/`ELIF`, which is reserved in the grammar and +lands with the sensor layer (docs/grammar.md §7). diff --git a/docs/flow.md b/docs/flow.md new file mode 100644 index 0000000..f71bf28 --- /dev/null +++ b/docs/flow.md @@ -0,0 +1,429 @@ +# Rubo Command Flow — Transport, Framing and Execution + +Status: **draft, v1**. Companion to `docs/grammar.md`, which specifies the +language. This document specifies everything around it: how bytes become +statements, how statements become motion, and how the robot is stopped. + +## 1. Principles + +1. **Nothing blocks.** The interpreter is a resumable state machine driven from + the main loop. A `MOVE` lasting ten seconds must not prevent `ABORT` from + being received in second three. This constraint shapes the entire design. +2. **The envelope is not the language.** Correlation tags and control words are + handled by the session layer and never reach the parser. `docs/grammar.md` + stays a specification of a language, not of a wire protocol. +3. **One framer for every transport.** USB, WiFi, BLE and the host test harness + all reduce to a byte stream. Only MTU and connection semantics differ. +4. **A dropped link stops the robot.** Every path that engages a motor is + covered by a deadman timer. This is the one requirement that is not + negotiable for convenience. + +## 2. Layer stack + +``` + ┌──────────────────────────────────────────────┐ + │ transport usb_cdc │ wifi_tcp │ ble_nus │ host_stdio + │ byte stream in/out, MTU-aware + ├──────────────────────────────────────────────┤ + │ framer bracket-balance statement extraction (§4) + ├──────────────────────────────────────────────┤ + │ session tags, control words, ACK/DONE/ERR, backpressure (§5–§7) + ├──────────────────────────────────────────────┤ + │ parser → validator → arena (docs/grammar.md) + ├──────────────────────────────────────────────┤ + │ scheduler resumable interpreter, explicit stack, ticks (§8) + ├──────────────────────────────────────────────┤ + │ HAL motor │ camera │ battery sim | pico + └──────────────────────────────────────────────┘ +``` + +Layers below the parser are shared; only `transport` and `HAL` have per-target +implementations. The host build swaps exactly those two and runs everything +else unmodified, which is what makes the language testable with no hardware. + +## 3. Transport interface + +```c +typedef struct { + int (*open)(void *cfg); + int (*read)(uint8_t *buf, size_t len); /* non-blocking; 0 = no data */ + int (*write)(const uint8_t *buf, size_t len); + bool (*connected)(void); + void (*close)(void); + size_t mtu; /* max bytes per write */ +} rubo_transport_t; +``` + +`read` must never block. Everything above this line is polled from the main +loop; a blocking read anywhere forfeits §1.1. + +| Transport | Implementation | MTU | Notes | +|-------------|----------------|-----|-------| +| `usb_cdc` | TinyUSB CDC (pico-sdk) | 64 | the development default; enumerate as ACM | +| `wifi_tcp` | lwIP listener, one client | ~1460 | CYW43; port configurable, default 3141 | +| `ble_nus` | Nordic UART Service | 20–244 | notify for TX, write-without-response for RX; chunking mandatory | +| `host_stdio`| stdin/stdout | — | host build; drives the whole stack under a test harness | +| `host_tcp` | POSIX socket | — | host build; lets a real client speak to a simulated robot | + +BLE's small MTU is not a special case: the framer is byte-oriented, so a +statement split across five notifications reassembles exactly as one arriving +in a single USB read. + +## 4. Framing + +`docs/grammar.md` §3.3 established that statements end at **bracket balance**, +not at newlines. The framer is therefore a small state machine, not a line +reader. + +### 4.1 Rule + +> A top-level statement is complete at the first newline encountered while +> bracket depth is zero and the buffer is non-empty. + +Inside brackets, newlines are ordinary whitespace. A multi-line `DEF` pasted +into a terminal arrives as one statement; a one-liner arrives as one statement. +Interactive typing and bulk paste need no mode switch. + +### 4.2 State machine + +| State | Input | Action | +|-----------|------------------|--------| +| `STMT` | `#` | → `COMMENT` | +| `STMT` | `[` | `depth++`; append | +| `STMT` | `]` | `depth--`; append; if `depth < 0` → error `E_SYNTAX`, → `DISCARD` | +| `STMT` | newline | if `depth == 0 && len > 0` → **emit**; else treat as space | +| `STMT` | other | append | +| `COMMENT` | newline | → `STMT` (apply the newline rule above) | +| `COMMENT` | other | discard | +| `DISCARD` | newline | reset `depth`/`len`, → `STMT` | +| `DISCARD` | other | discard | + +The framer must be **comment-aware**: a `[` inside a comment must not affect +depth, or a single `#` in a comment would desynchronise the stream permanently. +There are no string literals in the language, so no quote handling is needed. + +### 4.3 Failure and resynchronisation + +| Condition | Response | Recovery | +|-----------|----------|----------| +| `len > RUBO_MAX_STMT` | `ERR 10 statement too long` | → `DISCARD` | +| unbalanced `]` | `ERR 1 unexpected ]` | → `DISCARD` | +| partial statement idle > `RUBO_FRAME_TIMEOUT_MS` | `ERR 1 incomplete statement` | reset | +| transport disconnect mid-statement | — | reset buffer; §10 deadman applies | + +The idle timeout matters most on BLE, where a client can vanish mid-frame +leaving an unterminated `[` that would otherwise wedge the framer forever. + +### 4.4 Top-level `;` + +A bare `;` at depth zero is `E_SYNTAX`. One frame carries one statement; to +submit several as a unit, wrap them: `[ A; B; C ]`. This keeps "one statement, +one tag, one ACK, one DONE" exact. + +## 5. Envelope + +Two things wrap a statement and are stripped by the session layer before +parsing. Neither appears in `docs/grammar.md`, because neither is part of the +language. + +### 5.1 Correlation tags + +An optional `@` prefix. Responses echo it. + +``` +> @7 SQUARE side=1000 +< @7 ACK +< @7 DONE +``` + +Untagged statements are legal and produce untagged responses — convenient when +a human is typing. Tags are the client's to allocate; the robot never +interprets them beyond echoing. + +### 5.2 Control words + +Recognised before parsing, executed immediately, never queued, never subject to +`E_BUSY`. They bypass the interpreter entirely. + +| Word | Effect | +|---------|--------| +| `ABORT` | unwind the interpreter stack, `hal_stop_all()`, end the running program (§10) | +| `PING` | `PONG` — liveness, and it feeds the deadman timer | +| `STAT` | one-line status: running callable, tag, depth, arena free, link age | + +Control words are deliberately few. `ABORT` in particular cannot be a native +callable, because a callable can only run when the interpreter is free to run +it — which is precisely when you do not need it. + +## 6. Response vocabulary + +``` +@7 ACK statement admitted, parsed, validated; now executing +@7 DONE execution finished normally +@7 ERR rejected or failed; codes in grammar.md §10 +EVT [args...] asynchronous, untagged, broadcast +DAT bulk data chunk (§11) +DEND bulk data complete +PONG reply to PING +STAT = ... reply to STAT +``` + +**ACK and DONE are always both sent**, including for instantaneous statements +like `DEF` and `DEL`. Collapsing them for non-executing statements would save a +few bytes and cost every client a special case; a terminal client is free to +hide `ACK` when `DONE` follows immediately. + +`ERR` may arrive instead of `ACK` (rejected at parse or validation) or after +`ACK` (failed during execution). A client waits for exactly one of `DONE` or +`ERR` per tag. + +## 7. Statement lifecycle + +``` + bytes ─► RECEIVING ─► ADMISSION ─► PARSING ─► VALIDATING ─► RUNNING ─► DONE + │ │ │ │ │ + │ │ │ │ ├─► ERR (runtime: E_RANGE, E_FAULT) + │ │ │ │ └─► ABORTED (E_ABORT) + │ │ │ └─► ERR (E_UNKNOWN, E_ARITY, E_ARGNAME, + │ │ │ E_UNDEFINED, E_CYCLE, E_NOMEM…) + │ │ └─► ERR (E_SYNTAX, E_LIMIT) + │ └─► ERR 12 busy + └─► discarded (§4.3) +``` + +**Admission** comes before parsing: if a program is already running, the +statement is rejected immediately without being parsed at all (§9). There is +no point spending cycles validating something that cannot run. + +Validation is the last point at which rejection is free. Past `ACK`, a failure +means the robot has already moved, so the majority of `docs/grammar.md` §10 is +deliberately checkable before execution begins. + +## 8. Execution model + +### 8.1 Explicit stack, no C recursion + +The interpreter holds a statically allocated array of frames: + +```c +typedef struct { + const rubo_node_t *node; /* current statement in the body */ + uint16_t pc; /* index within the enclosing sequence */ + uint16_t iter; /* REPEAT counter */ + rubo_value_t args[RUBO_MAX_PARAMS]; + uint32_t deadline; /* native scratch: ms, 0 = none */ +} rubo_frame_t; + +static rubo_frame_t stack[RUBO_MAX_DEPTH]; +``` + +`docs/grammar.md` §5.1 guarantees the call graph is acyclic, so `RUBO_MAX_DEPTH` +frames are provably sufficient and depth is checked at definition time. Nothing +here recurses in C, so the MCU stack is not at risk regardless of what the user +sends. + +The explicit stack buys three things beyond safety: `ABORT` is a matter of +resetting an index, `STAT` can report exactly what is executing, and the whole +interpreter is trivially unit-testable on the host. + +### 8.2 Natives are resumable + +```c +typedef enum { RUBO_STEP_DONE, RUBO_STEP_PENDING, RUBO_STEP_ERR } rubo_step_t; + +typedef rubo_step_t (*rubo_native_fn)(rubo_frame_t *f, uint32_t now_ms); +``` + +A native with a duration sets `f->deadline` on first entry and returns +`RUBO_STEP_PENDING` until `now_ms >= f->deadline`. `MOVE dir=FWD speed=50 +dur=1000` engages the motors, returns immediately, and is re-entered each tick +until its deadline passes — during which the transport is polled normally and +`ABORT` is honoured within one tick. + +### 8.3 Main loop + +```c +for (;;) { + uint32_t now = rubo_millis(); + rubo_transport_poll(now); /* read bytes, feed framers */ + rubo_session_poll(now); /* frame → envelope → admit → parse → run */ + rubo_exec_tick(now); /* advance the interpreter, bounded work */ + rubo_hal_poll(now); /* sensors, deadman, event generation */ +} +``` + +Cooperative and single-core; no RTOS, and the second RP2040 core stays free for +the camera pipeline later. `rubo_exec_tick` runs at most `RUBO_STEPS_PER_TICK` +interpreter steps before yielding, so a deeply nested `REPEAT` of instantaneous +statements cannot starve the transport. + +## 9. Concurrency and backpressure + +**One interpreter, one running program, no queue.** + +| Situation | Behaviour | +|-----------|-----------| +| statement arrives, nothing running | `ACK`, execute | +| statement arrives, program running | `ERR 12 busy` — not parsed, not stored | +| `DEF`/`DEL` arrives while a program is running | `ERR 12 busy` | +| `ABORT` arrives | immediate, always (§5.2) | + +The client contract is therefore exactly one sentence: **one outstanding +statement at a time — wait for `DONE` or `ERR` before sending the next.** + +### 9.1 Why no queue + +Queueing looks friendlier and is not, for three reasons. + +**The language already solves it.** Chaining work is what sequences are for: +`[ SQUARE side=1000; ZIGZAG deg=30; STOP ]` is one statement, one tag, one +`ACK`, one `DONE`. It is validated as a whole before anything moves, runs with +no inter-statement gaps, and aborts as a whole. A queue is a worse duplicate of +a feature that already exists, reachable only by giving up whole-program +validation. + +**A queue invents a question with no good answer.** If `A` is running, `B` is +queued, and `A` fails with `E_FAULT` — does `B` run? Yes means the robot keeps +driving after a fault. No means an `ACK` was a promise the robot silently +broke. With `[A; B]` the question never arises: the sequence stops. Every +queued design has to pick a side and surprise somebody. + +**A queue is wrong for the case people actually want it for.** Live driving — +arrow keys, a joystick app — wants *preemption*: press left while moving +forward and left should happen now. A queue delivers the opposite, turning +steering input into a laggy backlog. Batch work wants sequences; interactive +work wants preemption; the queue serves neither. + +`DEF` and `DEL` are refused during execution for a separate reason: mutating +the arena while a callable may be live on the interpreter stack is not worth +making safe for a convenience nobody needs. + +### 9.2 If preemption is needed later + +Should live driving materialise, the addition is explicit and small: a `!` +prefix on the envelope meaning *abort whatever is running and execute this +instead* — `!DRIVE left=40 right=-40`. It reuses the `ABORT` path wholesale and +requires no queue, no reordering, and no change to §9.1's guarantees. It is +deliberately not in v1 because a preempting command is a loaded gun and should +be designed against a real teleop client rather than an imagined one. + +## 10. Abort and failsafe + +`ABORT` unwinds the stack, calls `hal_stop_all()`, and emits `EVT ABORT` +followed by `ERR 13` for the tag of the program it killed. With no queue there +is nothing else to flush — which is the second benefit of §9: abort has exactly +one victim, and its identity is never in doubt. + +Three independent guards cover the case where nobody is left to send `ABORT`: + +| Guard | Trigger | Action | +|-------|---------|--------| +| **Deadman** | no inbound bytes on the owning session for `RUBO_LINK_TIMEOUT_MS` **while motors are engaged** | abort + stop, `EVT LINKLOST` | +| **Disconnect** | transport reports `!connected()` while motors are engaged | abort + stop | +| **Motor cap** | any single motor-engaging native exceeds `RUBO_MOTOR_MAX_MS` | stop that native, `ERR 6` | + +The deadman is armed only while motors are engaged, so an idle robot on a quiet +link is not perpetually resetting itself. `PING` is sufficient to feed it, which +is why it is a control word rather than a native. + +### 10.1 A client must ping while it waits + +This falls out of §9 and is worth stating plainly, because it is the one place +where two rules interact in a way that surprises people: + +> A program that engages the motors for longer than `RUBO_LINK_TIMEOUT_MS` +> will be killed by the deadman unless the client sends something during it. + +`SQUARE side=1000` runs for about six seconds. A client that submits it and +then waits quietly for `DONE` — the obvious reading of §9's "wait for `DONE` +or `ERR`" — gets `EVT LINKLOST` instead. That is the deadman working +correctly: it cannot distinguish an attentive operator from a crashed one, and +guessing in the robot's favour is how robots drive into walls. + +So the client contract has a second sentence: **`PING` on an interval +(≈500 ms) whenever a program is running.** The reference client in +`host/main.c --script` does exactly this. + +## 11. Bulk data + +The protocol is text; camera frames are not. Frames leave as `DAT` records, +chunked to the transport MTU, base64-encoded: + +``` +< DAT 3 0 8 +< DAT 3 1 8 + … +< DEND 3 +``` + +`` correlates chunks to the `CAM action=SNAP` that produced them; `` +and `` let a client detect loss and reassemble. Base64 costs 33% +overhead, which for a B&W QQVGA snapshot (160×120×1bpp ≈ 2.4 KB → ≈ 3.2 KB) is +acceptable for stills and firmly not acceptable for video. A binary side +channel can be added later without disturbing the text protocol; streaming +video was never in scope for the command path. + +## 12. Multiple transports + +All configured transports may be open simultaneously. Each holds its own framer +and buffer. Statements from any transport compete for the same single execution +slot, `E_BUSY` is global, and `EVT`/`DAT` are broadcast to every connected +transport. + +This is honest rather than clever: two clients can interleave commands and +confuse each other. Exclusive session ownership — first-come with explicit +release or timeout takeover — is the obvious v2 refinement, but it needs a +policy decision about who may seize control of a moving robot, and that +decision is better made with the hardware in front of us. + +The deadman (§10) tracks the session that submitted the running program, not +the aggregate of all links. + +## 13. Constants + +| Constant | Suggested | Meaning | +|----------------------------|-----------|---------| +| `RUBO_FRAME_TIMEOUT_MS` | 2000 | partial statement idle timeout | +| `RUBO_LINK_TIMEOUT_MS` | 2000 | deadman, while motors engaged | +| `RUBO_MOTOR_MAX_MS` | 10000 | hard cap on one motor-engaging native | +| `RUBO_STEPS_PER_TICK` | 32 | interpreter steps before yielding | +| `RUBO_RX_BUF` | 1088 | ≥ `RUBO_MAX_STMT` + envelope | +| `RUBO_TCP_PORT` | 3141 | `wifi_tcp` default | + +## 14. Worked exchange + +``` +> @1 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 +> ] +> ] +< @1 ACK +< @1 DONE + +> @2 [ SQUARE side=1000; ZIGZAG deg=30 reps=5 ] +< @2 ACK +> @3 SQUARE side=200 +< @3 ERR 12 busy # one outstanding statement at a time +> @4 DEF FOO [ STOP ] +< @4 ERR 12 busy # definitions are not an exception +> ABORT +< EVT ABORT +< @2 ERR 13 aborted # exactly one victim +> @3 SQUARE side=200 +< @3 ACK +< @3 DONE +``` + +Note that the multi-line `DEF` at `@1` is a single statement: the framer emits +it only at the newline following the closing bracket. + +## 15. Open questions + +1. **Should `EVT` be subscribable?** Broadcasting every event to every + transport is fine at this scale and wasteful over BLE if telemetry gets + chatty. An `EVT` mask per session is cheap to add later. +2. **`STAT` output format.** Currently ad-hoc `k=v` pairs. If it grows, it + wants a schema. +3. **Exclusive session ownership** (§12) — deferred pending hardware. diff --git a/docs/grammar.md b/docs/grammar.md new file mode 100644 index 0000000..bd0fc67 --- /dev/null +++ b/docs/grammar.md @@ -0,0 +1,343 @@ +# 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. diff --git a/host/main.c b/host/main.c new file mode 100644 index 0000000..ce584c6 --- /dev/null +++ b/host/main.c @@ -0,0 +1,129 @@ +/* main.c — the host robot: the whole stack with simulated hardware. + * + * This is the main loop from docs/flow.md §8.3, unmodified. Only the + * transport and the HAL differ from the Pico build. + */ +#include "rubo/hal_sim.h" +#include "rubo/rubo.h" + +#include +#include +#include + +rubo_transport_t *rubo_transport_stdio(void); + +static rubo_transport_t *g_tx; + +static uint32_t now_ms(void) +{ + static struct timespec t0; + struct timespec t; + + clock_gettime(CLOCK_MONOTONIC, &t); + if (t0.tv_sec == 0) t0 = t; + return (uint32_t)((t.tv_sec - t0.tv_sec) * 1000 + + (t.tv_nsec - t0.tv_nsec) / 1000000); +} + +static void sink(void *ctx, const char *line) +{ + (void)ctx; + printf("< %s\n", line); + fflush(stdout); +} + +int main(int argc, char **argv) +{ + bool trace = false, script = false; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--trace") == 0) trace = true; + if (strcmp(argv[i], "--script") == 0) script = true; + } + + rubo_hal_sim_set_trace(trace); + + rubo_t *r = rubo_create(rubo_hal_sim(), sink, NULL); + if (!r) { fprintf(stderr, "out of memory\n"); return 1; } + + if (rubo_load_stdlib(r, rubo_stdlib) != RUBO_OK) { + fprintf(stderr, "standard library failed to load\n"); + rubo_destroy(r); + return 1; + } + + g_tx = rubo_transport_stdio(); + if (g_tx->open(g_tx) < 0) { + fprintf(stderr, "cannot open stdin\n"); + rubo_destroy(r); + return 1; + } + + rubo_framer_t fr; + rubo_framer_reset(&fr); + rubo_note_traffic(r, now_ms()); + + printf("rubo host — try: PING | STAT | SQUARE side=400 | ABORT\n"); + printf(" multi-line DEF works; end with a newline at depth 0\n"); + fflush(stdout); + + bool input_done = false; + + for (;;) { + uint32_t t = now_ms(); + uint8_t buf[128]; + + /* --script is the minimal correct client (docs/flow.md §9): one + * outstanding statement at a time. It stops reading while a program + * runs, and keeps the link alive meanwhile — which is exactly what + * a real client must do, since the deadman does not care that the + * operator is merely being quiet. */ + size_t want = sizeof buf; + if (script && rubo_state(r) != RUBO_IDLE) { + rubo_note_traffic(r, t); + want = 0; + } else if (script) { + want = 1; + } + + int n = (input_done || want == 0) ? 0 : g_tx->read(g_tx, buf, want); + if (n < 0) { + /* End of stdin. On a real transport a disconnect means the + * §10 guard fires; here it just means a script ran out, so the + * running program is allowed to finish. */ + input_done = true; + n = 0; + } + /* Do not exit with the motors still turning: a `dur=0` move is left + * running deliberately, and the thing that ends it is the deadman + * (docs/flow.md §10). Leaving early would hide that. */ + bool engaged = rubo_hal_sim_state()->left != 0 || + rubo_hal_sim_state()->right != 0; + if (input_done && rubo_state(r) == RUBO_IDLE && !engaged) break; + for (int i = 0; i < n; i++) { + int st = rubo_framer_push(&fr, (char)buf[i], t); + if (st == 1) { + rubo_submit(r, fr.buf, t); + } else if (st < 0) { + rubo_err_t e = (rubo_err_t)(-st); + printf("< ERR %d %s\n", (int)e, rubo_strerror(e)); + fflush(stdout); + } + } + + int to = rubo_framer_timeout(&fr, t); + if (to < 0) { + rubo_err_t e = (rubo_err_t)(-to); + printf("< ERR %d incomplete statement\n", (int)e); + fflush(stdout); + } + + rubo_tick(r, t); + + /* The Pico build spins; here, yield so the host is not pegged. */ + struct timespec nap = { .tv_sec = 0, .tv_nsec = 2 * 1000 * 1000 }; + nanosleep(&nap, NULL); + } + + rubo_destroy(r); + return 0; +} diff --git a/host/transport_stdio.c b/host/transport_stdio.c new file mode 100644 index 0000000..5ebe20b --- /dev/null +++ b/host/transport_stdio.c @@ -0,0 +1,61 @@ +/* transport_stdio.c — host transport over stdin/stdout (docs/flow.md §3). + * + * read() must never block, so stdin is put into non-blocking mode. This is + * the whole of what the Pico USB CDC transport will have to provide too. + */ +#include "rubo/rubo.h" + +#include +#include +#include +#include + +static int stdio_open(rubo_transport_t *t) +{ + (void)t; + int fl = fcntl(STDIN_FILENO, F_GETFL, 0); + if (fl < 0) return -1; + return fcntl(STDIN_FILENO, F_SETFL, fl | O_NONBLOCK); +} + +static int stdio_read(rubo_transport_t *t, uint8_t *buf, size_t len) +{ + (void)t; + ssize_t n = read(STDIN_FILENO, buf, len); + if (n < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) return 0; + return -1; + } + if (n == 0) return -1; /* EOF */ + return (int)n; +} + +static int stdio_write(rubo_transport_t *t, const uint8_t *buf, size_t len) +{ + (void)t; + size_t off = 0; + while (off < len) { + ssize_t n = write(STDOUT_FILENO, buf + off, len - off); + if (n <= 0) return -1; + off += (size_t)n; + } + return (int)len; +} + +static bool stdio_connected(rubo_transport_t *t) { (void)t; return true; } + +static void stdio_close(rubo_transport_t *t) { (void)t; } + +rubo_transport_t *rubo_transport_stdio(void) +{ + static rubo_transport_t t = { + .open = stdio_open, + .read = stdio_read, + .write = stdio_write, + .connected = stdio_connected, + .close = stdio_close, + .mtu = 256, + .ctx = NULL, + }; + return &t; +} diff --git a/include/rubo/hal_sim.h b/include/rubo/hal_sim.h new file mode 100644 index 0000000..9202a4a --- /dev/null +++ b/include/rubo/hal_sim.h @@ -0,0 +1,28 @@ +/* hal_sim.h — simulated hardware for the host build. + * + * Exists so the language, the framer and the interpreter can be exercised + * with no Pico attached (docs/flow.md §2). + */ +#ifndef RUBO_HAL_SIM_H +#define RUBO_HAL_SIM_H + +#include "rubo/rubo.h" + +typedef struct { + int left; /* -100..100 */ + int right; + uint32_t change_count; /* drive() calls that actually changed something */ + uint32_t stop_count; + bool camera_present; +} rubo_sim_state_t; + +const rubo_hal_t *rubo_hal_sim(void); +const rubo_sim_state_t *rubo_hal_sim_state(void); +void rubo_hal_sim_reset(void); +void rubo_hal_sim_set_camera(bool present); + +/* When set, every drive()/stop_all() is printed to stdout. Off by default + * so tests stay quiet. */ +void rubo_hal_sim_set_trace(bool on); + +#endif /* RUBO_HAL_SIM_H */ diff --git a/include/rubo/rubo.h b/include/rubo/rubo.h new file mode 100644 index 0000000..9e985b0 --- /dev/null +++ b/include/rubo/rubo.h @@ -0,0 +1,172 @@ +/* rubo.h — public interface for the Rubo command language and command flow. + * + * Specifications: + * docs/grammar.md — the language + * docs/flow.md — transport, framing, execution + */ +#ifndef RUBO_H +#define RUBO_H + +#include +#include +#include + +/* ------------------------------------------------------------------ limits + * docs/grammar.md §9, docs/flow.md §13. + */ +#define RUBO_MAX_NAME 15 /* identifier chars, excl. terminator */ +#define RUBO_MAX_PARAMS 4 /* parameters per callable */ +#define RUBO_MAX_DEPTH 8 /* interpreter call depth */ +#define RUBO_MAX_NEST 8 /* syntactic nesting of sequences */ +#define RUBO_MAX_ITER 65535 /* REPEAT count */ +#define RUBO_MAX_CALLABLES 64 /* natives + statics + dynamics */ +#define RUBO_MAX_SYMBOLS 192 /* interned identifiers */ +#define RUBO_MAX_STMT 1024 /* source bytes in one top-level stmt */ + +/* The arena is sized in instructions rather than bytes (docs/grammar.md §9 + * says bytes; instructions are fixed-size here, so this is the same limit + * expressed in the unit the implementation actually allocates in). */ +#define RUBO_ARENA_INSTRS 256 /* DYNAMIC + STATIC procedure storage */ +#define RUBO_SCRATCH_INSTRS 96 /* compile buffer for immediate programs */ + +#define RUBO_FRAME_TIMEOUT_MS 2000 /* partial statement idle timeout */ +#define RUBO_LINK_TIMEOUT_MS 2000 /* deadman, while motors engaged */ +#define RUBO_MOTOR_MAX_MS 10000 /* hard cap on one motor-engaging native */ +#define RUBO_STEPS_PER_TICK 32 /* interpreter steps before yielding */ +#define RUBO_TCP_PORT 3141 + +/* Open-loop turn calibration: milliseconds of on-the-spot rotation per degree + * at RUBO_TURN_SPEED. A placeholder until there are encoders to measure with + * (docs/grammar.md §8). */ +#define RUBO_TURN_MS_PER_DEG 6 +#define RUBO_TURN_SPEED 60 + +/* ------------------------------------------------------------- error codes + * docs/grammar.md §10. + */ +typedef enum { + RUBO_OK = 0, + RUBO_E_SYNTAX = 1, /* malformed input */ + RUBO_E_UNKNOWN = 2, /* no such callable */ + RUBO_E_ARITY = 3, /* missing required arg, or too many positionals */ + RUBO_E_ARGNAME = 4, /* unknown, duplicate or conflicting param name */ + RUBO_E_ARGTYPE = 5, /* wrong value type */ + RUBO_E_RANGE = 6, /* value outside accepted range */ + RUBO_E_UNDEFINED = 7, /* body references a callable that doesn't exist */ + RUBO_E_CYCLE = 8, /* redefinition would create a cycle */ + RUBO_E_NOMEM = 9, /* arena exhausted */ + RUBO_E_LIMIT = 10, /* a static limit exceeded */ + RUBO_E_READONLY = 11, /* delete/redefine a NATIVE or STATIC callable */ + RUBO_E_BUSY = 12, /* a program is already running */ + RUBO_E_ABORT = 13, /* execution interrupted */ + RUBO_E_FAULT = 14 /* HAL or hardware error */ +} rubo_err_t; + +const char *rubo_strerror(rubo_err_t e); + +/* ------------------------------------------------------------------ values + * docs/grammar.md §1.5: two value types only. + */ +typedef enum { + RUBO_V_NONE = 0, /* used as "any type" in a native signature */ + RUBO_V_NUM, + RUBO_V_WORD /* interned identifier id, e.g. FWD, LEFT */ +} rubo_vtype_t; + +typedef struct { + int32_t num; /* NUM: the value. WORD: the symbol id. */ + uint8_t type; /* rubo_vtype_t */ +} rubo_value_t; + +typedef uint16_t rubo_sym_t; +#define RUBO_SYM_NONE ((rubo_sym_t)0xFFFF) + +/* --------------------------------------------------------------------- HAL + * docs/flow.md §2. Swapped wholesale between the host and Pico builds. + */ +typedef struct { + void (*init)(void); + void (*drive)(int left, int right); /* -100..100 per side, 0 = coast */ + void (*stop_all)(void); + bool (*motors_engaged)(void); + int (*camera_snap)(void); /* 0 = ok, <0 = fault */ + void (*poll)(uint32_t now_ms); +} rubo_hal_t; + +/* --------------------------------------------------------------- transport + * docs/flow.md §3. read() must never block. + */ +typedef struct rubo_transport { + int (*open)(struct rubo_transport *t); + int (*read)(struct rubo_transport *t, uint8_t *buf, size_t len); + int (*write)(struct rubo_transport *t, const uint8_t *buf, size_t len); + bool (*connected)(struct rubo_transport *t); + void (*close)(struct rubo_transport *t); + size_t mtu; + void *ctx; +} rubo_transport_t; + +/* ------------------------------------------------------------------ framer + * docs/flow.md §4. One per transport. + */ +typedef enum { RUBO_F_STMT, RUBO_F_COMMENT, RUBO_F_DISCARD } rubo_fstate_t; + +typedef struct { + char buf[RUBO_MAX_STMT + 1]; + uint16_t len; + int16_t depth; + uint8_t state; + uint32_t last_byte_ms; /* for RUBO_FRAME_TIMEOUT_MS */ +} rubo_framer_t; + +void rubo_framer_reset(rubo_framer_t *f); + +/* Feed one byte. Returns: + * 1 a complete statement is available in f->buf (NUL-terminated) + * 0 more input needed + * <0 negated rubo_err_t; the framer has entered DISCARD and will resync + * at the next newline (docs/flow.md §4.3). */ +int rubo_framer_push(rubo_framer_t *f, char c, uint32_t now_ms); + +/* Returns negated rubo_err_t if a partial statement has gone stale. */ +int rubo_framer_timeout(rubo_framer_t *f, uint32_t now_ms); + +/* ------------------------------------------------------------------ engine */ +typedef struct rubo rubo_t; + +/* Response sink. One call per protocol line, without the trailing newline. */ +typedef void (*rubo_out_fn)(void *ctx, const char *line); + +typedef enum { RUBO_IDLE = 0, RUBO_RUNNING, RUBO_PENDING_ERR } rubo_state_t; + +rubo_t *rubo_create(const rubo_hal_t *hal, rubo_out_fn out, void *out_ctx); +void rubo_destroy(rubo_t *r); + +/* Load the firmware-resident standard library (docs/grammar.md §2). Returns + * the first error encountered, or RUBO_OK. */ +rubo_err_t rubo_load_stdlib(rubo_t *r, const char *const *defs); + +/* The standard library itself: written in the language, parsed at boot, so + * there is no second representation of a procedure to keep in sync. */ +extern const char *const rubo_stdlib[]; + +/* Submit one framed statement, envelope included (docs/flow.md §5). + * Emits ACK/DONE/ERR through the sink. Returns the admission/parse result; + * runtime failures arrive later through the sink. */ +rubo_err_t rubo_submit(rubo_t *r, const char *stmt, uint32_t now_ms); + +/* Advance execution. Must be called from the main loop (docs/flow.md §8.3). */ +void rubo_tick(rubo_t *r, uint32_t now_ms); + +void rubo_abort(rubo_t *r, uint32_t now_ms); +rubo_state_t rubo_state(const rubo_t *r); +void rubo_status_line(const rubo_t *r, char *out, size_t len, + uint32_t now_ms); + +/* Note the arrival of inbound traffic, feeding the deadman (docs/flow.md §10). */ +void rubo_note_traffic(rubo_t *r, uint32_t now_ms); + +/* Emit an asynchronous event line (docs/flow.md §6). */ +void rubo_emit_event(rubo_t *r, const char *fmt, ...); + +#endif /* RUBO_H */ diff --git a/src/exec.c b/src/exec.c new file mode 100644 index 0000000..26e557c --- /dev/null +++ b/src/exec.c @@ -0,0 +1,276 @@ +/* exec.c — the resumable interpreter (docs/flow.md §8). + * + * Two properties matter more than speed here: + * + * 1. Nothing recurses in C. The frame stack is a static array, so no + * program a client can send — however nested — can overflow the MCU + * stack. docs/grammar.md §5.1 guarantees the array is big enough. + * + * 2. Nothing blocks. A native that takes time returns PENDING and is + * re-entered on the next tick, so ABORT is honoured within one tick + * even in the middle of a ten-second move. + */ +#include "internal.h" + +#include +#include + +static void frame_pop(rubo_t *r); +static void program_end(rubo_t *r, rubo_err_t e, const char *msg); + +static rubo_value_t eval(const rubo_frame_t *f, const rubo_arg_t *a) +{ + if (a->kind == RUBO_A_LIT) return a->lit; + return f->args[a->slot]; +} + +void rubo_exec_reset(rubo_t *r) +{ + r->sp = -1; + r->lp = -1; + r->state = RUBO_IDLE; + r->tagged = false; + r->tag = 0; +} + +void rubo_exec_start(rubo_t *r, const rubo_instr_t *code, uint16_t n, + uint32_t now_ms) +{ + (void)now_ms; + r->sp = 0; + r->lp = -1; + memset(&r->stack[0], 0, sizeof r->stack[0]); + r->stack[0].code = code; + r->stack[0].ncode = n; + r->stack[0].pc = 0; + r->stack[0].callee = 0xFFFF; + r->state = RUBO_RUNNING; +} + +/* Drop any loop records belonging to frames at or above `sp`. */ +static void loops_unwind_to(rubo_t *r, int16_t sp) +{ + while (r->lp >= 0 && r->loops[r->lp].frame >= sp) r->lp--; +} + +static void frame_pop(rubo_t *r) +{ + loops_unwind_to(r, r->sp); + r->sp--; + if (r->sp < 0) program_end(r, RUBO_OK, NULL); +} + +static void tagf(rubo_t *r, char *out, size_t len) +{ + if (r->tagged) snprintf(out, len, "@%u ", (unsigned)r->tag); + else out[0] = '\0'; +} + +/* Terminate the running program, emitting DONE or ERR (docs/flow.md §6). */ +static void program_end(rubo_t *r, rubo_err_t e, const char *msg) +{ + char pfx[16]; + tagf(r, pfx, sizeof pfx); + + if (e == RUBO_OK) { + rubo_outf(r, "%sDONE", pfx); + } else { + if (r->hal && r->hal->stop_all) r->hal->stop_all(); + if (msg && *msg) rubo_outf(r, "%sERR %d %s", pfx, (int)e, msg); + else rubo_outf(r, "%sERR %d %s", pfx, (int)e, + rubo_strerror(e)); + } + rubo_exec_reset(r); +} + +void rubo_abort(rubo_t *r, uint32_t now_ms) +{ + (void)now_ms; + if (r->hal && r->hal->stop_all) r->hal->stop_all(); + + if (r->state != RUBO_RUNNING) { + rubo_outf(r, "EVT ABORT"); + rubo_exec_reset(r); + return; + } + rubo_outf(r, "EVT ABORT"); + /* docs/flow.md §10: with no queue there is exactly one victim. */ + program_end(r, RUBO_E_ABORT, "aborted"); +} + +rubo_state_t rubo_state(const rubo_t *r) +{ + return (rubo_state_t)r->state; +} + +void rubo_status_line(const rubo_t *r, char *out, size_t len, uint32_t now_ms) +{ + const char *running = "-"; + char tag[12] = "-"; + + if (r->state == RUBO_RUNNING) { + running = ""; + for (int16_t i = r->sp; i >= 0; i--) { + if (r->stack[i].callee != 0xFFFF) { + running = rubo_sym_name(&r->syms, + r->tab[r->stack[i].callee].name); + break; + } + } + } + if (r->tagged) snprintf(tag, sizeof tag, "%u", (unsigned)r->tag); + + snprintf(out, len, + "STAT state=%s run=%s tag=%s depth=%d arena=%u/%u proc=%u " + "link=%ums", + r->state == RUBO_RUNNING ? "running" : "idle", + running, tag, + (int)(r->sp + 1), + (unsigned)r->arena_used, (unsigned)RUBO_ARENA_INSTRS, + (unsigned)r->ncallables, + (unsigned)(now_ms - r->last_traffic_ms)); +} + +/* --------------------------------------------------------------- the loop */ + +static bool typecheck(rubo_t *r, const rubo_callable_t *c, uint8_t i, + rubo_value_t v, char *msg, size_t len) +{ + if (c->ptype[i] == RUBO_V_NONE) return true; + if (v.type == c->ptype[i]) return true; + snprintf(msg, len, "'%s': '%s' expects a %s", + rubo_sym_name(&r->syms, c->name), + rubo_sym_name(&r->syms, c->params[i]), + c->ptype[i] == RUBO_V_NUM ? "number" : "word"); + return false; +} + +static void deadman(rubo_t *r, uint32_t now_ms) +{ + bool engaged = r->hal && r->hal->motors_engaged && r->hal->motors_engaged(); + + if (engaged && !r->motors_were_engaged) r->motor_since_ms = now_ms; + r->motors_were_engaged = engaged; + if (!engaged) return; + + /* docs/flow.md §10: armed only while the motors are actually turning, + * so an idle robot on a quiet link does not reset itself. */ + if (now_ms - r->last_traffic_ms > RUBO_LINK_TIMEOUT_MS) { + rubo_outf(r, "EVT LINKLOST"); + if (r->hal->stop_all) r->hal->stop_all(); + if (r->state == RUBO_RUNNING) + program_end(r, RUBO_E_ABORT, "link lost"); + else + rubo_exec_reset(r); + } +} + +void rubo_tick(rubo_t *r, uint32_t now_ms) +{ + char msg[96]; + + if (r->hal && r->hal->poll) r->hal->poll(now_ms); + deadman(r, now_ms); + + int steps = 0; + while (r->state == RUBO_RUNNING && steps++ < RUBO_STEPS_PER_TICK) { + rubo_frame_t *f = &r->stack[r->sp]; + + /* A native frame: no code, just a C function to re-enter. */ + if (f->code == NULL) { + rubo_err_t e = RUBO_OK; + rubo_step_t st = r->tab[f->callee].fn(r, f, now_ms, &e); + if (st == RUBO_STEP_PENDING) return; /* yield the tick */ + if (st == RUBO_STEP_ERR) { + program_end(r, e == RUBO_OK ? RUBO_E_FAULT : e, NULL); + return; + } + frame_pop(r); + continue; + } + + if (f->pc >= f->ncode) { frame_pop(r); continue; } + + const rubo_instr_t *in = &f->code[f->pc]; + + switch (in->op) { + case RUBO_OP_HALT: + frame_pop(r); + break; + + case RUBO_OP_REPEAT: { + rubo_value_t cv = eval(f, &in->args[0]); + if (cv.type != RUBO_V_NUM) { + program_end(r, RUBO_E_ARGTYPE, "REPEAT needs a number"); + return; + } + if (cv.num < 0 || cv.num > RUBO_MAX_ITER) { + snprintf(msg, sizeof msg, "REPEAT count %d out of range 0..%d", + (int)cv.num, RUBO_MAX_ITER); + program_end(r, RUBO_E_RANGE, msg); + return; + } + if (cv.num == 0) { f->pc = in->a; break; } /* skip the body */ + if (r->lp + 1 >= RUBO_MAX_NEST) { + program_end(r, RUBO_E_LIMIT, "loops nested too deeply"); + return; + } + r->lp++; + r->loops[r->lp].back = (uint16_t)(f->pc + 1); + r->loops[r->lp].remaining = (uint16_t)cv.num; + r->loops[r->lp].frame = (uint8_t)r->sp; + f->pc++; + break; + } + + case RUBO_OP_ENDREP: { + if (r->lp < 0) { /* cannot happen; be loud anyway */ + program_end(r, RUBO_E_SYNTAX, "loop underflow"); + return; + } + rubo_loop_t *l = &r->loops[r->lp]; + if (--l->remaining > 0) { + f->pc = l->back; + } else { + r->lp--; + f->pc++; + } + break; + } + + case RUBO_OP_CALL: { + const rubo_callable_t *c = &r->tab[in->a]; + rubo_value_t argv[RUBO_MAX_PARAMS]; + + for (uint8_t i = 0; i < in->argc; i++) { + argv[i] = eval(f, &in->args[i]); + if (!typecheck(r, c, i, argv[i], msg, sizeof msg)) { + program_end(r, RUBO_E_ARGTYPE, msg); + return; + } + } + if (r->sp + 1 >= RUBO_MAX_DEPTH) { + program_end(r, RUBO_E_LIMIT, "call depth exceeded"); + return; + } + f->pc++; /* return address */ + + rubo_frame_t *nf = &r->stack[++r->sp]; + memset(nf, 0, sizeof *nf); + nf->callee = in->a; + memcpy(nf->args, argv, sizeof argv); + if (c->kind == RUBO_C_NATIVE) { + nf->code = NULL; /* a native frame */ + } else { + nf->code = &r->arena[c->body]; + nf->ncode = c->nbody; + } + break; + } + + default: + program_end(r, RUBO_E_SYNTAX, "bad instruction"); + return; + } + } +} diff --git a/src/framer.c b/src/framer.c new file mode 100644 index 0000000..8e96ffd --- /dev/null +++ b/src/framer.c @@ -0,0 +1,92 @@ +/* framer.c — statement extraction (docs/flow.md §4). + * + * A top-level statement completes at the first newline seen while bracket + * depth is zero. Inside brackets a newline is ordinary whitespace, so a + * multi-line DEF pasted into a terminal arrives as one statement and a + * one-liner arrives as one statement, with no mode switch. + * + * The framer must be comment-aware: without it, a single '[' inside a + * comment would desynchronise the stream permanently. + */ +#include "rubo/rubo.h" + +#include + +void rubo_framer_reset(rubo_framer_t *f) +{ + memset(f, 0, sizeof *f); + f->state = RUBO_F_STMT; +} + +static int discard(rubo_framer_t *f, rubo_err_t e) +{ + f->state = RUBO_F_DISCARD; + f->len = 0; + f->depth = 0; + return -(int)e; +} + +int rubo_framer_push(rubo_framer_t *f, char c, uint32_t now_ms) +{ + f->last_byte_ms = now_ms; + + if (c == '\r') return 0; + + switch (f->state) { + case RUBO_F_DISCARD: + if (c == '\n') { + f->state = RUBO_F_STMT; + f->len = 0; + f->depth = 0; + } + return 0; + + case RUBO_F_COMMENT: + if (c != '\n') return 0; + f->state = RUBO_F_STMT; + break; /* fall through to the newline rule */ + + default: + break; + } + + if (c == '#' && f->state == RUBO_F_STMT) { + /* The comment is dropped rather than buffered, so a line that is + * nothing but a comment leaves len == 0 and produces no statement + * — and therefore no spurious ACK/DONE. */ + f->state = RUBO_F_COMMENT; + return 0; + } + + if (c == '\n') { + if (f->depth == 0) { + if (f->len == 0) return 0; /* blank or comment-only line */ + f->buf[f->len] = '\0'; + f->len = 0; + return 1; /* statement ready */ + } + c = ' '; /* inside brackets: whitespace */ + } + + if (c == '[') { + if (f->depth > RUBO_MAX_NEST + 1) return discard(f, RUBO_E_LIMIT); + f->depth++; + } else if (c == ']') { + if (f->depth == 0) return discard(f, RUBO_E_SYNTAX); + f->depth--; + } + + if (f->len >= RUBO_MAX_STMT) return discard(f, RUBO_E_LIMIT); + f->buf[f->len++] = c; + return 0; +} + +int rubo_framer_timeout(rubo_framer_t *f, uint32_t now_ms) +{ + if (f->state == RUBO_F_DISCARD) return 0; + if (f->len == 0) return 0; + if (now_ms - f->last_byte_ms <= RUBO_FRAME_TIMEOUT_MS) return 0; + /* docs/flow.md §4.3: a client that vanishes mid-frame would otherwise + * leave an unterminated '[' wedging the framer forever. */ + return discard(f, RUBO_E_SYNTAX); +} diff --git a/src/hal_sim.c b/src/hal_sim.c new file mode 100644 index 0000000..6add079 --- /dev/null +++ b/src/hal_sim.c @@ -0,0 +1,66 @@ +/* hal_sim.c — simulated motors and camera for the host build. */ +#include "rubo/hal_sim.h" + +#include +#include + +static rubo_sim_state_t g_state; +static bool g_trace; + +static void sim_init(void) +{ + memset(&g_state, 0, sizeof g_state); + g_state.camera_present = false; +} + +static void sim_drive(int left, int right) +{ + if (left != g_state.left || right != g_state.right) { + g_state.change_count++; + if (g_trace) printf(" [motors] L=%-4d R=%-4d\n", left, right); + } + g_state.left = left; + g_state.right = right; +} + +static void sim_stop_all(void) +{ + g_state.stop_count++; + if (g_trace && (g_state.left || g_state.right)) + printf(" [motors] stop\n"); + g_state.left = g_state.right = 0; +} + +static bool sim_motors_engaged(void) +{ + return g_state.left != 0 || g_state.right != 0; +} + +static int sim_camera_snap(void) +{ + return g_state.camera_present ? 0 : -1; +} + +static void sim_poll(uint32_t now_ms) +{ + (void)now_ms; +} + +static const rubo_hal_t SIM = { + .init = sim_init, + .drive = sim_drive, + .stop_all = sim_stop_all, + .motors_engaged = sim_motors_engaged, + .camera_snap = sim_camera_snap, + .poll = sim_poll, +}; + +const rubo_hal_t *rubo_hal_sim(void) { return &SIM; } + +const rubo_sim_state_t *rubo_hal_sim_state(void) { return &g_state; } + +void rubo_hal_sim_reset(void) { sim_init(); } + +void rubo_hal_sim_set_camera(bool present) { g_state.camera_present = present; } + +void rubo_hal_sim_set_trace(bool on) { g_trace = on; } diff --git a/src/internal.h b/src/internal.h new file mode 100644 index 0000000..1dbcc79 --- /dev/null +++ b/src/internal.h @@ -0,0 +1,201 @@ +/* internal.h — structures shared across the rubo implementation. + * Not installed; nothing outside src/ should include this. + */ +#ifndef RUBO_INTERNAL_H +#define RUBO_INTERNAL_H + +#include "rubo/rubo.h" + +/* ---------------------------------------------------------------- symbols */ +typedef struct { + char name[RUBO_MAX_NAME + 1]; + rubo_sym_t next; /* unused; kept for a future hash chain */ +} rubo_symrec_t; + +typedef struct { + rubo_symrec_t rec[RUBO_MAX_SYMBOLS]; + uint16_t count; +} rubo_symtab_t; + +void rubo_sym_init(rubo_symtab_t *t); +/* Interns an identifier, upper-casing it. RUBO_SYM_NONE if the table is full + * or the name is too long. */ +rubo_sym_t rubo_sym_intern(rubo_symtab_t *t, const char *s, size_t len); +const char *rubo_sym_name(const rubo_symtab_t *t, rubo_sym_t id); + +/* ----------------------------------------------------------- instructions + * Bodies compile to a flat, fixed-width instruction array. Sequences are + * inlined; REPEAT brackets its body with jump targets. Nothing here refers + * to argument *names*: docs/grammar.md §4.2 named arguments are resolved to + * the callee's parameter order at definition time, so execution never sees + * them (which is what makes named args free at runtime). + */ +typedef enum { + RUBO_OP_CALL = 0, /* a: callee index; args: bound, in callee order */ + RUBO_OP_REPEAT, /* args[0]: count; a: index just past ENDREP */ + RUBO_OP_ENDREP, /* a: index of the first body instruction */ + RUBO_OP_HALT +} rubo_op_t; + +typedef enum { RUBO_A_LIT = 0, RUBO_A_PARAM } rubo_argkind_t; + +typedef struct { + uint8_t kind; /* rubo_argkind_t */ + uint8_t slot; /* PARAM: index into the enclosing frame's args */ + rubo_value_t lit; /* LIT: the value */ +} rubo_arg_t; + +typedef struct { + uint8_t op; + uint8_t argc; + uint16_t a; + rubo_arg_t args[RUBO_MAX_PARAMS]; +} rubo_instr_t; + +/* --------------------------------------------------------------- callables + * docs/grammar.md §2. One namespace, three kinds. + */ +typedef enum { + RUBO_C_FREE = 0, + RUBO_C_NATIVE, + RUBO_C_STATIC, + RUBO_C_DYNAMIC +} rubo_ckind_t; + +struct rubo_exec; +typedef struct rubo_frame rubo_frame_t; + +typedef enum { + RUBO_STEP_DONE = 0, + RUBO_STEP_PENDING, + RUBO_STEP_ERR +} rubo_step_t; + +/* docs/flow.md §8.2. A native returns PENDING to be re-entered next tick. */ +typedef rubo_step_t (*rubo_native_fn)(rubo_t *r, rubo_frame_t *f, + uint32_t now_ms, rubo_err_t *err); + +typedef struct { + rubo_sym_t name; + uint8_t kind; /* rubo_ckind_t */ + uint8_t nparams; + uint8_t depth; /* max call depth incl self */ + rubo_sym_t params[RUBO_MAX_PARAMS]; + uint8_t ptype[RUBO_MAX_PARAMS]; /* rubo_vtype_t, NONE = any */ + uint8_t has_default[RUBO_MAX_PARAMS]; + rubo_value_t defaults[RUBO_MAX_PARAMS]; + rubo_native_fn fn; /* NATIVE */ + uint16_t body; /* STATIC/DYNAMIC: arena ix */ + uint16_t nbody; +} rubo_callable_t; + +/* -------------------------------------------------------------- execution */ +struct rubo_frame { + const rubo_instr_t *code; + uint16_t ncode; + uint16_t pc; + uint16_t callee; /* callable index */ + rubo_value_t args[RUBO_MAX_PARAMS]; + /* native scratch */ + uint32_t deadline; + uint8_t started; +}; + +typedef struct { + uint16_t back; /* first instruction of the loop body */ + uint16_t remaining; + uint8_t frame; /* owning frame index */ +} rubo_loop_t; + +/* --------------------------------------------------------------- instance */ +struct rubo { + const rubo_hal_t *hal; + rubo_out_fn out; + void *out_ctx; + + rubo_symtab_t syms; + + rubo_callable_t tab[RUBO_MAX_CALLABLES]; + uint16_t ncallables; + + rubo_instr_t arena[RUBO_ARENA_INSTRS]; + uint16_t arena_used; + + /* The immediate program: a top-level statement compiles here rather than + * into the arena, so running a one-off never consumes procedure storage. */ + rubo_instr_t scratch[RUBO_SCRATCH_INSTRS]; + uint16_t scratch_used; + + rubo_frame_t stack[RUBO_MAX_DEPTH]; + int16_t sp; /* -1 = idle */ + rubo_loop_t loops[RUBO_MAX_NEST]; + int16_t lp; + + uint8_t state; /* rubo_state_t */ + uint32_t tag; /* current program's tag */ + bool tagged; + + /* Set while rubo_load_stdlib() runs, so procedures defined from the + * firmware standard library land as STATIC (undeletable) rather than + * DYNAMIC, without needing a second parser (docs/grammar.md §2). */ + bool stdlib_mode; + + /* deadman, docs/flow.md §10 */ + uint32_t last_traffic_ms; + uint32_t motor_since_ms; + bool motors_were_engaged; + + /* interned words used by the natives */ + rubo_sym_t s_fwd, s_back, s_left, s_right, s_snap; +}; + +/* ------------------------------------------------------------------ registry */ + +/* A native's signature, declared in the same terms as a DEF so that named + * arguments work identically for natives and procedures (docs/grammar.md + * §4.2 requires natives to declare parameter *names*, not just an arity). + * params: comma-separated names, "" for none + * types: one char per parameter — 'n' number, 'w' word, '*' any + * defaults: comma-separated; an empty field means the parameter is required + */ +typedef struct { + const char *name; + const char *params; + const char *types; + const char *defaults; + rubo_native_fn fn; +} rubo_native_def_t; + +void rubo_reg_init(rubo_t *r); +int rubo_reg_find(const rubo_t *r, rubo_sym_t name); +rubo_err_t rubo_reg_add_native(rubo_t *r, const rubo_native_def_t *def); + +/* True if callable index `needle` is reachable from the given body. */ +bool rubo_reg_references(const rubo_t *r, const rubo_instr_t *code, + uint16_t ncode, int needle); + +/* Commit a compiled body into the arena; returns the base index or <0. */ +int rubo_arena_put(rubo_t *r, const rubo_instr_t *code, uint16_t n); +/* Release a body and compact the arena, fixing up every callable's base. */ +void rubo_arena_release(rubo_t *r, uint16_t base, uint16_t n); + +/* -------------------------------------------------------------------- parser + * Parses one complete statement. On a DEF/DEL the registry is mutated and + * *immediate is false; otherwise the statement is compiled into r->scratch + * and *immediate is true. + */ +rubo_err_t rubo_parse_statement(rubo_t *r, const char *src, bool *immediate, + char *errmsg, size_t errlen); + +/* ------------------------------------------------------------------- executor */ +void rubo_exec_start(rubo_t *r, const rubo_instr_t *code, uint16_t n, + uint32_t now_ms); +void rubo_exec_reset(rubo_t *r); + +/* --------------------------------------------------------------------- natives */ +void rubo_natives_register(rubo_t *r); + +/* ---------------------------------------------------------------------- util */ +void rubo_outf(rubo_t *r, const char *fmt, ...); + +#endif /* RUBO_INTERNAL_H */ diff --git a/src/lexer.c b/src/lexer.c new file mode 100644 index 0000000..791b78b --- /dev/null +++ b/src/lexer.c @@ -0,0 +1,138 @@ +/* lexer.c — docs/grammar.md §3. + * + * Whitespace-insensitive, '#' comments to end of line, identifiers + * canonicalised to upper case. + */ +#include "lexer.h" + +#include + +static const struct { const char *s; uint8_t kw; } KEYWORDS[] = { + { "DEF", KW_DEF }, + { "DEL", KW_DEL }, + { "REPEAT", KW_REPEAT }, + { "IF", KW_IF }, + { "ELIF", KW_ELIF }, + { "ELSE", KW_ELSE }, +}; + +const char *rubo_kw_name(rubo_kw_t kw) +{ + for (size_t i = 0; i < sizeof KEYWORDS / sizeof KEYWORDS[0]; i++) + if (KEYWORDS[i].kw == kw) return KEYWORDS[i].s; + return "?"; +} + +static int is_alpha(char c) +{ + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; +} +static int is_digit(char c) { return c >= '0' && c <= '9'; } +static int is_identc(char c) { return is_alpha(c) || is_digit(c); } +static char upper(char c) { return (c >= 'a' && c <= 'z') ? (char)(c - 32) : c; } + +void rubo_lex_init(rubo_lexer_t *lx, const char *src) +{ + memset(lx, 0, sizeof *lx); + lx->p = src; + lx->err = RUBO_OK; + rubo_lex_next(lx); +} + +static void skip_space(rubo_lexer_t *lx) +{ + for (;;) { + while (*lx->p == ' ' || *lx->p == '\t' || *lx->p == '\r' || + *lx->p == '\n') + lx->p++; + if (*lx->p != '#') return; + while (*lx->p && *lx->p != '\n') lx->p++; + } +} + +void rubo_lex_next(rubo_lexer_t *lx) +{ + rubo_token_t *t = &lx->tok; + + memset(t, 0, sizeof *t); + skip_space(lx); + t->at = lx->p; + + char c = *lx->p; + if (c == '\0') { t->type = T_EOF; return; } + + switch (c) { + case '[': lx->p++; t->type = T_LBRACKET; return; + case ']': lx->p++; t->type = T_RBRACKET; return; + case ';': lx->p++; t->type = T_SEMI; return; + case '$': lx->p++; t->type = T_DOLLAR; return; + case '<': case '>': + lx->p++; + if (*lx->p == '=') lx->p++; + t->type = T_RELOP; + return; + case '!': + if (lx->p[1] == '=') { lx->p += 2; t->type = T_RELOP; return; } + lx->p++; + t->type = T_ERROR; + lx->err = RUBO_E_SYNTAX; + return; + case '=': + if (lx->p[1] == '=') { lx->p += 2; t->type = T_RELOP; return; } + lx->p++; + t->type = T_EQ; + return; + default: + break; + } + + /* A '-' begins a number only when a digit follows; there is no unary + * minus operator because there are no expressions. */ + if (is_digit(c) || (c == '-' && is_digit(lx->p[1]))) { + int neg = (c == '-'); + if (neg) lx->p++; + int64_t v = 0; + while (is_digit(*lx->p)) { + v = v * 10 + (*lx->p - '0'); + if (v > 2147483647LL) { v = 2147483647LL; } + lx->p++; + } + /* `12abc` is not a number followed by an identifier. */ + if (is_alpha(*lx->p)) { + t->type = T_ERROR; + lx->err = RUBO_E_SYNTAX; + return; + } + t->type = T_NUMBER; + t->num = (int32_t)(neg ? -v : v); + return; + } + + if (is_alpha(c)) { + size_t n = 0; + while (is_identc(*lx->p)) { + if (n < RUBO_MAX_NAME) t->text[n] = upper(*lx->p); + n++; + lx->p++; + } + if (n > RUBO_MAX_NAME) { + t->type = T_ERROR; + lx->err = RUBO_E_LIMIT; + return; + } + t->text[n] = '\0'; + for (size_t i = 0; i < sizeof KEYWORDS / sizeof KEYWORDS[0]; i++) { + if (strcmp(t->text, KEYWORDS[i].s) == 0) { + t->type = T_KEYWORD; + t->kw = KEYWORDS[i].kw; + return; + } + } + t->type = T_IDENT; + return; + } + + lx->p++; + t->type = T_ERROR; + lx->err = RUBO_E_SYNTAX; +} diff --git a/src/lexer.h b/src/lexer.h new file mode 100644 index 0000000..91c2ad9 --- /dev/null +++ b/src/lexer.h @@ -0,0 +1,54 @@ +/* lexer.h — tokeniser for the Rubo command language (docs/grammar.md §3). */ +#ifndef RUBO_LEXER_H +#define RUBO_LEXER_H + +#include "internal.h" + +typedef enum { + T_EOF = 0, + T_IDENT, + T_NUMBER, + T_KEYWORD, + T_LBRACKET, + T_RBRACKET, + T_SEMI, + T_EQ, + T_DOLLAR, + T_RELOP, /* reserved: docs/grammar.md §7 */ + T_ERROR +} rubo_toktype_t; + +typedef enum { + KW_NONE = 0, KW_DEF, KW_DEL, KW_REPEAT, KW_IF, KW_ELIF, KW_ELSE +} rubo_kw_t; + +typedef struct { + uint8_t type; + uint8_t kw; + int32_t num; + char text[RUBO_MAX_NAME + 1]; /* IDENT/KEYWORD, upper-cased */ + const char *at; /* position, for diagnostics */ +} rubo_token_t; + +typedef struct { + const char *p; + rubo_token_t tok; + rubo_err_t err; +} rubo_lexer_t; + +void rubo_lex_init(rubo_lexer_t *lx, const char *src); +void rubo_lex_next(rubo_lexer_t *lx); + +/* Two-token lookahead is needed exactly once — to tell a positional word + * argument from a named one (docs/grammar.md §4.2) — so it is provided by + * saving and restoring the whole lexer rather than by a token queue. */ +static inline void rubo_lex_save(const rubo_lexer_t *lx, rubo_lexer_t *save) { + *save = *lx; +} +static inline void rubo_lex_restore(rubo_lexer_t *lx, const rubo_lexer_t *save) { + *lx = *save; +} + +const char *rubo_kw_name(rubo_kw_t kw); + +#endif /* RUBO_LEXER_H */ diff --git a/src/natives.c b/src/natives.c new file mode 100644 index 0000000..e1cfd63 --- /dev/null +++ b/src/natives.c @@ -0,0 +1,176 @@ +/* natives.c — the built-in callables (docs/grammar.md §8). + * + * A native is a callable whose body is C. Nothing else distinguishes it: + * it declares parameter names and defaults exactly as a DEF does, which is + * what lets named arguments work uniformly across both (docs/grammar.md + * §4.2). + * + * Timed natives follow docs/flow.md §8.2: engage on first entry, then + * return PENDING until the deadline passes. They never sleep. + */ +#include "internal.h" + +#include + +static void engage(rubo_t *r, int left, int right) +{ + if (r->hal && r->hal->drive) r->hal->drive(left, right); +} + +static void halt(rubo_t *r) +{ + if (r->hal && r->hal->stop_all) r->hal->stop_all(); +} + +/* Shared tail for anything with a duration. dur == 0 means "until + * countermanded": the motors stay engaged and the deadman (docs/flow.md + * §10) becomes the thing that eventually stops them. */ +static rubo_step_t run_for(rubo_t *r, rubo_frame_t *f, uint32_t now, + int32_t dur, rubo_err_t *err) +{ + if (dur < 0 || dur > RUBO_MOTOR_MAX_MS) { + halt(r); + *err = RUBO_E_RANGE; + return RUBO_STEP_ERR; + } + if (dur == 0) return RUBO_STEP_DONE; + + if (!f->started) { + f->started = 1; + f->deadline = now + (uint32_t)dur; + } + if ((int32_t)(now - f->deadline) >= 0) { + engage(r, 0, 0); + return RUBO_STEP_DONE; + } + return RUBO_STEP_PENDING; +} + +/* ------------------------------------------------------------------ MOVE */ + +static rubo_step_t n_move(rubo_t *r, rubo_frame_t *f, uint32_t now, + rubo_err_t *err) +{ + int32_t dir = f->args[0].num; /* word symbol id */ + int32_t speed = f->args[1].num; + int32_t dur = f->args[2].num; + + if (!f->started) { + if (speed < 0 || speed > 100) { *err = RUBO_E_RANGE; return RUBO_STEP_ERR; } + int s = (int)speed; + if (dir == (int32_t)r->s_back) s = -s; + else if (dir != (int32_t)r->s_fwd) { *err = RUBO_E_RANGE; return RUBO_STEP_ERR; } + engage(r, s, s); + } + return run_for(r, f, now, dur, err); +} + +/* ------------------------------------------------------------------ TURN */ + +static rubo_step_t n_turn(rubo_t *r, rubo_frame_t *f, uint32_t now, + rubo_err_t *err) +{ + int32_t dir = f->args[0].num; + int32_t deg = f->args[1].num; + + if (!f->started) { + if (deg < 0 || deg > 3600) { *err = RUBO_E_RANGE; return RUBO_STEP_ERR; } + int s = RUBO_TURN_SPEED; + if (dir == (int32_t)r->s_left) engage(r, -s, s); + else if (dir == (int32_t)r->s_right) engage(r, s, -s); + else { *err = RUBO_E_RANGE; return RUBO_STEP_ERR; } + } + /* Open loop until there are encoders to close it with: degrees become + * milliseconds through a calibration constant (docs/grammar.md §8). */ + int32_t dur = deg * RUBO_TURN_MS_PER_DEG; + if (dur == 0) { engage(r, 0, 0); return RUBO_STEP_DONE; } + return run_for(r, f, now, dur, err); +} + +/* ----------------------------------------------------------------- DRIVE */ + +static rubo_step_t n_drive(rubo_t *r, rubo_frame_t *f, uint32_t now, + rubo_err_t *err) +{ + int32_t l = f->args[0].num; + int32_t rr = f->args[1].num; + int32_t dur = f->args[2].num; + + if (!f->started) { + if (l < -100 || l > 100 || rr < -100 || rr > 100) { + *err = RUBO_E_RANGE; + return RUBO_STEP_ERR; + } + engage(r, (int)l, (int)rr); + } + return run_for(r, f, now, dur, err); +} + +/* ------------------------------------------------------------ STOP / WAIT */ + +static rubo_step_t n_stop(rubo_t *r, rubo_frame_t *f, uint32_t now, + rubo_err_t *err) +{ + (void)f; (void)now; (void)err; + halt(r); + return RUBO_STEP_DONE; +} + +static rubo_step_t n_wait(rubo_t *r, rubo_frame_t *f, uint32_t now, + rubo_err_t *err) +{ + int32_t dur = f->args[0].num; + + if (dur < 0) { *err = RUBO_E_RANGE; return RUBO_STEP_ERR; } + if (dur == 0) return RUBO_STEP_DONE; + if (!f->started) { + f->started = 1; + f->deadline = now + (uint32_t)dur; + } + (void)r; + return ((int32_t)(now - f->deadline) >= 0) ? RUBO_STEP_DONE + : RUBO_STEP_PENDING; +} + +/* ------------------------------------------------------------------- CAM */ + +static rubo_step_t n_cam(rubo_t *r, rubo_frame_t *f, uint32_t now, + rubo_err_t *err) +{ + (void)now; + if (f->args[0].num != (int32_t)r->s_snap) { + *err = RUBO_E_RANGE; + return RUBO_STEP_ERR; + } + if (!r->hal || !r->hal->camera_snap || r->hal->camera_snap() < 0) { + *err = RUBO_E_FAULT; + return RUBO_STEP_ERR; + } + /* The frame itself goes out as DAT/DEND records (docs/flow.md §11). + * That path lands with the camera driver; there is no frame buffer to + * chunk until then. */ + return RUBO_STEP_DONE; +} + +/* -------------------------------------------------------------- registry */ + +static const rubo_native_def_t NATIVES[] = { + { "MOVE", "dir,speed,dur", "wnn", ",,0", n_move }, + { "TURN", "dir,deg", "wn", ",", n_turn }, + { "DRIVE", "left,right,dur", "nnn", ",,0", n_drive }, + { "STOP", "", "", "", n_stop }, + { "WAIT", "dur", "n", "", n_wait }, + { "CAM", "action", "w", "", n_cam }, +}; + +void rubo_natives_register(rubo_t *r) +{ + for (size_t i = 0; i < sizeof NATIVES / sizeof NATIVES[0]; i++) + rubo_reg_add_native(r, &NATIVES[i]); + + r->s_fwd = rubo_sym_intern(&r->syms, "FWD", 3); + r->s_back = rubo_sym_intern(&r->syms, "BACK", 4); + r->s_left = rubo_sym_intern(&r->syms, "LEFT", 4); + r->s_right = rubo_sym_intern(&r->syms, "RIGHT", 5); + r->s_snap = rubo_sym_intern(&r->syms, "SNAP", 4); +} diff --git a/src/parser.c b/src/parser.c new file mode 100644 index 0000000..e144751 --- /dev/null +++ b/src/parser.c @@ -0,0 +1,638 @@ +/* parser.c — recursive descent over docs/grammar.md §4. + * + * The grammar is LL(1) apart from one place: telling a positional word + * argument from a named one needs to see the token after an IDENT. That is + * handled by saving and restoring the lexer, not by a token queue. + * + * Named arguments are resolved here, at definition time, into the callee's + * parameter order — the executor never sees an argument name. Defaults are + * baked in at the same moment. This is why named arguments cost nothing at + * runtime (docs/grammar.md §4.2, §4.3). + */ +#include "internal.h" +#include "lexer.h" + +#include +#include +#include + +typedef struct { + rubo_t *r; + rubo_lexer_t lx; + rubo_instr_t *out; + uint16_t cap; + uint16_t n; + + rubo_sym_t params[RUBO_MAX_PARAMS]; + uint8_t nparams; + bool in_def; + uint8_t nest; + uint8_t maxdepth; /* deepest callee referenced */ + + rubo_err_t err; + char *emsg; + size_t elen; +} P; + +static void fail(P *p, rubo_err_t e, const char *fmt, ...) +{ + if (p->err != RUBO_OK) return; /* keep the first failure */ + p->err = e; + if (p->emsg && p->elen) { + va_list ap; + va_start(ap, fmt); + vsnprintf(p->emsg, p->elen, fmt, ap); + va_end(ap); + } +} + +static void advance(P *p) +{ + rubo_lex_next(&p->lx); + if (p->lx.tok.type == T_ERROR) + fail(p, p->lx.err ? p->lx.err : RUBO_E_SYNTAX, "bad token"); +} + +static bool at(const P *p, rubo_toktype_t t) { return p->lx.tok.type == t; } + +static int emit(P *p, uint8_t op) +{ + if (p->n >= p->cap) { + fail(p, RUBO_E_LIMIT, "program too large"); + return -1; + } + rubo_instr_t *in = &p->out[p->n]; + memset(in, 0, sizeof *in); + in->op = op; + return (int)p->n++; +} + +static rubo_sym_t intern_tok(P *p) +{ + rubo_sym_t s = rubo_sym_intern(&p->r->syms, p->lx.tok.text, + strlen(p->lx.tok.text)); + if (s == RUBO_SYM_NONE) fail(p, RUBO_E_LIMIT, "symbol table full"); + return s; +} + +static const char *sname(P *p, rubo_sym_t s) +{ + return rubo_sym_name(&p->r->syms, s); +} + +/* ------------------------------------------------------------------ args */ + +/* A literal: a number, or a bare word interned as a value. */ +static bool parse_literal(P *p, rubo_value_t *v) +{ + if (at(p, T_NUMBER)) { + v->type = RUBO_V_NUM; + v->num = p->lx.tok.num; + advance(p); + return true; + } + if (at(p, T_IDENT)) { + rubo_sym_t s = intern_tok(p); + if (p->err != RUBO_OK) return false; + v->type = RUBO_V_WORD; + v->num = (int32_t)s; + advance(p); + return true; + } + fail(p, RUBO_E_SYNTAX, "expected a number or a word"); + return false; +} + +static bool parse_arg(P *p, rubo_arg_t *a) +{ + memset(a, 0, sizeof *a); + + if (at(p, T_DOLLAR)) { + advance(p); + if (!at(p, T_IDENT)) { + fail(p, RUBO_E_SYNTAX, "expected a parameter name after '$'"); + return false; + } + rubo_sym_t s = intern_tok(p); + if (p->err != RUBO_OK) return false; + + /* docs/grammar.md §4.1: '$' always means "a parameter of the + * enclosing procedure", so an unknown one is an error here rather + * than a lookup deferred to runtime. */ + for (uint8_t i = 0; i < p->nparams; i++) { + if (p->params[i] == s) { + a->kind = RUBO_A_PARAM; + a->slot = i; + advance(p); + return true; + } + } + if (!p->in_def) + fail(p, RUBO_E_SYNTAX, + "'$%s': there are no parameters outside a DEF", sname(p, s)); + else + fail(p, RUBO_E_SYNTAX, "'$%s' is not a parameter of this procedure", + sname(p, s)); + return false; + } + + a->kind = RUBO_A_LIT; + return parse_literal(p, &a->lit); +} + +static bool starts_arg(const P *p) +{ + return at(p, T_NUMBER) || at(p, T_IDENT) || at(p, T_DOLLAR); +} + +/* Is the current IDENT the start of `name = value`? */ +static bool is_named_arg(P *p) +{ + if (!at(p, T_IDENT)) return false; + rubo_lexer_t save; + rubo_lex_save(&p->lx, &save); + rubo_lex_next(&p->lx); + bool named = (p->lx.tok.type == T_EQ); + rubo_lex_restore(&p->lx, &save); + return named; +} + +/* ------------------------------------------------------------------ call */ + +static void parse_call(P *p) +{ + rubo_sym_t name = intern_tok(p); + if (p->err != RUBO_OK) return; + + int ci = rubo_reg_find(p->r, name); + if (ci < 0) { + /* docs/grammar.md §5.1: inside a body this is the forward-reference + * rule biting, which is a different diagnosis from a typo typed at + * the top level, so the two get different codes. */ + if (p->in_def) + fail(p, RUBO_E_UNDEFINED, + "'%s' is not defined yet; a body may only call callables " + "that already exist", sname(p, name)); + else + fail(p, RUBO_E_UNKNOWN, "unknown callable '%s'", sname(p, name)); + return; + } + const rubo_callable_t *c = &p->r->tab[ci]; + advance(p); + + rubo_arg_t bound[RUBO_MAX_PARAMS]; + bool filled[RUBO_MAX_PARAMS] = { false }; + memset(bound, 0, sizeof bound); + + uint8_t npos = 0; + bool seen_named = false; + + while (starts_arg(p) && p->err == RUBO_OK) { + if (is_named_arg(p)) { + seen_named = true; + rubo_sym_t pn = intern_tok(p); + if (p->err != RUBO_OK) return; + advance(p); /* past the name */ + advance(p); /* past '=' */ + + int slot = -1; + for (uint8_t i = 0; i < c->nparams; i++) + if (c->params[i] == pn) { slot = i; break; } + if (slot < 0) { + fail(p, RUBO_E_ARGNAME, "'%s' has no parameter '%s'", + sname(p, c->name), sname(p, pn)); + return; + } + if (filled[slot]) { + fail(p, RUBO_E_ARGNAME, "'%s' given twice", sname(p, pn)); + return; + } + if (!parse_arg(p, &bound[slot])) return; + filled[slot] = true; + } else { + if (seen_named) { + fail(p, RUBO_E_SYNTAX, + "positional arguments must come before named ones"); + return; + } + if (npos >= c->nparams) { + fail(p, RUBO_E_ARITY, "'%s' takes %u argument%s", + sname(p, c->name), c->nparams, c->nparams == 1 ? "" : "s"); + return; + } + if (!parse_arg(p, &bound[npos])) return; + filled[npos] = true; + npos++; + } + } + if (p->err != RUBO_OK) return; + + /* Defaults, then the required-argument check (docs/grammar.md §4.3). */ + for (uint8_t i = 0; i < c->nparams; i++) { + if (filled[i]) continue; + if (!c->has_default[i]) { + fail(p, RUBO_E_ARITY, "'%s': missing required argument '%s'", + sname(p, c->name), sname(p, c->params[i])); + return; + } + bound[i].kind = RUBO_A_LIT; + bound[i].lit = c->defaults[i]; + } + + /* Literal arguments can be type-checked now; parameters cannot, and are + * checked by the executor when their value is known. */ + for (uint8_t i = 0; i < c->nparams; i++) { + if (bound[i].kind != RUBO_A_LIT) continue; + if (c->ptype[i] == RUBO_V_NONE) continue; + if (bound[i].lit.type != c->ptype[i]) { + fail(p, RUBO_E_ARGTYPE, "'%s': '%s' expects a %s", + sname(p, c->name), sname(p, c->params[i]), + c->ptype[i] == RUBO_V_NUM ? "number" : "word"); + return; + } + } + + if (c->depth > p->maxdepth) p->maxdepth = c->depth; + + int ix = emit(p, RUBO_OP_CALL); + if (ix < 0) return; + p->out[ix].a = (uint16_t)ci; + p->out[ix].argc = c->nparams; + memcpy(p->out[ix].args, bound, sizeof bound); +} + +/* ------------------------------------------------------- statements */ + +static void parse_statement(P *p); + +static void parse_sequence(P *p) +{ + if (p->nest >= RUBO_MAX_NEST) { + fail(p, RUBO_E_LIMIT, "sequences nested too deeply"); + return; + } + p->nest++; + advance(p); /* past '[' */ + + if (at(p, T_RBRACKET)) { /* the empty sequence is legal */ + advance(p); + p->nest--; + return; + } + for (;;) { + parse_statement(p); + if (p->err != RUBO_OK) return; + if (!at(p, T_SEMI)) break; + advance(p); + if (at(p, T_RBRACKET)) break; /* trailing ';' */ + } + if (!at(p, T_RBRACKET)) { + fail(p, RUBO_E_SYNTAX, "expected ';' or ']'"); + return; + } + advance(p); + p->nest--; +} + +static void parse_repeat(P *p) +{ + advance(p); /* past REPEAT */ + + rubo_arg_t count; + if (!parse_arg(p, &count)) return; + if (count.kind == RUBO_A_LIT && count.lit.type != RUBO_V_NUM) { + fail(p, RUBO_E_ARGTYPE, "REPEAT needs a number"); + return; + } + + int ri = emit(p, RUBO_OP_REPEAT); + if (ri < 0) return; + p->out[ri].argc = 1; + p->out[ri].args[0] = count; + + if (!at(p, T_LBRACKET)) { + fail(p, RUBO_E_SYNTAX, "REPEAT needs a sequence in brackets"); + return; + } + parse_sequence(p); + if (p->err != RUBO_OK) return; + + int ei = emit(p, RUBO_OP_ENDREP); + if (ei < 0) return; + p->out[ei].a = (uint16_t)(ri + 1); /* first body instruction */ + p->out[ri].a = (uint16_t)(ei + 1); /* past the loop */ +} + +static void parse_statement(P *p) +{ + switch (p->lx.tok.type) { + case T_LBRACKET: + parse_sequence(p); + return; + case T_IDENT: + parse_call(p); + return; + case T_KEYWORD: + switch (p->lx.tok.kw) { + case KW_REPEAT: + parse_repeat(p); + return; + case KW_IF: case KW_ELIF: case KW_ELSE: + fail(p, RUBO_E_SYNTAX, + "%s is reserved but not implemented; it lands with the " + "sensor layer (docs/grammar.md §7)", + rubo_kw_name(p->lx.tok.kw)); + return; + default: + fail(p, RUBO_E_SYNTAX, "%s is only valid at the top level", + rubo_kw_name(p->lx.tok.kw)); + return; + } + case T_RELOP: + fail(p, RUBO_E_SYNTAX, "comparisons are reserved (docs/grammar.md §7)"); + return; + case T_SEMI: + /* docs/flow.md §4.4 */ + fail(p, RUBO_E_SYNTAX, + "one statement per frame; wrap several in brackets: [ A; B ]"); + return; + default: + fail(p, RUBO_E_SYNTAX, "expected a statement"); + return; + } +} + +/* ------------------------------------------------------------------- DEF */ + +static void parse_def(P *p, bool *immediate) +{ + rubo_t *r = p->r; + + advance(p); /* past DEF */ + if (!at(p, T_IDENT)) { + fail(p, RUBO_E_SYNTAX, "DEF needs a name"); + return; + } + rubo_sym_t name = intern_tok(p); + if (p->err != RUBO_OK) return; + advance(p); + + int existing = rubo_reg_find(r, name); + if (existing >= 0 && r->tab[existing].kind != RUBO_C_DYNAMIC) { + fail(p, RUBO_E_READONLY, "'%s' is built in and cannot be redefined", + sname(p, name)); + return; + } + + rubo_sym_t pnames[RUBO_MAX_PARAMS]; + rubo_value_t pdefs[RUBO_MAX_PARAMS]; + uint8_t phas[RUBO_MAX_PARAMS]; + uint8_t np = 0; + memset(pdefs, 0, sizeof pdefs); + memset(phas, 0, sizeof phas); + + while (at(p, T_DOLLAR)) { + if (np >= RUBO_MAX_PARAMS) { + fail(p, RUBO_E_LIMIT, "at most %d parameters", RUBO_MAX_PARAMS); + return; + } + advance(p); + if (!at(p, T_IDENT)) { + fail(p, RUBO_E_SYNTAX, "expected a parameter name after '$'"); + return; + } + rubo_sym_t ps = intern_tok(p); + if (p->err != RUBO_OK) return; + for (uint8_t i = 0; i < np; i++) { + if (pnames[i] == ps) { + fail(p, RUBO_E_ARGNAME, "duplicate parameter '%s'", + sname(p, ps)); + return; + } + } + pnames[np] = ps; + advance(p); + + if (at(p, T_EQ)) { + advance(p); + if (!parse_literal(p, &pdefs[np])) return; + phas[np] = 1; + } else if (np > 0 && phas[np - 1]) { + /* Otherwise `DEF F $a=1 $b` would make positional calls behave + * in a way nobody can predict from reading the signature. */ + fail(p, RUBO_E_SYNTAX, + "'%s' has no default: parameters with defaults must come last", + sname(p, ps)); + return; + } + np++; + } + + if (!at(p, T_LBRACKET)) { + fail(p, RUBO_E_SYNTAX, "DEF needs a body in brackets"); + return; + } + + /* Compile the body into scratch, with the parameter list in scope. */ + p->out = r->scratch; + p->cap = RUBO_SCRATCH_INSTRS; + p->n = 0; + p->in_def = true; + p->nparams = np; + memcpy(p->params, pnames, sizeof pnames); + + parse_sequence(p); + if (p->err != RUBO_OK) return; + if (!at(p, T_EOF)) { + fail(p, RUBO_E_SYNTAX, "unexpected text after the body"); + return; + } + if (emit(p, RUBO_OP_HALT) < 0) return; + + uint8_t depth = (uint8_t)(p->maxdepth + 1); + if (depth > RUBO_MAX_DEPTH) { + fail(p, RUBO_E_LIMIT, + "call depth %u exceeds the limit of %d", depth, RUBO_MAX_DEPTH); + return; + } + + if (existing >= 0) { + /* docs/grammar.md §5.2 */ + if (rubo_reg_references(r, r->scratch, p->n, existing)) { + fail(p, RUBO_E_CYCLE, + "redefining '%s' this way would make it call itself", + sname(p, name)); + return; + } + const rubo_callable_t *old = &r->tab[existing]; + bool same_sig = (old->nparams == np); + for (uint8_t i = 0; same_sig && i < np; i++) + if (old->params[i] != pnames[i]) same_sig = false; + if (!same_sig) { + for (uint16_t i = 0; i < r->ncallables; i++) { + if ((int)i == existing) continue; + const rubo_callable_t *o = &r->tab[i]; + if (o->kind != RUBO_C_STATIC && o->kind != RUBO_C_DYNAMIC) + continue; + if (rubo_reg_references(r, &r->arena[o->body], o->nbody, + existing)) { + fail(p, RUBO_E_ARITY, + "'%s' is called by '%s'; its signature cannot change " + "while that holds", sname(p, name), sname(p, o->name)); + return; + } + } + } + } + + /* Commit the new body before releasing the old one, so a full arena + * fails cleanly instead of destroying a working procedure. */ + int base = rubo_arena_put(r, r->scratch, p->n); + if (base < 0) { + fail(p, RUBO_E_NOMEM, "no room for another procedure"); + return; + } + + int slot = existing; + if (slot < 0) { + for (uint16_t i = 0; i < r->ncallables; i++) + if (r->tab[i].kind == RUBO_C_FREE) { slot = (int)i; break; } + } + if (slot < 0) { + if (r->ncallables >= RUBO_MAX_CALLABLES) { + rubo_arena_release(r, (uint16_t)base, p->n); + fail(p, RUBO_E_LIMIT, "too many procedures"); + return; + } + slot = r->ncallables++; + } + + uint16_t old_base = 0, old_n = 0; + if (existing >= 0) { + old_base = r->tab[slot].body; + old_n = r->tab[slot].nbody; + } + + rubo_callable_t *c = &r->tab[slot]; + memset(c, 0, sizeof *c); + c->name = name; + c->kind = r->stdlib_mode ? RUBO_C_STATIC : RUBO_C_DYNAMIC; + c->nparams = np; + c->depth = depth; + c->body = (uint16_t)base; + c->nbody = p->n; + memcpy(c->params, pnames, sizeof pnames); + memcpy(c->defaults, pdefs, sizeof pdefs); + memcpy(c->has_default, phas, sizeof phas); + + if (existing >= 0) rubo_arena_release(r, old_base, old_n); + + r->scratch_used = 0; + *immediate = false; +} + +/* ------------------------------------------------------------------- DEL */ + +static void parse_del(P *p, bool *immediate) +{ + rubo_t *r = p->r; + + advance(p); + if (!at(p, T_IDENT)) { + fail(p, RUBO_E_SYNTAX, "DEL needs a name"); + return; + } + rubo_sym_t name = intern_tok(p); + if (p->err != RUBO_OK) return; + advance(p); + if (!at(p, T_EOF)) { + fail(p, RUBO_E_SYNTAX, "unexpected text after DEL"); + return; + } + + int ci = rubo_reg_find(r, name); + if (ci < 0) { + fail(p, RUBO_E_UNKNOWN, "unknown callable '%s'", sname(p, name)); + return; + } + if (r->tab[ci].kind != RUBO_C_DYNAMIC) { + fail(p, RUBO_E_READONLY, "'%s' is built in and cannot be deleted", + sname(p, name)); + return; + } + /* Deleting a procedure another one calls would leave a dangling callee, + * so it is refused rather than cascaded. */ + for (uint16_t i = 0; i < r->ncallables; i++) { + if ((int)i == ci) continue; + const rubo_callable_t *o = &r->tab[i]; + if (o->kind != RUBO_C_STATIC && o->kind != RUBO_C_DYNAMIC) continue; + if (rubo_reg_references(r, &r->arena[o->body], o->nbody, ci)) { + fail(p, RUBO_E_READONLY, "'%s' is called by '%s'", + sname(p, name), sname(p, o->name)); + return; + } + } + + uint16_t base = r->tab[ci].body, n = r->tab[ci].nbody; + memset(&r->tab[ci], 0, sizeof r->tab[ci]); + r->tab[ci].kind = RUBO_C_FREE; + rubo_arena_release(r, base, n); + + *immediate = false; +} + +/* ----------------------------------------------------------------- entry */ + +rubo_err_t rubo_parse_statement(rubo_t *r, const char *src, bool *immediate, + char *errmsg, size_t errlen) +{ + P p; + memset(&p, 0, sizeof p); + p.r = r; + p.out = r->scratch; + p.cap = RUBO_SCRATCH_INSTRS; + p.emsg = errmsg; + p.elen = errlen; + if (errmsg && errlen) errmsg[0] = '\0'; + + *immediate = false; + r->scratch_used = 0; + + rubo_lex_init(&p.lx, src); + if (p.lx.tok.type == T_ERROR) return p.lx.err ? p.lx.err : RUBO_E_SYNTAX; + if (at(&p, T_EOF)) return RUBO_OK; /* nothing to do */ + + if (at(&p, T_KEYWORD) && p.lx.tok.kw == KW_DEF) { + parse_def(&p, immediate); + return p.err; + } + if (at(&p, T_KEYWORD) && p.lx.tok.kw == KW_DEL) { + parse_del(&p, immediate); + return p.err; + } + + parse_statement(&p); + if (p.err != RUBO_OK) return p.err; + if (at(&p, T_SEMI)) { + /* docs/flow.md §4.4: one statement per frame. This is much the most + * likely way to trip over that, so it gets the specific message. */ + fail(&p, RUBO_E_SYNTAX, + "one statement per frame; wrap several in brackets: [ A; B ]"); + return p.err; + } + if (!at(&p, T_EOF)) { + fail(&p, RUBO_E_SYNTAX, "unexpected text after the statement"); + return p.err; + } + if (emit(&p, RUBO_OP_HALT) < 0) return p.err; + + if ((uint8_t)(p.maxdepth + 1) > RUBO_MAX_DEPTH) { + fail(&p, RUBO_E_LIMIT, "call depth %u exceeds the limit of %d", + p.maxdepth + 1, RUBO_MAX_DEPTH); + return p.err; + } + + r->scratch_used = p.n; + *immediate = true; + return RUBO_OK; +} diff --git a/src/registry.c b/src/registry.c new file mode 100644 index 0000000..fa7a0b1 --- /dev/null +++ b/src/registry.c @@ -0,0 +1,152 @@ +/* registry.c — the single namespace of callables (docs/grammar.md §2) and + * the instruction arena that holds procedure bodies. + * + * Bodies are position-independent: every jump target inside a body is an + * index relative to that body's first instruction. That is what makes + * arena compaction on DEL a memmove plus a base fix-up, rather than a + * relocation pass. + */ +#include "internal.h" + +#include + +void rubo_reg_init(rubo_t *r) +{ + r->ncallables = 0; + r->arena_used = 0; + memset(r->tab, 0, sizeof r->tab); +} + +int rubo_reg_find(const rubo_t *r, rubo_sym_t name) +{ + if (name == RUBO_SYM_NONE) return -1; + for (uint16_t i = 0; i < r->ncallables; i++) + if (r->tab[i].kind != RUBO_C_FREE && r->tab[i].name == name) + return (int)i; + return -1; +} + +/* --------------------------------------------------------------- natives */ + +static size_t field(const char *csv, size_t idx, char *out, size_t outlen) +{ + size_t f = 0, n = 0; + + out[0] = '\0'; + if (!csv) return 0; + for (const char *p = csv;; p++) { + if (*p == ',' || *p == '\0') { + if (f == idx) { out[n < outlen ? n : outlen - 1] = '\0'; return n; } + f++; + n = 0; + if (*p == '\0') return 0; + continue; + } + if (f == idx && n + 1 < outlen) out[n] = *p; + n++; + } +} + +static size_t count_fields(const char *csv) +{ + if (!csv || !*csv) return 0; + size_t n = 1; + for (const char *p = csv; *p; p++) if (*p == ',') n++; + return n; +} + +rubo_err_t rubo_reg_add_native(rubo_t *r, const rubo_native_def_t *def) +{ + char buf[RUBO_MAX_NAME + 1]; + + if (r->ncallables >= RUBO_MAX_CALLABLES) return RUBO_E_LIMIT; + + size_t np = count_fields(def->params); + if (np > RUBO_MAX_PARAMS) return RUBO_E_LIMIT; + + rubo_callable_t *c = &r->tab[r->ncallables]; + memset(c, 0, sizeof *c); + + c->name = rubo_sym_intern(&r->syms, def->name, strlen(def->name)); + if (c->name == RUBO_SYM_NONE) return RUBO_E_LIMIT; + c->kind = RUBO_C_NATIVE; + c->fn = def->fn; + c->nparams = (uint8_t)np; + c->depth = 1; + + for (size_t i = 0; i < np; i++) { + if (field(def->params, i, buf, sizeof buf) == 0) return RUBO_E_SYNTAX; + c->params[i] = rubo_sym_intern(&r->syms, buf, strlen(buf)); + if (c->params[i] == RUBO_SYM_NONE) return RUBO_E_LIMIT; + + char ty = def->types && strlen(def->types) > i ? def->types[i] : '*'; + c->ptype[i] = (ty == 'n') ? RUBO_V_NUM + : (ty == 'w') ? RUBO_V_WORD + : RUBO_V_NONE; + + if (field(def->defaults, i, buf, sizeof buf) > 0) { + c->has_default[i] = 1; + /* Native defaults are numeric literals only; a word default + * would need interning here and none of the natives want one. */ + int32_t v = 0, neg = 0; + const char *p = buf; + if (*p == '-') { neg = 1; p++; } + for (; *p >= '0' && *p <= '9'; p++) v = v * 10 + (*p - '0'); + c->defaults[i].type = RUBO_V_NUM; + c->defaults[i].num = neg ? -v : v; + } + } + + r->ncallables++; + return RUBO_OK; +} + +/* ----------------------------------------------------------- reachability */ + +bool rubo_reg_references(const rubo_t *r, const rubo_instr_t *code, + uint16_t ncode, int needle) +{ + /* The existing call graph is acyclic by construction (docs/grammar.md + * §5.1), so a plain depth-first walk terminates without a visited set. + * Depth is bounded by RUBO_MAX_DEPTH, which is why this can recurse. */ + for (uint16_t i = 0; i < ncode; i++) { + if (code[i].op != RUBO_OP_CALL) continue; + int callee = (int)code[i].a; + if (callee == needle) return true; + if (callee < 0 || callee >= (int)r->ncallables) continue; + const rubo_callable_t *c = &r->tab[callee]; + if (c->kind == RUBO_C_STATIC || c->kind == RUBO_C_DYNAMIC) { + if (rubo_reg_references(r, &r->arena[c->body], c->nbody, needle)) + return true; + } + } + return false; +} + +/* ----------------------------------------------------------------- arena */ + +int rubo_arena_put(rubo_t *r, const rubo_instr_t *code, uint16_t n) +{ + if ((uint32_t)r->arena_used + n > RUBO_ARENA_INSTRS) return -1; + uint16_t base = r->arena_used; + memcpy(&r->arena[base], code, (size_t)n * sizeof *code); + r->arena_used = (uint16_t)(base + n); + return (int)base; +} + +void rubo_arena_release(rubo_t *r, uint16_t base, uint16_t n) +{ + if (n == 0) return; + + uint16_t tail = (uint16_t)(base + n); + uint16_t move = (uint16_t)(r->arena_used - tail); + if (move) + memmove(&r->arena[base], &r->arena[tail], (size_t)move * sizeof r->arena[0]); + r->arena_used = (uint16_t)(r->arena_used - n); + + for (uint16_t i = 0; i < r->ncallables; i++) { + rubo_callable_t *c = &r->tab[i]; + if (c->kind != RUBO_C_STATIC && c->kind != RUBO_C_DYNAMIC) continue; + if (c->body >= tail) c->body = (uint16_t)(c->body - n); + } +} diff --git a/src/rubo.c b/src/rubo.c new file mode 100644 index 0000000..b9db5c3 --- /dev/null +++ b/src/rubo.c @@ -0,0 +1,109 @@ +/* rubo.c — instance lifecycle and output. */ +#include "internal.h" + +#include +#include +#include +#include + +const char *rubo_strerror(rubo_err_t e) +{ + switch (e) { + case RUBO_OK: return "ok"; + case RUBO_E_SYNTAX: return "syntax error"; + case RUBO_E_UNKNOWN: return "unknown callable"; + case RUBO_E_ARITY: return "wrong number of arguments"; + case RUBO_E_ARGNAME: return "bad argument name"; + case RUBO_E_ARGTYPE: return "wrong argument type"; + case RUBO_E_RANGE: return "value out of range"; + case RUBO_E_UNDEFINED: return "callable not defined yet"; + case RUBO_E_CYCLE: return "would create a cycle"; + case RUBO_E_NOMEM: return "out of procedure memory"; + case RUBO_E_LIMIT: return "limit exceeded"; + case RUBO_E_READONLY: return "built in"; + case RUBO_E_BUSY: return "busy"; + case RUBO_E_ABORT: return "aborted"; + case RUBO_E_FAULT: return "hardware fault"; + } + return "error"; +} + +void rubo_outf(rubo_t *r, const char *fmt, ...) +{ + char line[256]; + va_list ap; + + if (!r->out) return; + va_start(ap, fmt); + vsnprintf(line, sizeof line, fmt, ap); + va_end(ap); + r->out(r->out_ctx, line); +} + +void rubo_emit_event(rubo_t *r, const char *fmt, ...) +{ + char body[224]; + va_list ap; + + va_start(ap, fmt); + vsnprintf(body, sizeof body, fmt, ap); + va_end(ap); + rubo_outf(r, "EVT %s", body); +} + +void rubo_note_traffic(rubo_t *r, uint32_t now_ms) +{ + r->last_traffic_ms = now_ms; +} + +rubo_t *rubo_create(const rubo_hal_t *hal, rubo_out_fn out, void *out_ctx) +{ + /* The single allocation happens at boot; execution allocates nothing + * (docs/flow.md §1). */ + rubo_t *r = calloc(1, sizeof *r); + if (!r) return NULL; + + r->hal = hal; + r->out = out; + r->out_ctx = out_ctx; + + rubo_sym_init(&r->syms); + rubo_reg_init(r); + rubo_exec_reset(r); + rubo_natives_register(r); + + if (hal && hal->init) hal->init(); + return r; +} + +void rubo_destroy(rubo_t *r) +{ + free(r); +} + +rubo_err_t rubo_load_stdlib(rubo_t *r, const char *const *defs) +{ + char msg[160]; + + if (!defs) return RUBO_OK; + r->stdlib_mode = true; + for (size_t i = 0; defs[i]; i++) { + bool immediate = false; + rubo_err_t e = rubo_parse_statement(r, defs[i], &immediate, + msg, sizeof msg); + if (e != RUBO_OK) { + r->stdlib_mode = false; + rubo_outf(r, "EVT STDLIB %d %s", (int)e, msg[0] ? msg : ""); + return e; + } + if (immediate) { + /* The standard library is definitions, not commands: a stray + * executable statement would run at boot. */ + r->stdlib_mode = false; + rubo_outf(r, "EVT STDLIB %d not a definition", (int)RUBO_E_SYNTAX); + return RUBO_E_SYNTAX; + } + } + r->stdlib_mode = false; + return RUBO_OK; +} diff --git a/src/session.c b/src/session.c new file mode 100644 index 0000000..f55df83 --- /dev/null +++ b/src/session.c @@ -0,0 +1,117 @@ +/* session.c — the envelope and admission (docs/flow.md §5, §7, §9). + * + * Everything here happens before the parser sees anything, which is the + * point: correlation tags and control words are wire protocol, not + * language, and docs/grammar.md stays a specification of a language. + */ +#include "internal.h" + +#include +#include + +static bool is_space(char c) { return c == ' ' || c == '\t'; } +static char upper(char c) { return (c >= 'a' && c <= 'z') ? (char)(c - 32) : c; } + +static const char *skip_space(const char *s) +{ + while (is_space(*s)) s++; + return s; +} + +/* Matches a control word occupying the whole statement. */ +static bool control_is(const char *s, const char *word) +{ + size_t n = strlen(word); + for (size_t i = 0; i < n; i++) + if (upper(s[i]) != word[i]) return false; + return *skip_space(s + n) == '\0'; +} + +rubo_err_t rubo_submit(rubo_t *r, const char *stmt, uint32_t now_ms) +{ + char msg[160]; + char pfx[16]; + + rubo_note_traffic(r, now_ms); + + const char *s = skip_space(stmt); + + /* --- §5.1 correlation tag ------------------------------------------- */ + bool tagged = false; + uint32_t tag = 0; + if (*s == '@') { + const char *p = s + 1; + if (*p < '0' || *p > '9') { + rubo_outf(r, "ERR %d %s", (int)RUBO_E_SYNTAX, + "'@' must be followed by a tag number"); + return RUBO_E_SYNTAX; + } + while (*p >= '0' && *p <= '9') { + tag = tag * 10 + (uint32_t)(*p - '0'); + if (tag > 65535) tag = 65535; + p++; + } + tagged = true; + s = skip_space(p); + } + if (tagged) snprintf(pfx, sizeof pfx, "@%u ", (unsigned)tag); + else pfx[0] = '\0'; + + /* --- §5.2 control words --------------------------------------------- */ + /* Immediate, never queued, never subject to E_BUSY. ABORT in particular + * cannot be a callable: a callable only runs when the interpreter is + * free to run it, which is exactly when you do not need it. */ + if (control_is(s, "ABORT")) { + rubo_abort(r, now_ms); + return RUBO_OK; + } + if (control_is(s, "PING")) { + rubo_outf(r, "%sPONG", pfx); + return RUBO_OK; + } + if (control_is(s, "STAT")) { + char line[192]; + rubo_status_line(r, line, sizeof line, now_ms); + rubo_outf(r, "%s%s", pfx, line); + return RUBO_OK; + } + + if (*s == '\0') { + rubo_outf(r, "%sERR %d empty statement", pfx, (int)RUBO_E_SYNTAX); + return RUBO_E_SYNTAX; + } + + /* --- §7 admission, before parsing ----------------------------------- */ + /* No queue (docs/flow.md §9): there is no point spending cycles + * validating something that cannot run. */ + if (r->state == RUBO_RUNNING) { + rubo_outf(r, "%sERR %d busy", pfx, (int)RUBO_E_BUSY); + return RUBO_E_BUSY; + } + + /* --- parse and validate --------------------------------------------- */ + bool immediate = false; + rubo_err_t e = rubo_parse_statement(r, s, &immediate, msg, sizeof msg); + if (e != RUBO_OK) { + rubo_outf(r, "%sERR %d %s", pfx, (int)e, + msg[0] ? msg : rubo_strerror(e)); + return e; + } + + r->tagged = tagged; + r->tag = tag; + rubo_outf(r, "%sACK", pfx); + + if (!immediate) { + /* DEF and DEL complete at validation time. ACK and DONE are still + * both sent (docs/flow.md §6): a client that always waits for DONE + * needs no special case. */ + rubo_outf(r, "%sDONE", pfx); + r->tagged = false; + r->tag = 0; + return RUBO_OK; + } + + rubo_exec_start(r, r->scratch, r->scratch_used, now_ms); + return RUBO_OK; +} diff --git a/src/stdlib.c b/src/stdlib.c new file mode 100644 index 0000000..94c4c0b --- /dev/null +++ b/src/stdlib.c @@ -0,0 +1,30 @@ +/* stdlib.c — firmware-resident procedures (docs/grammar.md §2). + * + * These are written in the command language and parsed at boot by the same + * parser that handles anything arriving over the wire. That is the whole + * point of the STATIC kind: a procedure proven over the wire is promoted to + * firmware by pasting its text here, with no translation step and no second + * representation to keep in sync. + */ +#include "rubo/rubo.h" + +const char *const rubo_stdlib[] = { + "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" + "]", + + "DEF WIGGLE $deg=30 [" + " TURN dir=LEFT deg=$deg;" + " TURN dir=RIGHT deg=$deg" + "]", + + NULL, +}; diff --git a/src/sym.c b/src/sym.c new file mode 100644 index 0000000..84a28c0 --- /dev/null +++ b/src/sym.c @@ -0,0 +1,38 @@ +/* sym.c — identifier interning. + * + * Callable names, parameter names and word values all live in one table: + * they are the same lexical class (docs/grammar.md §3.1) and sharing the + * table means every comparison downstream is a uint16 compare. + */ +#include "internal.h" + +#include + +void rubo_sym_init(rubo_symtab_t *t) +{ + memset(t, 0, sizeof *t); +} + +static char upper(char c) { return (c >= 'a' && c <= 'z') ? (char)(c - 32) : c; } + +rubo_sym_t rubo_sym_intern(rubo_symtab_t *t, const char *s, size_t len) +{ + char tmp[RUBO_MAX_NAME + 1]; + + if (len == 0 || len > RUBO_MAX_NAME) return RUBO_SYM_NONE; + for (size_t i = 0; i < len; i++) tmp[i] = upper(s[i]); + tmp[len] = '\0'; + + for (uint16_t i = 0; i < t->count; i++) + if (strcmp(t->rec[i].name, tmp) == 0) return (rubo_sym_t)i; + + if (t->count >= RUBO_MAX_SYMBOLS) return RUBO_SYM_NONE; + memcpy(t->rec[t->count].name, tmp, len + 1); + return (rubo_sym_t)(t->count++); +} + +const char *rubo_sym_name(const rubo_symtab_t *t, rubo_sym_t id) +{ + if (id == RUBO_SYM_NONE || id >= t->count) return "?"; + return t->rec[id].name; +} diff --git a/tests/test_rubo.c b/tests/test_rubo.c new file mode 100644 index 0000000..cd1ced1 --- /dev/null +++ b/tests/test_rubo.c @@ -0,0 +1,560 @@ +/* test_rubo.c — host tests for the language, the framer and the executor. + * + * The emphasis is on the properties the design leans on: the DAG rule, the + * depth bound, admission with no queue, and abort. Those are the ones that + * stop being theoretical the moment there are motors attached. + */ +#include "rubo/hal_sim.h" +#include "rubo/rubo.h" + +#include +#include + +static int g_run, g_fail; +static const char *g_case = ""; + +#define CHECK(cond, ...) \ + do { \ + g_run++; \ + if (!(cond)) { \ + g_fail++; \ + printf("FAIL [%s] %s:%d: ", g_case, __FILE__, __LINE__); \ + printf(__VA_ARGS__); \ + printf("\n"); \ + } \ + } while (0) + +/* ----------------------------------------------------------- output capture */ + +#define MAXL 256 +static char g_lines[MAXL][256]; +static int g_nl; + +static void sink(void *ctx, const char *line) +{ + (void)ctx; + if (g_nl < MAXL) snprintf(g_lines[g_nl++], sizeof g_lines[0], "%s", line); +} + +static void clr(void) { g_nl = 0; } + +static bool saw(const char *sub) +{ + for (int i = 0; i < g_nl; i++) + if (strstr(g_lines[i], sub)) return true; + return false; +} + +static void dump(void) +{ + for (int i = 0; i < g_nl; i++) printf(" | %s\n", g_lines[i]); +} + +/* ------------------------------------------------------------------ fixture */ + +static uint32_t g_now; + +static rubo_t *mk(void) +{ + rubo_hal_sim_reset(); + g_now = 1000; + clr(); + rubo_t *r = rubo_create(rubo_hal_sim(), sink, NULL); + rubo_note_traffic(r, g_now); + rubo_load_stdlib(r, rubo_stdlib); + clr(); + return r; +} + +/* Advance time with the link alive. */ +static void run(rubo_t *r, uint32_t ms) +{ + for (uint32_t i = 0; i < ms; i += 5) { + g_now += 5; + rubo_note_traffic(r, g_now); + rubo_tick(r, g_now); + } +} + +/* Advance time with nothing arriving — the deadman's domain. */ +static void run_quiet(rubo_t *r, uint32_t ms) +{ + for (uint32_t i = 0; i < ms; i += 5) { + g_now += 5; + rubo_tick(r, g_now); + } +} + +static rubo_err_t sub(rubo_t *r, const char *s) +{ + return rubo_submit(r, s, g_now); +} + +/* Submit and run to completion (or until an implausible amount of simulated + * time has passed, which counts as a hang). */ +static rubo_err_t subrun(rubo_t *r, const char *s) +{ + rubo_err_t e = sub(r, s); + for (int i = 0; i < 4000 && rubo_state(r) != RUBO_IDLE; i++) { + g_now += 5; + rubo_note_traffic(r, g_now); + rubo_tick(r, g_now); + } + return e; +} + +#define CASE(name) do { g_case = (name); clr(); } while (0) + +/* ------------------------------------------------------------------- tests */ + +static void test_basics(void) +{ + rubo_t *r = mk(); + + CASE("stdlib loaded"); + CHECK(sub(r, "STAT") == RUBO_OK, "STAT failed"); + CHECK(saw("proc=9"), "expected 6 natives + 3 stdlib procedures"); + + CASE("control words bypass the language"); + clr(); + CHECK(sub(r, "PING") == RUBO_OK, "PING failed"); + CHECK(saw("PONG"), "no PONG"); + clr(); + CHECK(sub(r, "@7 PING") == RUBO_OK, "tagged PING failed"); + CHECK(saw("@7 PONG"), "tag not echoed"); + + CASE("ack then done"); + clr(); + CHECK(subrun(r, "@1 STOP") == RUBO_OK, "STOP failed"); + CHECK(saw("@1 ACK"), "no ACK"); + CHECK(saw("@1 DONE"), "no DONE"); + + CASE("unknown callable"); + clr(); + CHECK(sub(r, "WOBBLE") == RUBO_E_UNKNOWN, "expected E_UNKNOWN"); + CHECK(saw("ERR 2"), "wrong code"); + + CASE("empty sequence is legal"); + clr(); + CHECK(subrun(r, "[]") == RUBO_OK, "empty sequence rejected"); + CHECK(saw("DONE"), "no DONE for empty sequence"); + + rubo_destroy(r); +} + +static void test_named_args(void) +{ + rubo_t *r = mk(); + + CASE("named args are order-independent"); + clr(); + CHECK(subrun(r, "MOVE dur=20 speed=50 dir=FWD") == RUBO_OK, "reorder failed"); + CHECK(saw("DONE"), "did not complete"); + uint32_t named_changes = rubo_hal_sim_state()->change_count; + + rubo_hal_sim_reset(); + clr(); + CHECK(subrun(r, "MOVE FWD 50 20") == RUBO_OK, "positional failed"); + CHECK(rubo_hal_sim_state()->change_count == named_changes, + "positional and named forms behaved differently (%u vs %u)", + rubo_hal_sim_state()->change_count, named_changes); + + CASE("mixed positional then named"); + clr(); + CHECK(subrun(r, "MOVE FWD speed=50 dur=20") == RUBO_OK, "mixed failed"); + + CASE("positional after named is rejected"); + clr(); + CHECK(sub(r, "MOVE dir=FWD 50 20") == RUBO_E_SYNTAX, "expected E_SYNTAX"); + CHECK(saw("positional"), "unhelpful message"); + + CASE("defaults fill in"); + clr(); + CHECK(subrun(r, "SQUARE side=20") == RUBO_OK, "default speed not applied"); + CHECK(saw("DONE"), "did not complete"); + + CASE("missing required argument"); + clr(); + CHECK(sub(r, "SQUARE") == RUBO_E_ARITY, "expected E_ARITY"); + /* Identifiers come back canonicalised to upper case (docs/grammar.md §3.4). */ + CHECK(saw("ERR 3") && saw("SIDE"), "message should name the parameter"); + + CASE("unknown parameter name"); + clr(); + CHECK(sub(r, "SQUARE side=10 velocity=3") == RUBO_E_ARGNAME, "expected E_ARGNAME"); + CHECK(saw("ERR 4"), "wrong code"); + + CASE("duplicate argument"); + clr(); + CHECK(sub(r, "SQUARE side=10 side=20") == RUBO_E_ARGNAME, "expected E_ARGNAME"); + + CASE("too many positionals"); + clr(); + CHECK(sub(r, "STOP 1") == RUBO_E_ARITY, "expected E_ARITY"); + + CASE("literal type mismatch is caught before execution"); + clr(); + CHECK(sub(r, "MOVE 5 50 20") == RUBO_E_ARGTYPE, "expected E_ARGTYPE"); + CHECK(saw("ERR 5"), "wrong code"); + + CASE("a bad word value is a range error at runtime"); + clr(); + CHECK(subrun(r, "MOVE dir=SIDEWAYS speed=10 dur=10") == RUBO_OK, + "should parse; the word is only wrong at run time"); + CHECK(saw("ERR 6"), "expected E_RANGE"); + + rubo_destroy(r); +} + +static void test_definitions(void) +{ + rubo_t *r = mk(); + + CASE("define and call"); + clr(); + CHECK(sub(r, "DEF BOX $s [ MOVE dir=FWD speed=30 dur=$s ]") == RUBO_OK, + "DEF failed"); + CHECK(saw("ACK") && saw("DONE"), "DEF should ACK and DONE"); + clr(); + CHECK(subrun(r, "BOX s=20") == RUBO_OK, "call failed"); + CHECK(saw("DONE"), "did not complete"); + + CASE("forward references are refused (docs/grammar.md §5.1)"); + clr(); + CHECK(sub(r, "DEF LATER [ NOTYET ]") == RUBO_E_UNDEFINED, "expected E_UNDEFINED"); + CHECK(saw("ERR 7"), "wrong code"); + + CASE("$ must name a parameter of the enclosing procedure"); + clr(); + CHECK(sub(r, "DEF BAD $a [ MOVE dir=FWD speed=$b dur=10 ]") == RUBO_E_SYNTAX, + "expected E_SYNTAX"); + clr(); + CHECK(sub(r, "MOVE dir=FWD speed=$a dur=10") == RUBO_E_SYNTAX, + "no parameters at the top level"); + + CASE("duplicate parameter"); + clr(); + CHECK(sub(r, "DEF DUP $a $a [ STOP ]") == RUBO_E_ARGNAME, "expected E_ARGNAME"); + + CASE("defaults must come last"); + clr(); + CHECK(sub(r, "DEF ORD $a=1 $b [ STOP ]") == RUBO_E_SYNTAX, "expected E_SYNTAX"); + + CASE("natives are neither redefinable nor deletable"); + clr(); + CHECK(sub(r, "DEF MOVE $x [ STOP ]") == RUBO_E_READONLY, "expected E_READONLY"); + clr(); + CHECK(sub(r, "DEL MOVE") == RUBO_E_READONLY, "expected E_READONLY"); + + CASE("stdlib procedures are STATIC, so also read-only"); + clr(); + CHECK(sub(r, "DEL SQUARE") == RUBO_E_READONLY, "expected E_READONLY"); + + CASE("redefinition with the same signature is fine"); + clr(); + CHECK(sub(r, "DEF BOX $s [ MOVE dir=BACK speed=30 dur=$s ]") == RUBO_OK, + "same-signature redefinition rejected"); + + CASE("cycles are refused (docs/grammar.md §5.2)"); + clr(); + CHECK(sub(r, "DEF A [ STOP ]") == RUBO_OK, "DEF A failed"); + CHECK(sub(r, "DEF B [ A ]") == RUBO_OK, "DEF B failed"); + clr(); + CHECK(sub(r, "DEF A [ B ]") == RUBO_E_CYCLE, "expected E_CYCLE"); + CHECK(saw("ERR 8"), "wrong code"); + + CASE("signature cannot change while a caller exists"); + clr(); + CHECK(sub(r, "DEF A $x [ STOP ]") == RUBO_E_ARITY, "expected E_ARITY"); + CHECK(saw("B"), "message should name the caller"); + + CASE("DEL refuses to dangle a caller, then succeeds once free"); + clr(); + CHECK(sub(r, "DEL A") == RUBO_E_READONLY, "A is still called by B"); + clr(); + CHECK(sub(r, "DEL B") == RUBO_OK, "DEL B failed"); + CHECK(sub(r, "DEL A") == RUBO_OK, "DEL A failed after its caller went"); + clr(); + CHECK(sub(r, "A") == RUBO_E_UNKNOWN, "A should be gone"); + + CASE("depth limit is enforced at definition time"); + clr(); + CHECK(sub(r, "DEF P1 [ STOP ]") == RUBO_OK, "P1"); + CHECK(sub(r, "DEF P2 [ P1 ]") == RUBO_OK, "P2"); + CHECK(sub(r, "DEF P3 [ P2 ]") == RUBO_OK, "P3"); + CHECK(sub(r, "DEF P4 [ P3 ]") == RUBO_OK, "P4"); + CHECK(sub(r, "DEF P5 [ P4 ]") == RUBO_OK, "P5"); + CHECK(sub(r, "DEF P6 [ P5 ]") == RUBO_OK, "P6"); + CHECK(sub(r, "DEF P7 [ P6 ]") == RUBO_OK, "P7 should sit exactly at the limit"); + clr(); + CHECK(sub(r, "DEF P8 [ P7 ]") == RUBO_E_LIMIT, "expected E_LIMIT"); + CHECK(saw("ERR 10"), "wrong code"); + + rubo_destroy(r); +} + +static void test_repeat(void) +{ + rubo_t *r = mk(); + + CASE("REPEAT runs the body exactly n times"); + clr(); + rubo_hal_sim_reset(); + CHECK(subrun(r, "REPEAT 3 [ DRIVE left=10 right=10 dur=10 ]") == RUBO_OK, + "REPEAT failed"); + CHECK(saw("DONE"), "did not complete"); + /* Each iteration engages (0->10) then releases (10->0). */ + CHECK(rubo_hal_sim_state()->change_count == 6, + "expected 6 motor changes, got %u", + rubo_hal_sim_state()->change_count); + + CASE("REPEAT 0 skips the body"); + clr(); + rubo_hal_sim_reset(); + CHECK(subrun(r, "REPEAT 0 [ DRIVE left=10 right=10 dur=10 ]") == RUBO_OK, + "REPEAT 0 failed"); + CHECK(rubo_hal_sim_state()->change_count == 0, "body should not have run"); + + CASE("REPEAT nests"); + clr(); + rubo_hal_sim_reset(); + CHECK(subrun(r, "REPEAT 2 [ REPEAT 3 [ DRIVE left=10 right=10 dur=5 ] ]") + == RUBO_OK, "nested REPEAT failed"); + CHECK(rubo_hal_sim_state()->change_count == 12, + "expected 12 motor changes, got %u", + rubo_hal_sim_state()->change_count); + + CASE("REPEAT takes a parameter"); + clr(); + CHECK(sub(r, "DEF NTIMES $n [ REPEAT $n [ WAIT dur=1 ] ]") == RUBO_OK, + "DEF with REPEAT $n failed"); + clr(); + CHECK(subrun(r, "NTIMES n=3") == RUBO_OK, "call failed"); + CHECK(saw("DONE"), "did not complete"); + + CASE("a negative REPEAT count is a range error"); + clr(); + CHECK(subrun(r, "NTIMES n=-1") == RUBO_OK, "should parse"); + CHECK(saw("ERR 6"), "expected E_RANGE at run time"); + + rubo_destroy(r); +} + +static void test_admission_and_abort(void) +{ + rubo_t *r = mk(); + + CASE("busy: no queue (docs/flow.md §9)"); + clr(); + CHECK(sub(r, "@1 MOVE dir=FWD speed=50 dur=300") == RUBO_OK, "MOVE failed"); + run(r, 20); + CHECK(sub(r, "@2 STOP") == RUBO_E_BUSY, "second statement should be refused"); + CHECK(saw("@2 ERR 12"), "wrong code"); + + CASE("definitions are refused while running, too"); + clr(); + CHECK(sub(r, "@3 DEF X [ STOP ]") == RUBO_E_BUSY, "DEF should be refused"); + + CASE("control words are never refused"); + clr(); + CHECK(sub(r, "PING") == RUBO_OK, "PING refused while busy"); + CHECK(saw("PONG"), "no PONG"); + + CASE("abort has exactly one victim"); + clr(); + CHECK(sub(r, "ABORT") == RUBO_OK, "ABORT failed"); + CHECK(saw("EVT ABORT"), "no EVT ABORT"); + CHECK(saw("@1 ERR 13"), "the running program should report aborted"); + CHECK(rubo_hal_sim_state()->left == 0 && rubo_hal_sim_state()->right == 0, + "motors still engaged after ABORT"); + CHECK(rubo_state(r) == RUBO_IDLE, "should be idle after ABORT"); + + CASE("the robot accepts work again immediately"); + clr(); + CHECK(subrun(r, "@4 STOP") == RUBO_OK, "should accept after abort"); + CHECK(saw("@4 DONE"), "no DONE"); + + CASE("motor cap"); + clr(); + CHECK(subrun(r, "MOVE dir=FWD speed=50 dur=20000") == RUBO_OK, "should parse"); + CHECK(saw("ERR 6"), "expected E_RANGE past RUBO_MOTOR_MAX_MS"); + + rubo_destroy(r); +} + +static void test_deadman(void) +{ + rubo_t *r = mk(); + + CASE("a dropped link stops the robot (docs/flow.md §10)"); + clr(); + /* dur=0 means "until countermanded": the motors stay on. */ + CHECK(subrun(r, "MOVE dir=FWD speed=50 dur=0") == RUBO_OK, "MOVE failed"); + CHECK(rubo_hal_sim_state()->left != 0, "motors should be engaged"); + + clr(); + run_quiet(r, RUBO_LINK_TIMEOUT_MS + 200); + CHECK(saw("EVT LINKLOST"), "deadman did not fire"); + CHECK(rubo_hal_sim_state()->left == 0, "motors not stopped by deadman"); + + CASE("the deadman is not armed when the motors are idle"); + clr(); + run_quiet(r, RUBO_LINK_TIMEOUT_MS + 200); + CHECK(!saw("EVT LINKLOST"), "deadman fired on an idle robot"); + + rubo_destroy(r); +} + +static void test_reserved(void) +{ + rubo_t *r = mk(); + + CASE("IF is reserved but not implemented (docs/grammar.md §7)"); + clr(); + CHECK(sub(r, "IF DIST < 30 [ STOP ]") == RUBO_E_SYNTAX, "expected E_SYNTAX"); + CHECK(saw("reserved"), "message should say it is reserved"); + + CASE("a bare ';' at the top level (docs/flow.md §4.4)"); + clr(); + CHECK(sub(r, "STOP; STOP") == RUBO_E_SYNTAX, "expected E_SYNTAX"); + CHECK(saw("brackets"), "message should suggest bracketing"); + + rubo_destroy(r); +} + +/* -------------------------------------------------------------- the framer */ + +static int feed(rubo_framer_t *f, const char *s, char *out, size_t outlen) +{ + int emitted = 0; + for (const char *p = s; *p; p++) { + int st = rubo_framer_push(f, *p, 1000); + if (st == 1) { + emitted++; + if (out) snprintf(out, outlen, "%s", f->buf); + } else if (st < 0) { + return st; + } + } + return emitted; +} + +static void test_framer(void) +{ + rubo_framer_t f; + char got[RUBO_MAX_STMT + 1]; + + CASE("a statement ends at a newline only at depth zero"); + rubo_framer_reset(&f); + CHECK(feed(&f, "STOP\n", got, sizeof got) == 1, "one-liner not emitted"); + CHECK(strcmp(got, "STOP") == 0, "got '%s'", got); + + CASE("a multi-line DEF is one statement"); + rubo_framer_reset(&f); + int n = feed(&f, + "DEF SQ $s [\n" + " REPEAT 4 [\n" + " MOVE dir=FWD speed=50 dur=$s;\n" + " TURN dir=RIGHT deg=90\n" + " ]\n" + "]\n", got, sizeof got); + CHECK(n == 1, "expected exactly one statement, got %d", n); + CHECK(strstr(got, "DEF SQ") && strstr(got, "TURN"), + "reassembled wrongly: '%s'", got); + + CASE("comments cannot desynchronise the framer"); + rubo_framer_reset(&f); + CHECK(feed(&f, "# a [ bracket in a comment\n", got, sizeof got) == 0, + "a comment-only line should produce no statement"); + CHECK(feed(&f, "STOP\n", got, sizeof got) == 1, + "the framer was left out of step by a comment"); + CHECK(strcmp(got, "STOP") == 0, "got '%s'", got); + + CASE("a trailing comment is dropped"); + rubo_framer_reset(&f); + CHECK(feed(&f, "STOP # and then\n", got, sizeof got) == 1, "not emitted"); + CHECK(strstr(got, "#") == NULL, "comment leaked into the statement"); + + CASE("an unbalanced ']' is rejected and resynchronises"); + rubo_framer_reset(&f); + CHECK(feed(&f, "]\n", got, sizeof got) == -(int)RUBO_E_SYNTAX, + "expected a syntax error"); + rubo_framer_reset(&f); + CHECK(feed(&f, "] junk\nSTOP\n", got, sizeof got) < 0, "expected an error"); + + CASE("an over-long statement is rejected"); + rubo_framer_reset(&f); + int rc = 0; + for (int i = 0; i < RUBO_MAX_STMT + 8 && rc >= 0; i++) + rc = rubo_framer_push(&f, 'A', 1000); + CHECK(rc == -(int)RUBO_E_LIMIT, "expected E_LIMIT, got %d", rc); + + CASE("a partial statement goes stale"); + rubo_framer_reset(&f); + for (const char *p = "DEF X ["; *p; p++) rubo_framer_push(&f, *p, 1000); + CHECK(rubo_framer_timeout(&f, 1000) == 0, "should not be stale yet"); + CHECK(rubo_framer_timeout(&f, 1000 + RUBO_FRAME_TIMEOUT_MS + 1) < 0, + "should have gone stale"); + + CASE("chunked arrival reassembles identically"); + rubo_framer_reset(&f); + const char *msg = "[ STOP; WAIT dur=1 ]\n"; + int emitted = 0; + for (const char *p = msg; *p; p++) + if (rubo_framer_push(&f, *p, 1000) == 1) { + emitted++; + snprintf(got, sizeof got, "%s", f.buf); + } + CHECK(emitted == 1, "expected one statement"); + CHECK(strcmp(got, "[ STOP; WAIT dur=1 ]") == 0, "got '%s'", got); +} + +/* -------------------------------------------------------- end-to-end sanity */ + +static void test_end_to_end(void) +{ + rubo_t *r = mk(); + rubo_framer_t f; + rubo_framer_reset(&f); + + CASE("wire to motors"); + clr(); + const char *wire = + "@1 DEF ZIGZAG $deg=45 $reps=2 [\n" + " REPEAT $reps [\n" + " TURN dir=LEFT deg=$deg;\n" + " TURN dir=RIGHT deg=$deg\n" + " ]\n" + "]\n" + "@2 ZIGZAG deg=10 reps=2\n"; + + for (const char *p = wire; *p; p++) { + if (rubo_framer_push(&f, *p, g_now) == 1) { + rubo_submit(r, f.buf, g_now); + run(r, 300); + } + } + if (!saw("@1 DONE") || !saw("@2 DONE")) dump(); + CHECK(saw("@1 ACK") && saw("@1 DONE"), "the definition did not complete"); + CHECK(saw("@2 ACK") && saw("@2 DONE"), "the call did not complete"); + CHECK(!saw("ERR"), "unexpected error"); + CHECK(rubo_hal_sim_state()->left == 0, "motors left running"); + + rubo_destroy(r); +} + +int main(void) +{ + test_basics(); + test_named_args(); + test_definitions(); + test_repeat(); + test_admission_and_abort(); + test_deadman(); + test_reserved(); + test_framer(); + test_end_to_end(); + + printf("%d checks, %d failures\n", g_run, g_fail); + return g_fail ? 1 : 0; +}