From 40b507c3ecd3f067c77b463aaf846c7a050db4f4 Mon Sep 17 00:00:00 2001 From: Alex Orlenko Date: Thu, 4 Sep 2025 13:26:16 +0100 Subject: [PATCH] Add `ObjectLike::get_path` helper --- src/table.rs | 12 +- src/traits.rs | 44 ++++++- src/userdata/object.rs | 13 ++- src/util/mod.rs | 2 + src/util/path.rs | 255 +++++++++++++++++++++++++++++++++++++++++ tests/table.rs | 81 +++++++++++++ tests/userdata.rs | 19 ++- 7 files changed, 421 insertions(+), 5 deletions(-) create mode 100644 src/util/path.rs diff --git a/src/table.rs b/src/table.rs index 93e6855..b182a6c 100644 --- a/src/table.rs +++ b/src/table.rs @@ -6,7 +6,7 @@ use std::string::String as StdString; use crate::error::{Error, Result}; use crate::function::Function; -use crate::state::{LuaGuard, RawLua}; +use crate::state::{LuaGuard, RawLua, WeakLua}; use crate::traits::{FromLua, FromLuaMulti, IntoLua, IntoLuaMulti, ObjectLike}; use crate::types::{Integer, LuaType, ValueRef}; use crate::util::{assert_stack, check_stack, get_metatable_ptr, StackGuard}; @@ -943,6 +943,16 @@ impl ObjectLike for Table { fn to_string(&self) -> Result { Value::Table(Table(self.0.clone())).to_string() } + + #[inline] + fn to_value(&self) -> Value { + Value::Table(self.clone()) + } + + #[inline] + fn weak_lua(&self) -> &WeakLua { + &self.0.lua + } } /// A wrapped [`Table`] with customized serialization behavior. diff --git a/src/traits.rs b/src/traits.rs index 47dee4c..231d151 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -5,9 +5,9 @@ use std::sync::Arc; use crate::error::{Error, Result}; use crate::multi::MultiValue; use crate::private::Sealed; -use crate::state::{Lua, RawLua}; +use crate::state::{Lua, RawLua, WeakLua}; use crate::types::MaybeSend; -use crate::util::{check_stack, short_type_name}; +use crate::util::{check_stack, parse_lookup_path, short_type_name}; use crate::value::Value; #[cfg(feature = "async")] @@ -200,10 +200,50 @@ pub trait ObjectLike: Sealed { where R: FromLuaMulti; + /// Look up a value by a path of keys. + /// + /// The syntax is similar to accessing nested tables in Lua, with additional support for + /// `?` operator to perform safe navigation. + /// + /// For example, the path `a[1].c` is equivalent to `table.a[1].c` in Lua. + /// With `?` operator, `a[1]?.c` is equivalent to `table.a[1] and table.a[1].c or nil` in Lua. + /// + /// Bracket notation rules: + /// - `[123]` - integer keys + /// - `["string key"]` or `['string key']` - string keys (must be quoted) + /// - String keys support escape sequences: `\"`, `\'`, `\\` + fn get_path(&self, path: &str) -> Result { + let mut current = self.to_value(); + for (key, safe_nil) in parse_lookup_path(path)? { + current = match current { + Value::Table(table) => table.get::(key), + Value::UserData(ud) => ud.get::(key), + _ => { + let type_name = current.type_name(); + let err = format!("attempt to index a {type_name} value with key '{key}'"); + Err(Error::runtime(err)) + } + }?; + if safe_nil && (current == Value::Nil || current == Value::NULL) { + break; + } + } + + let lua = self.weak_lua().lock(); + V::from_lua(current, lua.lua()) + } + /// Converts the object to a string in a human-readable format. /// /// This might invoke the `__tostring` metamethod. fn to_string(&self) -> Result; + + /// Converts the object to a Lua value. + fn to_value(&self) -> Value; + + /// Gets a reference to the associated Lua state. + #[doc(hidden)] + fn weak_lua(&self) -> &WeakLua; } /// A trait for types that can be used as Lua functions. diff --git a/src/userdata/object.rs b/src/userdata/object.rs index c665a51..cc9543e 100644 --- a/src/userdata/object.rs +++ b/src/userdata/object.rs @@ -1,6 +1,7 @@ use std::string::String as StdString; use crate::error::{Error, Result}; +use crate::state::WeakLua; use crate::table::Table; use crate::traits::{FromLua, FromLuaMulti, IntoLua, IntoLuaMulti, ObjectLike}; use crate::userdata::AnyUserData; @@ -88,6 +89,16 @@ impl ObjectLike for AnyUserData { #[inline] fn to_string(&self) -> Result { - Value::UserData(AnyUserData(self.0.clone())).to_string() + Value::UserData(self.clone()).to_string() + } + + #[inline] + fn to_value(&self) -> Value { + Value::UserData(self.clone()) + } + + #[inline] + fn weak_lua(&self) -> &WeakLua { + &self.0.lua } } diff --git a/src/util/mod.rs b/src/util/mod.rs index f195e0a..f8ebf69 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -9,6 +9,7 @@ pub(crate) use error::{ error_traceback, error_traceback_thread, init_error_registry, pop_error, protect_lua_call, protect_lua_closure, WrappedFailure, }; +pub(crate) use path::parse_path as parse_lookup_path; pub(crate) use short_names::short_type_name; pub(crate) use types::TypeKey; pub(crate) use userdata::{ @@ -327,6 +328,7 @@ pub(crate) fn linenumber_to_usize(n: c_int) -> Option { } mod error; +mod path; mod short_names; mod types; mod userdata; diff --git a/src/util/path.rs b/src/util/path.rs new file mode 100644 index 0000000..35cd1f2 --- /dev/null +++ b/src/util/path.rs @@ -0,0 +1,255 @@ +use std::borrow::Cow; +use std::fmt; +use std::iter::Peekable; +use std::str::CharIndices; + +use crate::error::{Error, Result}; +use crate::state::Lua; +use crate::traits::IntoLua; +use crate::types::Integer; +use crate::value::Value; + +#[derive(Debug)] +pub(crate) enum PathKey<'a> { + Str(Cow<'a, str>), + Int(Integer), +} + +impl fmt::Display for PathKey<'_> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + PathKey::Str(s) => write!(f, "{}", s), + PathKey::Int(i) => write!(f, "{}", i), + } + } +} + +impl IntoLua for PathKey<'_> { + fn into_lua(self, lua: &Lua) -> Result { + match self { + PathKey::Str(s) => Ok(Value::String(lua.create_string(s.as_ref())?)), + PathKey::Int(i) => Ok(Value::Integer(i)), + } + } +} + +// Parses a path like `a.b[3]?.c["d"]` into segments of `(key, safe_nil)`. +pub(crate) fn parse_path<'a>(path: &'a str) -> Result, bool)>> { + fn read_ident<'a>(path: &'a str, chars: &mut Peekable>) -> (Cow<'a, str>, bool) { + let mut safe_nil = false; + let start = chars.peek().map(|&(i, _)| i).unwrap_or(path.len()); + let mut end = start; + while let Some(&(pos, c)) = chars.peek() { + if c == '.' || c == '?' || c.is_ascii_whitespace() || c == '[' { + if c == '?' { + safe_nil = true; + chars.next(); // consume '?' + } + break; + } + end = pos + c.len_utf8(); + chars.next(); + } + (Cow::Borrowed(&path[start..end]), safe_nil) + } + + let mut segments = Vec::new(); + let mut chars = path.char_indices().peekable(); + while let Some(&(pos, next)) = chars.peek() { + match next { + '.' => { + // Dot notation: identifier + chars.next(); + let (key, safe_nil) = read_ident(path, &mut chars); + if key.is_empty() { + return Err(Error::runtime(format!("empty key in path at position {pos}"))); + } + segments.push((PathKey::Str(key), safe_nil)); + } + '[' => { + // Bracket notation: either integer or quoted string + chars.next(); + let key = match chars.peek() { + Some(&(pos, c @ '0'..='9' | c @ '-')) => { + // Integer key + let negative = c == '-'; + if negative { + chars.next(); // consume '-' + } + let mut num: Option = None; + while let Some(&(_, c @ '0'..='9')) = chars.peek() { + let new_num = num + .unwrap_or(0) + .checked_mul(10) + .and_then(|n| n.checked_add((c as u8 - b'0') as Integer)) + .ok_or_else(|| { + Error::runtime(format!("integer overflow in path at position {pos}")) + })?; + num = Some(new_num); + chars.next(); // consume digit + } + match num { + Some(n) if negative => PathKey::Int(-n), + Some(n) => PathKey::Int(n), + None => { + let err = format!("invalid integer in path at position {pos}"); + return Err(Error::runtime(err)); + } + } + } + Some((_, '\'' | '"')) => { + // Quoted string + PathKey::Str(unquote_string(path, &mut chars)?) + } + Some((_, ']')) => { + return Err(Error::runtime(format!("empty key in path at position {pos}"))); + } + Some((pos, c)) => { + let err = format!("unexpected character '{c}' in path at position {pos}"); + return Err(Error::runtime(err)); + } + None => { + return Err(Error::runtime("unexpected end of path")); + } + }; + // Expect closing bracket + let mut safe_nil = false; + match chars.next() { + Some((_, ']')) => { + // Check for optional safe-nil operator + if let Some(&(_, '?')) = chars.peek() { + safe_nil = true; + chars.next(); // consume '?' + } + } + Some((pos, c)) => { + let err = format!("expected ']' in path at position {pos}, found '{c}'"); + return Err(Error::runtime(err)); + } + None => { + return Err(Error::runtime("unexpected end of path")); + } + } + segments.push((key, safe_nil)); + } + c if c.is_ascii_whitespace() => { + chars.next(); // Skip whitespace + } + _ if segments.is_empty() => { + // First segment without dot/bracket notation + let (key_cow, safe_nil) = read_ident(path, &mut chars); + if key_cow.is_empty() { + return Err(Error::runtime(format!("empty key in path at position {pos}"))); + } + segments.push((PathKey::Str(key_cow), safe_nil)); + } + c => { + let err = format!("unexpected character '{c}' in path at position {pos}"); + return Err(Error::runtime(err)); + } + } + } + Ok(segments) +} + +fn unquote_string<'a>(path: &'a str, chars: &mut Peekable>) -> Result> { + let (start_pos, first_quote) = chars.next().unwrap(); + let mut result = String::new(); + loop { + match chars.next() { + Some((pos, '\\')) => { + if result.is_empty() { + // First escape found, copy everything up to this point + result.push_str(&path[start_pos + 1..pos]); + } + match chars.next() { + Some((_, '\\')) => result.push('\\'), + Some((_, '"')) => result.push('"'), + Some((_, '\'')) => result.push('\''), + Some((_, other)) => { + result.push('\\'); + result.push(other); + } + None => continue, // will be handled by outer loop + } + } + Some((pos, c)) if c == first_quote => { + if !result.is_empty() { + return Ok(Cow::Owned(result)); + } + // No escapes, return borrowed slice + return Ok(Cow::Borrowed(&path[start_pos + 1..pos])); + } + Some((_, c)) => { + if !result.is_empty() { + result.push(c); + } + // If no escapes yet, continue tracking for potential borrowed slice + } + None => { + let err = format!("unexpected end of string at position {start_pos}"); + return Err(Error::runtime(err)); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{parse_path, PathKey}; + + #[test] + fn test_parse_path() { + // Test valid paths + let path = parse_path("a.b[3]?.c['d']").unwrap(); + assert_eq!(path.len(), 5); + assert!(matches!(path[0], (PathKey::Str(ref s), false) if s == "a")); + assert!(matches!(path[1], (PathKey::Str(ref s), false) if s == "b")); + assert!(matches!(path[2], (PathKey::Int(3), true))); + assert!(matches!(path[3], (PathKey::Str(ref s), false) if s == "c")); + assert!(matches!(path[4], (PathKey::Str(ref s), false) if s == "d")); + + // Test empty path + let path = parse_path("").unwrap(); + assert_eq!(path.len(), 0); + let path = parse_path(" ").unwrap(); + assert_eq!(path.len(), 0); + + // Test invalid dot syntax + let err = parse_path("a..b").unwrap_err().to_string(); + assert_eq!(err, "runtime error: empty key in path at position 1"); + let err = parse_path("a.b.").unwrap_err().to_string(); + assert_eq!(err, "runtime error: empty key in path at position 3"); + + // Test invalid bracket syntax + let err = parse_path("a[unclosed").unwrap_err().to_string(); + assert_eq!( + err, + "runtime error: unexpected character 'u' in path at position 2" + ); + let err = parse_path("a[]").unwrap_err().to_string(); + assert_eq!(err, "runtime error: empty key in path at position 1"); + let err = parse_path(r#"a["unclosed"#).unwrap_err().to_string(); + assert_eq!(err, "runtime error: unexpected end of string at position 2"); + let err = parse_path(r#"a["#).unwrap_err().to_string(); + assert_eq!(err, "runtime error: unexpected end of path"); + let err = parse_path(r#"a[123"#).unwrap_err().to_string(); + assert_eq!(err, "runtime error: unexpected end of path"); + let err = parse_path(r#"a['bla'123"#).unwrap_err().to_string(); + assert_eq!( + err, + "runtime error: expected ']' in path at position 7, found '1'" + ); + let err = parse_path(r#"a["bla"]x"#).unwrap_err().to_string(); + assert_eq!( + err, + "runtime error: unexpected character 'x' in path at position 8" + ); + + // Test bad integers + let err = parse_path("a[99999999999999999999]").unwrap_err().to_string(); + assert_eq!(err, "runtime error: integer overflow in path at position 2"); + let err = parse_path("a[-]").unwrap_err().to_string(); + assert_eq!(err, "runtime error: invalid integer in path at position 2"); + } +} diff --git a/tests/table.rs b/tests/table.rs index 922da8d..740587e 100644 --- a/tests/table.rs +++ b/tests/table.rs @@ -482,3 +482,84 @@ fn test_table_object_like() -> Result<()> { Ok(()) } + +#[test] +fn test_table_get_path() -> Result<()> { + let lua = Lua::new(); + + // Create a nested table structure + let table = lua + .load( + r#" + { + a = { + b = { + c = "hello", + d = 42 + }, + [1] = "first", + ["special key"] = "special value" + }, + abc = "top level", + x = {}, + ["🚀"] = "rocket", + [1] = { + ["nested-key"] = { + [42] = { + final = "hello!", + }, + }, + ["key\"with\"quotes"] = "value1", + ["key'with'quotes"] = "value2", + ["key\\with\\backslashes"] = "value3", + [-2] = "negative index", + }, + } + "#, + ) + .eval::()?; + + // Test basic dot notation + assert_eq!(table.get_path::(".a.b.c")?, "hello"); + assert_eq!(table.get_path::("a.b.c")?, "hello"); + assert_eq!(table.get_path::("a.b.d")?, 42); + assert_eq!(table.get_path::("abc")?, "top level"); + + // Test bracket notation with integer keys + assert_eq!(table.get_path::("a[1]")?, "first"); + assert_eq!(table.get_path::("[1][-2]")?, "negative index"); + + // Test bracket notation with string keys + assert_eq!(table.get_path::("a[\"special key\"]")?, "special value"); + assert_eq!(table.get_path::("a['special key']")?, "special value"); + assert_eq!(table.get_path::(r#"[1]["key\"with\"quotes"]"#)?, "value1"); + assert_eq!(table.get_path::(r#"[1]['key"with"quotes']"#)?, "value1"); + assert_eq!(table.get_path::(r#"[1]['key\'with\'quotes']"#)?, "value2"); + assert_eq!( + table.get_path::(r#"[1]["key\\with\\backslashes"]"#)?, + "value3" + ); + + // Test mixed notation + assert_eq!(table.get_path::("[1].nested-key[42].final")?, "hello!"); + + // Test unicode keys + assert_eq!(table.get_path::("🚀")?, "rocket"); + + // Test empty path returns the table itself + assert_eq!(table.get_path::
("")?, table); + + // Test safe navigation + assert_eq!(table.get_path::("a?.b.c")?, "hello"); + assert_eq!(table.get_path::("x.y?.z")?, Value::Nil); + assert_eq!(table.get_path::("[1].nested-key[43]?.final")?, Value::Nil); + + // Test path with whitespace + assert_eq!(table.get_path::(" .a [\"b\"] .c ")?, "hello"); + + // Test indexing non-indexable value + let err = table.get_path::("abc.c").unwrap_err().to_string(); + assert_eq!(err, "runtime error: attempt to index a string value with key 'c'"); + + Ok(()) +} diff --git a/tests/userdata.rs b/tests/userdata.rs index 2df1246..4e4a5aa 100644 --- a/tests/userdata.rs +++ b/tests/userdata.rs @@ -8,7 +8,7 @@ use std::sync::atomic::{AtomicI64, Ordering}; use mlua::{ AnyUserData, Error, ExternalError, Function, Lua, MetaMethod, Nil, ObjectLike, Result, String, UserData, - UserDataFields, UserDataMethods, UserDataRef, Value, Variadic, + UserDataFields, UserDataMethods, UserDataRef, UserDataRegistry, Value, Variadic, }; #[test] @@ -1345,3 +1345,20 @@ fn test_userdata_namecall() -> Result<()> { Ok(()) } + +#[test] +fn test_userdata_get_path() -> Result<()> { + let lua = Lua::new(); + + struct MyUd; + impl UserData for MyUd { + fn register(registry: &mut UserDataRegistry) { + registry.add_field("value", "userdata_value"); + } + } + + let ud = lua.create_userdata(MyUd)?; + assert_eq!(ud.get_path::(".value")?, "userdata_value"); + + Ok(()) +}