patx/relay-lang
Add break/continue flow control and refresh README examples
Commit 878e30b · patx · 2026-02-12T23:39:45-05:00
Comments
No comments yet.
Diff
diff --git a/README.md b/README.md
index 39b9982..f8d1608 100644
--- a/README.md
+++ b/README.md
@@ -106,13 +106,13 @@ relay examples/url_shortener.ry # requires local MongoDB
### Hello World
-```relay
+```python
print("Hello, Relay!")
```
### Async Hello World
-```relay
+```python
sleep(2000, print("world"))
print("hello")
```
@@ -127,7 +127,7 @@ Notice how "hello" prints immediately while "world" waits 2 seconds—all withou
### Simple Web Server
-```relay
+```python
app = WebApp()
server = WebServer()
@@ -206,7 +206,7 @@ If your goal is to learn Relay end-to-end, use this order:
Relay uses **indentation-based syntax** with 4 spaces per indentation level. Tabs are not allowed.
-```relay
+```python
fn example()
x = 10
if (x > 5)
@@ -221,7 +221,7 @@ fn example()
- Function bodies, control flow blocks, and loops all require indentation
- Comments start with `//` and continue to the end of the line
-```relay
+```python
// This is a comment
x = 42 // This is also a comment
```
@@ -230,7 +230,7 @@ x = 42 // This is also a comment
Relay supports loading code from multiple `.ry` files with `import`:
-```relay
+```python
import utils
import web.routes
import shared/helpers.ry
@@ -242,13 +242,15 @@ import shared/helpers.ry
- Relative paths are resolved from the importing file's directory
- A module is loaded only once per run (duplicate imports are ignored)
+The module filenames above are illustrative examples of resolution behavior.
+
Imported modules execute in the same global scope, so functions and variables they define become directly available.
### Data Types
#### Primitives
-```relay
+```python
// Integers
age = 25
count = -10
@@ -272,7 +274,7 @@ result = None
#### Collections
**Lists:**
-```relay
+```python
numbers = [1, 2, 3, 4, 5]
mixed = [1, "two", 3.0, True]
nested = [[1, 2], [3, 4]]
@@ -290,7 +292,7 @@ numbers[0] = 10
```
**Dictionaries:**
-```relay
+```python
user = {"name": "Ada", "age": 30, "active": True}
// Access values
@@ -307,7 +309,7 @@ age = user["age"] // 30
Relay supports destructuring assignment for iterables such as lists, strings, and dictionaries (dictionary keys are unpacked):
-```relay
+```python
a, b, c = [10, 20, 30]
x, y = "hi" // x = "h", y = "i"
k1, k2 = {"a": 1, "b": 2}
@@ -319,20 +321,22 @@ The number of variables must match the number of unpacked values.
#### If-Else
-```relay
+```python
if (condition)
// then block
print("condition is true")
```
-```relay
+```python
if (x > 0)
print("positive")
+else
+ print("zero or negative")
```
#### While Loops
-```relay
+```python
i = 0
while (i < 5)
print(i)
@@ -340,7 +344,7 @@ while (i < 5)
```
Augmented assignment operators are supported:
-```relay
+```python
i = 0
while (i < 5)
print(i)
@@ -349,7 +353,7 @@ while (i < 5)
#### For Loops
-```relay
+```python
// Iterate over lists
for (item in [1, 2, 3, 4, 5])
print(item)
@@ -362,11 +366,24 @@ for (key in user)
**Note:** For loops iterate over collection elements or dictionary keys.
+#### `break` and `continue`
+
+```python
+i = 0
+while (i < 6)
+ i =+ 1
+ if (i == 2)
+ continue
+ if (i == 5)
+ break
+ print(i)
+```
+
#### Error Handling (`try/except`)
Use `try/except` to catch runtime errors and continue execution:
-```relay
+```python
try
value = int("not-a-number")
print(value)
@@ -376,7 +393,7 @@ except
You can also bind the error message:
-```relay
+```python
try
result = missing_name + 1
except(err)
@@ -387,7 +404,7 @@ except(err)
#### Basic Functions
-```relay
+```python
fn greet(name)
return "Hello, " + name
@@ -397,7 +414,7 @@ print(message) // Hello, World
#### Default Parameters
-```relay
+```python
fn greet(name: str = "World")
return "Hello, " + name
@@ -409,7 +426,7 @@ print(greet("Relay")) // Hello, Relay
Relay supports runtime type checking for parameters:
-```relay
+```python
fn add(a: int, b: int)
return a + b
@@ -426,7 +443,7 @@ Type hints on regular functions are strict validation (no implicit coercion). Un
- `bool`: Boolean
- `json` or `Json`: JSON object
-```relay
+```python
fn process_data(data: json)
return data["key"]
```
@@ -435,7 +452,7 @@ fn process_data(data: json)
Functions can return any value:
-```relay
+```python
fn get_user()
return {"name": "Ada", "id": 1}
@@ -455,7 +472,7 @@ Relay's async model is unique: **there is no `await` keyword**. Instead, async o
When you call an async function without using its return value, it runs in the background:
-```relay
+```python
sleep(1000, print("delayed"))
print("immediate")
```
@@ -470,7 +487,7 @@ delayed
When you assign the result of an async operation, you get a `Deferred` value. The operation starts immediately but doesn't block:
-```relay
+```python
x = sleep(1000, 10) // Returns immediately with Deferred<10>
y = sleep(1000, 20) // Returns immediately with Deferred<20>
print(x + y) // Waits for both, then prints 30
@@ -483,23 +500,23 @@ Both sleeps start at the same time, so this takes ~1 second, not 2.
A `Deferred` value automatically resolves (waits for the async operation to complete) when:
1. **Used in an expression:**
-```relay
+```python
result = deferred_value + 10
```
2. **Passed to a function:**
-```relay
+```python
print(deferred_value)
```
3. **Returned from a function:**
-```relay
+```python
fn get_data()
return http.get("https://api.example.com")
```
4. **Used in a comparison:**
-```relay
+```python
if (deferred_value > 10)
print("greater than 10")
```
@@ -507,7 +524,7 @@ if (deferred_value > 10)
#### Concurrency Primitives
**`spawn(expr)`** - Run an expression in parallel:
-```relay
+```python
fn work(n)
sleep(500, n * 2)
@@ -518,24 +535,24 @@ result2 = task2.join()
```
**`all(tasks)`** - Wait for all tasks to complete:
-```relay
+```python
tasks = [spawn(work(1)), spawn(work(2)), spawn(work(3))]
results = all(tasks) // [2, 4, 6]
```
**`race(tasks)`** - Wait for the first task to complete:
-```relay
+```python
tasks = [spawn(sleep(1000, "slow")), spawn(sleep(100, "fast"))]
winner = race(tasks) // "fast"
```
**`timeout(expr, ms)`** - Add a timeout to any operation:
-```relay
+```python
result = timeout(http.get("https://slow-api.com"), 5000)
```
**`cancel(task)`** - Cancel a running task:
-```relay
+```python
task = spawn(long_operation())
cancel(task)
```
@@ -549,7 +566,7 @@ cancel(task)
#### `print(value, ...)`
Print values to stdout. Multiple arguments are printed space-separated.
-```relay
+```python
print("Hello") // Hello
print("x =", 42) // x = 42
print("a", "b", "c") // a b c
@@ -558,7 +575,7 @@ print("a", "b", "c") // a b c
#### `str(value)`
Convert any value to a string.
-```relay
+```python
str(123) // "123"
str(3.14) // "3.14"
str(True) // "True"
@@ -568,7 +585,7 @@ str([1, 2, 3]) // "[1, 2, 3]"
#### `int(value)`
Convert a value to an integer.
-```relay
+```python
int("42") // 42
int(3.9) // 3
int(True) // 1
@@ -578,7 +595,7 @@ int("invalid") // Runtime error
#### `float(value)`
Convert a value to a float.
-```relay
+```python
float("3.14") // 3.14
float(42) // 42.0
float("2.5e3") // 2500.0
@@ -591,7 +608,7 @@ float("2.5e3") // 2500.0
Sleep for the specified duration, then resolve to `value`.
-```relay
+```python
sleep(1000, print("done")) // Print after 1 second
result = sleep(2000, 42) // Wait 2s, result = 42
```
@@ -601,7 +618,7 @@ result = sleep(2000, 42) // Wait 2s, result = 42
Execute an expression in parallel. Returns a `Task` object.
-```relay
+```python
task = spawn(expensive_computation())
// Do other work...
result = task.join()
@@ -615,7 +632,7 @@ result = task.join()
Wait for all tasks to complete. Returns results in order.
-```relay
+```python
tasks = [spawn(work(1)), spawn(work(2)), spawn(work(3))]
results = all(tasks) // Wait for all, returns [result1, result2, result3]
```
@@ -625,7 +642,7 @@ results = all(tasks) // Wait for all, returns [result1, result2, result3]
Wait for the first task to complete, return its result.
-```relay
+```python
tasks = [
spawn(http.get("https://api1.com")),
spawn(http.get("https://api2.com"))
@@ -638,7 +655,7 @@ fastest = race(tasks) // Returns whichever completes first
Add a timeout to any async operation.
-```relay
+```python
result = timeout(http.get("https://slow.com"), 5000) // 5 second timeout
```
@@ -647,7 +664,7 @@ If the timeout is exceeded, a runtime error is raised.
#### `cancel(task)`
Cancel a running task.
-```relay
+```python
task = spawn(long_running_operation())
cancel(task)
```
@@ -659,7 +676,7 @@ cancel(task)
Read a file's contents as a UTF-8 string.
-```relay
+```python
content = read_file("data.txt")
print(content)
```
@@ -669,7 +686,7 @@ print(content)
Write a string to a file.
-```relay
+```python
save_file("Hello, World!", "output.txt")
```
@@ -678,7 +695,7 @@ save_file("Hello, World!", "output.txt")
Read and parse a JSON file.
-```relay
+```python
data = read_json("config.json")
print(data["api_key"])
```
@@ -688,7 +705,7 @@ print(data["api_key"])
Serialize data to JSON and write to a file.
-```relay
+```python
config = {"host": "localhost", "port": 8080}
save_json(config, "config.json")
```
@@ -698,7 +715,7 @@ save_json(config, "config.json")
#### `Http()`
Create an HTTP client instance.
-```relay
+```python
http = Http()
```
@@ -707,7 +724,7 @@ http = Http()
Send a GET request.
-```relay
+```python
http = Http()
resp = http.get(
"https://api.example.com/users",
@@ -725,7 +742,7 @@ print(resp.text) // Response body as string
Send requests with optional payloads and headers.
-```relay
+```python
http = Http()
payload = {"name": "Ada", "email": "[email protected]"}
resp = http.post(
@@ -744,7 +761,7 @@ HTTP responses have the following properties:
- `resp.json()` - Parse response body as JSON
- `resp.headers` - Response headers (dict)
-```relay
+```python
http = Http()
resp = http.get("https://api.github.com/users/octocat")
@@ -758,7 +775,7 @@ print(data["login"]) // octocat
#### `Email(host, port=587, username=None, password=None, from=None, tls="starttls")`
Create an SMTP email client instance.
-```relay
+```python
email = Email(
"smtp.example.com",
587,
@@ -784,7 +801,7 @@ Send an email asynchronously over SMTP.
- `from` can be passed per-call or configured in `Email(...)`
- `attachments` accepts a single attachment dict or list of attachment dicts
-```relay
+```python
email = Email(
host="smtp.example.com",
username="smtp-user",
@@ -807,7 +824,7 @@ Attachment dict format:
- `bytes` or `content` or `data` (required) - file payload (`bytes`, `str`, or `list[int]`)
- `content_type` (optional) - MIME type (default: `application/octet-stream`)
-```relay
+```python
result = email.send(
to="[email protected]",
subject="Monthly report",
@@ -835,7 +852,7 @@ Send result fields:
Render an email template string with MiniJinja variables.
-```relay
+```python
body = email.render("Hi {{ name }}, welcome!", {"name": "Ada"})
```
@@ -844,7 +861,7 @@ body = email.render("Hi {{ name }}, welcome!", {"name": "Ada"})
Load and render a template file asynchronously.
-```relay
+```python
html = email.render_file("templates/welcome.html", {"name": "Ada"})
email.send(to="[email protected]", subject="Welcome", html=html)
```
@@ -854,7 +871,7 @@ email.send(to="[email protected]", subject="Welcome", html=html)
#### `WebApp()`
Create a web application instance.
-```relay
+```python
app = WebApp()
```
@@ -869,7 +886,7 @@ Define HTTP endpoints using decorators:
- `@app.delete(path)`
- `@app.ws(path)`
-```relay
+```python
app = WebApp()
@app.get("/")
@@ -887,7 +904,7 @@ Decorator schema options:
- `body=...` - Validate/coerce form body
- `json=...` - Validate/coerce JSON body
-```relay
+```python
@app.get("/search", query={"limit": "int", "q?": "str"})
fn search(limit, q = None)
return {"limit": limit, "q": q}
@@ -901,7 +918,7 @@ fn create_user(name, age = None)
Use grouped route prefixes to organize larger APIs:
-```relay
+```python
app = WebApp()
api = app.group("/api")
v1 = api.group("/v1")
@@ -913,7 +930,7 @@ fn get_user(user_id)
Enable generated OpenAPI docs:
-```relay
+```python
app.openapi(title="Relay API", version="1.0.0")
// Exposes GET /openapi.json
```
@@ -926,7 +943,7 @@ WebSocket handlers receive a `socket` object with:
- `socket.send(value)` → sends text (or binary when `bytes`)
- `socket.close()` → closes the connection
-```relay
+```python
@app.ws("/chat")
fn chat_room()
while True
@@ -940,7 +957,7 @@ fn chat_room()
Use `<name>` syntax to capture path segments:
-```relay
+```python
@app.get("/users/<user_id>")
fn get_user(user_id)
return {"id": user_id, "name": "Ada"}
@@ -957,7 +974,7 @@ Handler parameters are automatically bound from:
2. **Request body form fields** (`application/x-www-form-urlencoded`)
3. **Query parameters** (lowest priority)
-```relay
+```python
// GET /search?q=relay&limit=10
@app.get("/search")
fn search(q: str, limit: int = 20)
@@ -976,7 +993,7 @@ fn get_user(user_id)
JSON bodies are available as the `data` parameter (default name) or by typing a handler param as `Json`.
-```relay
+```python
// POST /events with JSON body {"type":"signup","user":"ada"}
@app.post("/events")
fn create_event(data: Json)
@@ -998,7 +1015,7 @@ If middleware returns a non-`None` value, Relay short-circuits and sends that re
`next()` runs the remainder of the middleware chain and then the handler.
-```relay
+```python
fn audit(ctx, next)
print("before:", ctx["path"])
result = next()
@@ -1010,7 +1027,7 @@ fn audit(ctx, next)
Use type hints to enforce parameter types and enable automatic coercion:
-```relay
+```python
@app.post("/calculate")
fn calculate(a: int, b: int)
return {"result": a + b}
@@ -1030,7 +1047,7 @@ fn calculate(a: int, b: int)
Every handler has access to a `request` dictionary:
-```relay
+```python
@app.get("/debug")
fn debug_request()
print(request["method"]) // GET
@@ -1069,7 +1086,7 @@ Use helpers when you want payload access without binding handler parameters:
- `get_body()` - Parsed form fields as a dict (empty dict when unavailable)
- `get_json()` - Parsed JSON body (or `None` when unavailable)
-```relay
+```python
@app.get("/search")
fn search()
query = get_query()
@@ -1096,7 +1113,7 @@ Schema format:
- `"field?": "type"` for optional fields
- `"field": {"type": "int", "required": True, "default": 10}` for explicit rules
-```relay
+```python
@app.get("/search")
fn search()
params = require_query({"limit": "int", "q?": "str"})
@@ -1107,7 +1124,7 @@ fn search()
Access cookies via the `cookies` dict:
-```relay
+```python
@app.get("/")
fn index()
user_id = cookies["user_id"]
@@ -1118,7 +1135,7 @@ fn index()
Relay provides built-in session management with HttpOnly cookies:
-```relay
+```python
@app.get("/login")
fn login(username: str)
session["user"] = username
@@ -1141,13 +1158,13 @@ fn profile()
Customize cookie policy:
-```relay
+```python
app.session(secure=True, http_only=True, same_site="Lax")
```
Use a custom session backend (for any database/service):
-```relay
+```python
session_db = {}
fn load_session(sid)
@@ -1163,7 +1180,7 @@ app.session_backend(load_session, save_session)
Configure upload safety controls per app:
-```relay
+```python
app.uploads(
max_body_bytes=10 * 1024 * 1024, // default 10 MiB
max_file_bytes=5 * 1024 * 1024, // default 5 MiB per file field
@@ -1183,28 +1200,28 @@ When limits are exceeded or a MIME type is disallowed, Relay returns `400 bad_re
Handlers can return various types:
**JSON (automatic):**
-```relay
+```python
@app.get("/api/user")
fn get_user()
return {"name": "Ada", "id": 123} // Auto-serialized to JSON
```
**Plain text:**
-```relay
+```python
@app.get("/")
fn index()
return "Hello, World!" // Content-Type: text/plain
```
**HTML:**
-```relay
+```python
@app.get("/")
fn index()
return "<h1>Welcome</h1>" // Content-Type: text/html
```
**Custom Response:**
-```relay
+```python
@app.get("/custom")
fn custom()
return Response(
@@ -1215,7 +1232,7 @@ fn custom()
```
**Redirect:**
-```relay
+```python
@app.post("/old-path")
fn old_endpoint()
return app.redirect("/new-path")
@@ -1224,7 +1241,7 @@ fn old_endpoint()
#### `Response(body, status=200, content_type=None)`
Create a custom HTTP response.
-```relay
+```python
@app.get("/xml")
fn get_xml()
xml = "<root><item>data</item></root>"
@@ -1240,7 +1257,7 @@ fn get_xml()
Create a structured API error response.
When called inside a handler, Relay also includes `request_id` in the error payload.
-```relay
+```python
@app.post("/users")
fn create_user(name)
if (name == None)
@@ -1256,7 +1273,7 @@ fn create_user(name)
#### `auth_verify_password(password, hash)`
**Returns:** `bool`
-```relay
+```python
hash = auth_hash_password("super-secret")
is_valid = auth_verify_password("super-secret", hash) // True
```
@@ -1273,7 +1290,7 @@ Available methods:
- `store.get_hash(username)` - returns stored hash or `None`
- `store.set_hash(username, hash)` - stores precomputed hash
-```relay
+```python
store = AuthStore()
store.register("ada", "pw")
print(store.verify("ada", "pw")) // True
@@ -1295,7 +1312,7 @@ print(custom.verify("bob", "pw2")) // True
Create a redirect response.
-```relay
+```python
@app.post("/submit")
fn submit(data)
// Process data...
@@ -1308,7 +1325,7 @@ Relay uses **MiniJinja** (Rust implementation of Jinja2) for template interpolat
Template strings are evaluated anywhere in the interpreter (not only in web handlers) when a string contains both `{{` and `}}`.
-```relay
+```python
name = "Relay"
version = "0.1"
@@ -1321,7 +1338,7 @@ print("Count: {{ items | length }}")
Web handlers use the same engine:
-```relay
+```python
@app.get("/")
fn index()
return "<h1>{{ name }} v{{ version }}</h1>"
@@ -1331,7 +1348,7 @@ Templates can reference values in the current scope and support MiniJinja expres
Use `app.render_template(path, ...kwargs)` when you want explicit template rendering from a file without relying on implicit `{{ ... }}` string evaluation:
-```relay
+```python
app = WebApp()
html = app.render_template("templates/welcome.html", name="Ada", plan="Pro")
```
@@ -1339,14 +1356,14 @@ html = app.render_template("templates/welcome.html", name="Ada", plan="Pro")
#### `WebServer()`
Create a web server instance.
-```relay
+```python
server = WebServer()
```
#### `server.run(app)`
Start the web server.
-```relay
+```python
app = WebApp()
server = WebServer()
@@ -1369,7 +1386,7 @@ server.run(app) // Starts server on 127.0.0.1:8080
#### `Mongo(connection_string)`
Create a MongoDB client.
-```relay
+```python
mongo = Mongo("mongodb://localhost:27017")
```
@@ -1379,7 +1396,7 @@ mongodb://[username:password@]host[:port][/database]
```
Examples:
-```relay
+```python
// Local MongoDB
mongo = Mongo("mongodb://localhost:27017")
@@ -1395,7 +1412,7 @@ mongo = Mongo("mongodb://admin:password@localhost:27017")
Access a database.
-```relay
+```python
mongo = Mongo("mongodb://localhost:27017")
db = mongo.db("my_app")
```
@@ -1405,7 +1422,7 @@ db = mongo.db("my_app")
Access a collection.
-```relay
+```python
users = db.collection("users")
posts = db.collection("posts")
```
@@ -1415,7 +1432,7 @@ posts = db.collection("posts")
Insert a single document.
-```relay
+```python
users = db.collection("users")
result = users.insert_one({"name": "Ada", "email": "[email protected]"})
print(result["inserted_id"]) // ObjectId as string
@@ -1426,7 +1443,7 @@ print(result["inserted_id"]) // ObjectId as string
Insert multiple documents.
-```relay
+```python
users = db.collection("users")
docs = [
{"name": "Ada", "email": "[email protected]"},
@@ -1441,7 +1458,7 @@ print(result["inserted_ids"]) // Dict of index -> ObjectId string
Find a single document matching the filter.
-```relay
+```python
users = db.collection("users")
user = users.find_one({"email": "[email protected]"})
if (user != None)
@@ -1449,7 +1466,7 @@ if (user != None)
```
**Filter examples:**
-```relay
+```python
// Exact match
user = users.find_one({"name": "Ada"})
@@ -1465,7 +1482,7 @@ user = users.find_one({"_id": "507f1f77bcf86cd799439011"})
Find all documents matching the filter.
-```relay
+```python
users = db.collection("users")
active_users = users.find({"active": True})
for (user in active_users)
@@ -1473,7 +1490,7 @@ for (user in active_users)
```
**Find all documents:**
-```relay
+```python
all_users = users.find({})
```
@@ -1482,7 +1499,7 @@ all_users = users.find({})
Update a single document.
-```relay
+```python
users = db.collection("users")
result = users.update_one(
{"email": "[email protected]"},
@@ -1492,7 +1509,7 @@ print(result["modified_count"]) // 1
```
**Update operators:**
-```relay
+```python
// Set fields
users.update_one({"_id": id}, {"$set": {"status": "active"}})
@@ -1508,7 +1525,7 @@ users.update_one({"_id": id}, {"$unset": {"temp_field": ""}})
Update multiple documents.
-```relay
+```python
users = db.collection("users")
result = users.update_many(
{"active": False},
@@ -1522,7 +1539,7 @@ print(result["modified_count"])
Delete a single document.
-```relay
+```python
users = db.collection("users")
result = users.delete_one({"email": "[email protected]"})
print(result["deleted_count"]) // 1 or 0
@@ -1533,7 +1550,7 @@ print(result["deleted_count"]) // 1 or 0
Delete multiple documents.
-```relay
+```python
users = db.collection("users")
result = users.delete_many({"active": False})
print(result["deleted_count"]) // Number of deleted documents
@@ -1545,7 +1562,7 @@ print(result["deleted_count"]) // Number of deleted documents
### 1. Hello World (Async)
-```relay
+```python
sleep(2000, print("world"))
print("hello")
```
@@ -1558,7 +1575,7 @@ world
### 2. Simple Web API
-```relay
+```python
app = WebApp()
server = WebServer()
@@ -1575,7 +1592,7 @@ server.run(app)
### 3. Pastebin Service
-```relay
+```python
app = WebApp()
server = WebServer()
mongo = Mongo("mongodb://localhost:27017")
@@ -1620,7 +1637,7 @@ Template files used by this example:
### 4. Concurrent HTTP Requests
-```relay
+```python
http = Http()
fn fetch_user(user_id)
@@ -1641,7 +1658,7 @@ for (user in users)
### 5. File Processing Pipeline
-```relay
+```python
fn process_file(filename)
content = read_file(filename)
lines = len(content.split("\n"))
@@ -1659,7 +1676,7 @@ print("Processing complete!")
### 6. Session-based Authentication
-```relay
+```python
app = WebApp()
server = WebServer()
mongo = Mongo("mongodb://localhost:27017")
@@ -1697,7 +1714,7 @@ server.run(app)
### 7. REST API with MongoDB
-```relay
+```python
app = WebApp()
server = WebServer()
mongo = Mongo("mongodb://localhost:27017")
@@ -1747,7 +1764,7 @@ server.run(app)
### 8. Timeout and Error Handling
-```relay
+```python
http = Http()
fn fetch_with_timeout(url)
@@ -1760,7 +1777,7 @@ print(result.text)
### 9. Race Condition Example
-```relay
+```python
http = Http()
// Fetch from multiple mirrors, use whichever responds first
@@ -1780,7 +1797,7 @@ print("Fastest mirror returned:", fastest.text)
### 10. Background Task Processing
-```relay
+```python
fn process_item(item)
sleep(1000, print("Processed: " + str(item)))
@@ -1796,7 +1813,7 @@ print("All tasks started, continuing...")
### 11. SMTP Welcome Email with Templates
-```relay
+```python
email = Email(
host="smtp.example.com",
username="smtp-user",
@@ -1845,7 +1862,7 @@ Relay is built on:
When you call an async function, Relay immediately starts the operation and returns a `Deferred` value:
-```relay
+```python
// This starts the HTTP request immediately
response = http.get("https://api.example.com")
// response is Deferred<Response>
@@ -1862,26 +1879,26 @@ print(response.status) // <-- Blocks here if not complete
`Deferred` values automatically resolve when:
1. **Used in operations:**
-```relay
+```python
x = sleep(1000, 10)
y = x + 5 // Waits for x to resolve
```
2. **Passed to functions:**
-```relay
+```python
result = sleep(1000, 42)
print(result) // Waits before printing
```
3. **Used in control flow:**
-```relay
+```python
data = http.get("https://api.example.com")
if (data.status == 200) // Waits before comparison
print("Success")
```
4. **Indexed:**
-```relay
+```python
resp = http.get("https://api.example.com")
json_data = resp.json()
print(json_data["key"]) // Waits for json() before indexing
@@ -1891,7 +1908,7 @@ print(json_data["key"]) // Waits for json() before indexing
Expression statements (expressions not assigned to variables) run without blocking:
-```relay
+```python
// This starts the sleep but doesn't wait
sleep(1000, print("delayed"))
@@ -1907,7 +1924,7 @@ print("immediate")
Relay uses Tokio's work-stealing scheduler to run tasks concurrently:
-```relay
+```python
// Start 3 HTTP requests concurrently
task1 = spawn(http.get("https://api1.com"))
task2 = spawn(http.get("https://api2.com"))
@@ -1945,7 +1962,7 @@ Every web request is also assigned a `request_id` and echoed as the `x-request-i
MongoDB operations return `Deferred` values that resolve when the database operation completes:
-```relay
+```python
// This starts the query immediately
users = collection.find({"active": True})
@@ -1964,7 +1981,7 @@ for (user in users) // <-- Blocks here
### 1. Leverage Concurrent Execution
Instead of:
-```relay
+```python
// Sequential (slow)
result1 = http.get("https://api1.com")
result2 = http.get("https://api2.com")
@@ -1972,7 +1989,7 @@ result3 = http.get("https://api3.com")
```
Do:
-```relay
+```python
// Concurrent (fast)
tasks = [
spawn(http.get("https://api1.com")),
@@ -1986,7 +2003,7 @@ results = all(tasks)
Type hints provide automatic validation and coercion:
-```relay
+```python
@app.post("/calculate")
fn calculate(a: int, b: int, operation: str = "add")
if (operation == "add")
@@ -1999,7 +2016,7 @@ fn calculate(a: int, b: int, operation: str = "add")
Always check for `None` when querying databases or processing optional parameters:
-```relay
+```python
@app.get("/users/<user_id>")
fn get_user(user_id)
user = users.find_one({"_id": user_id})
@@ -2012,7 +2029,7 @@ fn get_user(user_id)
Don't try to maintain state in global variables. Use sessions:
-```relay
+```python
// Bad
current_user = None
@@ -2030,7 +2047,7 @@ fn login(username)
Always add timeouts to external HTTP requests:
-```relay
+```python
fn fetch_data(url)
return timeout(http.get(url), 10000) // 10 second timeout
```
@@ -2039,7 +2056,7 @@ fn fetch_data(url)
Split handlers into logical groups:
-```relay
+```python
app = WebApp()
server = WebServer()
@@ -2073,7 +2090,7 @@ server.run(app)
For cleaner counter increments:
-```relay
+```python
// Instead of
i = i + 1
@@ -2085,7 +2102,7 @@ i =+ 1
Structure handlers with early returns for error cases:
-```relay
+```python
@app.get("/posts/<post_id>")
fn get_post(post_id)
post = posts.find_one({"_id": post_id})
@@ -2110,7 +2127,7 @@ fn get_post(post_id)
**Fix:** Ensure all indentation uses 4 spaces (not tabs, not 2 spaces).
-```relay
+```python
// Wrong
fn example()
print("hello") // 2 spaces
@@ -2132,7 +2149,7 @@ fn example()
**Fix:** Use explicit type conversion:
-```relay
+```python
// Wrong
x = 10 + "5"
@@ -2146,7 +2163,7 @@ x = 10 + int("5")
**Fix:** Ensure variables are assigned before use:
-```relay
+```python
// Wrong
print(x)
x = 10
@@ -2162,7 +2179,7 @@ print(x)
**Fix:** Check list length before accessing:
-```relay
+```python
items = [1, 2, 3]
if (len(items) > 5)
print(items[5])
@@ -2171,7 +2188,7 @@ if (len(items) > 5)
### Debugging Tips
1. **Use print statements:** Relay's simplest debugging tool
-```relay
+```python
fn process_data(data)
print("Processing:", data) // Debug output
result = transform(data)
@@ -2180,7 +2197,7 @@ fn process_data(data)
```
2. **Check async resolution:** If something seems to hang, check if you're waiting for a `Deferred` value
-```relay
+```python
// This might hang if the HTTP request never completes
result = http.get("https://unreachable.com")
print(result.status)
@@ -2194,7 +2211,7 @@ result = timeout(http.get("https://unreachable.com"), 5000)
4. **Check file paths:** File operations use paths relative to where you run the `relay` command
5. **Inspect request objects:** Log the request object to debug handler issues
-```relay
+```python
@app.post("/debug")
fn debug()
print(request)
@@ -2204,7 +2221,7 @@ fn debug()
### Performance Tips
1. **Batch database operations:** Use `insert_many` instead of multiple `insert_one` calls
-```relay
+```python
// Slow
for (item in items)
collection.insert_one(item)
@@ -2214,7 +2231,7 @@ collection.insert_many(items)
```
2. **Use `spawn` for I/O-heavy tasks:** Parallelize independent operations
-```relay
+```python
// Serial: 5 seconds total
sleep(1000, "a")
sleep(1000, "b")
diff --git a/src/main.rs b/src/main.rs
index 729d7b5..c43be67 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -30,11 +30,11 @@ use axum::{
Router,
};
-use async_recursion::async_recursion;
use argon2::{
- password_hash::{SaltString, rand_core::OsRng},
+ password_hash::{rand_core::OsRng, SaltString},
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
};
+use async_recursion::async_recursion;
use futures::{stream::TryStreamExt, SinkExt, StreamExt};
use indexmap::IndexMap;
use lettre::{
@@ -103,6 +103,8 @@ enum Stmt {
expr: Expr,
},
Return(Option<Expr>),
+ Break,
+ Continue,
If {
cond: Expr,
@@ -253,7 +255,7 @@ enum Tok {
Colon, // used in dict literals only
Op(String),
- Keyword(String), // fn if for while return try except import True False None str int float
+ Keyword(String), // fn if else for while return break continue try except import True False None str int float
}
#[derive(Debug, Clone)]
@@ -487,8 +489,8 @@ impl<'a> Lexer<'a> {
let id = self.read_ident();
let k = match id.as_str() {
"fn" | "if" | "else" | "for" | "while" | "return" | "try"
- | "except" | "import" | "True" | "False" | "None" | "str" | "int"
- | "float" => Tok::Keyword(id),
+ | "except" | "import" | "break" | "continue" | "True" | "False"
+ | "None" | "str" | "int" | "float" => Tok::Keyword(id),
_ => Tok::Ident(id),
};
out.push(self.wrap(k));
@@ -728,6 +730,12 @@ impl Parser {
if self.peek_kw("return") {
return self.parse_return();
}
+ if self.peek_kw("break") {
+ return self.parse_break();
+ }
+ if self.peek_kw("continue") {
+ return self.parse_continue();
+ }
if self.peek_kw("try") {
return self.parse_try_except();
}
@@ -746,8 +754,7 @@ impl Parser {
}
let target = self.parse_expr()?;
- if self.peek_is(&Tok::Assign) || self.peek_is(&Tok::AugAdd) || self.peek_is(&Tok::AugSub)
- {
+ if self.peek_is(&Tok::Assign) || self.peek_is(&Tok::AugAdd) || self.peek_is(&Tok::AugSub) {
if !Self::is_assignment_target(&target) {
return Err(self.err_here("Invalid assignment target"));
}
@@ -935,6 +942,22 @@ impl Parser {
Ok(Stmt::Return(Some(self.parse_expr()?)))
}
+ fn parse_break(&mut self) -> RResult<Stmt> {
+ self.expect_kw("break")?;
+ if self.peek_is(&Tok::Newline) || self.peek_is(&Tok::Dedent) || self.eof() {
+ return Ok(Stmt::Break);
+ }
+ Err(self.err_here("break does not take a value"))
+ }
+
+ fn parse_continue(&mut self) -> RResult<Stmt> {
+ self.expect_kw("continue")?;
+ if self.peek_is(&Tok::Newline) || self.peek_is(&Tok::Dedent) || self.eof() {
+ return Ok(Stmt::Continue);
+ }
+ Err(self.err_here("continue does not take a value"))
+ }
+
fn parse_try_except(&mut self) -> RResult<Stmt> {
self.expect_kw("try")?;
self.expect_newline()?;
@@ -1604,6 +1627,8 @@ struct ModuleState {
enum Flow {
None,
Return(Value),
+ Break,
+ Continue,
}
impl Evaluator {
@@ -1628,6 +1653,12 @@ impl Evaluator {
match self.eval_stmt(s).await? {
Flow::None => {}
Flow::Return(v) => return Ok(v),
+ Flow::Break => {
+ return Err(RelayError::Runtime("break used outside loop".into()));
+ }
+ Flow::Continue => {
+ return Err(RelayError::Runtime("continue used outside loop".into()));
+ }
}
last = Value::None;
}
@@ -1703,7 +1734,7 @@ impl Evaluator {
for s in b {
match self.eval_stmt(s).await? {
Flow::None => {}
- r @ Flow::Return(_) => return Ok(r),
+ r => return Ok(r),
}
}
Ok(Flow::None)
@@ -1719,6 +1750,12 @@ impl Evaluator {
match self.eval_stmt(s).await? {
Flow::None => {}
Flow::Return(v) => return Ok(v),
+ Flow::Break => {
+ return Err(RelayError::Runtime("break used outside loop".into()));
+ }
+ Flow::Continue => {
+ return Err(RelayError::Runtime("continue used outside loop".into()));
+ }
}
continue;
}
@@ -1728,6 +1765,12 @@ impl Evaluator {
_ => match self.eval_stmt(s).await? {
Flow::None => return Ok(Value::None),
Flow::Return(v) => return Ok(v),
+ Flow::Break => {
+ return Err(RelayError::Runtime("break used outside loop".into()));
+ }
+ Flow::Continue => {
+ return Err(RelayError::Runtime("continue used outside loop".into()));
+ }
},
}
}
@@ -1809,6 +1852,8 @@ impl Evaluator {
};
Ok(Flow::Return(v))
}
+ Stmt::Break => Ok(Flow::Break),
+ Stmt::Continue => Ok(Flow::Continue),
Stmt::If {
cond,
then_block,
@@ -1840,8 +1885,11 @@ impl Evaluator {
let mut env = self.env.lock().await;
env.pop();
drop(env);
- if let Flow::Return(v) = r {
- return Ok(Flow::Return(v));
+ match r {
+ Flow::None => {}
+ Flow::Continue => continue,
+ Flow::Break => break,
+ Flow::Return(v) => return Ok(Flow::Return(v)),
}
}
Ok(Flow::None)
@@ -1859,8 +1907,11 @@ impl Evaluator {
let mut env = self.env.lock().await;
env.pop();
drop(env);
- if let Flow::Return(v) = r {
- return Ok(Flow::Return(v));
+ match r {
+ Flow::None => {}
+ Flow::Continue => continue,
+ Flow::Break => break,
+ Flow::Return(v) => return Ok(Flow::Return(v)),
}
}
Ok(Flow::None)
@@ -2669,8 +2720,8 @@ fn assign_member_value(object: Value, name: &str, value: Value) -> RResult<Value
fn assign_index_value(container: Value, index: Value, value: Value) -> RResult<Value> {
match (container, index) {
(Value::List(mut values), Value::Int(i)) => {
- let idx = usize::try_from(i)
- .map_err(|_| RelayError::Runtime("Index out of range".into()))?;
+ let idx =
+ usize::try_from(i).map_err(|_| RelayError::Runtime("Index out of range".into()))?;
let slot = values
.get_mut(idx)
.ok_or_else(|| RelayError::Runtime("Index out of range".into()))?;
@@ -2682,9 +2733,9 @@ fn assign_index_value(container: Value, index: Value, value: Value) -> RResult<V
Ok(Value::Dict(map))
}
(Value::Json(mut j), Value::Str(key)) => {
- let obj = j.as_object_mut().ok_or_else(|| {
- RelayError::Type("JSON indexing expects object value".into())
- })?;
+ let obj = j
+ .as_object_mut()
+ .ok_or_else(|| RelayError::Type("JSON indexing expects object value".into()))?;
obj.insert(key, value_to_json(&value));
Ok(Value::Json(j))
}
@@ -2841,12 +2892,10 @@ fn install_stdlib(env: &mut Env, evaluator: Arc<Evaluator>) -> RResult<()> {
};
match request {
- Some(Value::Dict(req)) => {
- Ok(req
- .get("form")
- .cloned()
- .unwrap_or(Value::Dict(IndexMap::new())))
- }
+ Some(Value::Dict(req)) => Ok(req
+ .get("form")
+ .cloned()
+ .unwrap_or(Value::Dict(IndexMap::new()))),
_ => Ok(Value::Dict(IndexMap::new())),
}
})
@@ -2865,12 +2914,10 @@ fn install_stdlib(env: &mut Env, evaluator: Arc<Evaluator>) -> RResult<()> {
};
match request {
- Some(Value::Dict(req)) => {
- Ok(req
- .get("query")
- .cloned()
- .unwrap_or(Value::Dict(IndexMap::new())))
- }
+ Some(Value::Dict(req)) => Ok(req
+ .get("query")
+ .cloned()
+ .unwrap_or(Value::Dict(IndexMap::new()))),
_ => Ok(Value::Dict(IndexMap::new())),
}
})
@@ -3426,9 +3473,7 @@ fn install_stdlib(env: &mut Env, evaluator: Arc<Evaluator>) -> RResult<()> {
}
};
Ok(Value::Obj(Object::AuthStore(AuthStoreHandle::callback(
- evaluator,
- load_fn,
- save_fn,
+ evaluator, load_fn, save_fn,
))))
})
})),
@@ -3482,7 +3527,12 @@ fn expect_int(args: &[Value], i: usize, sig: &str) -> RResult<i64> {
}
}
-fn kwarg_or_arg(kwargs: &[(String, Value)], args: &[Value], name: &str, index: usize) -> Option<Value> {
+fn kwarg_or_arg(
+ kwargs: &[(String, Value)],
+ args: &[Value],
+ name: &str,
+ index: usize,
+) -> Option<Value> {
kwargs
.iter()
.find(|(k, _)| k == name)
@@ -3516,7 +3566,9 @@ fn parse_u16_value(v: Value, sig: &str, field: &str) -> RResult<u16> {
_ => return Err(RelayError::Type(format!("{sig} {field} must be int"))),
};
if !(0..=65535).contains(&n) {
- return Err(RelayError::Type(format!("{sig} {field} must be in range 0..65535")));
+ return Err(RelayError::Type(format!(
+ "{sig} {field} must be in range 0..65535"
+ )));
}
Ok(n as u16)
}
@@ -3572,9 +3624,11 @@ fn parse_optional_mime_allowlist(
Ok(Some(set))
}
}
- Value::Json(J::Array(items)) => {
- parse_optional_mime_allowlist(Some(Value::List(items.iter().map(json_to_value).collect())), sig, field)
- }
+ Value::Json(J::Array(items)) => parse_optional_mime_allowlist(
+ Some(Value::List(items.iter().map(json_to_value).collect())),
+ sig,
+ field,
+ ),
_ => Err(RelayError::Type(format!(
"{sig} {field} must be str or list[str]"
))),
@@ -3594,7 +3648,9 @@ fn parse_string_list_value(v: Value, sig: &str, field: &str) -> RResult<Vec<Stri
.iter()
.map(|item| expect_str_value(json_to_value(item), sig, field))
.collect(),
- _ => Err(RelayError::Type(format!("{sig} {field} must be str or list[str]"))),
+ _ => Err(RelayError::Type(format!(
+ "{sig} {field} must be str or list[str]"
+ ))),
}
}
@@ -3657,9 +3713,8 @@ fn validate_data_with_schema(data: Value, schema: Value, sig: &str) -> RResult<V
match (existing, expected_type, default) {
(Some(v), Some(expected_ty), _) => {
- let coerced = coerce_param_type(&expected_ty, v).map_err(|e| {
- RelayError::Type(format!("validate({field}): {e}"))
- })?;
+ let coerced = coerce_param_type(&expected_ty, v)
+ .map_err(|e| RelayError::Type(format!("validate({field}): {e}")))?;
input.insert(field, coerced);
}
(Some(_), None, Some(default)) => {
@@ -3937,13 +3992,7 @@ impl WebAppHandle {
}
// New spec: decorator attaches directly to the app: @app.get("/"), @app.post("/")
- fn register(
- &self,
- method: String,
- path: String,
- fn_name: String,
- validation: RouteValidation,
- ) {
+ fn register(&self, method: String, path: String, fn_name: String, validation: RouteValidation) {
let mut st = self.inner.lock().unwrap();
st.routes.push(RouteSpec {
method,
@@ -4027,13 +4076,7 @@ impl RouteHandle {
}
}
- fn register(
- &self,
- method: String,
- path: String,
- fn_name: String,
- validation: RouteValidation,
- ) {
+ fn register(&self, method: String, path: String, fn_name: String, validation: RouteValidation) {
let full_path = join_route_prefix(&self.prefix, &path);
let mut st = self.app.inner.lock().unwrap();
st.routes.push(RouteSpec {
@@ -4214,22 +4257,22 @@ async fn run_app(app: WebAppHandle) -> RResult<()> {
let upload_config = app_handle.upload_config();
let (json_body, form_body) =
match parse_request_body(&headers, body, &upload_config).await {
- Ok(parts) => parts,
- Err(e) => {
- let error = error_response(
- 400,
- "bad_request",
- &e.to_string(),
- None,
- Some(request_id),
- );
- return Ok::<_, (StatusCode, String)>(
- value_to_axum_response(Value::Response(error))
- .await
- .into_response(),
- );
- }
- };
+ Ok(parts) => parts,
+ Err(e) => {
+ let error = error_response(
+ 400,
+ "bad_request",
+ &e.to_string(),
+ None,
+ Some(request_id),
+ );
+ return Ok::<_, (StatusCode, String)>(
+ value_to_axum_response(Value::Response(error))
+ .await
+ .into_response(),
+ );
+ }
+ };
let req = RequestParts {
method: method.to_string(),
@@ -4932,7 +4975,9 @@ fn parse_headers_value(v: Value, sig: &str) -> RResult<HashMap<String, String>>
}
Ok(out)
}
- _ => Err(RelayError::Type(format!("{sig} headers must be dict/json object"))),
+ _ => Err(RelayError::Type(format!(
+ "{sig} headers must be dict/json object"
+ ))),
}
}
@@ -4966,7 +5011,11 @@ async fn execute_http_request(
"PUT" => client.put(url),
"PATCH" => client.patch(url),
"DELETE" => client.delete(url),
- _ => return Err(RelayError::Runtime(format!("Unsupported HTTP method: {method}"))),
+ _ => {
+ return Err(RelayError::Runtime(format!(
+ "Unsupported HTTP method: {method}"
+ )))
+ }
};
for (k, v) in headers {
@@ -5289,9 +5338,9 @@ fn parse_attachment_bytes(v: Value, sig: &str) -> RResult<Vec<u8>> {
}
Ok(out)
}
- Value::Json(J::Array(values)) => parse_attachment_bytes(Value::List(
- values.iter().map(json_to_value).collect(),
- ), sig),
+ Value::Json(J::Array(values)) => {
+ parse_attachment_bytes(Value::List(values.iter().map(json_to_value).collect()), sig)
+ }
_ => Err(RelayError::Type(format!(
"{sig} attachment content must be bytes, str, or list[int]"
))),
@@ -5305,12 +5354,9 @@ fn parse_email_attachment(v: Value, sig: &str) -> RResult<EmailAttachmentSpec> {
.ok_or_else(|| RelayError::Type(format!("{sig} attachment missing filename")))?;
let filename = expect_str_value(filename, sig, "attachment.filename")?;
- let content_type = parse_optional_str_value(
- map.remove("content_type"),
- sig,
- "attachment.content_type",
- )?
- .unwrap_or_else(|| "application/octet-stream".to_string());
+ let content_type =
+ parse_optional_str_value(map.remove("content_type"), sig, "attachment.content_type")?
+ .unwrap_or_else(|| "application/octet-stream".to_string());
let body_value = map
.remove("bytes")
@@ -5368,10 +5414,10 @@ fn smtp_sender_for_config(config: &EmailConfig) -> EmailSender {
host.as_str(),
)
.map_err(|e| RelayError::Runtime(format!("email transport setup failed: {e}")))?,
- EmailTlsMode::Wrapper => AsyncSmtpTransport::<Tokio1Executor>::relay(
- host.as_str(),
- )
- .map_err(|e| RelayError::Runtime(format!("email transport setup failed: {e}")))?,
+ EmailTlsMode::Wrapper => AsyncSmtpTransport::<Tokio1Executor>::relay(host.as_str())
+ .map_err(|e| {
+ RelayError::Runtime(format!("email transport setup failed: {e}"))
+ })?,
EmailTlsMode::Insecure => {
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host.as_str())
}
@@ -5415,19 +5461,22 @@ impl EmailHandle {
}
fn from_constructor_args(args: &[Value], kwargs: &[(String, Value)]) -> RResult<Self> {
- let sig = "Email(host, port=587, username=None, password=None, from=None, tls=\"starttls\")";
+ let sig =
+ "Email(host, port=587, username=None, password=None, from=None, tls=\"starttls\")";
- let host = kwarg_or_arg(kwargs, args, "host", 0).ok_or_else(|| {
- RelayError::Type(format!("{sig} missing host"))
- })?;
+ let host = kwarg_or_arg(kwargs, args, "host", 0)
+ .ok_or_else(|| RelayError::Type(format!("{sig} missing host")))?;
let host = expect_str_value(host, sig, "host")?;
let port = kwarg_or_arg(kwargs, args, "port", 1).unwrap_or(Value::Int(587));
let port = parse_u16_value(port, sig, "port")?;
- let username = parse_optional_str_value(kwarg_or_arg(kwargs, args, "username", 2), sig, "username")?;
- let password = parse_optional_str_value(kwarg_or_arg(kwargs, args, "password", 3), sig, "password")?;
- let default_from = parse_optional_str_value(kwarg_or_arg(kwargs, args, "from", 4), sig, "from")?;
+ let username =
+ parse_optional_str_value(kwarg_or_arg(kwargs, args, "username", 2), sig, "username")?;
+ let password =
+ parse_optional_str_value(kwarg_or_arg(kwargs, args, "password", 3), sig, "password")?;
+ let default_from =
+ parse_optional_str_value(kwarg_or_arg(kwargs, args, "from", 4), sig, "from")?;
let tls_raw = parse_optional_str_value(kwarg_or_arg(kwargs, args, "tls", 5), sig, "tls")?
.unwrap_or_else(|| "starttls".to_string());
@@ -5454,7 +5503,11 @@ impl EmailHandle {
Self { config, sender }
}
- fn prepare_send_request(&self, args: &[Value], kwargs: &[(String, Value)]) -> RResult<PreparedEmail> {
+ fn prepare_send_request(
+ &self,
+ args: &[Value],
+ kwargs: &[(String, Value)],
+ ) -> RResult<PreparedEmail> {
let sig = "email.send(to, subject, text=None, html=None, cc=None, bcc=None, reply_to=None, from=None, headers=None, attachments=None)";
let to_raw = kwarg_or_arg(kwargs, args, "to", 0)
@@ -5486,15 +5539,15 @@ impl EmailHandle {
sig,
"bcc",
)?;
- let reply_to = parse_optional_mailbox(kwarg_or_arg(kwargs, args, "reply_to", 6), sig, "reply_to")?;
+ let reply_to =
+ parse_optional_mailbox(kwarg_or_arg(kwargs, args, "reply_to", 6), sig, "reply_to")?;
- let from_override = parse_optional_str_value(kwarg_or_arg(kwargs, args, "from", 7), sig, "from")?;
+ let from_override =
+ parse_optional_str_value(kwarg_or_arg(kwargs, args, "from", 7), sig, "from")?;
let from = from_override
.or_else(|| self.config.default_from.clone())
.ok_or_else(|| {
- RelayError::Type(format!(
- "{sig} requires from in Email(...) or send(...)"
- ))
+ RelayError::Type(format!("{sig} requires from in Email(...) or send(...)"))
})?;
let from = parse_mailbox(from, sig, "from")?;
@@ -5502,7 +5555,8 @@ impl EmailHandle {
.map(|v| parse_headers_value(v, sig))
.transpose()?
.unwrap_or_default();
- let attachments = parse_email_attachments(kwarg_or_arg(kwargs, args, "attachments", 9), sig)?;
+ let attachments =
+ parse_email_attachments(kwarg_or_arg(kwargs, args, "attachments", 9), sig)?;
let mut builder = MailMessage::builder().from(from).subject(subject);
for mb in &to {
@@ -5519,10 +5573,10 @@ impl EmailHandle {
}
for (name, value) in headers {
- let header_name = mail_header::HeaderName::new_from_ascii(name.clone())
- .map_err(|e| RelayError::Type(format!(
- "{sig} invalid header name '{name}': {e}"
- )))?;
+ let header_name =
+ mail_header::HeaderName::new_from_ascii(name.clone()).map_err(|e| {
+ RelayError::Type(format!("{sig} invalid header name '{name}': {e}"))
+ })?;
builder = builder.raw_header(mail_header::HeaderValue::new(header_name, value));
}
@@ -5634,17 +5688,11 @@ impl EmailHandle {
);
out.insert(
"smtp_code".into(),
- metadata
- .smtp_code
- .map(Value::Int)
- .unwrap_or(Value::None),
+ metadata.smtp_code.map(Value::Int).unwrap_or(Value::None),
);
out.insert(
"smtp_message".into(),
- metadata
- .smtp_message
- .map(Value::Str)
- .unwrap_or(Value::None),
+ metadata.smtp_message.map(Value::Str).unwrap_or(Value::None),
);
Ok(Value::Dict(out))
}));
@@ -5652,39 +5700,35 @@ impl EmailHandle {
})
}))
}
- "render" => {
- Value::Builtin(Arc::new(move |args, kwargs| {
- Box::pin(async move {
- let sig = "email.render(template, data=None)";
- let template = kwarg_or_arg(&kwargs, &args, "template", 0)
- .ok_or_else(|| RelayError::Type(format!("{sig} missing template")))?;
- let template = expect_str_value(template, sig, "template")?;
- let data = parse_template_locals(kwarg_or_arg(&kwargs, &args, "data", 1), sig)?;
+ "render" => Value::Builtin(Arc::new(move |args, kwargs| {
+ Box::pin(async move {
+ let sig = "email.render(template, data=None)";
+ let template = kwarg_or_arg(&kwargs, &args, "template", 0)
+ .ok_or_else(|| RelayError::Type(format!("{sig} missing template")))?;
+ let template = expect_str_value(template, sig, "template")?;
+ let data = parse_template_locals(kwarg_or_arg(&kwargs, &args, "data", 1), sig)?;
+ let rendered = render_template(&template, &data)?;
+ Ok(Value::Str(rendered))
+ })
+ })),
+ "render_file" => Value::Builtin(Arc::new(move |args, kwargs| {
+ Box::pin(async move {
+ let sig = "email.render_file(path, data=None)";
+ let path = kwarg_or_arg(&kwargs, &args, "path", 0)
+ .ok_or_else(|| RelayError::Type(format!("{sig} missing path")))?;
+ let path = expect_str_value(path, sig, "path")?;
+ let data = parse_template_locals(kwarg_or_arg(&kwargs, &args, "data", 1), sig)?;
+
+ let d = Deferred::new(Box::pin(async move {
+ let template = tokio::fs::read_to_string(path)
+ .await
+ .map_err(|e| RelayError::Runtime(e.to_string()))?;
let rendered = render_template(&template, &data)?;
Ok(Value::Str(rendered))
- })
- }))
- }
- "render_file" => {
- Value::Builtin(Arc::new(move |args, kwargs| {
- Box::pin(async move {
- let sig = "email.render_file(path, data=None)";
- let path = kwarg_or_arg(&kwargs, &args, "path", 0)
- .ok_or_else(|| RelayError::Type(format!("{sig} missing path")))?;
- let path = expect_str_value(path, sig, "path")?;
- let data = parse_template_locals(kwarg_or_arg(&kwargs, &args, "data", 1), sig)?;
-
- let d = Deferred::new(Box::pin(async move {
- let template = tokio::fs::read_to_string(path)
- .await
- .map_err(|e| RelayError::Runtime(e.to_string()))?;
- let rendered = render_template(&template, &data)?;
- Ok(Value::Str(rendered))
- }));
- Ok(Value::Deferred(Arc::new(d)))
- })
- }))
- }
+ }));
+ Ok(Value::Deferred(Arc::new(d)))
+ })
+ })),
_ => Value::None,
}
}
@@ -5726,9 +5770,7 @@ impl AuthStoreHandle {
match &self.backend {
AuthStoreBackend::Memory(store) => Ok(store.lock().await.get(&username).cloned()),
AuthStoreBackend::Callback {
- load_fn,
- evaluator,
- ..
+ load_fn, evaluator, ..
} => {
let out = evaluator
.call_named_function(load_fn, vec![Value::Str(username)])
@@ -5752,9 +5794,7 @@ impl AuthStoreHandle {
Ok(())
}
AuthStoreBackend::Callback {
- save_fn,
- evaluator,
- ..
+ save_fn, evaluator, ..
} => {
let _ = evaluator
.call_named_function(save_fn, vec![Value::Str(username), Value::Str(hash)])
@@ -5815,8 +5855,10 @@ impl AuthStoreHandle {
Value::Builtin(Arc::new(move |args, _| {
let store = store.clone();
Box::pin(async move {
- let username = expect_str(&args, 0, "auth_store.verify(username, password)")?;
- let password = expect_str(&args, 1, "auth_store.verify(username, password)")?;
+ let username =
+ expect_str(&args, 0, "auth_store.verify(username, password)")?;
+ let password =
+ expect_str(&args, 1, "auth_store.verify(username, password)")?;
let Some(hash) = store.load_hash(username).await? else {
return Ok(Value::Bool(false));
};
@@ -6318,42 +6360,32 @@ impl Object {
Value::Builtin(Arc::new(move |args, _| {
let a = a.clone();
Box::pin(async move {
- let load_fn = args
- .get(0)
- .cloned()
- .ok_or_else(|| {
- RelayError::Type(
- "app.session_backend(load_fn, save_fn) expects load_fn"
- .into(),
- )
- })?;
- let save_fn = args
- .get(1)
- .cloned()
- .ok_or_else(|| {
- RelayError::Type(
- "app.session_backend(load_fn, save_fn) expects save_fn"
- .into(),
- )
- })?;
- let load_fn = match load_fn {
- Value::Function(f) => f.name.clone(),
- _ => {
- return Err(RelayError::Type(
+ let load_fn = args.get(0).cloned().ok_or_else(|| {
+ RelayError::Type(
+ "app.session_backend(load_fn, save_fn) expects load_fn".into(),
+ )
+ })?;
+ let save_fn = args.get(1).cloned().ok_or_else(|| {
+ RelayError::Type(
+ "app.session_backend(load_fn, save_fn) expects save_fn".into(),
+ )
+ })?;
+ let load_fn =
+ match load_fn {
+ Value::Function(f) => f.name.clone(),
+ _ => return Err(RelayError::Type(
"app.session_backend(load_fn, save_fn) expects functions"
.into(),
- ))
- }
- };
- let save_fn = match save_fn {
- Value::Function(f) => f.name.clone(),
- _ => {
- return Err(RelayError::Type(
+ )),
+ };
+ let save_fn =
+ match save_fn {
+ Value::Function(f) => f.name.clone(),
+ _ => return Err(RelayError::Type(
"app.session_backend(load_fn, save_fn) expects functions"
.into(),
- ))
- }
- };
+ )),
+ };
a.set_session_backend(SessionBackendConfig::Callback {
load_fn,
@@ -6575,11 +6607,14 @@ impl Evaluator {
let Some(j) = json_body.clone() else {
return Ok(validation_error("Missing JSON body".to_string()));
};
- let validated =
- match validate_data_with_schema(Value::Json(j), schema, "decorator json validate") {
- Ok(v) => v,
- Err(e) => return Ok(validation_error(e.to_string())),
- };
+ let validated = match validate_data_with_schema(
+ Value::Json(j),
+ schema,
+ "decorator json validate",
+ ) {
+ Ok(v) => v,
+ Err(e) => return Ok(validation_error(e.to_string())),
+ };
let map = expect_object_like(validated, "decorator json validate")?;
json_body = Some(value_to_json(&Value::Dict(map.clone())));
json_values = Some(map);
@@ -6587,14 +6622,11 @@ impl Evaluator {
if let Some(schema) = route_validation.validate_schema {
if let Some(j) = json_body.clone() {
- let validated = match validate_data_with_schema(
- Value::Json(j),
- schema,
- "decorator validate",
- ) {
- Ok(v) => v,
- Err(e) => return Ok(validation_error(e.to_string())),
- };
+ let validated =
+ match validate_data_with_schema(Value::Json(j), schema, "decorator validate") {
+ Ok(v) => v,
+ Err(e) => return Ok(validation_error(e.to_string())),
+ };
let map = expect_object_like(validated, "decorator validate")?;
json_body = Some(value_to_json(&Value::Dict(map.clone())));
json_values = Some(map);
@@ -6680,10 +6712,7 @@ impl Evaluator {
req_dict.insert("method".into(), Value::Str(method));
req_dict.insert("path".into(), Value::Str(path));
req_dict.insert("request_id".into(), Value::Str(request_id.clone()));
- req_dict.insert(
- "query".into(),
- Value::Dict(query_values.clone()),
- );
+ req_dict.insert("query".into(), Value::Dict(query_values.clone()));
req_dict.insert(
"headers".into(),
Value::Dict(
@@ -6705,10 +6734,7 @@ impl Evaluator {
if let Some(j) = json_body.clone() {
req_dict.insert("json".into(), Value::Json(j));
}
- req_dict.insert(
- "form".into(),
- Value::Dict(form_values.clone()),
- );
+ req_dict.insert("form".into(), Value::Dict(form_values.clone()));
let existing_session = self.load_session_data(&app, &session_id).await?;
let session_cfg = app.session_config();
@@ -6759,6 +6785,8 @@ impl Evaluator {
match evaluator.eval_block(&handler_fn.body).await? {
Flow::None => Ok(Value::None),
Flow::Return(v) => Ok(v),
+ Flow::Break => Err(RelayError::Runtime("break used outside loop".into())),
+ Flow::Continue => Err(RelayError::Runtime("continue used outside loop".into())),
}
})
});
@@ -6980,6 +7008,153 @@ mod tests {
));
}
+ #[test]
+ fn parses_break_and_continue_statements_in_loop_body() {
+ let src = r#"fn loop_control()
+ while (True)
+ continue
+ break
+"#;
+
+ let program = parse_src(src).expect("loop control statements should parse");
+ let Stmt::FuncDef { body, .. } = &program.stmts[0] else {
+ panic!("expected function definition");
+ };
+ let Stmt::While { body, .. } = &body[0] else {
+ panic!("expected while statement");
+ };
+ assert!(matches!(body[0], Stmt::Continue));
+ assert!(matches!(body[1], Stmt::Break));
+ }
+
+ #[tokio::test]
+ async fn while_loop_respects_break_and_continue() {
+ let src = r#"i = 0
+sum = 0
+while (i < 6)
+ i =+ 1
+ if (i == 2)
+ continue
+ if (i == 5)
+ break
+ sum =+ i
+"#;
+
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+ evaluator
+ .eval_program(&program)
+ .await
+ .expect("program should evaluate");
+
+ let env_lock = env.lock().await;
+ let i = env_lock.get("i").expect("i should exist");
+ let sum = env_lock.get("sum").expect("sum should exist");
+ assert!(matches!(i, Value::Int(5)));
+ assert!(matches!(sum, Value::Int(8)));
+ }
+
+ #[tokio::test]
+ async fn for_loop_respects_break_and_continue() {
+ let src = r#"sum = 0
+for (n in [1, 2, 3, 4, 5])
+ if (n == 2)
+ continue
+ if (n == 5)
+ break
+ sum =+ n
+"#;
+
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+ evaluator
+ .eval_program(&program)
+ .await
+ .expect("program should evaluate");
+
+ let env_lock = env.lock().await;
+ let sum = env_lock.get("sum").expect("sum should exist");
+ assert!(matches!(sum, Value::Int(8)));
+ }
+
+ #[tokio::test]
+ async fn break_outside_loop_raises_runtime_error() {
+ let program = parse_src("break\n").expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+
+ let err = evaluator.eval_program(&program).await;
+ let err = match err {
+ Ok(v) => panic!("break outside loop should fail, got {}", v.repr()),
+ Err(e) => e,
+ };
+ assert!(matches!(
+ err,
+ RelayError::Runtime(msg) if msg.contains("break used outside loop")
+ ));
+ }
+
+ #[tokio::test]
+ async fn continue_outside_loop_raises_runtime_error() {
+ let program = parse_src("continue\n").expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+
+ let err = evaluator.eval_program(&program).await;
+ let err = match err {
+ Ok(v) => panic!("continue outside loop should fail, got {}", v.repr()),
+ Err(e) => e,
+ };
+ assert!(matches!(
+ err,
+ RelayError::Runtime(msg) if msg.contains("continue used outside loop")
+ ));
+ }
+
+ #[tokio::test]
+ async fn break_inside_function_without_loop_raises_runtime_error() {
+ let src = r#"fn bad()
+ break
+
+bad()
+"#;
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+
+ let err = evaluator.eval_program(&program).await;
+ let err = match err {
+ Ok(v) => panic!("break outside loop should fail, got {}", v.repr()),
+ Err(e) => e,
+ };
+ assert!(matches!(
+ err,
+ RelayError::Runtime(msg) if msg.contains("break used outside loop")
+ ));
+ }
+
#[test]
fn bson_object_id_is_exposed_as_plain_string() {
let oid = ObjectId::parse_str("698d35e0ef97187ab267d11e").expect("valid object id");
@@ -7063,10 +7238,8 @@ mod tests {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::CONTENT_TYPE,
- axum::http::HeaderValue::from_str(&format!(
- "multipart/form-data; boundary={boundary}"
- ))
- .expect("header should be valid"),
+ axum::http::HeaderValue::from_str(&format!("multipart/form-data; boundary={boundary}"))
+ .expect("header should be valid"),
);
let body = format!(
@@ -7118,8 +7291,7 @@ mod tests {
..UploadConfig::default()
};
- let err = parse_request_body(&headers, Bytes::from_static(b"{\"x\":1}"), &upload_cfg)
- .await;
+ let err = parse_request_body(&headers, Bytes::from_static(b"{\"x\":1}"), &upload_cfg).await;
let err = match err {
Ok(_) => panic!("body larger than limit should fail"),
Err(err) => err,
@@ -7136,10 +7308,8 @@ mod tests {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::CONTENT_TYPE,
- axum::http::HeaderValue::from_str(&format!(
- "multipart/form-data; boundary={boundary}"
- ))
- .expect("header should be valid"),
+ axum::http::HeaderValue::from_str(&format!("multipart/form-data; boundary={boundary}"))
+ .expect("header should be valid"),
);
let body = format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"big.txt\"\r\nContent-Type: text/plain\r\n\r\nhello world\r\n--{boundary}--\r\n"
@@ -7149,8 +7319,7 @@ mod tests {
..UploadConfig::default()
};
- let err = parse_request_body(&headers, Bytes::from(body), &upload_cfg)
- .await;
+ let err = parse_request_body(&headers, Bytes::from(body), &upload_cfg).await;
let err = match err {
Ok(_) => panic!("file larger than max_file_bytes should fail"),
Err(err) => err,
@@ -7167,10 +7336,8 @@ mod tests {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::CONTENT_TYPE,
- axum::http::HeaderValue::from_str(&format!(
- "multipart/form-data; boundary={boundary}"
- ))
- .expect("header should be valid"),
+ axum::http::HeaderValue::from_str(&format!("multipart/form-data; boundary={boundary}"))
+ .expect("header should be valid"),
);
let body = format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"hello.txt\"\r\nContent-Type: text/plain\r\n\r\nhello world\r\n--{boundary}--\r\n"
@@ -7180,8 +7347,7 @@ mod tests {
..UploadConfig::default()
};
- let err = parse_request_body(&headers, Bytes::from(body), &upload_cfg)
- .await;
+ let err = parse_request_body(&headers, Bytes::from(body), &upload_cfg).await;
let err = match err {
Ok(_) => panic!("disallowed mime should fail"),
Err(err) => err,
@@ -7499,7 +7665,10 @@ fn from_query()
}
let mut form = HashMap::new();
- form.insert("name".to_string(), Value::Str("from-form-helper".to_string()));
+ form.insert(
+ "name".to_string(),
+ Value::Str("from-form-helper".to_string()),
+ );
let out_form = evaluator
.call_web_handler(
WebAppHandle::new(),
@@ -7584,10 +7753,8 @@ fn from_query()
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::CONTENT_TYPE,
- axum::http::HeaderValue::from_str(&format!(
- "multipart/form-data; boundary={boundary}"
- ))
- .expect("header should be valid"),
+ axum::http::HeaderValue::from_str(&format!("multipart/form-data; boundary={boundary}"))
+ .expect("header should be valid"),
);
let body = format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"hello.txt\"\r\nContent-Type: text/plain\r\n\r\nhello world\r\n--{boundary}--\r\n"
@@ -7715,7 +7882,10 @@ fn from_query()
);
let body = String::from_utf8(resp.body).expect("response body should be utf-8");
let parsed: J = serde_json::from_str(&body).expect("body should be json");
- assert_eq!(parsed["error"]["code"], J::String("bad_request".to_string()));
+ assert_eq!(
+ parsed["error"]["code"],
+ J::String("bad_request".to_string())
+ );
assert_eq!(
parsed["error"]["message"],
J::String("Missing field".to_string())
@@ -7724,7 +7894,10 @@ fn from_query()
parsed["error"]["request_id"],
J::String("rid-test".to_string())
);
- assert_eq!(parsed["error"]["details"]["field"], J::String("name".to_string()));
+ assert_eq!(
+ parsed["error"]["details"]["field"],
+ J::String("name".to_string())
+ );
}
other => panic!("expected response, got {}", other.repr()),
}
@@ -8231,7 +8404,10 @@ custom_ok = custom.verify("bob", "pw2")
let env_lock = env.lock().await;
assert!(matches!(env_lock.get("ok").expect("ok"), Value::Bool(true)));
- assert!(matches!(env_lock.get("bad").expect("bad"), Value::Bool(false)));
+ assert!(matches!(
+ env_lock.get("bad").expect("bad"),
+ Value::Bool(false)
+ ));
assert!(matches!(
env_lock.get("store_ok").expect("store_ok"),
Value::Bool(true)
@@ -8411,7 +8587,10 @@ fn get_user(user_id)
};
let state = app.inner.lock().unwrap();
- let route = state.routes.first().expect("one route should be registered");
+ let route = state
+ .routes
+ .first()
+ .expect("one route should be registered");
assert_eq!(route.path, "/api/v1/users/<user_id>");
}
@@ -8428,7 +8607,10 @@ fn get_user(user_id)
"2026.1",
);
- assert_eq!(doc["info"]["title"], J::String("Relay Test API".to_string()));
+ assert_eq!(
+ doc["info"]["title"],
+ J::String("Relay Test API".to_string())
+ );
assert_eq!(doc["info"]["version"], J::String("2026.1".to_string()));
assert!(doc["paths"]["/users/{id}"]["get"].is_object());
}
@@ -8598,7 +8780,10 @@ fn get_user(user_id)
Value::Str("Attachment test".into()),
Value::Str("See attachment".into()),
],
- vec![("attachments".into(), Value::List(vec![Value::Dict(attachment)]))],
+ vec![(
+ "attachments".into(),
+ Value::List(vec![Value::Dict(attachment)]),
+ )],
)
.await
.expect("send should succeed");
@@ -8607,7 +8792,10 @@ fn get_user(user_id)
Value::Deferred(d) => d,
other => panic!("send should return Deferred, got {}", other.repr()),
};
- let _ = deferred.resolve().await.expect("send deferred should resolve");
+ let _ = deferred
+ .resolve()
+ .await
+ .expect("send deferred should resolve");
let formatted = captured
.lock()
@@ -8721,7 +8909,10 @@ fn get_user(user_id)
.await
.expect("render_file call should succeed");
let rendered_file = match rendered_file {
- Value::Deferred(d) => d.resolve().await.expect("render_file deferred should resolve"),
+ Value::Deferred(d) => d
+ .resolve()
+ .await
+ .expect("render_file deferred should resolve"),
other => panic!("render_file should return Deferred, got {}", other.repr()),
};
assert!(matches!(