Skip to content

Instantly share code, notes, and snippets.

@raspberrypisig
Last active November 9, 2025 20:19
Show Gist options
  • Select an option

  • Save raspberrypisig/3c2294bb22176c4d24ca8dd83083666b to your computer and use it in GitHub Desktop.

Select an option

Save raspberrypisig/3c2294bb22176c4d24ca8dd83083666b to your computer and use it in GitHub Desktop.

Excellent question. Introducing a gpio_proxy.rs is a fantastic architectural decision that significantly improves the transpiler's design. This is a classic software engineering pattern known as a Facade or Adapter, and it creates a clean "impedance match" between the high-level concepts of your scripting language and the low-level details of the hardware abstraction layer.

You are correct: this proxy would mirror the Starlark gpio.star API in Rust, acting as a stable bridge to the potentially complex or changing esp-hal implementation.

This approach introduces a new layer: the Target Abstraction Layer (TAL).

The Role of gpio_proxy.rs

The gpio_proxy.rs file will define a set of structs and functions that are:

  1. Domain-Specific: The API will look like the Starlark API (e.g., Pin::new(13, "output"), pin.high()), not like the esp-hal API (Output::new(io.pins.gpio13, ...)).
  2. A Stable Target: The transpiler (build.rs) will now generate code that only calls functions in gpio_proxy.rs. It no longer needs to know anything about esp-hal itself.
  3. The Bridge: The proxy's implementation contains all the specific, potentially messy logic for calling esp-hal, handling pin ownership, dealing with unwrap()s, etc.

This decouples the "what" (the logic in Starlark) from the "how" (the specific hardware calls in esp-hal).


Step 1: Implementing gpio_proxy.rs

This file defines the stable API that our transpiler will target.

// src/gpio_proxy.rs

use esp_hal::gpio::{self, AnyPin, GpioPin, Input, Level, Output, Pull};
use esp_hal::prelude::*;

// An enum to hold the specific, type-safe `esp-hal` pin driver.
// This allows our GpioProxy to manage both inputs and outputs.
pub enum PinMode<'d> {
    Output(Output<'d, AnyPin<'d>>),
    Input(Input<'d, AnyPin<'d>>),
}

// The proxy struct itself. It owns the configured `esp-hal` pin object.
pub struct GpioProxy<'d> {
    number: u8,
    mode: PinMode<'d>,
}

// This is the implementation of our stable, high-level API.
impl<'d> GpioProxy<'d> {
    /// Creates a new output pin. This mirrors the Starlark `Pin(num, OUT)` call.
    pub fn new_output(
        io_pins: &'d mut gpio::Pins<'d>,
        pin_number: u8,
        initial_level: Level,
    ) -> Self {
        // All the `esp-hal` specific logic is encapsulated here.
        // The transpiler doesn't need to know about `degrade`.
        let pin = GpioPin::new(io_pins, pin_number).degrade();
        let output = Output::new(pin, initial_level);
        
        GpioProxy {
            number,
            mode: PinMode::Output(output),
        }
    }

    /// Sets the output pin level to HIGH. Mirrors `pin.high()`.
    pub fn high(&mut self) {
        if let PinMode::Output(pin) = &mut self.mode {
            // The proxy handles the Result/unwrap logic.
            pin.set_high().unwrap();
        } else {
            // In a real implementation, you might panic or log an error.
        }
    }

    /// Sets the output pin level to LOW. Mirrors `pin.low()`.
    pub fn low(&mut self) {
        if let PinMode::Output(pin) = &mut self.mode {
            pin.set_low().unwrap();
        }
    }
    
    // You would add `new_input`, `value()`, etc. here to fully mirror the API.
}

Step 2: Rewriting the Transpiler (build.rs) to Target the Proxy

The transpiler's logic is now dramatically simplified. It doesn't need to construct complex esp-hal calls; it just maps Starlark calls directly to GpioProxy calls.

// build.rs (TranspilationAgent logic)

// ... inside the transpile_statement function ...

