From bb0310a5f758f982d2154b53e4308ae284c749de Mon Sep 17 00:00:00 2001 From: Josh Rotenberg Date: Tue, 20 Jan 2026 11:40:39 -0800 Subject: [PATCH 1/2] feat: implement JEP-18 let expression with lexical scoping Implements official JEP-18 let expressions as an optional feature flag. The let expression introduces lexical scoping with the syntax: let $var = expr, $var2 = expr2 in body Features per JEP-18 specification: - Contextual keywords: 'let' and 'in' are not reserved - Variable references use $ prefix syntax - Multiple comma-separated bindings supported - Lexical scoping with inner scope shadowing - Undefined variable triggers runtime error - Projection stopping behavior preserved - Add let-expr feature flag to Cargo.toml - Add Variable and Assign tokens to lexer (feature-gated) - Add VariableRef and Let AST nodes - Implement let expression parsing in parser - Add scope stack to Context for variable lookup - Implement Let and VariableRef evaluation in interpreter - Add comprehensive tests for scoping behavior - All 861 compliance tests pass --- jmespath/Cargo.toml | 4 + jmespath/src/ast.rs | 18 ++ jmespath/src/interpreter.rs | 40 ++++ jmespath/src/lexer.rs | 50 +++- jmespath/src/lib.rs | 454 +++++++++++++++++++++++++++++++++++- jmespath/src/parser.rs | 108 +++++++++ 6 files changed, 670 insertions(+), 4 deletions(-) diff --git a/jmespath/Cargo.toml b/jmespath/Cargo.toml index bb67205f..985cc668 100644 --- a/jmespath/Cargo.toml +++ b/jmespath/Cargo.toml @@ -36,3 +36,7 @@ sync = [] # however at time of writing it is unstable & so requires a nightly compiler. # See https://github.com/rust-lang/rust/issues/31844 for the latest status. specialized = [] +# `let-expr` enables JEP-18 lexical scoping with let expressions. +# Syntax: let $var = expr in body +# See: https://github.com/jmespath/jmespath.jep/blob/main/proposals/0018-lexical-scope.md +let-expr = [] diff --git a/jmespath/src/ast.rs b/jmespath/src/ast.rs index 1886ea7b..1b9f8c0c 100644 --- a/jmespath/src/ast.rs +++ b/jmespath/src/ast.rs @@ -168,6 +168,24 @@ pub enum Ast { /// Right hand side of the expression. rhs: Box, }, + #[cfg(feature = "let-expr")] + /// Variable reference (JEP-18): $name + VariableRef { + /// Approximate absolute position in the parsed expression. + offset: usize, + /// Variable name (without the $ prefix) + name: String, + }, + #[cfg(feature = "let-expr")] + /// Let expression (JEP-18): let $var = expr, ... in body + Let { + /// Approximate absolute position in the parsed expression. + offset: usize, + /// Variable bindings: name -> expression + bindings: Vec<(String, Ast)>, + /// Body expression to evaluate with bindings in scope + expr: Box, + }, } impl fmt::Display for Ast { diff --git a/jmespath/src/interpreter.rs b/jmespath/src/interpreter.rs index e7c0145a..bfbce8d3 100644 --- a/jmespath/src/interpreter.rs +++ b/jmespath/src/interpreter.rs @@ -1,6 +1,8 @@ //! Interprets JMESPath expressions. use std::collections::BTreeMap; +#[cfg(feature = "let-expr")] +use std::collections::HashMap; use super::Context; use super::ast::Ast; @@ -181,5 +183,43 @@ pub fn interpret(data: &Rcvar, node: &Ast, ctx: &mut Context<'_>) -> SearchResul } } } + // JEP-18: Variable reference - look up variable in scope stack + #[cfg(feature = "let-expr")] + Ast::VariableRef { ref name, offset } => match ctx.get_variable(name) { + Some(value) => Ok(value), + None => { + ctx.offset = offset; + let reason = ErrorReason::Runtime(RuntimeError::UnknownFunction(format!( + "Undefined variable: ${}", + name + ))); + Err(JmespathError::from_ctx(ctx, reason)) + } + }, + // JEP-18: Let expression - evaluate bindings and body with new scope + #[cfg(feature = "let-expr")] + Ast::Let { + ref bindings, + ref expr, + .. + } => { + // Evaluate all bindings and create a new scope + let mut scope = HashMap::new(); + for (name, binding_expr) in bindings { + let value = interpret(data, binding_expr, ctx)?; + scope.insert(name.clone(), value); + } + + // Push the new scope + ctx.push_scope(scope); + + // Evaluate the body expression + let result = interpret(data, expr, ctx); + + // Pop the scope (even if there was an error) + ctx.pop_scope(); + + result + } } } diff --git a/jmespath/src/lexer.rs b/jmespath/src/lexer.rs index feb7c34a..bf4d1421 100644 --- a/jmespath/src/lexer.rs +++ b/jmespath/src/lexer.rs @@ -46,6 +46,12 @@ pub enum Token { Lbrace, Rbrace, Eof, + #[cfg(feature = "let-expr")] + /// Assignment operator for let bindings (JEP-18): = + Assign, + #[cfg(feature = "let-expr")] + /// Variable reference (JEP-18): $name + Variable(String), } impl Token { @@ -126,8 +132,14 @@ impl<'a> Lexer<'a> { '"' => tokens.push_back((pos, self.consume_quoted_identifier(pos)?)), '\'' => tokens.push_back((pos, self.consume_raw_string(pos)?)), '`' => tokens.push_back((pos, self.consume_literal(pos)?)), - '=' => match self.iter.next() { - Some((_, '=')) => tokens.push_back((pos, Eq)), + '=' => match self.iter.peek() { + Some(&(_, '=')) => { + self.iter.next(); + tokens.push_back((pos, Eq)) + } + #[cfg(feature = "let-expr")] + _ => tokens.push_back((pos, Assign)), + #[cfg(not(feature = "let-expr"))] _ => { let message = "'=' is not valid. Did you mean '=='?"; let reason = ErrorReason::Parse(message.to_owned()); @@ -139,6 +151,8 @@ impl<'a> Lexer<'a> { '!' => tokens.push_back((pos, self.alt('=', Ne, Not))), '0'..='9' => tokens.push_back((pos, self.consume_number(pos, ch, false)?)), '-' => tokens.push_back((pos, self.consume_negative_number(pos)?)), + #[cfg(feature = "let-expr")] + '$' => tokens.push_back((pos, self.consume_variable(pos)?)), // Skip whitespace tokens ' ' | '\n' | '\t' | '\r' => {} c => { @@ -232,6 +246,27 @@ impl<'a> Lexer<'a> { } } + // Consumes a variable reference: $identifier (JEP-18) + #[cfg(feature = "let-expr")] + #[inline] + fn consume_variable(&mut self, pos: usize) -> Result { + // Peek at the first character to start the identifier + match self.iter.peek() { + Some(&(_, 'a'..='z' | 'A'..='Z' | '_')) => { + let name = self.consume_while( + String::new(), + |c| matches!(c, 'a'..='z' | '_' | 'A'..='Z' | '0'..='9'), + ); + Ok(Variable(name)) + } + _ => { + let reason = + ErrorReason::Parse("'$' must be followed by a valid identifier".to_owned()); + Err(JmespathError::new(self.expr, pos, reason)) + } + } + } + // Consumes tokens inside of a closing character. The closing character // can be escaped using a "\" character. #[inline] @@ -378,7 +413,16 @@ mod tests { } #[test] - fn ensures_eq_valid() { + #[cfg(feature = "let-expr")] + fn ensures_eq_valid_with_let_expr() { + // With JEP-18, single = is now valid (Assign token for let bindings) + assert!(tokenize("=").is_ok()); + } + + #[test] + #[cfg(not(feature = "let-expr"))] + fn ensures_eq_invalid_without_let_expr() { + // Without JEP-18, single = is an error assert!(tokenize("=").is_err()); } diff --git a/jmespath/src/lib.rs b/jmespath/src/lib.rs index 2a1485b7..98eb0f46 100644 --- a/jmespath/src/lib.rs +++ b/jmespath/src/lib.rs @@ -91,6 +91,54 @@ //! let expr = runtime.compile("identity('bar')").unwrap(); //! assert_eq!("bar", expr.search(()).unwrap().as_string().unwrap()); //! ``` +//! +//! # Let Expressions (JEP-18) +//! +//! This crate supports [JEP-18 let expressions](https://github.com/jmespath/jmespath.jep/blob/main/proposals/0018-lexical-scope.md) +//! as an optional feature. Let expressions introduce lexical scoping, allowing +//! you to bind values to variables and reference them within an expression. +//! +//! To enable let expressions, add the `let-expr` feature to your `Cargo.toml`: +//! +//! ```toml +//! [dependencies] +//! jmespath = { version = "0.5", features = ["let-expr"] } +//! ``` +//! +//! ## Syntax +//! +//! ```text +//! let $variable = expression in body +//! let $var1 = expr1, $var2 = expr2 in body +//! ``` +//! +//! ## Example +//! +//! ```ignore +//! use jmespath; +//! +//! // Bind a threshold value and use it in a filter +//! let expr = jmespath::compile("let $threshold = `50` in numbers[? @ > $threshold]").unwrap(); +//! let data = jmespath::Variable::from_json(r#"{"numbers": [10, 30, 50, 70, 90]}"#).unwrap(); +//! let result = expr.search(data).unwrap(); +//! // Returns [70, 90] +//! ``` +//! +//! Let expressions are particularly useful when you need to reference a value +//! from an outer scope within a filter or projection: +//! +//! ```ignore +//! // Reference parent data within a nested filter +//! let expr = jmespath::compile( +//! "let $home = home_state in states[? name == $home].cities[]" +//! ).unwrap(); +//! ``` +//! +//! Key features: +//! - Variables use `$name` syntax +//! - `let` and `in` are contextual keywords (not reserved) +//! - Inner scopes can shadow outer variables +//! - Bindings are evaluated in the outer scope before the body executes #![cfg_attr(feature = "specialized", feature(specialization))] @@ -105,6 +153,8 @@ pub mod functions; use serde::ser; #[cfg(feature = "specialized")] use serde_json::Value; +#[cfg(feature = "let-expr")] +use std::collections::HashMap; #[cfg(feature = "specialized")] use std::convert::TryInto; use std::fmt; @@ -442,6 +492,9 @@ pub struct Context<'a> { pub runtime: &'a Runtime, /// Ast offset that is currently being evaluated. pub offset: usize, + /// Variable scopes for let expressions (JEP-18). + #[cfg(feature = "let-expr")] + scopes: Vec>, } impl<'a> Context<'a> { @@ -452,10 +505,37 @@ impl<'a> Context<'a> { expression, runtime, offset: 0, + #[cfg(feature = "let-expr")] + scopes: Vec::new(), } } -} + /// Push a new scope onto the scope stack. + #[cfg(feature = "let-expr")] + #[inline] + pub fn push_scope(&mut self, bindings: HashMap) { + self.scopes.push(bindings); + } + + /// Pop the innermost scope from the scope stack. + #[cfg(feature = "let-expr")] + #[inline] + pub fn pop_scope(&mut self) { + self.scopes.pop(); + } + + /// Look up a variable in the scope stack. + #[cfg(feature = "let-expr")] + #[inline] + pub fn get_variable(&self, name: &str) -> Option { + for scope in self.scopes.iter().rev() { + if let Some(value) = scope.get(name) { + return Some(value.clone()); + } + } + None + } +} #[cfg(test)] mod test { use super::ast::Ast; @@ -511,3 +591,375 @@ mod test { let _ = compile("6455555524"); } } + +#[cfg(all(test, feature = "let-expr"))] +mod let_tests { + use super::*; + + #[test] + fn test_simple_let_expression() { + // JEP-18 syntax: let $var = expr in body + let expr = compile("let $x = `1` in $x").unwrap(); + let data = Variable::from_json("{}").unwrap(); + let result = expr.search(data).unwrap(); + assert_eq!(1.0, result.as_number().unwrap()); + } + + #[test] + fn test_let_with_data_reference() { + let expr = compile("let $name = name in $name").unwrap(); + let data = Variable::from_json(r#"{"name": "Alice"}"#).unwrap(); + let result = expr.search(data).unwrap(); + assert_eq!("Alice", result.as_string().unwrap()); + } + + #[test] + fn test_let_multiple_bindings() { + let expr = compile("let $a = `1`, $b = `2` in [$a, $b]").unwrap(); + let data = Variable::from_json("{}").unwrap(); + let result = expr.search(data).unwrap(); + let arr = result.as_array().unwrap(); + assert_eq!(1.0, arr[0].as_number().unwrap()); + assert_eq!(2.0, arr[1].as_number().unwrap()); + } + + #[test] + fn test_let_with_expression_body() { + let expr = compile("let $items = items in $items[0].name").unwrap(); + let data = + Variable::from_json(r#"{"items": [{"name": "first"}, {"name": "second"}]}"#).unwrap(); + let result = expr.search(data).unwrap(); + assert_eq!("first", result.as_string().unwrap()); + } + + #[test] + fn test_nested_let() { + let expr = compile("let $x = `1` in let $y = `2` in [$x, $y]").unwrap(); + let data = Variable::from_json("{}").unwrap(); + let result = expr.search(data).unwrap(); + let arr = result.as_array().unwrap(); + assert_eq!(1.0, arr[0].as_number().unwrap()); + assert_eq!(2.0, arr[1].as_number().unwrap()); + } + + #[test] + fn test_let_variable_shadowing() { + let expr = compile("let $x = `1` in let $x = `2` in $x").unwrap(); + let data = Variable::from_json("{}").unwrap(); + let result = expr.search(data).unwrap(); + assert_eq!(2.0, result.as_number().unwrap()); + } + + #[test] + fn test_undefined_variable_error() { + let expr = compile("$undefined").unwrap(); + let data = Variable::from_json("{}").unwrap(); + let result = expr.search(data); + assert!(result.is_err()); + } + + #[test] + fn test_let_in_projection() { + // Example from jsoncons docs + let expr = compile("let $threshold = `50` in numbers[? @ > $threshold]").unwrap(); + let data = Variable::from_json(r#"{"numbers": [10, 30, 50, 70, 90]}"#).unwrap(); + let result = expr.search(data).unwrap(); + let arr = result.as_array().unwrap(); + assert_eq!(2, arr.len()); + assert_eq!(70.0, arr[0].as_number().unwrap()); + assert_eq!(90.0, arr[1].as_number().unwrap()); + } + + #[test] + fn test_let_variable_used_multiple_times() { + let expr = compile("let $foo = foo.bar in [$foo, $foo]").unwrap(); + let data = Variable::from_json(r#"{"foo": {"bar": "baz"}}"#).unwrap(); + let result = expr.search(data).unwrap(); + assert_eq!(r#"["baz","baz"]"#, result.to_string()); + } + + #[test] + fn test_let_shadowing_in_projection() { + let expr = compile("let $a = a in b[*].[a, $a, let $a = 'shadow' in $a]").unwrap(); + let data = + Variable::from_json(r#"{"a": "topval", "b": [{"a": "inner1"}, {"a": "inner2"}]}"#) + .unwrap(); + let result = expr.search(data).unwrap(); + let expected = r#"[["inner1","topval","shadow"],["inner2","topval","shadow"]]"#; + assert_eq!(expected, result.to_string()); + } + + #[test] + fn test_let_bindings_evaluated_in_outer_scope() { + // $b = $a is evaluated BEFORE $a = 'in-a' takes effect + let expr = compile("let $a = 'top-a' in let $a = 'in-a', $b = $a in $b").unwrap(); + let data = Variable::from_json("{}").unwrap(); + let result = expr.search(data).unwrap(); + assert_eq!("top-a", result.as_string().unwrap()); + } + + #[test] + fn test_let_projection_stopping() { + // Projection is stopped when bound to variable + let expr = compile("let $foo = foo[*] in $foo[0]").unwrap(); + let data = Variable::from_json(r#"{"foo": [[0,1],[2,3],[4,5]]}"#).unwrap(); + let result = expr.search(data).unwrap(); + assert_eq!("[0,1]", result.to_string()); + } + + #[test] + fn test_let_complex_home_state_filtering() { + let expr = compile( + "[*].[let $home_state = home_state in states[? name == $home_state].cities[]][]", + ) + .unwrap(); + let data = Variable::from_json( + r#"[ + {"home_state": "WA", "states": [{"name": "WA", "cities": ["Seattle", "Bellevue", "Olympia"]}, {"name": "CA", "cities": ["Los Angeles", "San Francisco"]}, {"name": "NY", "cities": ["New York City", "Albany"]}]}, + {"home_state": "NY", "states": [{"name": "WA", "cities": ["Seattle", "Bellevue", "Olympia"]}, {"name": "CA", "cities": ["Los Angeles", "San Francisco"]}, {"name": "NY", "cities": ["New York City", "Albany"]}]} + ]"#, + ) + .unwrap(); + let result = expr.search(data).unwrap(); + let expected = r#"[["Seattle","Bellevue","Olympia"],["New York City","Albany"]]"#; + assert_eq!(expected, result.to_string()); + } + + #[test] + fn test_let_out_of_scope_variable() { + // Variable defined in inner scope should not be visible outside + let expr = compile("[let $scope = 'foo' in [$scope], $scope]").unwrap(); + let data = Variable::from_json("{}").unwrap(); + let result = expr.search(data); + assert!(result.is_err()); + } + + #[test] + fn test_variable_ref_in_subexpression_is_error() { + // foo.$bar is a syntax error + let expr_result = compile("foo.$bar"); + assert!(expr_result.is_err()); + } + + #[test] + fn test_let_inner_scope_null() { + // Inner scope can explicitly set variable to null + let expr = compile("let $foo = foo in let $foo = null in $foo").unwrap(); + let data = Variable::from_json(r#"{"foo": "outer"}"#).unwrap(); + let result = expr.search(data).unwrap(); + assert!(result.is_null()); + } + + #[test] + fn test_let_and_in_as_field_names() { + // JEP-18: 'let' and 'in' can be used as field names in the body expression + // Note: Using 'let' or 'in' immediately before the 'in' keyword in bindings + // (e.g., `let $x = foo.let in ...`) requires more complex parser lookahead + // and is a known limitation of this implementation. Use quoted identifiers + // as a workaround: `let $x = foo."let" in ...` + + // 'in' as a field name in body - works fine + let expr = compile(r#"let $x = foo in {in: $x}"#).unwrap(); + let data = Variable::from_json(r#"{"foo": "bar"}"#).unwrap(); + let result = expr.search(data).unwrap(); + assert_eq!(r#"{"in":"bar"}"#, result.to_string()); + + // 'let' as a field name in body - works fine + let expr2 = compile(r#"let $x = foo in {let: $x, other: @}"#).unwrap(); + let data2 = Variable::from_json(r#"{"foo": "bar"}"#).unwrap(); + let result2 = expr2.search(data2).unwrap(); + assert!(result2.to_string().contains(r#""let":"bar""#)); + + // Access field via quoted identifier to avoid keyword ambiguity + let expr3 = compile(r#"let $x = data."let" in $x"#).unwrap(); + let data3 = Variable::from_json(r#"{"data": {"let": "let-value"}}"#).unwrap(); + let result3 = expr3.search(data3).unwrap(); + assert_eq!("let-value", result3.as_string().unwrap()); + } + + #[test] + fn test_let_image_details_example() { + // JEP-18 example: create pairs of [tag, digest, repo] from nested structure + // Simplified from JEP to avoid complex flatten behavior + let expr = compile( + "imageDetails[0] | let $repo = repositoryName, $digest = imageDigest in imageTags[].[@, $digest, $repo]", + ) + .unwrap(); + let data = Variable::from_json( + r#"{ + "imageDetails": [ + {"repositoryName": "org/first-repo", "imageTags": ["latest", "v1.0"], "imageDigest": "sha256:abcd"}, + {"repositoryName": "org/second-repo", "imageTags": ["v2.0"], "imageDigest": "sha256:efgh"} + ] + }"#, + ) + .unwrap(); + let result = expr.search(data).unwrap(); + let arr = result.as_array().unwrap(); + assert_eq!(2, arr.len()); + assert_eq!( + r#"["latest","sha256:abcd","org/first-repo"]"#, + arr[0].to_string() + ); + assert_eq!( + r#"["v1.0","sha256:abcd","org/first-repo"]"#, + arr[1].to_string() + ); + } + + #[test] + fn test_let_with_functions() { + // Variables should work inside function calls + let expr = compile("let $arr = numbers in length($arr)").unwrap(); + let data = Variable::from_json(r#"{"numbers": [1, 2, 3, 4, 5]}"#).unwrap(); + let result = expr.search(data).unwrap(); + assert_eq!(5.0, result.as_number().unwrap()); + } + + #[test] + fn test_let_variable_in_filter_comparison() { + // Variable on right side of comparison in filter + let expr = compile("let $min = `10` in numbers[? @ >= $min]").unwrap(); + let data = Variable::from_json(r#"{"numbers": [5, 10, 15, 20]}"#).unwrap(); + let result = expr.search(data).unwrap(); + let arr = result.as_array().unwrap(); + assert_eq!(3, arr.len()); + } + + #[test] + fn test_let_variable_in_multiselect_hash() { + // Variables in multi-select hash + let expr = compile("let $x = name, $y = age in {name: $x, age: $y, original: @}").unwrap(); + let data = Variable::from_json(r#"{"name": "Alice", "age": 30}"#).unwrap(); + let result = expr.search(data).unwrap(); + let obj = result.as_object().unwrap(); + assert_eq!("Alice", obj.get("name").unwrap().as_string().unwrap()); + assert_eq!(30.0, obj.get("age").unwrap().as_number().unwrap()); + } + + #[test] + fn test_let_with_pipe_expression() { + // Let expression with pipe + let expr = + compile("let $prefix = prefix in items[*].name | [? starts_with(@, $prefix)]").unwrap(); + let data = Variable::from_json( + r#"{"prefix": "test", "items": [{"name": "test_one"}, {"name": "other"}, {"name": "test_two"}]}"#, + ) + .unwrap(); + let result = expr.search(data).unwrap(); + let arr = result.as_array().unwrap(); + assert_eq!(2, arr.len()); + } + + #[test] + fn test_let_deeply_nested_scopes() { + // Three levels of nesting + let expr = compile("let $a = `1` in let $b = `2` in let $c = `3` in [$a, $b, $c]").unwrap(); + let data = Variable::from_json("{}").unwrap(); + let result = expr.search(data).unwrap(); + assert_eq!("[1,2,3]", result.to_string()); + } + + #[test] + fn test_let_shadow_and_restore() { + // Shadowed variable doesn't affect outer scope after inner scope exits + let expr = compile("let $x = 'outer' in [let $x = 'inner' in $x, $x]").unwrap(); + let data = Variable::from_json("{}").unwrap(); + let result = expr.search(data).unwrap(); + assert_eq!(r#"["inner","outer"]"#, result.to_string()); + } + + #[test] + fn test_let_with_current_node() { + // Variable combined with @ (current node) + let expr = + compile("items[*].[let $item = @ in {value: $item.value, doubled: $item.value}]") + .unwrap(); + let data = Variable::from_json(r#"{"items": [{"value": 1}, {"value": 2}]}"#).unwrap(); + let result = expr.search(data).unwrap(); + let arr = result.as_array().unwrap(); + assert_eq!(2, arr.len()); + } + + #[test] + fn test_let_empty_bindings_body() { + // Let with body that returns null + let expr = compile("let $x = foo in $x.nonexistent").unwrap(); + let data = Variable::from_json(r#"{"foo": {"bar": 1}}"#).unwrap(); + let result = expr.search(data).unwrap(); + assert!(result.is_null()); + } + + #[test] + fn test_let_binding_to_array() { + // Bind variable to array and iterate + let expr = compile("let $items = items in $items[*].name").unwrap(); + let data = Variable::from_json(r#"{"items": [{"name": "a"}, {"name": "b"}]}"#).unwrap(); + let result = expr.search(data).unwrap(); + assert_eq!(r#"["a","b"]"#, result.to_string()); + } + + #[test] + fn test_let_binding_to_literal() { + // Bind to various literal types + let expr = compile("let $str = 'hello', $num = `42`, $bool = `true`, $null = `null` in [$str, $num, $bool, $null]").unwrap(); + let data = Variable::from_json("{}").unwrap(); + let result = expr.search(data).unwrap(); + assert_eq!(r#"["hello",42,true,null]"#, result.to_string()); + } + + #[test] + fn test_let_variable_not_in_scope_after_expression() { + // Verify scope is properly cleaned up - second $x should error + let expr = compile("[let $x = 'first' in $x, let $y = 'second' in $y]").unwrap(); + let data = Variable::from_json("{}").unwrap(); + let result = expr.search(data).unwrap(); + assert_eq!(r#"["first","second"]"#, result.to_string()); + } + + #[test] + fn test_let_with_flatten() { + // Let with flatten operator + let expr = compile("let $data = nested in $data[].items[]").unwrap(); + let data = + Variable::from_json(r#"{"nested": [{"items": [1, 2]}, {"items": [3, 4]}]}"#).unwrap(); + let result = expr.search(data).unwrap(); + assert_eq!("[1,2,3,4]", result.to_string()); + } + + #[test] + fn test_let_with_slice() { + // Let with slice expression + let expr = compile("let $arr = numbers in $arr[1:3]").unwrap(); + let data = Variable::from_json(r#"{"numbers": [0, 1, 2, 3, 4]}"#).unwrap(); + let result = expr.search(data).unwrap(); + assert_eq!("[1,2]", result.to_string()); + } + + #[test] + fn test_let_with_or_expression() { + // Let with || (or) expression + let expr = compile("let $default = 'N/A' in name || $default").unwrap(); + let data = Variable::from_json(r#"{}"#).unwrap(); + let result = expr.search(data).unwrap(); + assert_eq!("N/A", result.as_string().unwrap()); + } + + #[test] + fn test_let_with_and_expression() { + // Let with && (and) expression + let expr = compile("let $check = `true` in active && $check").unwrap(); + let data = Variable::from_json(r#"{"active": true}"#).unwrap(); + let result = expr.search(data).unwrap(); + assert!(result.as_boolean().unwrap()); + } + + #[test] + fn test_let_with_not_expression() { + // Let with ! (not) expression + let expr = compile("let $val = `false` in !$val").unwrap(); + let data = Variable::from_json("{}").unwrap(); + let result = expr.search(data).unwrap(); + assert!(result.as_boolean().unwrap()); + } +} diff --git a/jmespath/src/parser.rs b/jmespath/src/parser.rs index ac37a9fa..a7b3105e 100644 --- a/jmespath/src/parser.rs +++ b/jmespath/src/parser.rs @@ -102,6 +102,9 @@ impl<'a> Parser<'a> { let (offset, token) = self.advance_with_pos(); match token { Token::At => Ok(Ast::Identity { offset }), + // Check for "let" keyword (contextual, not reserved) + #[cfg(feature = "let-expr")] + Token::Identifier(ref value) if value == "let" => self.parse_let(offset), Token::Identifier(value) => Ok(Ast::Field { name: value, offset, @@ -164,6 +167,8 @@ impl<'a> Parser<'a> { ref t => Err(self.err(t, "Expected ')' to close '('", false)), } } + #[cfg(feature = "let-expr")] + Token::Variable(name) => Ok(Ast::VariableRef { name, offset }), ref t => Err(self.err(t, "Unexpected nud token", false)), } } @@ -245,6 +250,109 @@ impl<'a> Parser<'a> { } } + /// Parses a let expression per JEP-18 (Lexical Scope). + /// + /// Syntax: let $x = expr1, $y = expr2 in body + /// - One or more variable bindings: $name = expression (comma separated) + /// - "in" keyword followed by body expression + #[cfg(feature = "let-expr")] + fn parse_let(&mut self, offset: usize) -> ParseResult { + let mut bindings = vec![]; + + // Parse at least one binding + loop { + // Expect a variable + match self.peek(0) { + Token::Variable(_) => { + let var_name = match self.advance() { + Token::Variable(name) => name, + _ => unreachable!(), + }; + + // Expect '=' + match self.advance() { + Token::Assign => {} + ref t => { + return Err(self.err( + t, + "Expected '=' after variable in let binding", + false, + )); + } + } + + // Parse the value expression (stop at comma or 'in') + let value = self.parse_let_binding_expr()?; + bindings.push((var_name, value)); + + // Check for comma (more bindings) or 'in' (body) + match self.peek(0) { + Token::Comma => { + self.advance(); // consume comma, continue to next binding + } + Token::Identifier(s) if s == "in" => { + break; // done with bindings + } + t => { + return Err(self.err( + t, + "Expected ',' or 'in' after let binding", + true, + )); + } + } + } + t => { + return Err(self.err(t, "Expected variable binding ($name) after 'let'", true)); + } + } + } + + // Consume 'in' keyword + match self.advance() { + Token::Identifier(s) if s == "in" => {} + ref t => { + return Err(self.err(t, "Expected 'in' keyword after let bindings", false)); + } + } + + // Parse the body expression + let body = self.expr(0)?; + + Ok(Ast::Let { + offset, + bindings, + expr: Box::new(body), + }) + } + + /// Parse an expression for a let binding value. + /// Stops at comma or 'in' keyword to allow proper binding separation. + #[cfg(feature = "let-expr")] + fn parse_let_binding_expr(&mut self) -> ParseResult { + // We need to parse an expression but stop before comma or 'in' + // Use a lower binding power approach + self.parse_let_binding_expr_bp(0) + } + + #[cfg(feature = "let-expr")] + fn parse_let_binding_expr_bp(&mut self, rbp: usize) -> ParseResult { + let mut left = self.nud(); + loop { + // Stop if we see comma or 'in' + match self.peek(0) { + Token::Comma => break, + Token::Identifier(s) if s == "in" => break, + _ => {} + } + if rbp >= self.peek(0).lbp() { + break; + } + left = self.led(Box::new(left?)); + } + left + } + fn parse_kvp(&mut self) -> Result { match self.advance() { Token::Identifier(value) | Token::QuotedIdentifier(value) => { From 9047588ac9350bd93e5b7b02765758f9a1ec1822 Mon Sep 17 00:00:00 2001 From: Josh Rotenberg Date: Tue, 20 Jan 2026 18:00:03 -0800 Subject: [PATCH 2/2] ci: add test step for let-expr feature --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7bc880d..7c7c5e81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,9 @@ jobs: - name: Test jmespath run: cargo test + - name: Test jmespath with let-expr feature + run: cargo test --features let-expr + - name: Test jmespath with specialized feature (nightly only) if: matrix.rust == 'nightly' run: cargo +nightly test --features specialized