From 9a7f75ad6be281e7aff4029b1161fc6e080244dc Mon Sep 17 00:00:00 2001 From: Alex Orlenko Date: Sun, 16 Nov 2025 23:13:11 +0000 Subject: [PATCH] Update `require` implementation to satisfy Luau 0.700 --- Cargo.toml | 4 +- src/luau/require.rs | 322 +++++++---------------------------------- src/luau/require/fs.rs | 278 +++++++++++++++++++++++++++++++++++ 3 files changed, 333 insertions(+), 271 deletions(-) create mode 100644 src/luau/require/fs.rs diff --git a/Cargo.toml b/Cargo.toml index 47726ba..e1a239b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "mlua" version = "0.11.4" # remember to update mlua_derive authors = ["Aleksandr Orlenko ", "kyren "] -rust-version = "1.79.0" +rust-version = "1.80.0" edition = "2021" repository = "https://github.com/mlua-rs/mlua" documentation = "https://docs.rs/mlua" @@ -62,7 +62,7 @@ parking_lot = { version = "0.12", features = ["arc_lock"] } anyhow = { version = "1.0", optional = true } rustversion = "1.0" -ffi = { package = "mlua-sys", version = "0.8.3", path = "mlua-sys" } +ffi = { package = "mlua-sys", version = "0.9.0", path = "mlua-sys" } [dev-dependencies] trybuild = "1.0" diff --git a/src/luau/require.rs b/src/luau/require.rs index 7e232f7..d8c75b4 100644 --- a/src/luau/require.rs +++ b/src/luau/require.rs @@ -1,12 +1,10 @@ use std::cell::RefCell; -use std::collections::VecDeque; use std::ffi::CStr; use std::io::Result as IoResult; use std::ops::{Deref, DerefMut}; use std::os::raw::{c_char, c_int, c_void}; -use std::path::{Component, Path, PathBuf}; use std::result::Result as StdResult; -use std::{env, fmt, fs, mem, ptr}; +use std::{fmt, mem, ptr}; use crate::error::{Error, Result}; use crate::function::Function; @@ -14,6 +12,9 @@ use crate::state::{callback_error_ext, Lua}; use crate::table::Table; use crate::types::MaybeSend; +// TODO: Rename to FsRequirer +pub use fs::TextRequirer; + /// An error that can occur during navigation in the Luau `require-by-string` system. #[cfg(any(feature = "luau", doc))] #[cfg_attr(docsrs, doc(cfg(feature = "luau")))] @@ -50,6 +51,9 @@ impl From for NavigateError { #[cfg(feature = "luau")] type WriteResult = ffi::luarequire_WriteResult; +#[cfg(feature = "luau")] +type ConfigStatus = ffi::luarequire_ConfigStatus; + /// A trait for handling modules loading and navigation in the Luau `require-by-string` system. #[cfg(any(feature = "luau", doc))] #[cfg_attr(docsrs, doc(cfg(feature = "luau")))] @@ -73,7 +77,7 @@ pub trait Require { /// Navigate to the given child directory. fn to_child(&mut self, name: &str) -> StdResult<(), NavigateError>; - /// Returns whether the context is currently pointing at a module + /// Returns whether the context is currently pointing at a module. fn has_module(&self) -> bool; /// Provides a cache key representing the current module. @@ -103,226 +107,31 @@ impl fmt::Debug for dyn Require { } } -/// The standard implementation of Luau `require-by-string` navigation. -#[derive(Default, Debug)] -pub struct TextRequirer { - /// An absolute path to the current Luau module (not mapped to a physical file) - abs_path: PathBuf, - /// A relative path to the current Luau module (not mapped to a physical file) - rel_path: PathBuf, - /// A physical path to the current Luau module, which is a file or a directory with an - /// `init.lua(u)` file - resolved_path: Option, +struct Context { + require: Box, + config_cache: Option>>, } -impl TextRequirer { - /// The prefix used for chunk names in the require system. - /// Only chunk names starting with this prefix are allowed to be used in `require`. - const CHUNK_PREFIX: &str = "@"; - - /// The file extensions that are considered valid for Luau modules. - const FILE_EXTENSIONS: &[&str] = &["luau", "lua"]; - - /// Creates a new `TextRequirer` instance. - pub fn new() -> Self { - Self::default() - } - - fn normalize_chunk_name(chunk_name: &str) -> &str { - if let Some((path, line)) = chunk_name.rsplit_once(':') { - if line.parse::().is_ok() { - return path; - } - } - chunk_name - } - - // Normalizes the path by removing unnecessary components - fn normalize_path(path: &Path) -> PathBuf { - let mut components = VecDeque::new(); - - for comp in path.components() { - match comp { - Component::Prefix(..) | Component::RootDir => { - components.push_back(comp); - } - Component::CurDir => {} - Component::ParentDir => { - if matches!(components.back(), None | Some(Component::ParentDir)) { - components.push_back(Component::ParentDir); - } else if matches!(components.back(), Some(Component::Normal(..))) { - components.pop_back(); - } - } - Component::Normal(..) => components.push_back(comp), - } - } - - if matches!(components.front(), None | Some(Component::Normal(..))) { - components.push_front(Component::CurDir); - } - - // Join the components back together - components.into_iter().collect() - } - - /// Resolve a Luau module path to a physical file or directory. - /// - /// Empty directories without init files are considered valid as "intermediate" directories. - fn resolve_module(path: &Path) -> StdResult, NavigateError> { - let mut found_path = None; - - if path.components().next_back() != Some(Component::Normal("init".as_ref())) { - let current_ext = (path.extension().and_then(|s| s.to_str())) - .map(|s| format!("{s}.")) - .unwrap_or_default(); - for ext in Self::FILE_EXTENSIONS { - let candidate = path.with_extension(format!("{current_ext}{ext}")); - if candidate.is_file() && found_path.replace(candidate).is_some() { - return Err(NavigateError::Ambiguous); - } - } - } - if path.is_dir() { - for component in Self::FILE_EXTENSIONS.iter().map(|ext| format!("init.{ext}")) { - let candidate = path.join(component); - if candidate.is_file() && found_path.replace(candidate).is_some() { - return Err(NavigateError::Ambiguous); - } - } - - if found_path.is_none() { - // Directories without init files are considered valid "intermediate" path - return Ok(None); - } - } - - Ok(Some(found_path.ok_or(NavigateError::NotFound)?)) - } -} - -impl Require for TextRequirer { - fn is_require_allowed(&self, chunk_name: &str) -> bool { - chunk_name.starts_with(Self::CHUNK_PREFIX) - } - - fn reset(&mut self, chunk_name: &str) -> StdResult<(), NavigateError> { - if !chunk_name.starts_with(Self::CHUNK_PREFIX) { - return Err(NavigateError::NotFound); - } - let chunk_name = Self::normalize_chunk_name(&chunk_name[1..]); - let chunk_path = Self::normalize_path(chunk_name.as_ref()); - - if chunk_path.extension() == Some("rs".as_ref()) { - // Special case for Rust source files, reset to the current directory - let chunk_filename = chunk_path.file_name().unwrap(); - let cwd = env::current_dir().map_err(|_| NavigateError::NotFound)?; - self.abs_path = Self::normalize_path(&cwd.join(chunk_filename)); - self.rel_path = ([Component::CurDir, Component::Normal(chunk_filename)].into_iter()).collect(); - self.resolved_path = None; - - return Ok(()); - } - - if chunk_path.is_absolute() { - let resolved_path = Self::resolve_module(&chunk_path)?; - self.abs_path = chunk_path.clone(); - self.rel_path = chunk_path; - self.resolved_path = resolved_path; - } else { - // Relative path - let cwd = env::current_dir().map_err(|_| NavigateError::NotFound)?; - let abs_path = Self::normalize_path(&cwd.join(&chunk_path)); - let resolved_path = Self::resolve_module(&abs_path)?; - self.abs_path = abs_path; - self.rel_path = chunk_path; - self.resolved_path = resolved_path; - } - - Ok(()) - } - - fn jump_to_alias(&mut self, path: &str) -> StdResult<(), NavigateError> { - let path = Self::normalize_path(path.as_ref()); - let resolved_path = Self::resolve_module(&path)?; - - self.abs_path = path.clone(); - self.rel_path = path; - self.resolved_path = resolved_path; - - Ok(()) - } - - fn to_parent(&mut self) -> StdResult<(), NavigateError> { - let mut abs_path = self.abs_path.clone(); - if !abs_path.pop() { - // It's important to return `NotFound` if we reached the root, as it's a "recoverable" error if we - // cannot go beyond the root directory. - // Luau "require-by-string` has a special logic to search for config file to resolve aliases. - return Err(NavigateError::NotFound); - } - let mut rel_parent = self.rel_path.clone(); - rel_parent.pop(); - let resolved_path = Self::resolve_module(&abs_path)?; - - self.abs_path = abs_path; - self.rel_path = Self::normalize_path(&rel_parent); - self.resolved_path = resolved_path; - - Ok(()) - } - - fn to_child(&mut self, name: &str) -> StdResult<(), NavigateError> { - let abs_path = self.abs_path.join(name); - let rel_path = self.rel_path.join(name); - let resolved_path = Self::resolve_module(&abs_path)?; - - self.abs_path = abs_path; - self.rel_path = rel_path; - self.resolved_path = resolved_path; - - Ok(()) - } - - fn has_module(&self) -> bool { - (self.resolved_path.as_deref()) - .map(Path::is_file) - .unwrap_or(false) - } - - fn cache_key(&self) -> String { - self.resolved_path.as_deref().unwrap().display().to_string() - } - - fn has_config(&self) -> bool { - self.abs_path.is_dir() && self.abs_path.join(".luaurc").is_file() - } - - fn config(&self) -> IoResult> { - fs::read(self.abs_path.join(".luaurc")) - } - - fn loader(&self, lua: &Lua) -> Result { - let name = format!("@{}", self.rel_path.display()); - lua.load(self.resolved_path.as_deref().unwrap()) - .set_name(name) - .into_function() - } -} - -struct Context(Box); - impl Deref for Context { type Target = dyn Require; fn deref(&self) -> &Self::Target { - &*self.0 + &*self.require } } impl DerefMut for Context { fn deref_mut(&mut self) -> &mut Self::Target { - &mut *self.0 + &mut *self.require + } +} + +impl Context { + fn new(require: impl Require + MaybeSend + 'static) -> Self { + Context { + require: Box::new(require), + config_cache: None, + } } } @@ -447,9 +256,18 @@ pub(super) unsafe extern "C-unwind" fn init_config(config: *mut ffi::luarequire_ write_to_buffer(buffer, buffer_size, size_out, cache_key.as_bytes()) } - unsafe extern "C-unwind" fn is_config_present(state: *mut ffi::lua_State, ctx: *mut c_void) -> bool { - let this = try_borrow!(state, ctx); - this.has_config() + unsafe extern "C-unwind" fn get_config_status( + state: *mut ffi::lua_State, + ctx: *mut c_void, + ) -> ConfigStatus { + let mut this = try_borrow_mut!(state, ctx); + if this.has_config() { + this.config_cache = Some(this.config()); + if let Some(Ok(data)) = &this.config_cache { + return detect_config_format(data); + } + } + ConfigStatus::Absent } unsafe extern "C-unwind" fn get_config( @@ -459,8 +277,10 @@ pub(super) unsafe extern "C-unwind" fn init_config(config: *mut ffi::luarequire_ buffer_size: usize, size_out: *mut usize, ) -> WriteResult { - let this = try_borrow!(state, ctx); - let config = callback_error_ext(state, ptr::null_mut(), true, move |_, _| Ok(this.config()?)); + let mut this = try_borrow_mut!(state, ctx); + let config = callback_error_ext(state, ptr::null_mut(), true, move |_, _| { + Ok(this.config_cache.take().unwrap_or_else(|| this.config())?) + }); write_to_buffer(buffer, buffer_size, size_out, &config) } @@ -489,12 +309,24 @@ pub(super) unsafe extern "C-unwind" fn init_config(config: *mut ffi::luarequire_ (*config).get_chunkname = get_chunkname; (*config).get_loadname = get_loadname; (*config).get_cache_key = get_cache_key; - (*config).is_config_present = is_config_present; + (*config).get_config_status = get_config_status; (*config).get_alias = None; (*config).get_config = Some(get_config); (*config).load = load; } +/// Detect configuration file format (JSON or Luau) +fn detect_config_format(data: &[u8]) -> ConfigStatus { + let data = data.trim_ascii(); + if data.starts_with(b"{") { + let data = &data[1..].trim_ascii_start(); + if data.starts_with(b"\"") || data == b"}" { + return ConfigStatus::PresentJson; + } + } + ConfigStatus::PresentLuau +} + /// Helper function to write data to a buffer #[cfg(feature = "luau")] unsafe fn write_to_buffer( @@ -545,7 +377,7 @@ pub(super) fn create_require_function( let (get_cache_key, find_current_file, proxyrequire, registered_modules, loader_cache) = unsafe { lua.exec_raw::<(Function, Function, Function, Table, Table)>((), move |state| { - let context = Context(Box::new(require)); + let context = Context::new(require); let context_ptr = ffi::lua_newuserdata_t(state, RefCell::new(context)); ffi::lua_pushcclosured(state, get_cache_key, cstr!("get_cache_key"), 1); ffi::lua_pushcfunctiond(state, find_current_file, cstr!("find_current_file")); @@ -637,52 +469,4 @@ pub(super) fn create_require_function( .into_function() } -#[cfg(test)] -mod tests { - use std::path::Path; - - use super::TextRequirer; - - #[test] - fn test_path_normalize() { - for (input, expected) in [ - // Basic formatting checks - ("", "./"), - (".", "./"), - ("a/relative/path", "./a/relative/path"), - // Paths containing extraneous '.' and '/' symbols - ("./remove/extraneous/symbols/", "./remove/extraneous/symbols"), - ("./remove/extraneous//symbols", "./remove/extraneous/symbols"), - ("./remove/extraneous/symbols/.", "./remove/extraneous/symbols"), - ("./remove/extraneous/./symbols", "./remove/extraneous/symbols"), - ("../remove/extraneous/symbols/", "../remove/extraneous/symbols"), - ("../remove/extraneous//symbols", "../remove/extraneous/symbols"), - ("../remove/extraneous/symbols/.", "../remove/extraneous/symbols"), - ("../remove/extraneous/./symbols", "../remove/extraneous/symbols"), - ("/remove/extraneous/symbols/", "/remove/extraneous/symbols"), - ("/remove/extraneous//symbols", "/remove/extraneous/symbols"), - ("/remove/extraneous/symbols/.", "/remove/extraneous/symbols"), - ("/remove/extraneous/./symbols", "/remove/extraneous/symbols"), - // Paths containing '..' - ("./remove/me/..", "./remove"), - ("./remove/me/../", "./remove"), - ("../remove/me/..", "../remove"), - ("../remove/me/../", "../remove"), - ("/remove/me/..", "/remove"), - ("/remove/me/../", "/remove"), - ("./..", "../"), - ("./../", "../"), - ("../..", "../../"), - ("../../", "../../"), - // '..' disappears if path is absolute and component is non-erasable - ("/../", "/"), - ] { - let path = TextRequirer::normalize_path(input.as_ref()); - assert_eq!( - &path, - expected.as_ref() as &Path, - "wrong normalization for {input}" - ); - } - } -} +mod fs; diff --git a/src/luau/require/fs.rs b/src/luau/require/fs.rs new file mode 100644 index 0000000..4e7261b --- /dev/null +++ b/src/luau/require/fs.rs @@ -0,0 +1,278 @@ +use std::collections::VecDeque; +use std::io::Result as IoResult; +use std::path::{Component, Path, PathBuf}; +use std::result::Result as StdResult; +use std::{env, fs}; + +use crate::error::Result; +use crate::function::Function; +use crate::state::Lua; + +use super::{NavigateError, Require}; + +/// The standard implementation of Luau `require-by-string` navigation. +#[derive(Default, Debug)] +pub struct TextRequirer { + /// An absolute path to the current Luau module (not mapped to a physical file) + abs_path: PathBuf, + /// A relative path to the current Luau module (not mapped to a physical file) + rel_path: PathBuf, + /// A physical path to the current Luau module, which is a file or a directory with an + /// `init.lua(u)` file + resolved_path: Option, +} + +impl TextRequirer { + /// The prefix used for chunk names in the require system. + /// Only chunk names starting with this prefix are allowed to be used in `require`. + const CHUNK_PREFIX: &str = "@"; + + /// The file extensions that are considered valid for Luau modules. + const FILE_EXTENSIONS: &[&str] = &["luau", "lua"]; + + /// The filename for the JSON configuration file. + const LUAURC_CONFIG_FILENAME: &str = ".luaurc"; + + /// The filename for the Luau configuration file. + const LUAU_CONFIG_FILENAME: &str = ".config.luau"; + + /// Creates a new `TextRequirer` instance. + pub fn new() -> Self { + Self::default() + } + + fn normalize_chunk_name(chunk_name: &str) -> &str { + if let Some((path, line)) = chunk_name.rsplit_once(':') { + if line.parse::().is_ok() { + return path; + } + } + chunk_name + } + + // Normalizes the path by removing unnecessary components + fn normalize_path(path: &Path) -> PathBuf { + let mut components = VecDeque::new(); + + for comp in path.components() { + match comp { + Component::Prefix(..) | Component::RootDir => { + components.push_back(comp); + } + Component::CurDir => {} + Component::ParentDir => { + if matches!(components.back(), None | Some(Component::ParentDir)) { + components.push_back(Component::ParentDir); + } else if matches!(components.back(), Some(Component::Normal(..))) { + components.pop_back(); + } + } + Component::Normal(..) => components.push_back(comp), + } + } + + if matches!(components.front(), None | Some(Component::Normal(..))) { + components.push_front(Component::CurDir); + } + + // Join the components back together + components.into_iter().collect() + } + + /// Resolve a Luau module path to a physical file or directory. + /// + /// Empty directories without init files are considered valid as "intermediate" directories. + fn resolve_module(path: &Path) -> StdResult, NavigateError> { + let mut found_path = None; + + if path.components().next_back() != Some(Component::Normal("init".as_ref())) { + let current_ext = (path.extension().and_then(|s| s.to_str())) + .map(|s| format!("{s}.")) + .unwrap_or_default(); + for ext in Self::FILE_EXTENSIONS { + let candidate = path.with_extension(format!("{current_ext}{ext}")); + if candidate.is_file() && found_path.replace(candidate).is_some() { + return Err(NavigateError::Ambiguous); + } + } + } + if path.is_dir() { + for component in Self::FILE_EXTENSIONS.iter().map(|ext| format!("init.{ext}")) { + let candidate = path.join(component); + if candidate.is_file() && found_path.replace(candidate).is_some() { + return Err(NavigateError::Ambiguous); + } + } + + if found_path.is_none() { + // Directories without init files are considered valid "intermediate" path + return Ok(None); + } + } + + Ok(Some(found_path.ok_or(NavigateError::NotFound)?)) + } +} + +impl Require for TextRequirer { + fn is_require_allowed(&self, chunk_name: &str) -> bool { + chunk_name.starts_with(Self::CHUNK_PREFIX) + } + + fn reset(&mut self, chunk_name: &str) -> StdResult<(), NavigateError> { + if !chunk_name.starts_with(Self::CHUNK_PREFIX) { + return Err(NavigateError::NotFound); + } + let chunk_name = Self::normalize_chunk_name(&chunk_name[1..]); + let chunk_path = Self::normalize_path(chunk_name.as_ref()); + + if chunk_path.extension() == Some("rs".as_ref()) { + // Special case for Rust source files, reset to the current directory + let chunk_filename = chunk_path.file_name().unwrap(); + let cwd = env::current_dir().map_err(|_| NavigateError::NotFound)?; + self.abs_path = Self::normalize_path(&cwd.join(chunk_filename)); + self.rel_path = ([Component::CurDir, Component::Normal(chunk_filename)].into_iter()).collect(); + self.resolved_path = None; + + return Ok(()); + } + + if chunk_path.is_absolute() { + let resolved_path = Self::resolve_module(&chunk_path)?; + self.abs_path = chunk_path.clone(); + self.rel_path = chunk_path; + self.resolved_path = resolved_path; + } else { + // Relative path + let cwd = env::current_dir().map_err(|_| NavigateError::NotFound)?; + let abs_path = Self::normalize_path(&cwd.join(&chunk_path)); + let resolved_path = Self::resolve_module(&abs_path)?; + self.abs_path = abs_path; + self.rel_path = chunk_path; + self.resolved_path = resolved_path; + } + + Ok(()) + } + + fn jump_to_alias(&mut self, path: &str) -> StdResult<(), NavigateError> { + let path = Self::normalize_path(path.as_ref()); + let resolved_path = Self::resolve_module(&path)?; + + self.abs_path = path.clone(); + self.rel_path = path; + self.resolved_path = resolved_path; + + Ok(()) + } + + fn to_parent(&mut self) -> StdResult<(), NavigateError> { + let mut abs_path = self.abs_path.clone(); + if !abs_path.pop() { + // It's important to return `NotFound` if we reached the root, as it's a "recoverable" error if we + // cannot go beyond the root directory. + // Luau "require-by-string` has a special logic to search for config file to resolve aliases. + return Err(NavigateError::NotFound); + } + let mut rel_parent = self.rel_path.clone(); + rel_parent.pop(); + let resolved_path = Self::resolve_module(&abs_path)?; + + self.abs_path = abs_path; + self.rel_path = Self::normalize_path(&rel_parent); + self.resolved_path = resolved_path; + + Ok(()) + } + + fn to_child(&mut self, name: &str) -> StdResult<(), NavigateError> { + let abs_path = self.abs_path.join(name); + let rel_path = self.rel_path.join(name); + let resolved_path = Self::resolve_module(&abs_path)?; + + self.abs_path = abs_path; + self.rel_path = rel_path; + self.resolved_path = resolved_path; + + Ok(()) + } + + fn has_module(&self) -> bool { + (self.resolved_path.as_deref()) + .map(Path::is_file) + .unwrap_or(false) + } + + fn cache_key(&self) -> String { + self.resolved_path.as_deref().unwrap().display().to_string() + } + + fn has_config(&self) -> bool { + self.abs_path.is_dir() && self.abs_path.join(Self::LUAURC_CONFIG_FILENAME).is_file() + || self.abs_path.is_dir() && self.abs_path.join(Self::LUAU_CONFIG_FILENAME).is_file() + } + + fn config(&self) -> IoResult> { + if self.abs_path.join(Self::LUAURC_CONFIG_FILENAME).is_file() { + return fs::read(self.abs_path.join(Self::LUAURC_CONFIG_FILENAME)); + } + fs::read(self.abs_path.join(Self::LUAU_CONFIG_FILENAME)) + } + + fn loader(&self, lua: &Lua) -> Result { + let name = format!("@{}", self.rel_path.display()); + lua.load(self.resolved_path.as_deref().unwrap()) + .set_name(name) + .into_function() + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::TextRequirer; + + #[test] + fn test_path_normalize() { + for (input, expected) in [ + // Basic formatting checks + ("", "./"), + (".", "./"), + ("a/relative/path", "./a/relative/path"), + // Paths containing extraneous '.' and '/' symbols + ("./remove/extraneous/symbols/", "./remove/extraneous/symbols"), + ("./remove/extraneous//symbols", "./remove/extraneous/symbols"), + ("./remove/extraneous/symbols/.", "./remove/extraneous/symbols"), + ("./remove/extraneous/./symbols", "./remove/extraneous/symbols"), + ("../remove/extraneous/symbols/", "../remove/extraneous/symbols"), + ("../remove/extraneous//symbols", "../remove/extraneous/symbols"), + ("../remove/extraneous/symbols/.", "../remove/extraneous/symbols"), + ("../remove/extraneous/./symbols", "../remove/extraneous/symbols"), + ("/remove/extraneous/symbols/", "/remove/extraneous/symbols"), + ("/remove/extraneous//symbols", "/remove/extraneous/symbols"), + ("/remove/extraneous/symbols/.", "/remove/extraneous/symbols"), + ("/remove/extraneous/./symbols", "/remove/extraneous/symbols"), + // Paths containing '..' + ("./remove/me/..", "./remove"), + ("./remove/me/../", "./remove"), + ("../remove/me/..", "../remove"), + ("../remove/me/../", "../remove"), + ("/remove/me/..", "/remove"), + ("/remove/me/../", "/remove"), + ("./..", "../"), + ("./../", "../"), + ("../..", "../../"), + ("../../", "../../"), + // '..' disappears if path is absolute and component is non-erasable + ("/../", "/"), + ] { + let path = TextRequirer::normalize_path(input.as_ref()); + assert_eq!( + &path, + expected.as_ref() as &Path, + "wrong normalization for {input}" + ); + } + } +}