let token_stream = match stmt.node {
    // Match assignment: `led = gpio.Pin(13, mode=gpio.OUT)`
    starlark::syntax::Stmt::Assign(assign) => {
        let var_name = assign.lhs.node.to_string();
        // Simplified parsing for brevity...
        let pin_number: u8 = 13; // Parsed from Starlark AST

        let var_ident = Ident::new(&var_name, Span::call_site());
        let pin_lit = syn::Literal::u8_unsuffixed(pin_number);

        // BEFORE (targeting esp-hal directly):
        // quote! { let mut #var_ident = Output::new(io.pins.gpio13.degrade(), Level::Low); }

        // AFTER (targeting the stable GpioProxy API):
        quote! {
            let mut #var_ident = GpioProxy::new_output(&mut io.pins, #pin_lit, Level::Low);
        }
    }

    // Match a method call statement: `led.high()`
    starlark::syntax::Stmt::Expression(expr) => {
        // Simplified parsing...
        let obj_name = "led";
        let method_name = "high";

        let obj_ident = Ident::new(&obj_name, Span::call_site());
        let method_ident = Ident::new(&method_name, Span::call_site());

        // BEFORE:
        // quote! { #obj_ident.set_high().unwrap(); }

        // AFTER (a clean 1:1 mapping):
        quote! {
            #obj_ident.#method_ident();
        }
    }
    _ => quote! {},
};
// ... rest of the transpiler ...
```The transpiler is now cleaner, more readable, and completely insulated from the details of `esp-hal`.

### Step 3: Updating `main.rs`

The main application file simply needs to bring the proxy into scope.

```rust
// src/main.rs
#![no_std]
#![no_main]

// ... other use statements ...
use esp_hal::gpio::{Io, Level};

// Include the proxy module
mod gpio_proxy;
use gpio_proxy::GpioProxy;

#[entry]
fn main() -> ! {
    // ... standard setup ...
    let mut io = Io::new(peripherals.GPIO, peripherals.IO_MUX);

    // This generated code now calls our `GpioProxy`, not `esp-hal` directly.
    include!(concat!(env!("OUT_DIR"), "/transpiled_script.rs"));

    loop {}
}

Updating AGENTS.md with the Proxy Layer

This new component fundamentally improves the architecture. Here's how to update the AGENTS.md document.

AGENTS.md (Revised Section)

3. Architecture Revision: The Target Abstraction Layer

To enhance modularity and maintainability, the system architecture introduces a crucial new component: the Target Abstraction Layer (TAL), implemented in gpio_proxy.rs. This layer acts as a stable bridge between the code generated by the transpiler and the low-level hardware drivers (esp-hal).

The revised data flow is as follows:

graph TD
    A[Starlark Script (.star)] --> B{Parsing Agent};
    B --> C[Starlark AST];
    C --> D{Transpilation Agent};
    D --> E[syn AST (Targeting TAL API)];
    E --> F{Code Generation Agent};
    F --> G[Generated Rust Code (.rs)];
    G -- "Calls API of" --> H[Target Abstraction Layer (gpio_proxy.rs)];
    H -- "Encapsulates calls to" --> I[Hardware Abstraction Layer (esp-hal)];
Loading

3.2. Transpilation Agent (Revised)

The Transpilation Agent's responsibility is updated. It no longer needs knowledge of the underlying esp-hal primitives.

  • Input: A Starlark AstModule struct.
  • Process: Translates Starlark statements into syn AST structures that exclusively call the public API defined by the Target Abstraction Layer (gpio_proxy.rs).
  • Output: A Vec<syn::Stmt> containing calls like GpioProxy::new_output(...) and led.high().

3.5. New Component: Target Abstraction Layer (TAL)

This is the bridge component that isolates the transpiler from the hardware details.

  • Agent Name: GpioProxy
  • Implementation: src/gpio_proxy.rs
  • Responsibility:
    1. Expose a Stable, High-Level API: It provides a Rust API that mirrors the concepts of the Starlark gpio.star module (e.g., new_output, high, low).
    2. Encapsulate HAL Complexity: It contains all the implementation details for interacting with esp-hal, including pin ownership, type-state management, error handling (unwrap()), and hardware-specific function calls (.degrade()).
  • Benefits:
    • Decoupling: The transpiler (build.rs) is completely decoupled from esp-hal. If esp-hal undergoes a breaking API change, only the gpio_proxy.rs implementation needs to be updated.
    • Maintainability: The concerns are separated. The transpiler handles language translation; the proxy handles hardware interaction.
    • Portability: To target a different microcontroller (e.g., an STM32), a new proxy implementation could be written that conforms to the same TAL API, requiring zero changes to the transpiler itself.

