patx/relay-lang
Document list comprehensions destructuring and try/except
Commit 03fb2ee · Harrison Erd · 2026-02-10T12:20:31-05:00
Comments
No comments yet.
Diff
diff --git a/README.md b/README.md
index 843e30a..3fbc3df 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,9 @@ Relay is designed for building high-performance web services, APIs, and network
- **Session Management**: Built-in session handling with HttpOnly cookies
- **Template Rendering**: Lightweight Jinja-style templating (`{{ variable }}`)
- **Type Hints**: Optional runtime type checking for function parameters
+- **List Comprehensions**: Python-style inline list transforms with optional filtering
+- **Destructuring Assignment**: Unpack lists/strings/dicts into multiple variables
+- **Error Handling**: `try/except` blocks for graceful runtime error recovery
- **Concurrency Primitives**: `spawn`, `all`, `race`, `timeout`, and `cancel` for parallel execution
@@ -202,6 +205,10 @@ numbers = [1, 2, 3, 4, 5]
mixed = [1, "two", 3.0, True]
nested = [[1, 2], [3, 4]]
+// List comprehensions
+squares = [n * n for n in numbers]
+evens = [n for n in numbers if n % 2 == 0]
+
// Access elements
first = numbers[0] // 1
last = numbers[4] // 5
@@ -224,6 +231,18 @@ age = user["age"] // 30
**Important:** Dictionary keys are automatically stringified. `{1: "value"}` becomes `{"1": "value"}`.
+#### Destructuring Assignment
+
+Relay supports destructuring assignment for iterables such as lists, strings, and dictionaries (dictionary keys are unpacked):
+
+```relay
+a, b, c = [10, 20, 30]
+x, y = "hi" // x = "h", y = "i"
+k1, k2 = {"a": 1, "b": 2}
+```
+
+The number of variables must match the number of unpacked values.
+
### Control Flow
#### If-Else
@@ -271,6 +290,27 @@ for (key in user)
**Note:** For loops iterate over collection elements or dictionary keys.
+#### Error Handling (`try/except`)
+
+Use `try/except` to catch runtime errors and continue execution:
+
+```relay
+try
+ value = int("not-a-number")
+ print(value)
+except
+ print("Could not parse integer")
+```
+
+You can also bind the error message:
+
+```relay
+try
+ result = missing_name + 1
+except(err)
+ print("Error:", err)
+```
+
### Functions
#### Basic Functions
@@ -1801,9 +1841,9 @@ Found a bug? [Open an issue](https://github.com/patx/relay-lang/issues) with:
**v0.2 (Planned):**
- [ ] Multiple file support and imports
-- [ ] List comprehensions
-- [ ] Destructuring assignment
-- [ ] Error handling with try/except
+- [x] List comprehensions
+- [x] Destructuring assignment
+- [x] Error handling with try/except
- [ ] WebSocket support
- [ ] Static file serving
- [ ] Middleware support
diff --git a/src/main.rs b/src/main.rs
index 361ec13..d827eda 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -79,6 +79,10 @@ enum Stmt {
op: AugOp,
expr: Expr,
},
+ DestructureAssign {
+ targets: Vec<String>,
+ expr: Expr,
+ },
Return(Option<Expr>),
If {
@@ -95,6 +99,11 @@ enum Stmt {
iter: Expr,
body: Vec<Stmt>,
},
+ TryExcept {
+ err_name: Option<String>,
+ try_block: Vec<Stmt>,
+ except_block: Vec<Stmt>,
+ },
FuncDef {
name: String,
@@ -137,6 +146,12 @@ enum Expr {
Ident(String),
List(Vec<Expr>),
+ ListComp {
+ expr: Box<Expr>,
+ var: String,
+ iter: Box<Expr>,
+ filter: Option<Box<Expr>>,
+ },
Dict(Vec<(Expr, Expr)>),
Member {
@@ -411,8 +426,10 @@ impl<'a> Lexer<'a> {
if is_ident_start(c) {
let id = self.read_ident();
let k = match id.as_str() {
- "fn" | "if" | "for" | "while" | "return" | "True" | "False"
- | "None" | "str" | "int" | "float" => Tok::Keyword(id),
+ "fn" | "if" | "for" | "while" | "return" | "try" | "except"
+ | "True" | "False" | "None" | "str" | "int" | "float" => {
+ Tok::Keyword(id)
+ }
_ => Tok::Ident(id),
};
out.push(self.wrap(k));
@@ -655,6 +672,13 @@ impl Parser {
if self.peek_kw("return") {
return self.parse_return();
}
+ if self.peek_kw("try") {
+ return self.parse_try_except();
+ }
+
+ if self.peek_destructure_assign() {
+ return self.parse_destructure_assign();
+ }
// assignment
if let Some(name) = self.peek_ident_string() {
@@ -797,6 +821,64 @@ impl Parser {
Ok(Stmt::Return(Some(self.parse_expr()?)))
}
+ fn parse_try_except(&mut self) -> RResult<Stmt> {
+ self.expect_kw("try")?;
+ self.expect_newline()?;
+ let try_block = self.parse_block()?;
+ self.skip_newlines();
+
+ self.expect_kw("except")?;
+ let err_name = if self.peek_is(&Tok::LParen) {
+ self.bump();
+ let name = self.expect_ident()?;
+ self.expect(&Tok::RParen)?;
+ Some(name)
+ } else {
+ None
+ };
+ self.expect_newline()?;
+ let except_block = self.parse_block()?;
+
+ Ok(Stmt::TryExcept {
+ err_name,
+ try_block,
+ except_block,
+ })
+ }
+
+ fn peek_destructure_assign(&self) -> bool {
+ let mut idx = 0usize;
+ if !matches!(
+ self.t.get(self.i + idx).map(|t| &t.kind),
+ Some(Tok::Ident(_))
+ ) {
+ return false;
+ }
+ idx += 1;
+ let mut count = 1usize;
+ while matches!(self.t.get(self.i + idx).map(|t| &t.kind), Some(Tok::Comma))
+ && matches!(
+ self.t.get(self.i + idx + 1).map(|t| &t.kind),
+ Some(Tok::Ident(_))
+ )
+ {
+ idx += 2;
+ count += 1;
+ }
+ count > 1 && matches!(self.t.get(self.i + idx).map(|t| &t.kind), Some(Tok::Assign))
+ }
+
+ fn parse_destructure_assign(&mut self) -> RResult<Stmt> {
+ let mut targets = vec![self.expect_ident()?];
+ while self.peek_is(&Tok::Comma) {
+ self.bump();
+ targets.push(self.expect_ident()?);
+ }
+ self.expect(&Tok::Assign)?;
+ let expr = self.parse_expr()?;
+ Ok(Stmt::DestructureAssign { targets, expr })
+ }
+
fn parse_block(&mut self) -> RResult<Vec<Stmt>> {
self.expect(&Tok::Indent)?;
self.skip_newlines();
@@ -944,13 +1026,34 @@ impl Parser {
Tok::LBracket => {
let mut items = Vec::new();
if !self.peek_is(&Tok::RBracket) {
- loop {
- items.push(self.parse_expr()?);
- if self.peek_is(&Tok::Comma) {
- self.bump();
- continue;
+ let first = self.parse_expr()?;
+ if self.peek_kw("for") {
+ self.bump();
+ let var = self.expect_ident()?;
+ let kw_in = self.expect_ident()?;
+ if kw_in != "in" {
+ return Err(self.err_here("Expected 'in' in list comprehension"));
}
- break;
+ let iter = self.parse_expr()?;
+ let filter = if self.peek_kw("if") {
+ self.bump();
+ Some(Box::new(self.parse_expr()?))
+ } else {
+ None
+ };
+ self.expect(&Tok::RBracket)?;
+ return Ok(Expr::ListComp {
+ expr: Box::new(first),
+ var,
+ iter: Box::new(iter),
+ filter,
+ });
+ }
+
+ items.push(first);
+ while self.peek_is(&Tok::Comma) {
+ self.bump();
+ items.push(self.parse_expr()?);
}
}
self.expect(&Tok::RBracket)?;
@@ -1462,6 +1565,25 @@ impl Evaluator {
}
Ok(Flow::None)
}
+ Stmt::DestructureAssign { targets, expr } => {
+ let itv = self.resolve_if_needed(self.eval_expr(expr).await?).await?;
+ let items = self.to_iter_items(itv, "Destructuring assignment")?;
+ if items.len() != targets.len() {
+ return Err(RelayError::Runtime(format!(
+ "Destructuring assignment expected {} values, got {}",
+ targets.len(),
+ items.len()
+ )));
+ }
+
+ let mut env = self.env.lock().await;
+ for (name, value) in targets.iter().zip(items.into_iter()) {
+ if !env.assign_existing(name, value.clone()) {
+ env.set(name, value);
+ }
+ }
+ Ok(Flow::None)
+ }
Stmt::Return(e) => {
let v = if let Some(e) = e {
self.resolve_deferred_only(self.eval_expr(e).await?).await?
@@ -1509,16 +1631,7 @@ impl Evaluator {
}
Stmt::For { var, iter, body } => {
let itv = self.resolve_if_needed(self.eval_expr(iter).await?).await?;
- let items = match itv {
- Value::List(v) => v,
- Value::Str(s) => s.chars().map(|c| Value::Str(c.to_string())).collect(),
- Value::Dict(m) => m.keys().map(|k| Value::Str(k.clone())).collect(),
- _ => {
- return Err(RelayError::Type(
- "for(each in iterable) expects list/string/dict".into(),
- ))
- }
- };
+ let items = self.to_iter_items(itv, "for(each in iterable)")?;
for item in items {
let mut env = self.env.lock().await;
@@ -1535,6 +1648,29 @@ impl Evaluator {
}
Ok(Flow::None)
}
+ Stmt::TryExcept {
+ err_name,
+ try_block,
+ except_block,
+ } => match self.eval_block(try_block).await {
+ Ok(flow) => Ok(flow),
+ Err(e) => {
+ let mut env = self.env.lock().await;
+ env.push();
+ if let Some(name) = err_name {
+ env.set(name, Value::Str(e.to_string()));
+ }
+ drop(env);
+
+ let result = self.eval_block(except_block).await;
+
+ let mut env = self.env.lock().await;
+ env.pop();
+ drop(env);
+
+ result
+ }
+ },
Stmt::FuncDef {
name,
params,
@@ -1579,6 +1715,40 @@ impl Evaluator {
}
Ok(Value::List(v))
}
+ Expr::ListComp {
+ expr,
+ var,
+ iter,
+ filter,
+ } => {
+ let itv = self.resolve_if_needed(self.eval_expr(iter).await?).await?;
+ let items = self.to_iter_items(itv, "list comprehension")?;
+ let mut out = Vec::new();
+
+ for item in items {
+ let mut env = self.env.lock().await;
+ env.push();
+ env.set(var, item);
+ drop(env);
+
+ let should_include = if let Some(cond) = filter {
+ let cv = self.resolve_if_needed(self.eval_expr(cond).await?).await?;
+ cv.truthy()
+ } else {
+ true
+ };
+
+ if should_include {
+ let v = self.resolve_if_needed(self.eval_expr(expr).await?).await?;
+ out.push(v);
+ }
+
+ let mut env = self.env.lock().await;
+ env.pop();
+ }
+
+ Ok(Value::List(out))
+ }
Expr::Dict(kvs) => {
let mut m = IndexMap::new();
for (k, v) in kvs {
@@ -1716,6 +1886,17 @@ impl Evaluator {
}
}
+ fn to_iter_items(&self, value: Value, context: &str) -> RResult<Vec<Value>> {
+ match value {
+ Value::List(v) => Ok(v),
+ Value::Str(s) => Ok(s.chars().map(|c| Value::Str(c.to_string())).collect()),
+ Value::Dict(m) => Ok(m.keys().map(|k| Value::Str(k.clone())).collect()),
+ _ => Err(RelayError::Type(format!(
+ "{context} expects list/string/dict"
+ ))),
+ }
+ }
+
async fn call_value(
&self,
callee: Value,