Excellent. Writing unit tests for a transpiler is a critical step that ensures its correctness and makes it maintainable.

The key to testing a build.rs script is to isolate the core logic from the parts that perform file I/O. We will refactor the Transpiler into its own module and create a simple function that takes Starlark code as a string and returns the generated Rust code as a string. This makes it a "pure function" that is easy to unit test.

We will use the prettyplease crate to format the generated TokenStream into a canonical string format, which makes our test assertions robust against minor formatting changes.

Step 1: Update Cargo.toml

We need to add prettyplease as a dev-dependency because it's only used for testing. Note that build-dependencies can also serve as dev-dependencies for the build script's own tests.

# In your Cargo.toml

[package]
name = "esp-starlark-transpiler" # Or your crate name
version = "0.1.0"
edition = "2021"

# ... your regular dependencies ...

[build-dependencies]
starlark = { version = "0.13.0", features = ["syn"] }
syn = { version = "2.0", features = ["full", "extra-traits", "parsing"] }
quote = "1.0"
proc-macro2 = "1.0"
prettyplease = "0.2" # Add for testing the build script

Step 2: Refactor build.rs for Testability

We'll structure the file so the logic is self-contained and the main function is just a thin wrapper. The unit tests will live inside the same file under a #[cfg(test)] module.

Here is the complete, testable build.rs file.

// build.rs

// This module contains the core transpilation logic, isolated from file I/O.
mod transpiler_logic {
    use starlark::syntax::{AstModule, Dialect, AstStmt, AstExpr};
    use std::collections::HashSet;
    use quote::quote;
    use syn::{Stmt, Ident};
    use syn::parse::Parser;
    use proc_macro2::{Span, TokenStream};

    pub struct Transpiler {
        symbol_table: HashSet<String>,
    }

    impl Transpiler {
        fn new() -> Self {
            Transpiler { symbol_table: HashSet::new() }
        }

        fn transpile_module(&mut self, ast: AstModule) -> Vec<Stmt> {
            ast.statement.stmts.into_iter()
                .flat_map(|stmt| self.transpile_statement(stmt))
                .collect()
        }

        fn transpile_statement(&mut self, stmt: AstStmt) -> Vec<Stmt> {
            let token_stream = match stmt.node {
                starlark::syntax::Stmt::Assign(assign) => {
                    let var_name = assign.lhs.node.to_string();
                    let call_expr = &assign.rhs;
                    if let AstExpr::Call(call) = &call_expr.node {
                        if let AstExpr::Dot(dot_expr) = &call.fun.node {
                             if dot_expr.0.to_string() == "gpio" && dot_expr.1.as_str() == "Pin" {
                                 let pin_arg = &call.args[0].node;
                                 let pin_number: u8 = pin_arg.to_string().parse().unwrap();
                                 let var_ident = Ident::new(&var_name, Span::call_site());
                                 let pin_ident = Ident::new(&format!("gpio{}", pin_number), Span::call_site());
                                 self.symbol_table.insert(var_name);
                                 quote! { let mut #var_ident = Output::new(io.pins.#pin_ident.degrade(), Level::Low); }
                             } else { quote!{} }
                        } else { quote!{} }
                    } else { quote!{} }
                }
                starlark::syntax::Stmt::Expression(expr) => {
                     match &expr.node {
                         AstExpr::Call(call) => {
                             if let AstExpr::Dot(dot_expr) = &call.fun.node {
                                 let obj_name = dot_expr.0.to_string();
                                 let method_name = dot_expr.1.as_str();
                                 let obj_ident = Ident::new(&obj_name, Span::call_site());
                                 match method_name {
                                     "high" => { let method_ident = Ident::new("set_high", Span::call_site()); quote! { #obj_ident.#method_ident().unwrap(); } }
                                     "low" => { let method_ident = Ident::new("set_low", Span::call_site()); quote! { #obj_ident.#method_ident().unwrap(); } }
                                     _ => quote!{}
                                 }
                             } else if call.fun.to_string() == "print" {
                                 let msg = call.args[0].to_string();
                                 quote! { esp_println!("{}", #msg); }
                             } else { quote!{} }
                         },
                         _ => quote!{}
                     }
                }
                _ => quote! {},
            };
            if token_stream.is_empty() {
                vec![]
            } else {
                syn::Block::parse_within.parse2(quote!({ #token_stream })).unwrap()
            }
        }
    }

    /// The main, testable entry point for the transpiler.
    /// Takes Starlark source code and returns a `TokenStream` of Rust code.
    pub fn transpile_source(starlark_code: &str) -> Result<TokenStream, String> {
        let ast = AstModule::parse("script.star", starlark_code.to_string(), &Dialect::Standard)
            .map_err(|e| e.to_string())?;
        
        let mut transpiler = Transpiler::new();
        let rust_statements = transpiler.transpile_module(ast);

        Ok(quote! { #(#rust_statements)* })
    }
}

// The `main` function is now a thin wrapper around the testable logic.
fn main() {
    use std::env;
    use std::fs;
    use std::path::Path;

    println!("cargo:rerun-if-changed=my_script.star");
    println!("cargo:rerun-if-changed=build.rs");

    let script_path = Path::new("my_script.star");
    let script_content = fs::read_to_string(script_path).expect("Failed to read Starlark script");
    
    let final_code = transpiler_logic::transpile_source(&script_content).unwrap();
    
    let out_dir = env::var("OUT_DIR").unwrap();
    let dest_path = Path::new(&out_dir).join("transpiled_script.rs");
    fs::write(dest_path, final_code.to_string()).unwrap();
}


// Unit tests for the transpiler live here.
#[cfg(test)]
mod tests {
    use super::transpiler_logic::transpile_source;
    use syn::File;

    /// A helper function to transpile and then format the code for stable comparison.
    fn transpile_and_format(starlark_code: &str) -> String {
        let tokens = transpile_source(starlark_code).unwrap();
        // Parse the TokenStream into a syn::File for pretty-printing.
        let file: File = syn::parse2(tokens).unwrap();
        prettyplease::unparse(&file)
    }

    #[test]
    fn test_pin_declaration() {
        let starlark = "led = gpio.Pin(13, mode=gpio.OUT)";
        let expected_rust = "let mut led = Output::new(io.pins.gpio13.degrade(), Level::Low);\n";
        
        let actual_rust = transpile_and_format(starlark);

        assert_eq!(actual_rust, expected_rust);
    }

    #[test]
    fn test_method_call_high() {
        let starlark = "led.high()";
        let expected_rust = "led.set_high().unwrap();\n";
        
        let actual_rust = transpile_and_format(starlark);

        assert_eq!(actual_rust, expected_rust);
    }

    #[test]
    fn test_method_call_low() {
        let starlark = "led.low()";
        let expected_rust = "led.set_low().unwrap();\n";

        let actual_rust = transpile_and_format(starlark);

        assert_eq!(actual_rust, expected_rust);
    }

    #[test]
    fn test_print_function_call() {
        let starlark = r#"print("Hello, ESP32!")"#;
        let expected_rust = r#"esp_println!("{}", "Hello, ESP32!");
"#;

        let actual_rust = transpile_and_format(starlark);

        assert_eq!(actual_rust, expected_rust);
    }

    #[test]
    fn test_full_script_sequence() {
        let starlark = r#"
led = gpio.Pin(2, mode=gpio.OUT)
led.high()
led.low()
"#;
        // Note: prettyplease adds a newline at the end.
        let expected_rust = r#"let mut led = Output::new(io.pins.gpio2.degrade(), Level::Low);
led.set_high().unwrap();
led.set_low().unwrap();
"#;

        let actual_rust = transpile_and_format(starlark);
        
        assert_eq!(actual_rust, expected_rust);
    }

    #[test]
    fn test_empty_script() {
        let starlark = "";
        let expected_rust = "\n"; // prettyplease on an empty file gives a newline.
        
        let actual_rust = transpile_and_format(starlark);

        assert_eq!(actual_rust, expected_rust);
    }
}

Step 3: How to Run the Tests

Because these tests are part of a build.rs script, you cannot run them with a simple cargo test. You need to tell Cargo to test the build script itself.

Execute the following command in your terminal:

cargo test --build

If you named your package something other than the directory name, you might need to be more specific:

cargo test -p esp-starlark-transpiler --build

What you will see:

   Compiling esp-starlark-transpiler v0.1.0 (/path/to/project)
    Finished test [unoptimized + debuginfo] target(s) in 1.50s
     Running unittests (build-script-build)

running 6 tests
test tests::test_pin_declaration ... ok
test tests::test_method_call_low ... ok
test tests::test_method_call_high ... ok
test tests::test_print_function_call ... ok
test tests::test_empty_script ... ok
test tests::test_full_script_sequence ... ok

test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

You have now successfully created a suite of unit tests for your Starlark-to-Rust transpiler, ensuring its logic is correct and isolated from external dependencies like the file system. This is a robust and professional way to develop build-time tooling.

#
# gpio.star: A Starlark Module for GPIO Control on ESP32
#
# This file defines the public API for GPIO operations. All function and
# method bodies are implemented with `pass` as a placeholder. In a real
# implementation, each `pass` would be replaced with a call to a
# corresponding native function provided by the Rust host environment.
#
# --- Module Constants ---
# These constants define the standard names used for GPIO configuration.
# Pin modes
OUT = "output"
IN = "input"
# Pin levels
HIGH = 1
LOW = 0
# Pull resistor configurations
PULL_UP = "pull_up"
PULL_DOWN = "pull_down"
PULL_NONE = "pull_none"
class Pin:
"""
Represents a single physical GPIO pin.
The internal state would be managed by an opaque handle from the native host.
"""
def __init__(self, pin_number, mode, pull=PULL_NONE, initial_level=LOW):
"""
Initializes and configures a GPIO pin.
In a real implementation, this would call a native Rust function like
`_native_setup_pin()` to configure the hardware and would store an
opaque handle to the pin object in an instance variable like `self._handle`.
"""
# Placeholder for the pin number and mode for the __repr__ method.
self._number = pin_number
self._mode = mode
pass
def high(self):
"""
Sets the output pin level to HIGH. Fails if the pin is not an output.
Placeholder for: _native_set_level(self._handle, HIGH)
"""
pass
def low(self):
"""
Sets the output pin level to LOW. Fails if the pin is not an output.
Placeholder for: _native_set_level(self._handle, LOW)
"""
pass
def value(self, level=None):
"""
Gets or sets the value of the pin.
- If `level` is provided, sets the pin's level (for output pins).
- If `level` is not provided, returns the pin's level (for input pins).
Placeholder for:
- set: _native_set_level(self._handle, level)
- get: return _native_get_level(self._handle)
"""
pass
def cleanup(self):
"""
De-initializes the pin, returning it to a safe state.
Placeholder for: _native_cleanup(self._handle)
"""
pass
def __repr__(self):
"""Provides a string representation of the Pin object."""
return "<Pin(GPIO{}, mode='{}')>".format(self._number, self._mode)
def cleanup(pin_number=None):
"""
De-initializes one or all pins managed by the native layer.
Placeholder for: _native_cleanup_all() or _native_cleanup(pin_number)
"""
pass
# --- Public API Export ---
# This dictionary defines what symbols are available when a user script
# executes `load("gpio.star", "gpio")`.
PIN = Pin
CONSTANTS = {
"OUT": OUT,
"IN": IN,
"HIGH": HIGH,
"LOW": LOW,
"PULL_UP": PULL_UP,
"PULL_DOWN": PULL_DOWN,
"PULL_NONE": PULL_NONE,
}
# gpio.star
OUT = "output"
IN = "input"
HIGH = 1
LOW = 0
class Pin:
def __init__(self, pin_number, mode, **kwargs): pass
def high(self): pass
def low(self): pass
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment