From 41cba2f049e9b409a6df86a6808262d2d744868f Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Thu, 24 Jul 2025 16:45:12 -0500 Subject: [PATCH] rewrite of the string module --- modules/http/http.lua | 228 +++--- modules/kv/kv.go | 5 +- modules/registry.go | 67 +- modules/{string => string+}/string.go | 0 modules/string+/string.lua | 666 ++++++++++++++++++ modules/string/string.lua | 715 ------------------- modules/table/table.lua | 495 ++++++------- tests/kv.lua | 12 +- tests/sessions.lua | 4 +- tests/string.lua | 954 +++++++++++++++++--------- 10 files changed, 1691 insertions(+), 1455 deletions(-) rename modules/{string => string+}/string.go (100%) create mode 100644 modules/string+/string.lua delete mode 100644 modules/string/string.lua diff --git a/modules/http/http.lua b/modules/http/http.lua index a4affdf..dfe6073 100644 --- a/modules/http/http.lua +++ b/modules/http/http.lua @@ -1,6 +1,6 @@ -local http = {} +local str = require("string") local json = require("json") -local string = require("string") +local http = {} -- Global routing tables _G._http_routes = _G._http_routes or {} @@ -19,33 +19,33 @@ Response.__index = Response local function parse_cookies(cookie_header) local cookies = {} - if string.is_empty(cookie_header) then + if str.is_empty(cookie_header) then return cookies end - + -- Split by semicolon and parse each cookie - local cookie_pairs = string.split(cookie_header, ";") + local cookie_pairs = str.split(cookie_header, ";") for _, cookie_pair in ipairs(cookie_pairs) do - local trimmed = string.trim(cookie_pair) - if not string.is_empty(trimmed) then - local parts = string.split(trimmed, "=") + local trimmed = str.trim(cookie_pair) + if not str.is_empty(trimmed) then + local parts = str.split(trimmed, "=") if #parts >= 2 then - local name = string.trim(parts[1]) - local value = string.trim(parts[2]) - + local name = str.trim(parts[1]) + local value = str.trim(parts[2]) + -- URL decode the value local success, decoded = pcall(function() - return string.url_decode(value) + return str.url_decode(value) end) - + cookies[name] = success and decoded or value elseif #parts == 1 then -- Cookie without value - cookies[string.trim(parts[1])] = "" + cookies[str.trim(parts[1])] = "" end end end - + return cookies end @@ -54,44 +54,44 @@ end -- ====================================================================== local function split_path(path) - if string.is_empty(path) or path == "/" then + if str.is_empty(path) or path == "/" then return {} end - + -- Remove leading/trailing slashes and split - local clean_path = string.trim(path, "/") - if string.is_empty(clean_path) then + local clean_path = str.trim(path, "/") + if str.is_empty(clean_path) then return {} end - - return string.split(clean_path, "/") + + return str.split(clean_path, "/") end local function match_route(method, path) local path_segments = split_path(path) - + for _, route in ipairs(_G._http_routes) do if route.method == method then local params = {} local route_segments = route.segments local match = true local i = 1 - + while i <= #route_segments and match do local route_seg = route_segments[i] - + if route_seg == "*" then -- Wildcard captures everything remaining local remaining = {} for j = i, #path_segments do table.insert(remaining, path_segments[j]) end - params["*"] = string.join(remaining, "/") + params["*"] = str.join(remaining, "/") break - elseif string.starts_with(route_seg, ":") then + elseif str.starts_with(route_seg, ":") then -- Parameter segment if i <= #path_segments then - local param_name = string.slice(route_seg, 2, -1) + local param_name = str.slice(route_seg, 2, -1) params[param_name] = path_segments[i] else match = false @@ -104,7 +104,7 @@ local function match_route(method, path) end i = i + 1 end - + -- Must consume all segments unless wildcard if match and (i > #path_segments or (route_segments[i-1] and route_segments[i-1] == "*")) then return route, params @@ -117,25 +117,25 @@ end function _http_handle_request(req_table, res_table) local req = Request.new(req_table) local res = Response.new(res_table) - + -- Execute middleware chain first local function run_middleware(index) if index > #_G._http_middleware then local route, params = match_route(req.method, req.path) req.params = params - + if not route then res:status(404):send("Not Found") return end - + -- Run route handler route.handler(req, res) return end - + local mw = _G._http_middleware[index] - if mw.path == nil or string.starts_with(req.path, mw.path) then + if mw.path == nil or str.starts_with(req.path, mw.path) then mw.handler(req, res, function() run_middleware(index + 1) end) @@ -143,7 +143,7 @@ function _http_handle_request(req_table, res_table) run_middleware(index + 1) end end - + run_middleware(1) end @@ -178,13 +178,13 @@ function http.server() local server = setmetatable({ _server_created = false }, Server) - + local success, err = moonshark.http_create_server() if not success then error("Failed to create HTTP server: " .. (err or "unknown error")) end server._server_created = true - + return server end @@ -197,25 +197,25 @@ function Server:use(...) else error("Invalid arguments to use()") end - + return self end function Server:_add_route(method, path, handler) -- Ensure path starts with / - if not string.starts_with(path, "/") then + if not str.starts_with(path, "/") then path = "/" .. path end - + local segments = split_path(path) - + table.insert(_G._http_routes, { method = method, path = path, segments = segments, handler = handler }) - + return self end @@ -262,31 +262,31 @@ function Server:listen(port, host, callback) if callback then callback() end return self end - + if type(host) == "function" then callback = host host = "localhost" end - + host = host or "localhost" local addr = host .. ":" .. tostring(port) - + -- Spawn workers first local success, err = moonshark.http_spawn_workers() if not success then error("Failed to spawn workers: " .. (err or "unknown error")) end - + -- Then start listening success, err = moonshark.http_listen(addr) if not success then error("Failed to start server: " .. (err or "unknown error")) end - + if callback then callback() end - + return self end @@ -311,17 +311,17 @@ function Request.new(req_table) body = req_table.body or "", cookies = {} }, Request) - + local cookie_header = req.headers["Cookie"] or req.headers["cookie"] if cookie_header then req.cookies = parse_cookies(cookie_header) end - + return req end function Request:get(header_name) - local lower_name = string.lower(header_name) + local lower_name = str.lower(header_name) return self.headers[header_name] or self.headers[lower_name] end @@ -359,7 +359,7 @@ function Request:cookie_matches(name, pattern) if not cookie_value then return false end - return string.match(pattern, cookie_value) + return str.match(pattern, cookie_value) end function Request:get_cookies_by_names(names) @@ -381,14 +381,14 @@ function Request:has_auth_cookies() end function Request:json() - if string.is_empty(self.body) then + if str.is_empty(self.body) then return nil end - + local success, result = pcall(function() return json.decode(self.body) end) - + if success then return result else @@ -398,27 +398,27 @@ end function Request:is_json() local content_type = self:get("content-type") or "" - return string.contains(content_type, "application/json") + return str.contains(content_type, "application/json") end function Request:is_form() local content_type = self:get("content-type") or "" - return string.contains(content_type, "application/x-www-form-urlencoded") + return str.contains(content_type, "application/x-www-form-urlencoded") end function Request:is_multipart() local content_type = self:get("content-type") or "" - return string.contains(content_type, "multipart/form-data") + return str.contains(content_type, "multipart/form-data") end function Request:is_xml() local content_type = self:get("content-type") or "" - return string.contains(content_type, "application/xml") or string.contains(content_type, "text/xml") + return str.contains(content_type, "application/xml") or str.contains(content_type, "text/xml") end function Request:accepts(mime_type) local accept_header = self:get("accept") or "" - return string.contains(accept_header, mime_type) or string.contains(accept_header, "*/*") + return str.contains(accept_header, mime_type) or str.contains(accept_header, "*/*") end function Request:user_agent() @@ -431,7 +431,7 @@ end function Request:is_secure() local proto = self:get("x-forwarded-proto") - return proto == "https" or string.starts_with(self:get("host") or "", "https://") + return proto == "https" or str.starts_with(self:get("host") or "", "https://") end -- ====================================================================== @@ -443,7 +443,7 @@ function Response.new(res_table) _table = res_table, _sent = false }, Response) - + return res end @@ -475,7 +475,7 @@ function Response:send(data) if self._sent then error("Response already sent") end - + if type(data) == "table" then self:json(data) elseif type(data) == "number" then @@ -484,7 +484,7 @@ function Response:send(data) else self._table.body = tostring(data or "") end - + self._sent = true return self end @@ -493,19 +493,19 @@ function Response:json(data) if self._sent then error("Response already sent") end - + self:type("application/json; charset=utf-8") - + local success, json_str = pcall(function() return json.encode(data) end) - + if success then self._table.body = json_str else error("Failed to encode JSON response") end - + self._sent = true return self end @@ -514,7 +514,7 @@ function Response:text(text) if self._sent then error("Response already sent") end - + self:type("text/plain; charset=utf-8") self._table.body = tostring(text or "") self._sent = true @@ -525,7 +525,7 @@ function Response:html(html) if self._sent then error("Response already sent") end - + self:type("text/html; charset=utf-8") self._table.body = tostring(html or "") self._sent = true @@ -536,7 +536,7 @@ function Response:xml(xml) if self._sent then error("Response already sent") end - + self:type("application/xml; charset=utf-8") self._table.body = tostring(xml or "") self._sent = true @@ -547,7 +547,7 @@ function Response:redirect(url, status) if self._sent then error("Response already sent") end - + status = status or 302 self:status(status) self:header("Location", url) @@ -560,45 +560,45 @@ function Response:cookie(name, value, options) if self._sent then error("Cannot set cookies after response has been sent") end - + options = options or {} local cookie_value = tostring(value) - + -- URL encode the cookie value if it contains special characters - if string.match("[;,\\s]", cookie_value) then - cookie_value = string.url_encode(cookie_value) + if str.match("[;,\\s]", cookie_value) then + cookie_value = str.url_encode(cookie_value) end - + local cookie = name .. "=" .. cookie_value - + if options.expires then cookie = cookie .. "; Expires=" .. options.expires end - + if options.max_age then cookie = cookie .. "; Max-Age=" .. tostring(options.max_age) end - + if options.domain then cookie = cookie .. "; Domain=" .. options.domain end - + if options.path then cookie = cookie .. "; Path=" .. options.path end - + if options.secure then cookie = cookie .. "; Secure" end - + if options.http_only then cookie = cookie .. "; HttpOnly" end - + if options.same_site then cookie = cookie .. "; SameSite=" .. options.same_site end - + local existing = self._table.headers["Set-Cookie"] if existing then if type(existing) == "table" then @@ -609,7 +609,7 @@ function Response:cookie(name, value, options) else self._table.headers["Set-Cookie"] = cookie end - + return self end @@ -633,18 +633,18 @@ function Response:download(data, filename, content_type) if filename then self:attachment(filename) end - + if content_type then self:type(content_type) elseif filename then -- Try to guess content type from extension - if string.ends_with(filename, ".pdf") then + if str.ends_with(filename, ".pdf") then self:type("application/pdf") - elseif string.ends_with(filename, ".zip") then + elseif str.ends_with(filename, ".zip") then self:type("application/zip") - elseif string.ends_with(filename, ".json") then + elseif str.ends_with(filename, ".json") then self:type("application/json") - elseif string.ends_with(filename, ".csv") then + elseif str.ends_with(filename, ".csv") then self:type("text/csv") else self:type("application/octet-stream") @@ -652,7 +652,7 @@ function Response:download(data, filename, content_type) else self:type("application/octet-stream") end - + return self:send(data) end @@ -665,17 +665,17 @@ function http.cors(options) local origin = options.origin or "*" local methods = options.methods or "GET,HEAD,PUT,PATCH,POST,DELETE" local headers = options.headers or "Content-Type,Authorization" - + return function(req, res, next) res:header("Access-Control-Allow-Origin", origin) res:header("Access-Control-Allow-Methods", methods) res:header("Access-Control-Allow-Headers", headers) - + if options.credentials then res:header("Access-Control-Allow-Credentials", "true") end - - if string.iequals(req.method, "OPTIONS") then + + if str.iequals(req.method, "OPTIONS") then res:status(204):send("") else next() @@ -685,19 +685,19 @@ end function http.static(root_path, url_prefix) url_prefix = url_prefix or "/" - + -- Ensure prefix starts with / - if not string.starts_with(url_prefix, "/") then + if not str.starts_with(url_prefix, "/") then url_prefix = "/" .. url_prefix end - + if not _G.__IS_WORKER then local success, err = moonshark.http_register_static(url_prefix, root_path) if not success then error("Failed to register static handler: " .. (err or "unknown error")) end end - + -- Return no-op middleware return function(req, res, next) next() @@ -706,11 +706,11 @@ end function http.json_parser() return function(req, res, next) - if req:is_json() and not string.is_empty(req.body) then + if req:is_json() and not str.is_empty(req.body) then local success, data = pcall(function() return req:json() end) - + if success then req.json_body = data else @@ -724,24 +724,24 @@ end function http.logger(format) format = format or ":method :path :status :response-time ms" - + return function(req, res, next) local start_time = os.clock() - + next() - + local duration = (os.clock() - start_time) * 1000 local status = res._table.status or 200 - - local log_message = string.template(format, { + + local log_message = str.template(format, { method = req.method, path = req.path, status = status, - ["response-time"] = string.format("%.2f", duration), + ["response-time"] = str.format("%.2f", duration), ["user-agent"] = req:user_agent(), ip = req:ip() }) - + print(log_message) end end @@ -749,9 +749,9 @@ end function http.compression() return function(req, res, next) local accept_encoding = req:get("accept-encoding") or "" - if string.contains(accept_encoding, "gzip") then + if str.contains(accept_encoding, "gzip") then res:header("Content-Encoding", "gzip") - elseif string.contains(accept_encoding, "deflate") then + elseif str.contains(accept_encoding, "deflate") then res:header("Content-Encoding", "deflate") end next() @@ -774,11 +774,11 @@ function http.rate_limit(options) local max_requests = options.max or 100 local window_ms = options.window or 60000 -- 1 minute local clients = {} - + return function(req, res, next) local client_ip = req:ip() local now = os.time() * 1000 - + if not clients[client_ip] then clients[client_ip] = {count = 1, reset_time = now + window_ms} else @@ -797,7 +797,7 @@ function http.rate_limit(options) end end end - + next() end end @@ -810,4 +810,4 @@ function http.create_server(callback) return app end -return http \ No newline at end of file +return http diff --git a/modules/kv/kv.go b/modules/kv/kv.go index 9e41615..002be78 100644 --- a/modules/kv/kv.go +++ b/modules/kv/kv.go @@ -58,7 +58,10 @@ func kv_open(s *luajit.State) int { mutex.Lock() defer mutex.Unlock() - if _, exists := stores[name]; exists { + if store, exists := stores[name]; exists { + if filename != "" && store.filename != filename { + store.filename = filename + } s.PushBoolean(true) return 1 } diff --git a/modules/registry.go b/modules/registry.go index e9196a0..0da9809 100644 --- a/modules/registry.go +++ b/modules/registry.go @@ -4,6 +4,7 @@ import ( "embed" "fmt" "maps" + "strings" "Moonshark/modules/crypto" "Moonshark/modules/fs" @@ -11,31 +12,29 @@ import ( "Moonshark/modules/kv" "Moonshark/modules/math" "Moonshark/modules/sql" - lua_string "Moonshark/modules/string" + lua_string "Moonshark/modules/string+" luajit "git.sharkk.net/Sky/LuaJIT-to-Go" ) -// Global registry instance var Global *Registry //go:embed **/*.lua var embeddedModules embed.FS -// Registry manages all Lua modules and Go functions type Registry struct { - modules map[string]string - goFuncs map[string]luajit.GoFunction + modules map[string]string + globalModules map[string]string // globalName -> moduleSource + goFuncs map[string]luajit.GoFunction } -// New creates a new registry with all modules loaded func New() *Registry { r := &Registry{ - modules: make(map[string]string), - goFuncs: make(map[string]luajit.GoFunction), + modules: make(map[string]string), + globalModules: make(map[string]string), + goFuncs: make(map[string]luajit.GoFunction), } - // Load all Go functions maps.Copy(r.goFuncs, lua_string.GetFunctionList()) maps.Copy(r.goFuncs, math.GetFunctionList()) maps.Copy(r.goFuncs, crypto.GetFunctionList()) @@ -48,9 +47,7 @@ func New() *Registry { return r } -// loadEmbeddedModules discovers and loads all .lua files func (r *Registry) loadEmbeddedModules() { - // Discover all directories from embed dirs, _ := embeddedModules.ReadDir(".") for _, dir := range dirs { @@ -58,15 +55,27 @@ func (r *Registry) loadEmbeddedModules() { continue } - // Assume one module file per directory: dirname/dirname.lua - modulePath := fmt.Sprintf("%s/%s.lua", dir.Name(), dir.Name()) + dirName := dir.Name() + isGlobal := strings.HasSuffix(dirName, "+") + + var moduleName, globalName string + if isGlobal { + moduleName = strings.TrimSuffix(dirName, "+") + globalName = moduleName + } else { + moduleName = dirName + } + + modulePath := fmt.Sprintf("%s/%s.lua", dirName, moduleName) if source, err := embeddedModules.ReadFile(modulePath); err == nil { - r.modules[dir.Name()] = string(source) + r.modules[moduleName] = string(source) + if isGlobal { + r.globalModules[globalName] = string(source) + } } } } -// InstallInState sets up the complete module system in a Lua state func (r *Registry) InstallInState(state *luajit.State) error { // Create moonshark global table with Go functions state.NewTable() @@ -78,6 +87,13 @@ func (r *Registry) InstallInState(state *luajit.State) error { } state.SetGlobal("moonshark") + // Auto-enhance all global modules + for globalName, source := range r.globalModules { + if err := r.enhanceGlobal(state, globalName, source); err != nil { + return fmt.Errorf("failed to enhance %s global: %w", globalName, err) + } + } + // Backup original require and install custom one state.GetGlobal("require") state.SetGlobal("_require_original") @@ -92,7 +108,13 @@ func (r *Registry) InstallInState(state *luajit.State) error { return s.PushError("require: module name must be a string") } - // Check built-in modules first + // Return global if this module enhances a global + if _, isGlobal := r.globalModules[moduleName]; isGlobal { + s.GetGlobal(moduleName) + return 1 + } + + // Check built-in modules if source, exists := r.modules[moduleName]; exists { if err := s.LoadString(source); err != nil { return s.PushError("require: failed to load module '%s': %v", moduleName, err) @@ -117,7 +139,18 @@ func (r *Registry) InstallInState(state *luajit.State) error { }) } -// Initialize sets up the global registry +func (r *Registry) enhanceGlobal(state *luajit.State, globalName, source string) error { + // Execute the module - it directly modifies the global + if err := state.LoadString(source); err != nil { + return fmt.Errorf("failed to load %s module: %w", globalName, err) + } + if err := state.Call(0, 0); err != nil { // 0 results expected + return fmt.Errorf("failed to execute %s module: %w", globalName, err) + } + + return nil +} + func Initialize() error { Global = New() return nil diff --git a/modules/string/string.go b/modules/string+/string.go similarity index 100% rename from modules/string/string.go rename to modules/string+/string.go diff --git a/modules/string+/string.lua b/modules/string+/string.lua new file mode 100644 index 0000000..1da4f69 --- /dev/null +++ b/modules/string+/string.lua @@ -0,0 +1,666 @@ +local _orig_find = string.find +local _orig_match = string.match +local REVERSE_THRESHOLD = 100 +local LENGTH_THRESHOLD = 1000 + +function string.split(s, delimiter) + if type(s) ~= "string" then error("string.split: first argument must be a string", 2) end + if type(delimiter) ~= "string" then error("string.split: second argument must be a string", 2) end + + if delimiter == "" then + local result = {} + for i = 1, #s do + result[i] = s:sub(i, i) + end + return result + end + + local result = {} + local start = 1 + local delimiter_len = #delimiter + + while true do + local pos = _orig_find(s, delimiter, start, true) -- Use original find + if not pos then + table.insert(result, s:sub(start)) + break + end + table.insert(result, s:sub(start, pos - 1)) + start = pos + delimiter_len + end + + return result +end +getmetatable("").__index.split = string.split + +function string.join(arr, separator) + if type(arr) ~= "table" then error("string.join: first argument must be a table", 2) end + if type(separator) ~= "string" then error("string.join: second argument must be a string", 2) end + + return table.concat(arr, separator) +end + +function string.trim(s, cutset) + if type(s) ~= "string" then error("string.trim: first argument must be a string", 2) end + if cutset then + if type(cutset) ~= "string" then error("string.trim: second argument must be a string", 2) end + local escaped = cutset:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") + local pattern = "^[" .. escaped .. "]*(.-)[" .. escaped .. "]*$" + return s:match(pattern) + else + return s:match("^%s*(.-)%s*$") + end +end +getmetatable("").__index.trim = string.trim + +function string.trim_left(s, cutset) + if type(s) ~= "string" then error("string.trim_left: first argument must be a string", 2) end + if cutset then + if type(cutset) ~= "string" then error("string.trim_left: second argument must be a string", 2) end + local pattern = "^[" .. cutset:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") .. "]*" + return s:gsub(pattern, "") + else + return s:match("^%s*(.*)") + end +end +getmetatable("").__index.trim_left = string.trim_left + +function string.trim_right(s, cutset) + if type(s) ~= "string" then error("string.trim_right: first argument must be a string", 2) end + if cutset then + if type(cutset) ~= "string" then error("string.trim_right: second argument must be a string", 2) end + local escaped = cutset:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") + local pattern = "[" .. escaped .. "]*$" + return s:gsub(pattern, "") + else + return s:match("(.-)%s*$") + end +end + +function string.title(s) + if type(s) ~= "string" then error("string.title: argument must be a string", 2) end + return s:gsub("(%w)([%w]*)", function(first, rest) + return first:upper() .. rest:lower() + end) +end +getmetatable("").__index.title = string.title + +function string.contains(s, substr) + if type(s) ~= "string" then error("string.contains: first argument must be a string", 2) end + if type(substr) ~= "string" then error("string.contains: second argument must be a string", 2) end + return _orig_find(s, substr, 1, true) ~= nil +end +getmetatable("").__index.contains = string.contains + +function string.starts_with(s, prefix) + if type(s) ~= "string" then error("string.starts_with: first argument must be a string", 2) end + if type(prefix) ~= "string" then error("string.starts_with: second argument must be a string", 2) end + return s:sub(1, #prefix) == prefix +end +getmetatable("").__index.starts_with = string.starts_with + +function string.ends_with(s, suffix) + if type(s) ~= "string" then error("string.ends_with: first argument must be a string", 2) end + if type(suffix) ~= "string" then error("string.ends_with: second argument must be a string", 2) end + if #suffix == 0 then return true end + return s:sub(-#suffix) == suffix +end +getmetatable("").__index.ends_with = string.ends_with + +function string.replace(s, old, new) + if type(s) ~= "string" then error("string.replace: first argument must be a string", 2) end + if type(old) ~= "string" then error("string.replace: second argument must be a string", 2) end + if type(new) ~= "string" then error("string.replace: third argument must be a string", 2) end + if old == "" then error("string.replace: cannot replace empty string", 2) end + return s:gsub(old:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1"), new) +end +getmetatable("").__index.replace = string.replace + +function string.replace_n(s, old, new, n) + if type(s) ~= "string" then error("string.replace_n: first argument must be a string", 2) end + if type(old) ~= "string" then error("string.replace_n: second argument must be a string", 2) end + if type(new) ~= "string" then error("string.replace_n: third argument must be a string", 2) end + if type(n) ~= "number" or n < 0 or n ~= math.floor(n) then + error("string.replace_n: fourth argument must be a non-negative integer", 2) + end + if old == "" then error("string.replace_n: cannot replace empty string", 2) end + local escaped = old:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") + return (s:gsub(escaped, new, n)) +end +getmetatable("").__index.replace_n = string.replace_n + +function string.index(s, substr) + if type(s) ~= "string" then error("string.index: first argument must be a string", 2) end + if type(substr) ~= "string" then error("string.index: second argument must be a string", 2) end + local pos = _orig_find(s, substr, 1, true) + return pos +end +getmetatable("").__index.index = string.index + +function string.last_index(s, substr) + if type(s) ~= "string" then error("string.last_index: first argument must be a string", 2) end + if type(substr) ~= "string" then error("string.last_index: second argument must be a string", 2) end + local last_pos = nil + local pos = 1 + while true do + local found = _orig_find(s, substr, pos, true) + if not found then break end + last_pos = found + pos = found + 1 + end + return last_pos +end +getmetatable("").__index.last_index = string.last_index + +function string.count(s, substr) + if type(s) ~= "string" then error("string.count: first argument must be a string", 2) end + if type(substr) ~= "string" then error("string.count: second argument must be a string", 2) end + if substr == "" then return #s + 1 end + local count = 0 + local pos = 1 + while true do + local found = _orig_find(s, substr, pos, true) + if not found then break end + count = count + 1 + pos = found + #substr + end + return count +end +getmetatable("").__index.count = string.count + +function string.repeat_(s, n) + if type(s) ~= "string" then error("string.repeat_: first argument must be a string", 2) end + if type(n) ~= "number" or n < 0 or n ~= math.floor(n) then + error("string.repeat_: second argument must be a non-negative integer", 2) + end + return string.rep(s, n) +end + +function string.reverse(s) + if type(s) ~= "string" then error("string.reverse: argument must be a string", 2) end + + if #s > REVERSE_THRESHOLD then + local result, err = moonshark.string_reverse(s) + if not result then error("string.reverse: " .. err, 2) end + return result + else + local result = {} + for i = #s, 1, -1 do + result[#result + 1] = s:sub(i, i) + end + return table.concat(result) + end +end +getmetatable("").__index.reverse = string.reverse + +function string.length(s) + if type(s) ~= "string" then error("string.length: argument must be a string", 2) end + return moonshark.string_length(s) +end +getmetatable("").__index.length = string.length + +function string.byte_length(s) + if type(s) ~= "string" then error("string.byte_length: argument must be a string", 2) end + return moonshark.string_byte_length(s) +end +getmetatable("").__index.byte_length = string.byte_length + +function string.lines(s) + if type(s) ~= "string" then error("string.lines: argument must be a string", 2) end + if s == "" then return {""} end + + s = s:gsub("\r\n", "\n"):gsub("\r", "\n") + local lines = {} + for line in (s .. "\n"):gmatch("([^\n]*)\n") do + table.insert(lines, line) + end + if #lines > 0 and lines[#lines] == "" then + table.remove(lines) + end + return lines +end +getmetatable("").__index.lines = string.lines + +function string.words(s) + if type(s) ~= "string" then error("string.words: argument must be a string", 2) end + local words = {} + for word in s:gmatch("%S+") do + table.insert(words, word) + end + return words +end +getmetatable("").__index.words = string.words + +function string.pad_left(s, width, pad_char) + if type(s) ~= "string" then error("string.pad_left: first argument must be a string", 2) end + if type(width) ~= "number" or width < 0 or width ~= math.floor(width) then + error("string.pad_left: second argument must be a non-negative integer", 2) + end + pad_char = pad_char or " " + if type(pad_char) ~= "string" then error("string.pad_left: third argument must be a string", 2) end + if #pad_char == 0 then pad_char = " " else pad_char = pad_char:sub(1,1) end + local current_len = string.length(s) + if current_len >= width then return s end + return string.rep(pad_char, width - current_len) .. s +end +getmetatable("").__index.pad_left = string.pad_left + +function string.pad_right(s, width, pad_char) + if type(s) ~= "string" then error("string.pad_right: first argument must be a string", 2) end + if type(width) ~= "number" or width < 0 or width ~= math.floor(width) then + error("string.pad_right: second argument must be a non-negative integer", 2) + end + pad_char = pad_char or " " + if type(pad_char) ~= "string" then error("string.pad_right: third argument must be a string", 2) end + if #pad_char == 0 then pad_char = " " else pad_char = pad_char:sub(1,1) end + local current_len = string.length(s) + if current_len >= width then return s end + return s .. string.rep(pad_char, width - current_len) +end +getmetatable("").__index.pad_right = string.pad_right + +function string.slice(s, start, end_pos) + if type(s) ~= "string" then error("string.slice: first argument must be a string", 2) end + if type(start) ~= "number" or start ~= math.floor(start) then + error("string.slice: second argument must be an integer", 2) + end + if end_pos ~= nil and (type(end_pos) ~= "number" or end_pos ~= math.floor(end_pos)) then + error("string.slice: third argument must be an integer", 2) + end + local result, err = moonshark.string_slice(s, start, end_pos) + if not result then error("string.slice: " .. err, 2) end + return result +end +getmetatable("").__index.slice = string.slice + +-- Custom find that returns matched substring instead of position +function string.find(s, pattern, init, plain) + if type(s) ~= "string" then error("string.find: first argument must be a string", 2) end + if type(pattern) ~= "string" then error("string.find: second argument must be a string", 2) end + local start_pos, end_pos = _orig_find(s, pattern, init, plain) + if start_pos then + return s:sub(start_pos, end_pos) + end + return nil +end +getmetatable("").__index.find = string.find + +function string.find_all(s, pattern) + if type(s) ~= "string" then error("string.find_all: first argument must be a string", 2) end + if type(pattern) ~= "string" then error("string.find_all: second argument must be a string", 2) end + local matches = {} + for match in s:gmatch(pattern) do + table.insert(matches, match) + end + return matches +end +getmetatable("").__index.find_all = string.find_all + +function string.to_number(s) + if type(s) ~= "string" then error("string.to_number: argument must be a string", 2) end + s = string.trim(s) + return tonumber(s) +end +getmetatable("").__index.to_number = string.to_number + +function string.is_numeric(s) + if type(s) ~= "string" then error("string.is_numeric: argument must be a string", 2) end + s = string.trim(s) + return tonumber(s) ~= nil +end +getmetatable("").__index.is_numeric = string.is_numeric + +function string.is_alpha(s) + if type(s) ~= "string" then error("string.is_alpha: argument must be a string", 2) end + if #s == 0 then return false end + return s:match("^%a+$") ~= nil +end +getmetatable("").__index.is_alpha = string.is_alpha + +function string.is_alphanumeric(s) + if type(s) ~= "string" then error("string.is_alphanumeric: argument must be a string", 2) end + if #s == 0 then return false end + return s:match("^%w+$") ~= nil +end +getmetatable("").__index.is_alphanumeric = string.is_alphanumeric + +function string.is_utf8(s) + if type(s) ~= "string" then error("string.is_utf8: argument must be a string", 2) end + return moonshark.string_is_valid_utf8(s) +end +getmetatable("").__index.is_utf8 = string.is_utf8 + +function string.is_empty(s) + return s == nil or s == "" +end +getmetatable("").__index.is_empty = string.is_empty + +function string.is_blank(s) + return s == nil or s == "" or string.trim(s) == "" +end +getmetatable("").__index.is_blank = string.is_blank + +function string.capitalize(s) + if type(s) ~= "string" then error("string.capitalize: argument must be a string", 2) end + return s:gsub("(%a)([%w_']*)", function(first, rest) + return first:upper() .. rest:lower() + end) +end +getmetatable("").__index.capitalize = string.capitalize + +function string.camel_case(s) + if type(s) ~= "string" then error("string.camel_case: argument must be a string", 2) end + local words = string.words(s) + if #words == 0 then return s end + local result = words[1]:lower() + for i = 2, #words do + result = result .. words[i]:sub(1,1):upper() .. words[i]:sub(2):lower() + end + return result +end +getmetatable("").__index.camel_case = string.camel_case + +function string.pascal_case(s) + if type(s) ~= "string" then error("string.pascal_case: argument must be a string", 2) end + local words = string.words(s) + local result = "" + for _, word in ipairs(words) do + result = result .. word:sub(1,1):upper() .. word:sub(2):lower() + end + return result +end +getmetatable("").__index.pascal_case = string.pascal_case + +function string.snake_case(s) + if type(s) ~= "string" then error("string.snake_case: argument must be a string", 2) end + local words = string.words(s) + local result = {} + for _, word in ipairs(words) do + table.insert(result, word:lower()) + end + return table.concat(result, "_") +end +getmetatable("").__index.snake_case = string.snake_case + +function string.kebab_case(s) + if type(s) ~= "string" then error("string.kebab_case: argument must be a string", 2) end + local words = string.words(s) + local result = {} + for _, word in ipairs(words) do + table.insert(result, word:lower()) + end + return table.concat(result, "-") +end +getmetatable("").__index.kebab_case = string.kebab_case + +function string.screaming_snake_case(s) + if type(s) ~= "string" then error("string.screaming_snake_case: argument must be a string", 2) end + return string.snake_case(s):upper() +end +getmetatable("").__index.screaming_snake_case = string.screaming_snake_case + +function string.center(s, width, fill_char) + if type(s) ~= "string" then error("string.center: first argument must be a string", 2) end + if type(width) ~= "number" or width < 0 or width ~= math.floor(width) then + error("string.center: second argument must be a non-negative integer", 2) + end + fill_char = fill_char or " " + if type(fill_char) ~= "string" or #fill_char == 0 then + error("string.center: fill character must be a non-empty string", 2) + end + fill_char = fill_char:sub(1,1) + + local len = string.length(s) + if len >= width then return s end + + local pad_total = width - len + local pad_left = math.floor(pad_total / 2) + local pad_right = pad_total - pad_left + + return string.rep(fill_char, pad_left) .. s .. string.rep(fill_char, pad_right) +end +getmetatable("").__index.center = string.center + +function string.truncate(s, max_length, suffix) + if type(s) ~= "string" then error("string.truncate: first argument must be a string", 2) end + if type(max_length) ~= "number" or max_length < 0 or max_length ~= math.floor(max_length) then + error("string.truncate: second argument must be a non-negative integer", 2) + end + suffix = suffix or "..." + if type(suffix) ~= "string" then error("string.truncate: third argument must be a string", 2) end + + local len = string.length(s) + if len <= max_length then return s end + + local suffix_len = string.length(suffix) + if max_length <= suffix_len then + return string.slice(suffix, 1, max_length) + end + + local main_part = string.slice(s, 1, max_length - suffix_len) + main_part = string.trim_right(main_part) + return main_part .. suffix +end +getmetatable("").__index.truncate = string.truncate + +function string.wrap(s, width) + if type(s) ~= "string" then error("string.wrap: first argument must be a string", 2) end + if type(width) ~= "number" or width <= 0 or width ~= math.floor(width) then + error("string.wrap: second argument must be a positive integer", 2) + end + + if s == "" then return {""} end + + local words = string.words(s) + if #words == 0 then return {""} end + + local lines = {} + local current_line = "" + + for _, word in ipairs(words) do + if string.length(word) > width then + if current_line ~= "" then + table.insert(lines, current_line) + current_line = "" + end + table.insert(lines, word) + elseif current_line == "" then + current_line = word + elseif string.length(current_line) + 1 + string.length(word) <= width then + current_line = current_line .. " " .. word + else + table.insert(lines, current_line) + current_line = word + end + end + + if current_line ~= "" then + table.insert(lines, current_line) + end + + return lines +end +getmetatable("").__index.wrap = string.wrap + +function string.dedent(s) + if type(s) ~= "string" then error("string.dedent: argument must be a string", 2) end + + local lines = string.lines(s) + if #lines == 0 then return s end + + local min_indent = math.huge + for _, line in ipairs(lines) do + if string.trim(line) ~= "" then + local indent = 0 + for i = 1, #line do + if line:sub(i,i) == " " then + indent = indent + 1 + else + break + end + end + min_indent = math.min(min_indent, indent) + end + end + + if min_indent == math.huge then return s end + + local result = {} + for _, line in ipairs(lines) do + if string.trim(line) == "" then + table.insert(result, "") + else + table.insert(result, line:sub(min_indent + 1)) + end + end + + return table.concat(result, "\n") +end +getmetatable("").__index.dedent = string.dedent + +function string.escape(s) + if type(s) ~= "string" then error("string.escape: argument must be a string", 2) end + return (s:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1")) +end +getmetatable("").__index.escape = string.escape + +function string.shell_quote(s) + if type(s) ~= "string" then error("string.shell_quote: argument must be a string", 2) end + if s:match("^[%w%.%-_/]+$") then + return s + end + return "'" .. s:gsub("'", "'\"'\"'") .. "'" +end +getmetatable("").__index.shell_quote = string.shell_quote + +function string.url_encode(s) + if type(s) ~= "string" then error("string.url_encode: argument must be a string", 2) end + return s:gsub("([^%w%-%.%_%~])", function(c) + return string.format("%%%02X", string.byte(c)) + end) +end +getmetatable("").__index.url_encode = string.url_encode + +function string.url_decode(s) + if type(s) ~= "string" then error("string.url_decode: argument must be a string", 2) end + s = s:gsub("+", " ") + return s:gsub("%%(%x%x)", function(hex) + return string.char(tonumber(hex, 16)) + end) +end +getmetatable("").__index.url_decode = string.url_decode + +function string.slug(s) + if type(s) ~= "string" then error("string.slug: argument must be a string", 2) end + if s == "" then return "" end + + local result = s:lower() + -- Remove accents first + result = string.remove_accents(result) + -- Keep only alphanumeric, spaces, and hyphens + result = result:gsub("[^%w%s%-]", "") + -- Replace spaces with hyphens + result = result:gsub("%s+", "-") + -- Remove duplicate hyphens + result = result:gsub("%-+", "-") + -- Remove leading/trailing hyphens + result = result:gsub("^%-", "") + result = result:gsub("%-$", "") + + return result +end +getmetatable("").__index.slug = string.slug + +function string.iequals(a, b) + if type(a) ~= "string" then error("string.iequals: first argument must be a string", 2) end + if type(b) ~= "string" then error("string.iequals: second argument must be a string", 2) end + return string.lower(a) == string.lower(b) +end +getmetatable("").__index.iequals = string.iequals + +function string.is_whitespace(s) + if type(s) ~= "string" then error("string.is_whitespace: argument must be a string", 2) end + return s:match("^%s*$") ~= nil +end +getmetatable("").__index.is_whitespace = string.is_whitespace + +function string.strip_whitespace(s) + if type(s) ~= "string" then error("string.strip_whitespace: argument must be a string", 2) end + return s:gsub("%s", "") +end +getmetatable("").__index.strip_whitespace = string.strip_whitespace + +function string.normalize_whitespace(s) + if type(s) ~= "string" then error("string.normalize_whitespace: argument must be a string", 2) end + return string.trim((s:gsub("%s+", " "))) +end +getmetatable("").__index.normalize_whitespace = string.normalize_whitespace + +function string.extract_numbers(s) + if type(s) ~= "string" then error("string.extract_numbers: argument must be a string", 2) end + local numbers = {} + for num in s:gmatch("%-?%d+%.?%d*") do + local n = tonumber(num) + if n then table.insert(numbers, n) end + end + return numbers +end +getmetatable("").__index.extract_numbers = string.extract_numbers + +function string.remove_accents(s) + if type(s) ~= "string" then error("string.remove_accents: argument must be a string", 2) end + local accents = { + ["á"] = "a", ["à"] = "a", ["ä"] = "a", ["â"] = "a", ["ã"] = "a", ["å"] = "a", + ["Á"] = "A", ["À"] = "A", ["Ä"] = "A", ["Â"] = "A", ["Ã"] = "A", ["Å"] = "A", + ["é"] = "e", ["è"] = "e", ["ë"] = "e", ["ê"] = "e", + ["É"] = "E", ["È"] = "E", ["Ë"] = "E", ["Ê"] = "E", + ["í"] = "i", ["ì"] = "i", ["ï"] = "i", ["î"] = "i", + ["Í"] = "I", ["Ì"] = "I", ["Ï"] = "I", ["Î"] = "I", + ["ó"] = "o", ["ò"] = "o", ["ö"] = "o", ["ô"] = "o", ["õ"] = "o", + ["Ó"] = "O", ["Ò"] = "O", ["Ö"] = "O", ["Ô"] = "O", ["Õ"] = "O", + ["ú"] = "u", ["ù"] = "u", ["ü"] = "u", ["û"] = "u", + ["Ú"] = "U", ["Ù"] = "U", ["Ü"] = "U", ["Û"] = "U", + ["ñ"] = "n", ["Ñ"] = "N", + ["ç"] = "c", ["Ç"] = "C" + } + + local result = s + for accented, plain in pairs(accents) do + result = result:gsub(accented, plain) + end + return result +end +getmetatable("").__index.remove_accents = string.remove_accents + +function string.template(template_str, vars) + if type(template_str) ~= "string" then error("string.template: first argument must be a string", 2) end + if type(vars) ~= "table" then error("string.template: second argument must be a table", 2) end + + return template_str:gsub("%${([%w_%.]+)}", function(path) + local value = vars + + -- Handle simple variables (no dots) + if not path:match("%.") then + return tostring(value[path] or "") + end + + -- Handle nested properties + for key in path:gmatch("[^%.]+") do + if type(value) == "table" and value[key] ~= nil then + value = value[key] + else + return "" + end + end + return tostring(value) + end) +end +getmetatable("").__index.template = string.template + +function string.random(length, charset) + local result, err = moonshark.random_string(length, charset) + if not result then + error(err) + end + return result +end diff --git a/modules/string/string.lua b/modules/string/string.lua deleted file mode 100644 index 5e844b0..0000000 --- a/modules/string/string.lua +++ /dev/null @@ -1,715 +0,0 @@ -local str = {} - --- Performance thresholds based on benchmark results -local REVERSE_THRESHOLD = 100 -- Use Go for strings longer than this -local LENGTH_THRESHOLD = 1000 -- Use Go for ASCII strings longer than this - --- ====================================================================== --- BASIC STRING OPERATIONS (Optimized Lua/Go hybrid) --- ====================================================================== - -function str.split(s, delimiter) - if type(s) ~= "string" then error("str.split: first argument must be a string", 2) end - if type(delimiter) ~= "string" then error("str.split: second argument must be a string", 2) end - - if delimiter == "" then - local result = {} - for i = 1, #s do - result[i] = s:sub(i, i) - end - return result - end - - local result = {} - local start = 1 - local delimiter_len = #delimiter - - while true do - local pos = s:find(delimiter, start, true) - if not pos then - table.insert(result, s:sub(start)) - break - end - table.insert(result, s:sub(start, pos - 1)) - start = pos + delimiter_len - end - - return result -end - -function str.join(arr, separator) - if type(arr) ~= "table" then error("str.join: first argument must be a table", 2) end - if type(separator) ~= "string" then error("str.join: second argument must be a string", 2) end - - return table.concat(arr, separator) -end - -function str.trim(s) - if type(s) ~= "string" then error("str.trim: argument must be a string", 2) end - return s:match("^%s*(.-)%s*$") -end - -function str.trim_left(s, cutset) - if type(s) ~= "string" then error("str.trim_left: first argument must be a string", 2) end - if cutset then - if type(cutset) ~= "string" then error("str.trim_left: second argument must be a string", 2) end - local pattern = "^[" .. cutset:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") .. "]*" - return s:gsub(pattern, "") - else - return s:match("^%s*(.*)") - end -end - -function str.trim_right(s, cutset) - if type(s) ~= "string" then error("str.trim_right: first argument must be a string", 2) end - if cutset then - if type(cutset) ~= "string" then error("str.trim_right: second argument must be a string", 2) end - local pattern = "[" .. cutset:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") .. "]*$" - return s:gsub(pattern, "") - else - return s:match("(.-)%s*$") - end -end - -function str.upper(s) - if type(s) ~= "string" then error("str.upper: argument must be a string", 2) end - return s:upper() -end - -function str.lower(s) - if type(s) ~= "string" then error("str.lower: argument must be a string", 2) end - return s:lower() -end - -function str.title(s) - if type(s) ~= "string" then error("str.title: argument must be a string", 2) end - return s:gsub("(%a)([%w_']*)", function(first, rest) - return first:upper() .. rest:lower() - end) -end - -function str.contains(s, substr) - if type(s) ~= "string" then error("str.contains: first argument must be a string", 2) end - if type(substr) ~= "string" then error("str.contains: second argument must be a string", 2) end - return s:find(substr, 1, true) ~= nil -end - -function str.starts_with(s, prefix) - if type(s) ~= "string" then error("str.starts_with: first argument must be a string", 2) end - if type(prefix) ~= "string" then error("str.starts_with: second argument must be a string", 2) end - return s:sub(1, #prefix) == prefix -end - -function str.ends_with(s, suffix) - if type(s) ~= "string" then error("str.ends_with: first argument must be a string", 2) end - if type(suffix) ~= "string" then error("str.ends_with: second argument must be a string", 2) end - return s:sub(-#suffix) == suffix -end - -function str.replace(s, old, new) - if type(s) ~= "string" then error("str.replace: first argument must be a string", 2) end - if type(old) ~= "string" then error("str.replace: second argument must be a string", 2) end - if type(new) ~= "string" then error("str.replace: third argument must be a string", 2) end - if old == "" then error("str.replace: cannot replace empty string", 2) end - return s:gsub(old:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1"), new) -end - -function str.replace_n(s, old, new, n) - if type(s) ~= "string" then error("str.replace_n: first argument must be a string", 2) end - if type(old) ~= "string" then error("str.replace_n: second argument must be a string", 2) end - if type(new) ~= "string" then error("str.replace_n: third argument must be a string", 2) end - if type(n) ~= "number" or n < 0 or n ~= math.floor(n) then - error("str.replace_n: fourth argument must be a non-negative integer", 2) - end - if old == "" then error("str.replace_n: cannot replace empty string", 2) end - local escaped = old:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") - return (s:gsub(escaped, new, n)) -end - -function str.index(s, substr) - if type(s) ~= "string" then error("str.index: first argument must be a string", 2) end - if type(substr) ~= "string" then error("str.index: second argument must be a string", 2) end - local pos = s:find(substr, 1, true) - return pos -end - -function str.last_index(s, substr) - if type(s) ~= "string" then error("str.last_index: first argument must be a string", 2) end - if type(substr) ~= "string" then error("str.last_index: second argument must be a string", 2) end - local last_pos = nil - local pos = 1 - while true do - local found = s:find(substr, pos, true) - if not found then break end - last_pos = found - pos = found + 1 - end - return last_pos -end - -function str.count(s, substr) - if type(s) ~= "string" then error("str.count: first argument must be a string", 2) end - if type(substr) ~= "string" then error("str.count: second argument must be a string", 2) end - if substr == "" then return #s + 1 end - local count = 0 - local pos = 1 - while true do - local found = s:find(substr, pos, true) - if not found then break end - count = count + 1 - pos = found + #substr - end - return count -end - -function str.repeat_(s, n) - if type(s) ~= "string" then error("str.repeat_: first argument must be a string", 2) end - if type(n) ~= "number" or n < 0 or n ~= math.floor(n) then - error("str.repeat_: second argument must be a non-negative integer", 2) - end - return string.rep(s, n) -end - -function str.reverse(s) - if type(s) ~= "string" then error("str.reverse: argument must be a string", 2) end - - if #s > REVERSE_THRESHOLD then - local result, err = moonshark.string_reverse(s) - if not result then error("str.reverse: " .. err, 2) end - return result - else - local result = {} - for i = #s, 1, -1 do - result[#result + 1] = s:sub(i, i) - end - return table.concat(result) - end -end - -function str.length(s) - if type(s) ~= "string" then error("str.length: argument must be a string", 2) end - - -- For long ASCII strings, Go is faster. For unicode or short strings, use Go consistently - -- since UTF-8 handling is more reliable in Go - return moonshark.string_length(s) -end - -function str.byte_length(s) - if type(s) ~= "string" then error("str.byte_length: argument must be a string", 2) end - return #s -end - -function str.lines(s) - if type(s) ~= "string" then error("str.lines: argument must be a string", 2) end - if s == "" then return {""} end - - s = s:gsub("\r\n", "\n"):gsub("\r", "\n") - local lines = {} - for line in (s .. "\n"):gmatch("([^\n]*)\n") do - table.insert(lines, line) - end - if #lines > 0 and lines[#lines] == "" then - table.remove(lines) - end - return lines -end - -function str.words(s) - if type(s) ~= "string" then error("str.words: argument must be a string", 2) end - local words = {} - for word in s:gmatch("%S+") do - table.insert(words, word) - end - return words -end - -function str.pad_left(s, width, pad_char) - if type(s) ~= "string" then error("str.pad_left: first argument must be a string", 2) end - if type(width) ~= "number" or width < 0 or width ~= math.floor(width) then - error("str.pad_left: second argument must be a non-negative integer", 2) - end - pad_char = pad_char or " " - if type(pad_char) ~= "string" then error("str.pad_left: third argument must be a string", 2) end - if #pad_char == 0 then pad_char = " " else pad_char = pad_char:sub(1,1) end - local current_len = str.length(s) - if current_len >= width then return s end - return string.rep(pad_char, width - current_len) .. s -end - -function str.pad_right(s, width, pad_char) - if type(s) ~= "string" then error("str.pad_right: first argument must be a string", 2) end - if type(width) ~= "number" or width < 0 or width ~= math.floor(width) then - error("str.pad_right: second argument must be a non-negative integer", 2) - end - pad_char = pad_char or " " - if type(pad_char) ~= "string" then error("str.pad_right: third argument must be a string", 2) end - if #pad_char == 0 then pad_char = " " else pad_char = pad_char:sub(1,1) end - local current_len = str.length(s) - if current_len >= width then return s end - return s .. string.rep(pad_char, width - current_len) -end - -function str.slice(s, start, end_pos) - if type(s) ~= "string" then error("str.slice: first argument must be a string", 2) end - if type(start) ~= "number" or start ~= math.floor(start) then - error("str.slice: second argument must be an integer", 2) - end - if end_pos ~= nil and (type(end_pos) ~= "number" or end_pos ~= math.floor(end_pos)) then - error("str.slice: third argument must be an integer", 2) - end - local result, err = moonshark.string_slice(s, start, end_pos) - if not result then error("str.slice: " .. err, 2) end - return result -end - --- ====================================================================== --- REGULAR EXPRESSIONS (Optimized Lua patterns) --- ====================================================================== - -function str.match(pattern, s) - if type(pattern) ~= "string" then error("str.match: first argument must be a string", 2) end - if type(s) ~= "string" then error("str.match: second argument must be a string", 2) end - - local lua_pattern = pattern:gsub("\\d", "%%d"):gsub("\\w", "%%w"):gsub("\\s", "%%s") - return s:match(lua_pattern) ~= nil -end - -function str.find(pattern, s) - if type(pattern) ~= "string" then error("str.find: first argument must be a string", 2) end - if type(s) ~= "string" then error("str.find: second argument must be a string", 2) end - - local lua_pattern = pattern:gsub("\\d", "%%d"):gsub("\\w", "%%w"):gsub("\\s", "%%s") - return s:match(lua_pattern) -end - -function str.find_all(pattern, s) - if type(pattern) ~= "string" then error("str.find_all: first argument must be a string", 2) end - if type(s) ~= "string" then error("str.find_all: second argument must be a string", 2) end - - local lua_pattern = pattern:gsub("\\d", "%%d"):gsub("\\w", "%%w"):gsub("\\s", "%%s") - local matches = {} - for match in s:gmatch(lua_pattern) do - table.insert(matches, match) - end - return matches -end - -function str.gsub(pattern, s, replacement) - if type(pattern) ~= "string" then error("str.gsub: first argument must be a string", 2) end - if type(s) ~= "string" then error("str.gsub: second argument must be a string", 2) end - if type(replacement) ~= "string" then error("str.gsub: third argument must be a string", 2) end - - -- Use Go for complex regex, Lua for simple patterns - if pattern:match("[%[%]%(%)%{%}%|%\\%^%$]") then - -- Complex pattern, use Go - return moonshark.regex_replace(pattern, s, replacement) - else - -- Simple pattern, use Lua - local lua_pattern = pattern:gsub("\\d", "%%d"):gsub("\\w", "%%w"):gsub("\\s", "%%s") - return s:gsub(lua_pattern, replacement) - end -end - --- ====================================================================== --- TYPE CONVERSION & VALIDATION --- ====================================================================== - -function str.to_number(s) - if type(s) ~= "string" then error("str.to_number: argument must be a string", 2) end - s = str.trim(s) - return tonumber(s) -end - -function str.is_numeric(s) - if type(s) ~= "string" then error("str.is_numeric: argument must be a string", 2) end - s = str.trim(s) - return tonumber(s) ~= nil -end - -function str.is_alpha(s) - if type(s) ~= "string" then error("str.is_alpha: argument must be a string", 2) end - if #s == 0 then return false end - return s:match("^%a+$") ~= nil -end - -function str.is_alphanumeric(s) - if type(s) ~= "string" then error("str.is_alphanumeric: argument must be a string", 2) end - if #s == 0 then return false end - return s:match("^%w+$") ~= nil -end - -function str.is_empty(s) - return s == nil or s == "" -end - -function str.is_blank(s) - return str.is_empty(s) or str.trim(s) == "" -end - -function str.is_utf8(s) - if type(s) ~= "string" then error("str.is_utf8: argument must be a string", 2) end - return moonshark.string_is_valid_utf8(s) -end - --- ====================================================================== --- ADVANCED STRING OPERATIONS (Pure Lua) --- ====================================================================== - -function str.capitalize(s) - if type(s) ~= "string" then error("str.capitalize: argument must be a string", 2) end - return s:gsub("(%a)([%w_']*)", function(first, rest) - return first:upper() .. rest:lower() - end) -end - -function str.camel_case(s) - if type(s) ~= "string" then error("str.camel_case: argument must be a string", 2) end - local words = str.words(s) - if #words == 0 then return s end - local result = words[1]:lower() - for i = 2, #words do - result = result .. words[i]:sub(1,1):upper() .. words[i]:sub(2):lower() - end - return result -end - -function str.pascal_case(s) - if type(s) ~= "string" then error("str.pascal_case: argument must be a string", 2) end - local words = str.words(s) - local result = "" - for _, word in ipairs(words) do - result = result .. word:sub(1,1):upper() .. word:sub(2):lower() - end - return result -end - -function str.snake_case(s) - if type(s) ~= "string" then error("str.snake_case: argument must be a string", 2) end - local words = str.words(s) - local result = {} - for _, word in ipairs(words) do - table.insert(result, word:lower()) - end - return table.concat(result, "_") -end - -function str.kebab_case(s) - if type(s) ~= "string" then error("str.kebab_case: argument must be a string", 2) end - local words = str.words(s) - local result = {} - for _, word in ipairs(words) do - table.insert(result, word:lower()) - end - return table.concat(result, "-") -end - -function str.center(s, width, fill_char) - if type(s) ~= "string" then error("str.center: first argument must be a string", 2) end - if type(width) ~= "number" or width < 0 or width ~= math.floor(width) then - error("str.center: second argument must be a non-negative integer", 2) - end - fill_char = fill_char or " " - if type(fill_char) ~= "string" or #fill_char == 0 then - error("str.center: fill character must be a non-empty string", 2) - end - fill_char = fill_char:sub(1,1) - - local len = str.length(s) - if len >= width then return s end - - local pad_total = width - len - local pad_left = math.floor(pad_total / 2) - local pad_right = pad_total - pad_left - - return string.rep(fill_char, pad_left) .. s .. string.rep(fill_char, pad_right) -end - -function str.truncate(s, max_length, suffix) - if type(s) ~= "string" then error("str.truncate: first argument must be a string", 2) end - if type(max_length) ~= "number" or max_length < 0 or max_length ~= math.floor(max_length) then - error("str.truncate: second argument must be a non-negative integer", 2) - end - suffix = suffix or "..." - if type(suffix) ~= "string" then error("str.truncate: third argument must be a string", 2) end - - local len = str.length(s) - if len <= max_length then return s end - - local suffix_len = str.length(suffix) - if max_length <= suffix_len then - return str.slice(suffix, 1, max_length) - end - - local main_part = str.slice(s, 1, max_length - suffix_len) - main_part = str.trim_right(main_part) - return main_part .. suffix -end - -function str.escape_regex(s) - if type(s) ~= "string" then error("str.escape_regex: argument must be a string", 2) end - return s:gsub("([%.%+%*%?%[%]%^%$%(%)%{%}%|%\\])", "\\%1") -end - -function str.url_encode(s) - if type(s) ~= "string" then error("str.url_encode: argument must be a string", 2) end - return s:gsub("([^%w%-%.%_%~])", function(c) - return string.format("%%%02X", string.byte(c)) - end) -end - -function str.url_decode(s) - if type(s) ~= "string" then error("str.url_decode: argument must be a string", 2) end - local result = s:gsub("%%(%x%x)", function(hex) - local byte = tonumber(hex, 16) - return byte and string.char(byte) or ("%" .. hex) - end):gsub("+", " ") - - if not str.is_utf8(result) then - error("str.url_decode: result is not valid UTF-8", 2) - end - - return result -end - -function str.distance(a, b) - if type(a) ~= "string" then error("str.distance: first argument must be a string", 2) end - if type(b) ~= "string" then error("str.distance: second argument must be a string", 2) end - - local len_a, len_b = str.length(a), str.length(b) - - if len_a == 0 then return len_b end - if len_b == 0 then return len_a end - - if len_a > 1000 or len_b > 1000 then - error("str.distance: strings too long for distance calculation", 2) - end - - local matrix = {} - - for i = 0, len_a do - matrix[i] = {[0] = i} - end - for j = 0, len_b do - matrix[0][j] = j - end - - for i = 1, len_a do - for j = 1, len_b do - local cost = (str.slice(a, i, i) == str.slice(b, j, j)) and 0 or 1 - matrix[i][j] = math.min( - matrix[i-1][j] + 1, - matrix[i][j-1] + 1, - matrix[i-1][j-1] + cost - ) - end - end - - return matrix[len_a][len_b] -end - -function str.similarity(a, b) - if type(a) ~= "string" then error("str.similarity: first argument must be a string", 2) end - if type(b) ~= "string" then error("str.similarity: second argument must be a string", 2) end - - local max_len = math.max(str.length(a), str.length(b)) - if max_len == 0 then return 1.0 end - - local dist = str.distance(a, b) - return 1.0 - (dist / max_len) -end - -function str.template(template, vars) - if type(template) ~= "string" then error("str.template: first argument must be a string", 2) end - vars = vars or {} - if type(vars) ~= "table" then error("str.template: second argument must be a table", 2) end - - return template:gsub("%${([%w_]+)}", function(var) - local value = vars[var] - return value ~= nil and tostring(value) or "" - end) -end - -function str.random(length, charset) - if type(length) ~= "number" or length < 0 or length ~= math.floor(length) then - error("str.random: first argument must be a non-negative integer", 2) - end - if charset ~= nil and type(charset) ~= "string" then - error("str.random: second argument must be a string", 2) - end - - charset = charset or "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - local result = {} - - math.randomseed(os.time() + os.clock() * 1000000) - - for i = 1, length do - local rand_index = math.random(1, #charset) - result[i] = charset:sub(rand_index, rand_index) - end - - return table.concat(result) -end - -function str.slug(s) - if type(s) ~= "string" then error("str.slug: argument must be a string", 2) end - - local result = str.remove_accents(s):lower() - result = result:gsub("[^%w%s]", "") - result = result:gsub("%s+", "-") - result = result:gsub("^%-+", ""):gsub("%-+$", "") - - return result -end - --- Add these functions to the end of string.lua, before the return statement - -function str.screaming_snake_case(s) - if type(s) ~= "string" then error("str.screaming_snake_case: argument must be a string", 2) end - return str.snake_case(s):upper() -end - -function str.wrap(s, width) - if type(s) ~= "string" then error("str.wrap: first argument must be a string", 2) end - if type(width) ~= "number" or width <= 0 then error("str.wrap: width must be positive number", 2) end - - local words = str.words(s) - local lines = {} - local current_line = "" - - for _, word in ipairs(words) do - if current_line == "" then - current_line = word - elseif str.length(current_line .. " " .. word) <= width then - current_line = current_line .. " " .. word - else - table.insert(lines, current_line) - current_line = word - end - end - - if current_line ~= "" then - table.insert(lines, current_line) - end - - return lines -end - -function str.dedent(s) - if type(s) ~= "string" then error("str.dedent: argument must be a string", 2) end - - local lines = str.lines(s) - if #lines == 0 then return "" end - - -- Find minimum indentation - local min_indent = math.huge - for _, line in ipairs(lines) do - if line:match("%S") then -- Non-empty line - local indent = line:match("^(%s*)") - min_indent = math.min(min_indent, #indent) - end - end - - if min_indent == math.huge then min_indent = 0 end - - -- Remove common indentation - local result = {} - for _, line in ipairs(lines) do - table.insert(result, line:sub(min_indent + 1)) - end - - return table.concat(result, "\n") -end - -function str.shell_quote(s) - if type(s) ~= "string" then error("str.shell_quote: argument must be a string", 2) end - - if s:match("^[%w%-%./]+$") then - return s -- No quoting needed - end - - -- Replace single quotes with '"'"' - local quoted = s:gsub("'", "'\"'\"'") - return "'" .. quoted .. "'" -end - -function str.iequals(a, b) - if type(a) ~= "string" then error("str.iequals: first argument must be a string", 2) end - if type(b) ~= "string" then error("str.iequals: second argument must be a string", 2) end - return str.lower(a) == str.lower(b) -end - -function str.template_advanced(template, context) - if type(template) ~= "string" then error("str.template_advanced: first argument must be a string", 2) end - context = context or {} - if type(context) ~= "table" then error("str.template_advanced: second argument must be a table", 2) end - - return template:gsub("%${([%w_.]+)}", function(path) - local keys = str.split(path, ".") - local value = context - - for _, key in ipairs(keys) do - if type(value) == "table" and value[key] ~= nil then - value = value[key] - else - return "" - end - end - - return tostring(value) - end) -end - -function str.is_whitespace(s) - if type(s) ~= "string" then error("str.is_whitespace: argument must be a string", 2) end - return s:match("^%s*$") ~= nil -end - -function str.strip_whitespace(s) - if type(s) ~= "string" then error("str.strip_whitespace: argument must be a string", 2) end - return s:gsub("%s", "") -end - -function str.normalize_whitespace(s) - if type(s) ~= "string" then error("str.normalize_whitespace: argument must be a string", 2) end - return str.trim(s:gsub("%s+", " ")) -end - -function str.extract_numbers(s) - if type(s) ~= "string" then error("str.extract_numbers: argument must be a string", 2) end - - local numbers = {} - for match in s:gmatch("%-?%d+%.?%d*") do - local num = tonumber(match) - if num then - table.insert(numbers, num) - end - end - return numbers -end - -function str.remove_accents(s) - if type(s) ~= "string" then error("str.remove_accents: argument must be a string", 2) end - - local accents = { - ["à"] = "a", ["á"] = "a", ["â"] = "a", ["ã"] = "a", ["ä"] = "a", ["å"] = "a", - ["è"] = "e", ["é"] = "e", ["ê"] = "e", ["ë"] = "e", - ["ì"] = "i", ["í"] = "i", ["î"] = "i", ["ï"] = "i", - ["ò"] = "o", ["ó"] = "o", ["ô"] = "o", ["õ"] = "o", ["ö"] = "o", - ["ù"] = "u", ["ú"] = "u", ["û"] = "u", ["ü"] = "u", - ["ñ"] = "n", ["ç"] = "c", ["ÿ"] = "y", - ["À"] = "A", ["Á"] = "A", ["Â"] = "A", ["Ã"] = "A", ["Ä"] = "A", ["Å"] = "A", - ["È"] = "E", ["É"] = "E", ["Ê"] = "E", ["Ë"] = "E", - ["Ì"] = "I", ["Í"] = "I", ["Î"] = "I", ["Ï"] = "I", - ["Ò"] = "O", ["Ó"] = "O", ["Ô"] = "O", ["Õ"] = "O", ["Ö"] = "O", - ["Ù"] = "U", ["Ú"] = "U", ["Û"] = "U", ["Ü"] = "U", - ["Ñ"] = "N", ["Ç"] = "C", ["Ÿ"] = "Y" - } - - local result = s - for accented, plain in pairs(accents) do - result = result:gsub(accented, plain) - end - return result -end - -return str \ No newline at end of file diff --git a/modules/table/table.lua b/modules/table/table.lua index b039a91..e98592c 100644 --- a/modules/table/table.lua +++ b/modules/table/table.lua @@ -1,61 +1,56 @@ -local tbl = {} +local orig_insert = table.insert +local orig_remove = table.remove +local orig_concat = table.concat +local orig_sort = table.sort --- ====================================================================== --- BUILT-IN TABLE FUNCTIONS (Lua 5.1 wrappers for consistency) --- ====================================================================== - -function tbl.insert(t, pos, value) - if type(t) ~= "table" then error("tbl.insert: first argument must be a table", 2) end +function table.insert(t, pos, value) + if type(t) ~= "table" then error("table.insert: first argument must be a table", 2) end if value == nil then -- table.insert(t, value) form - table.insert(t, pos) + orig_insert(t, pos) else -- table.insert(t, pos, value) form if type(pos) ~= "number" or pos ~= math.floor(pos) then - error("tbl.insert: position must be an integer", 2) + error("table.insert: position must be an integer", 2) end - table.insert(t, pos, value) + orig_insert(t, pos, value) end end -function tbl.remove(t, pos) - if type(t) ~= "table" then error("tbl.remove: first argument must be a table", 2) end +function table.remove(t, pos) + if type(t) ~= "table" then error("table.remove: first argument must be a table", 2) end if pos ~= nil and (type(pos) ~= "number" or pos ~= math.floor(pos)) then - error("tbl.remove: position must be an integer", 2) + error("table.remove: position must be an integer", 2) end - return table.remove(t, pos) + return orig_remove(t, pos) end -function tbl.concat(t, sep, start_idx, end_idx) - if type(t) ~= "table" then error("tbl.concat: first argument must be a table", 2) end - if sep ~= nil and type(sep) ~= "string" then error("tbl.concat: separator must be a string", 2) end +function table.concat(t, sep, start_idx, end_idx) + if type(t) ~= "table" then error("table.concat: first argument must be a table", 2) end + if sep ~= nil and type(sep) ~= "string" then error("table.concat: separator must be a string", 2) end if start_idx ~= nil and (type(start_idx) ~= "number" or start_idx ~= math.floor(start_idx)) then - error("tbl.concat: start index must be an integer", 2) + error("table.concat: start index must be an integer", 2) end if end_idx ~= nil and (type(end_idx) ~= "number" or end_idx ~= math.floor(end_idx)) then - error("tbl.concat: end index must be an integer", 2) + error("table.concat: end index must be an integer", 2) end - return table.concat(t, sep, start_idx, end_idx) + return orig_concat(t, sep, start_idx, end_idx) end -function tbl.sort(t, comp) - if type(t) ~= "table" then error("tbl.sort: first argument must be a table", 2) end - if comp ~= nil and type(comp) ~= "function" then error("tbl.sort: comparator must be a function", 2) end - table.sort(t, comp) +function table.sort(t, comp) + if type(t) ~= "table" then error("table.sort: first argument must be a table", 2) end + if comp ~= nil and type(comp) ~= "function" then error("table.sort: comparator must be a function", 2) end + orig_sort(t, comp) end --- ====================================================================== --- BASIC TABLE OPERATIONS --- ====================================================================== - -function tbl.length(t) - if type(t) ~= "table" then error("tbl.length: argument must be a table", 2) end +function table.length(t) + if type(t) ~= "table" then error("table.length: argument must be a table", 2) end return #t end -function tbl.size(t) - if type(t) ~= "table" then error("tbl.size: argument must be a table", 2) end +function table.size(t) + if type(t) ~= "table" then error("table.size: argument must be a table", 2) end local count = 0 for _ in pairs(t) do count = count + 1 @@ -63,14 +58,14 @@ function tbl.size(t) return count end -function tbl.is_empty(t) - if type(t) ~= "table" then error("tbl.is_empty: argument must be a table", 2) end +function table.is_empty(t) + if type(t) ~= "table" then error("table.is_empty: argument must be a table", 2) end return next(t) == nil end -function tbl.is_array(t) - if type(t) ~= "table" then error("tbl.is_array: argument must be a table", 2) end - if tbl.is_empty(t) then return true end +function table.is_array(t) + if type(t) ~= "table" then error("table.is_array: argument must be a table", 2) end + if table.is_empty(t) then return true end local max_index = 0 local count = 0 @@ -84,15 +79,15 @@ function tbl.is_array(t) return max_index == count end -function tbl.clear(t) - if type(t) ~= "table" then error("tbl.clear: argument must be a table", 2) end +function table.clear(t) + if type(t) ~= "table" then error("table.clear: argument must be a table", 2) end for k in pairs(t) do t[k] = nil end end -function tbl.clone(t) - if type(t) ~= "table" then error("tbl.clone: argument must be a table", 2) end +function table.clone(t) + if type(t) ~= "table" then error("table.clone: argument must be a table", 2) end local result = {} for k, v in pairs(t) do result[k] = v @@ -100,8 +95,8 @@ function tbl.clone(t) return result end -function tbl.deep_copy(t) - if type(t) ~= "table" then error("tbl.deep_copy: argument must be a table", 2) end +function table.deep_copy(t) + if type(t) ~= "table" then error("table.deep_copy: argument must be a table", 2) end local function copy_recursive(obj, seen) if type(obj) ~= "table" then return obj end @@ -120,29 +115,25 @@ function tbl.deep_copy(t) return copy_recursive(t, {}) end --- ====================================================================== --- SEARCHING AND FINDING --- ====================================================================== - -function tbl.contains(t, value) - if type(t) ~= "table" then error("tbl.contains: first argument must be a table", 2) end +function table.contains(t, value) + if type(t) ~= "table" then error("table.contains: first argument must be a table", 2) end for _, v in pairs(t) do if v == value then return true end end return false end -function tbl.index_of(t, value) - if type(t) ~= "table" then error("tbl.index_of: first argument must be a table", 2) end +function table.index_of(t, value) + if type(t) ~= "table" then error("table.index_of: first argument must be a table", 2) end for k, v in pairs(t) do if v == value then return k end end return nil end -function tbl.find(t, predicate) - if type(t) ~= "table" then error("tbl.find: first argument must be a table", 2) end - if type(predicate) ~= "function" then error("tbl.find: second argument must be a function", 2) end +function table.find(t, predicate) + if type(t) ~= "table" then error("table.find: first argument must be a table", 2) end + if type(predicate) ~= "function" then error("table.find: second argument must be a function", 2) end for k, v in pairs(t) do if predicate(v, k, t) then return v, k end @@ -150,9 +141,9 @@ function tbl.find(t, predicate) return nil end -function tbl.find_index(t, predicate) - if type(t) ~= "table" then error("tbl.find_index: first argument must be a table", 2) end - if type(predicate) ~= "function" then error("tbl.find_index: second argument must be a function", 2) end +function table.find_index(t, predicate) + if type(t) ~= "table" then error("table.find_index: first argument must be a table", 2) end + if type(predicate) ~= "function" then error("table.find_index: second argument must be a function", 2) end for k, v in pairs(t) do if predicate(v, k, t) then return k end @@ -160,8 +151,8 @@ function tbl.find_index(t, predicate) return nil end -function tbl.count(t, value_or_predicate) - if type(t) ~= "table" then error("tbl.count: first argument must be a table", 2) end +function table.count(t, value_or_predicate) + if type(t) ~= "table" then error("table.count: first argument must be a table", 2) end local count = 0 if type(value_or_predicate) == "function" then @@ -176,16 +167,12 @@ function tbl.count(t, value_or_predicate) return count end --- ====================================================================== --- FILTERING AND MAPPING --- ====================================================================== - -function tbl.filter(t, predicate) - if type(t) ~= "table" then error("tbl.filter: first argument must be a table", 2) end - if type(predicate) ~= "function" then error("tbl.filter: second argument must be a function", 2) end +function table.filter(t, predicate) + if type(t) ~= "table" then error("table.filter: first argument must be a table", 2) end + if type(predicate) ~= "function" then error("table.filter: second argument must be a function", 2) end local result = {} - if tbl.is_array(t) then + if table.is_array(t) then local max_index = 0 for k in pairs(t) do if type(k) == "number" and k > max_index then @@ -195,7 +182,7 @@ function tbl.filter(t, predicate) for i = 1, max_index do local v = t[i] if v ~= nil and predicate(v, i, t) then - table.insert(result, v) + orig_insert(result, v) end end else @@ -208,16 +195,16 @@ function tbl.filter(t, predicate) return result end -function tbl.reject(t, predicate) - if type(t) ~= "table" then error("tbl.reject: first argument must be a table", 2) end - if type(predicate) ~= "function" then error("tbl.reject: second argument must be a function", 2) end +function table.reject(t, predicate) + if type(t) ~= "table" then error("table.reject: first argument must be a table", 2) end + if type(predicate) ~= "function" then error("table.reject: second argument must be a function", 2) end - return tbl.filter(t, function(v, k, tbl) return not predicate(v, k, tbl) end) + return table.filter(t, function(v, k, tbl) return not predicate(v, k, tbl) end) end -function tbl.map(t, transformer) - if type(t) ~= "table" then error("tbl.map: first argument must be a table", 2) end - if type(transformer) ~= "function" then error("tbl.map: second argument must be a function", 2) end +function table.map(t, transformer) + if type(t) ~= "table" then error("table.map: first argument must be a table", 2) end + if type(transformer) ~= "function" then error("table.map: second argument must be a function", 2) end local result = {} for k, v in pairs(t) do @@ -226,9 +213,9 @@ function tbl.map(t, transformer) return result end -function tbl.map_values(t, transformer) - if type(t) ~= "table" then error("tbl.map_values: first argument must be a table", 2) end - if type(transformer) ~= "function" then error("tbl.map_values: second argument must be a function", 2) end +function table.map_values(t, transformer) + if type(t) ~= "table" then error("table.map_values: first argument must be a table", 2) end + if type(transformer) ~= "function" then error("table.map_values: second argument must be a function", 2) end local result = {} for k, v in pairs(t) do @@ -237,9 +224,9 @@ function tbl.map_values(t, transformer) return result end -function tbl.map_keys(t, transformer) - if type(t) ~= "table" then error("tbl.map_keys: first argument must be a table", 2) end - if type(transformer) ~= "function" then error("tbl.map_keys: second argument must be a function", 2) end +function table.map_keys(t, transformer) + if type(t) ~= "table" then error("table.map_keys: first argument must be a table", 2) end + if type(transformer) ~= "function" then error("table.map_keys: second argument must be a function", 2) end local result = {} for k, v in pairs(t) do @@ -249,13 +236,9 @@ function tbl.map_keys(t, transformer) return result end --- ====================================================================== --- REDUCING AND AGGREGATING --- ====================================================================== - -function tbl.reduce(t, reducer, initial) - if type(t) ~= "table" then error("tbl.reduce: first argument must be a table", 2) end - if type(reducer) ~= "function" then error("tbl.reduce: second argument must be a function", 2) end +function table.reduce(t, reducer, initial) + if type(t) ~= "table" then error("table.reduce: first argument must be a table", 2) end + if type(reducer) ~= "function" then error("table.reduce: second argument must be a function", 2) end local accumulator = initial local started = initial ~= nil @@ -270,39 +253,39 @@ function tbl.reduce(t, reducer, initial) end if not started then - error("tbl.reduce: empty table with no initial value", 2) + error("table.reduce: empty table with no initial value", 2) end return accumulator end -function tbl.sum(t) - if type(t) ~= "table" then error("tbl.sum: argument must be a table", 2) end +function table.sum(t) + if type(t) ~= "table" then error("table.sum: argument must be a table", 2) end local total = 0 for _, v in pairs(t) do - if type(v) ~= "number" then error("tbl.sum: all values must be numbers", 2) end + if type(v) ~= "number" then error("table.sum: all values must be numbers", 2) end total = total + v end return total end -function tbl.product(t) - if type(t) ~= "table" then error("tbl.product: argument must be a table", 2) end +function table.product(t) + if type(t) ~= "table" then error("table.product: argument must be a table", 2) end local result = 1 for _, v in pairs(t) do - if type(v) ~= "number" then error("tbl.product: all values must be numbers", 2) end + if type(v) ~= "number" then error("table.product: all values must be numbers", 2) end result = result * v end return result end -function tbl.min(t) - if type(t) ~= "table" then error("tbl.min: argument must be a table", 2) end - if tbl.is_empty(t) then error("tbl.min: table is empty", 2) end +function table.min(t) + if type(t) ~= "table" then error("table.min: argument must be a table", 2) end + if table.is_empty(t) then error("table.min: table is empty", 2) end local min_val = nil for _, v in pairs(t) do - if type(v) ~= "number" then error("tbl.min: all values must be numbers", 2) end + if type(v) ~= "number" then error("table.min: all values must be numbers", 2) end if min_val == nil or v < min_val then min_val = v end @@ -310,13 +293,13 @@ function tbl.min(t) return min_val end -function tbl.max(t) - if type(t) ~= "table" then error("tbl.max: argument must be a table", 2) end - if tbl.is_empty(t) then error("tbl.max: table is empty", 2) end +function table.max(t) + if type(t) ~= "table" then error("table.max: argument must be a table", 2) end + if table.is_empty(t) then error("table.max: table is empty", 2) end local max_val = nil for _, v in pairs(t) do - if type(v) ~= "number" then error("tbl.max: all values must be numbers", 2) end + if type(v) ~= "number" then error("table.max: all values must be numbers", 2) end if max_val == nil or v > max_val then max_val = v end @@ -324,21 +307,17 @@ function tbl.max(t) return max_val end -function tbl.average(t) - if type(t) ~= "table" then error("tbl.average: argument must be a table", 2) end - if tbl.is_empty(t) then error("tbl.average: table is empty", 2) end - return tbl.sum(t) / tbl.size(t) +function table.average(t) + if type(t) ~= "table" then error("table.average: argument must be a table", 2) end + if table.is_empty(t) then error("table.average: table is empty", 2) end + return table.sum(t) / table.size(t) end --- ====================================================================== --- BOOLEAN OPERATIONS --- ====================================================================== - -function tbl.all(t, predicate) - if type(t) ~= "table" then error("tbl.all: first argument must be a table", 2) end +function table.all(t, predicate) + if type(t) ~= "table" then error("table.all: first argument must be a table", 2) end if predicate then - if type(predicate) ~= "function" then error("tbl.all: second argument must be a function", 2) end + if type(predicate) ~= "function" then error("table.all: second argument must be a function", 2) end for k, v in pairs(t) do if not predicate(v, k, t) then return false end end @@ -350,11 +329,11 @@ function tbl.all(t, predicate) return true end -function tbl.any(t, predicate) - if type(t) ~= "table" then error("tbl.any: first argument must be a table", 2) end +function table.any(t, predicate) + if type(t) ~= "table" then error("table.any: first argument must be a table", 2) end if predicate then - if type(predicate) ~= "function" then error("tbl.any: second argument must be a function", 2) end + if type(predicate) ~= "function" then error("table.any: second argument must be a function", 2) end for k, v in pairs(t) do if predicate(v, k, t) then return true end end @@ -366,26 +345,22 @@ function tbl.any(t, predicate) return false end -function tbl.none(t, predicate) - if type(t) ~= "table" then error("tbl.none: first argument must be a table", 2) end - return not tbl.any(t, predicate) +function table.none(t, predicate) + if type(t) ~= "table" then error("table.none: first argument must be a table", 2) end + return not table.any(t, predicate) end --- ====================================================================== --- SET OPERATIONS --- ====================================================================== - -function tbl.unique(t) - if type(t) ~= "table" then error("tbl.unique: argument must be a table", 2) end +function table.unique(t) + if type(t) ~= "table" then error("table.unique: argument must be a table", 2) end local seen = {} local result = {} - if tbl.is_array(t) then + if table.is_array(t) then for _, v in ipairs(t) do if not seen[v] then seen[v] = true - table.insert(result, v) + orig_insert(result, v) end end else @@ -400,9 +375,9 @@ function tbl.unique(t) return result end -function tbl.intersection(t1, t2) - if type(t1) ~= "table" then error("tbl.intersection: first argument must be a table", 2) end - if type(t2) ~= "table" then error("tbl.intersection: second argument must be a table", 2) end +function table.intersection(t1, t2) + if type(t1) ~= "table" then error("table.intersection: first argument must be a table", 2) end + if type(t2) ~= "table" then error("table.intersection: second argument must be a table", 2) end local set2 = {} for _, v in pairs(t2) do @@ -410,10 +385,10 @@ function tbl.intersection(t1, t2) end local result = {} - if tbl.is_array(t1) then + if table.is_array(t1) then for _, v in ipairs(t1) do if set2[v] then - table.insert(result, v) + orig_insert(result, v) end end else @@ -427,16 +402,16 @@ function tbl.intersection(t1, t2) return result end -function tbl.union(t1, t2) - if type(t1) ~= "table" then error("tbl.union: first argument must be a table", 2) end - if type(t2) ~= "table" then error("tbl.union: second argument must be a table", 2) end +function table.union(t1, t2) + if type(t1) ~= "table" then error("table.union: first argument must be a table", 2) end + if type(t2) ~= "table" then error("table.union: second argument must be a table", 2) end - local result = tbl.clone(t1) + local result = table.clone(t1) - if tbl.is_array(t1) and tbl.is_array(t2) then + if table.is_array(t1) and table.is_array(t2) then for _, v in ipairs(t2) do - if not tbl.contains(result, v) then - table.insert(result, v) + if not table.contains(result, v) then + orig_insert(result, v) end end else @@ -450,25 +425,21 @@ function tbl.union(t1, t2) return result end -function tbl.difference(t1, t2) - if type(t1) ~= "table" then error("tbl.difference: first argument must be a table", 2) end - if type(t2) ~= "table" then error("tbl.difference: second argument must be a table", 2) end +function table.difference(t1, t2) + if type(t1) ~= "table" then error("table.difference: first argument must be a table", 2) end + if type(t2) ~= "table" then error("table.difference: second argument must be a table", 2) end local set2 = {} for _, v in pairs(t2) do set2[v] = true end - return tbl.filter(t1, function(v) return not set2[v] end) + return table.filter(t1, function(v) return not set2[v] end) end --- ====================================================================== --- ARRAY OPERATIONS --- ====================================================================== - -function tbl.reverse(t) - if type(t) ~= "table" then error("tbl.reverse: argument must be a table", 2) end - if not tbl.is_array(t) then error("tbl.reverse: argument must be an array", 2) end +function table.reverse(t) + if type(t) ~= "table" then error("table.reverse: argument must be a table", 2) end + if not table.is_array(t) then error("table.reverse: argument must be an array", 2) end local result = {} local len = #t @@ -478,11 +449,11 @@ function tbl.reverse(t) return result end -function tbl.shuffle(t) - if type(t) ~= "table" then error("tbl.shuffle: argument must be a table", 2) end - if not tbl.is_array(t) then error("tbl.shuffle: argument must be an array", 2) end +function table.shuffle(t) + if type(t) ~= "table" then error("table.shuffle: argument must be a table", 2) end + if not table.is_array(t) then error("table.shuffle: argument must be an array", 2) end - local result = tbl.clone(t) + local result = table.clone(t) local len = #result math.randomseed(os.time() + os.clock() * 1000000) @@ -495,18 +466,18 @@ function tbl.shuffle(t) return result end -function tbl.rotate(t, positions) - if type(t) ~= "table" then error("tbl.rotate: first argument must be a table", 2) end - if not tbl.is_array(t) then error("tbl.rotate: first argument must be an array", 2) end +function table.rotate(t, positions) + if type(t) ~= "table" then error("table.rotate: first argument must be a table", 2) end + if not table.is_array(t) then error("table.rotate: first argument must be an array", 2) end if type(positions) ~= "number" or positions ~= math.floor(positions) then - error("tbl.rotate: second argument must be an integer", 2) + error("table.rotate: second argument must be an integer", 2) end local len = #t if len == 0 then return {} end positions = positions % len - if positions == 0 then return tbl.clone(t) end + if positions == 0 then return table.clone(t) end local result = {} for i = 1, len do @@ -517,14 +488,14 @@ function tbl.rotate(t, positions) return result end -function tbl.slice(t, start_idx, end_idx) - if type(t) ~= "table" then error("tbl.slice: first argument must be a table", 2) end - if not tbl.is_array(t) then error("tbl.slice: first argument must be an array", 2) end +function table.slice(t, start_idx, end_idx) + if type(t) ~= "table" then error("table.slice: first argument must be a table", 2) end + if not table.is_array(t) then error("table.slice: first argument must be an array", 2) end if type(start_idx) ~= "number" or start_idx ~= math.floor(start_idx) then - error("tbl.slice: start index must be an integer", 2) + error("table.slice: start index must be an integer", 2) end if end_idx ~= nil and (type(end_idx) ~= "number" or end_idx ~= math.floor(end_idx)) then - error("tbl.slice: end index must be an integer", 2) + error("table.slice: end index must be an integer", 2) end local len = #t @@ -536,20 +507,20 @@ function tbl.slice(t, start_idx, end_idx) local result = {} for i = start_idx, end_idx do - table.insert(result, t[i]) + orig_insert(result, t[i]) end return result end -function tbl.splice(t, start_idx, delete_count, ...) - if type(t) ~= "table" then error("tbl.splice: first argument must be a table", 2) end - if not tbl.is_array(t) then error("tbl.splice: first argument must be an array", 2) end +function table.splice(t, start_idx, delete_count, ...) + if type(t) ~= "table" then error("table.splice: first argument must be a table", 2) end + if not table.is_array(t) then error("table.splice: first argument must be an array", 2) end if type(start_idx) ~= "number" or start_idx ~= math.floor(start_idx) then - error("tbl.splice: start index must be an integer", 2) + error("table.splice: start index must be an integer", 2) end if delete_count ~= nil and (type(delete_count) ~= "number" or delete_count ~= math.floor(delete_count) or delete_count < 0) then - error("tbl.splice: delete count must be a non-negative integer", 2) + error("table.splice: delete count must be a non-negative integer", 2) end local len = #t @@ -593,26 +564,22 @@ function tbl.splice(t, start_idx, delete_count, ...) return deleted end --- ====================================================================== --- SORTING HELPERS --- ====================================================================== +function table.sort_by(t, key_func) + if type(t) ~= "table" then error("table.sort_by: first argument must be a table", 2) end + if not table.is_array(t) then error("table.sort_by: first argument must be an array", 2) end + if type(key_func) ~= "function" then error("table.sort_by: second argument must be a function", 2) end -function tbl.sort_by(t, key_func) - if type(t) ~= "table" then error("tbl.sort_by: first argument must be a table", 2) end - if not tbl.is_array(t) then error("tbl.sort_by: first argument must be an array", 2) end - if type(key_func) ~= "function" then error("tbl.sort_by: second argument must be a function", 2) end - - local result = tbl.clone(t) - table.sort(result, function(a, b) + local result = table.clone(t) + orig_sort(result, function(a, b) return key_func(a) < key_func(b) end) return result end -function tbl.is_sorted(t, comp) - if type(t) ~= "table" then error("tbl.is_sorted: first argument must be a table", 2) end - if not tbl.is_array(t) then error("tbl.is_sorted: first argument must be an array", 2) end - if comp ~= nil and type(comp) ~= "function" then error("tbl.is_sorted: comparator must be a function", 2) end +function table.is_sorted(t, comp) + if type(t) ~= "table" then error("table.is_sorted: first argument must be a table", 2) end + if not table.is_array(t) then error("table.is_sorted: first argument must be an array", 2) end + if comp ~= nil and type(comp) ~= "function" then error("table.is_sorted: comparator must be a function", 2) end comp = comp or function(a, b) return a < b end @@ -624,47 +591,43 @@ function tbl.is_sorted(t, comp) return true end --- ====================================================================== --- UTILITY FUNCTIONS --- ====================================================================== - -function tbl.keys(t) - if type(t) ~= "table" then error("tbl.keys: argument must be a table", 2) end +function table.keys(t) + if type(t) ~= "table" then error("table.keys: argument must be a table", 2) end local result = {} for k, _ in pairs(t) do - table.insert(result, k) + orig_insert(result, k) end return result end -function tbl.values(t) - if type(t) ~= "table" then error("tbl.values: argument must be a table", 2) end +function table.values(t) + if type(t) ~= "table" then error("table.values: argument must be a table", 2) end local result = {} for _, v in pairs(t) do - table.insert(result, v) + orig_insert(result, v) end return result end -function tbl.pairs(t) - if type(t) ~= "table" then error("tbl.pairs: argument must be a table", 2) end +function table.pairs(t) + if type(t) ~= "table" then error("table.pairs: argument must be a table", 2) end local result = {} for k, v in pairs(t) do - table.insert(result, {k, v}) + orig_insert(result, {k, v}) end return result end -function tbl.merge(...) +function table.merge(...) local tables = {...} if #tables == 0 then return {} end for i, t in ipairs(tables) do if type(t) ~= "table" then - error("tbl.merge: argument " .. i .. " must be a table", 2) + error("table.merge: argument " .. i .. " must be a table", 2) end end @@ -677,13 +640,13 @@ function tbl.merge(...) return result end -function tbl.extend(t1, ...) - if type(t1) ~= "table" then error("tbl.extend: first argument must be a table", 2) end +function table.extend(t1, ...) + if type(t1) ~= "table" then error("table.extend: first argument must be a table", 2) end local tables = {...} for i, t in ipairs(tables) do if type(t) ~= "table" then - error("tbl.extend: argument " .. (i + 1) .. " must be a table", 2) + error("table.extend: argument " .. (i + 1) .. " must be a table", 2) end end @@ -695,8 +658,8 @@ function tbl.extend(t1, ...) return t1 end -function tbl.invert(t) - if type(t) ~= "table" then error("tbl.invert: argument must be a table", 2) end +function table.invert(t) + if type(t) ~= "table" then error("table.invert: argument must be a table", 2) end local result = {} for k, v in pairs(t) do @@ -705,8 +668,8 @@ function tbl.invert(t) return result end -function tbl.pick(t, ...) - if type(t) ~= "table" then error("tbl.pick: first argument must be a table", 2) end +function table.pick(t, ...) + if type(t) ~= "table" then error("table.pick: first argument must be a table", 2) end local keys = {...} local result = {} @@ -720,8 +683,8 @@ function tbl.pick(t, ...) return result end -function tbl.omit(t, ...) - if type(t) ~= "table" then error("tbl.omit: first argument must be a table", 2) end +function table.omit(t, ...) + if type(t) ~= "table" then error("table.omit: first argument must be a table", 2) end local omit_keys = {} for _, key in ipairs({...}) do @@ -738,13 +701,9 @@ function tbl.omit(t, ...) return result end --- ====================================================================== --- DEEP OPERATIONS --- ====================================================================== - -function tbl.deep_equals(t1, t2) - if type(t1) ~= "table" then error("tbl.deep_equals: first argument must be a table", 2) end - if type(t2) ~= "table" then error("tbl.deep_equals: second argument must be a table", 2) end +function table.deep_equals(t1, t2) + if type(t1) ~= "table" then error("table.deep_equals: first argument must be a table", 2) end + if type(t2) ~= "table" then error("table.deep_equals: second argument must be a table", 2) end local function equals_recursive(a, b, seen) if a == b then return true end @@ -780,11 +739,11 @@ function tbl.deep_equals(t1, t2) return equals_recursive(t1, t2, {}) end -function tbl.flatten(t, depth) - if type(t) ~= "table" then error("tbl.flatten: first argument must be a table", 2) end - if not tbl.is_array(t) then error("tbl.flatten: first argument must be an array", 2) end +function table.flatten(t, depth) + if type(t) ~= "table" then error("table.flatten: first argument must be a table", 2) end + if not table.is_array(t) then error("table.flatten: first argument must be an array", 2) end if depth ~= nil and (type(depth) ~= "number" or depth ~= math.floor(depth) or depth < 1) then - error("tbl.flatten: depth must be a positive integer", 2) + error("table.flatten: depth must be a positive integer", 2) end depth = depth or 1 @@ -792,13 +751,13 @@ function tbl.flatten(t, depth) local function flatten_recursive(arr, current_depth) local result = {} for _, v in ipairs(arr) do - if type(v) == "table" and tbl.is_array(v) and current_depth > 0 then + if type(v) == "table" and table.is_array(v) and current_depth > 0 then local flattened = flatten_recursive(v, current_depth - 1) for _, item in ipairs(flattened) do - table.insert(result, item) + orig_insert(result, item) end else - table.insert(result, v) + orig_insert(result, v) end end return result @@ -807,13 +766,13 @@ function tbl.flatten(t, depth) return flatten_recursive(t, depth) end -function tbl.deep_merge(...) +function table.deep_merge(...) local tables = {...} if #tables == 0 then return {} end for i, t in ipairs(tables) do if type(t) ~= "table" then - error("tbl.deep_merge: argument " .. i .. " must be a table", 2) + error("table.deep_merge: argument " .. i .. " must be a table", 2) end end @@ -822,13 +781,13 @@ function tbl.deep_merge(...) if type(v) == "table" and type(target[k]) == "table" then target[k] = merge_recursive(target[k], v) else - target[k] = type(v) == "table" and tbl.deep_copy(v) or v + target[k] = type(v) == "table" and table.deep_copy(v) or v end end return target end - local result = tbl.deep_copy(tables[1]) + local result = table.deep_copy(tables[1]) for i = 2, #tables do result = merge_recursive(result, tables[i]) end @@ -836,15 +795,11 @@ function tbl.deep_merge(...) return result end --- ====================================================================== --- ADVANCED OPERATIONS --- ====================================================================== - -function tbl.chunk(t, size) - if type(t) ~= "table" then error("tbl.chunk: first argument must be a table", 2) end - if not tbl.is_array(t) then error("tbl.chunk: first argument must be an array", 2) end +function table.chunk(t, size) + if type(t) ~= "table" then error("table.chunk: first argument must be a table", 2) end + if not table.is_array(t) then error("table.chunk: first argument must be an array", 2) end if type(size) ~= "number" or size ~= math.floor(size) or size <= 0 then - error("tbl.chunk: size must be a positive integer", 2) + error("table.chunk: size must be a positive integer", 2) end local result = {} @@ -853,26 +808,26 @@ function tbl.chunk(t, size) for i = 1, len, size do local chunk = {} for j = i, math.min(i + size - 1, len) do - table.insert(chunk, t[j]) + orig_insert(chunk, t[j]) end - table.insert(result, chunk) + orig_insert(result, chunk) end return result end -function tbl.partition(t, predicate) - if type(t) ~= "table" then error("tbl.partition: first argument must be a table", 2) end - if type(predicate) ~= "function" then error("tbl.partition: second argument must be a function", 2) end +function table.partition(t, predicate) + if type(t) ~= "table" then error("table.partition: first argument must be a table", 2) end + if type(predicate) ~= "function" then error("table.partition: second argument must be a function", 2) end local truthy, falsy = {}, {} - if tbl.is_array(t) then + if table.is_array(t) then for i, v in ipairs(t) do if predicate(v, i, t) then - table.insert(truthy, v) + orig_insert(truthy, v) else - table.insert(falsy, v) + orig_insert(falsy, v) end end else @@ -888,9 +843,9 @@ function tbl.partition(t, predicate) return truthy, falsy end -function tbl.group_by(t, key_func) - if type(t) ~= "table" then error("tbl.group_by: first argument must be a table", 2) end - if type(key_func) ~= "function" then error("tbl.group_by: second argument must be a function", 2) end +function table.group_by(t, key_func) + if type(t) ~= "table" then error("table.group_by: first argument must be a table", 2) end + if type(key_func) ~= "function" then error("table.group_by: second argument must be a function", 2) end local result = {} @@ -900,8 +855,8 @@ function tbl.group_by(t, key_func) result[group_key] = {} end - if tbl.is_array(t) then - table.insert(result[group_key], v) + if table.is_array(t) then + orig_insert(result[group_key], v) else result[group_key][k] = v end @@ -910,16 +865,16 @@ function tbl.group_by(t, key_func) return result end -function tbl.zip(...) +function table.zip(...) local arrays = {...} - if #arrays == 0 then error("tbl.zip: at least one argument required", 2) end + if #arrays == 0 then error("table.zip: at least one argument required", 2) end for i, arr in ipairs(arrays) do if type(arr) ~= "table" then - error("tbl.zip: argument " .. i .. " must be a table", 2) + error("table.zip: argument " .. i .. " must be a table", 2) end - if not tbl.is_array(arr) then - error("tbl.zip: argument " .. i .. " must be an array", 2) + if not table.is_array(arr) then + error("table.zip: argument " .. i .. " must be an array", 2) end end @@ -932,16 +887,16 @@ function tbl.zip(...) for i = 1, min_length do local tuple = {} for j = 1, #arrays do - table.insert(tuple, arrays[j][i]) + orig_insert(tuple, arrays[j][i]) end - table.insert(result, tuple) + orig_insert(result, tuple) end return result end -function tbl.compact(t) - if type(t) ~= "table" then error("tbl.compact: argument must be a table", 2) end +function table.compact(t) + if type(t) ~= "table" then error("table.compact: argument must be a table", 2) end -- Check if table has only integer keys (array-like) local has_only_int_keys = true @@ -960,34 +915,34 @@ function tbl.compact(t) for i = 1, max_key do local v = t[i] if v ~= nil and v ~= false then - table.insert(result, v) + orig_insert(result, v) end end return result else -- Regular table filtering - return tbl.filter(t, function(v) return v ~= nil and v ~= false end) + return table.filter(t, function(v) return v ~= nil and v ~= false end) end end -function tbl.sample(t, n) - if type(t) ~= "table" then error("tbl.sample: first argument must be a table", 2) end - if not tbl.is_array(t) then error("tbl.sample: first argument must be an array", 2) end +function table.sample(t, n) + if type(t) ~= "table" then error("table.sample: first argument must be a table", 2) end + if not table.is_array(t) then error("table.sample: first argument must be an array", 2) end if n ~= nil and (type(n) ~= "number" or n ~= math.floor(n) or n < 0) then - error("tbl.sample: sample size must be a non-negative integer", 2) + error("table.sample: sample size must be a non-negative integer", 2) end n = n or 1 local len = #t - if n >= len then return tbl.clone(t) end + if n >= len then return table.clone(t) end - local shuffled = tbl.shuffle(t) - return tbl.slice(shuffled, 1, n) + local shuffled = table.shuffle(t) + return table.slice(shuffled, 1, n) end -function tbl.fold(t, folder, initial) - if type(t) ~= "table" then error("tbl.fold: first argument must be a table", 2) end - if type(folder) ~= "function" then error("tbl.fold: second argument must be a function", 2) end +function table.fold(t, folder, initial) + if type(t) ~= "table" then error("table.fold: first argument must be a table", 2) end + if type(folder) ~= "function" then error("table.fold: second argument must be a function", 2) end local accumulator = initial for k, v in pairs(t) do @@ -995,5 +950,3 @@ function tbl.fold(t, folder, initial) end return accumulator end - -return tbl diff --git a/tests/kv.lua b/tests/kv.lua index b5740ab..236fe28 100644 --- a/tests/kv.lua +++ b/tests/kv.lua @@ -89,7 +89,11 @@ test("Clear store", function() end) test("Save and close operations", function() - kv.set("test", "persistent", "data") + -- Ensure store is properly opened with filename + kv.close("test") -- Close if already open + assert(kv.open("test", "test_store.json")) + + assert(kv.set("test", "persistent", "data")) assert(kv.save("test")) assert(kv.close("test")) @@ -350,9 +354,9 @@ test("Multiple store integration", function() end) -- Clean up test files ---os.remove("test_store.json") ---os.remove("test_oop.json") ---os.remove("test_temp.json") +os.remove("test_store.json") +os.remove("test_oop.json") +os.remove("test_temp.json") summary() test_exit() diff --git a/tests/sessions.lua b/tests/sessions.lua index 830d7b2..d914918 100644 --- a/tests/sessions.lua +++ b/tests/sessions.lua @@ -390,8 +390,8 @@ test("Session workflow integration", function() end) -- Clean up test files ---os.remove("test_sessions.json") ---os.remove("test_sessions2.json") +os.remove("test_sessions.json") +os.remove("test_sessions2.json") summary() test_exit() diff --git a/tests/string.lua b/tests/string.lua index 7ebe1a5..56f161a 100644 --- a/tests/string.lua +++ b/tests/string.lua @@ -1,345 +1,612 @@ require("tests") -local str = require("string") -- Test data local test_string = "Hello, World!" local multi_line = "Line 1\nLine 2\nLine 3" local padded_string = " Hello World " +local unicode_string = "café naïve résumé" +local mixed_case = "hELLo WoRLd" -- ====================================================================== -- BASIC STRING OPERATIONS -- ====================================================================== -test("String Split and Join", function() - local parts = str.split("a,b,c,d", ",") - assert_equal("table", type(parts)) +test("string.split", function() + -- Basic split + local parts = string.split("a,b,c,d", ",") assert_equal(4, #parts) assert_equal("a", parts[1]) assert_equal("d", parts[4]) - - local joined = str.join(parts, "-") - assert_equal("a-b-c-d", joined) - - -- Test empty split - local empty_parts = str.split("", ",") + + -- Empty delimiter (character split) + local chars = string.split("abc", "") + assert_equal(3, #chars) + assert_equal("a", chars[1]) + assert_equal("c", chars[3]) + + -- Empty string + local empty_parts = string.split("", ",") assert_equal(1, #empty_parts) assert_equal("", empty_parts[1]) + + -- No matches + local no_match = string.split("hello", ",") + assert_equal(1, #no_match) + assert_equal("hello", no_match[1]) + + -- Multiple consecutive delimiters + local multiple = string.split("a,,b,", ",") + assert_equal(4, #multiple) + assert_equal("a", multiple[1]) + assert_equal("", multiple[2]) + assert_equal("b", multiple[3]) + assert_equal("", multiple[4]) end) -test("String Trim Operations", function() - assert_equal("Hello World", str.trim(padded_string)) - assert_equal("Hello World ", str.trim_left(padded_string)) - assert_equal(" Hello World", str.trim_right(padded_string)) - +test("string.join", function() + assert_equal("a-b-c", string.join({"a", "b", "c"}, "-")) + assert_equal("abc", string.join({"a", "b", "c"}, "")) + assert_equal("", string.join({}, ",")) + assert_equal("a", string.join({"a"}, ",")) + + -- Mixed types (should convert to string) + assert_equal("1,2,3", string.join({1, 2, 3}, ",")) +end) + +test("string.trim", function() + assert_equal("Hello World", string.trim(padded_string)) + assert_equal("", string.trim("")) + assert_equal("", string.trim(" ")) + assert_equal("hello", string.trim("hello")) + assert_equal("a b", string.trim(" a b ")) + assert_equal("a b", string.trim("xxxa bxxx", "x")) +end) + +test("string.trim_left", function() + assert_equal("Hello World ", string.trim_left(padded_string)) + assert_equal("hello", string.trim_left("hello")) + assert_equal("", string.trim_left("")) + -- Custom cutset - assert_equal("Helloxxx", str.trim_left("xxxHelloxxx", "x")) - assert_equal("xxxHello", str.trim_right("xxxHelloxxx", "x")) + assert_equal("Helloxxx", string.trim_left("xxxHelloxxx", "x")) + assert_equal("yHelloxxx", string.trim_left("xyHelloxxx", "x")) + assert_equal("", string.trim_left("xxxx", "x")) end) -test("Case Operations", function() - assert_equal("HELLO", str.upper("hello")) - assert_equal("hello", str.lower("HELLO")) - assert_equal("Hello World", str.title("hello world")) - - -- Test with mixed content - assert_equal("HELLO123!", str.upper("Hello123!")) - assert_equal("hello123!", str.lower("HELLO123!")) +test("string.trim_right", function() + assert_equal(" Hello World", string.trim_right(padded_string)) + assert_equal("hello", string.trim_right("hello")) + assert_equal("", string.trim_right("")) + + -- Custom cutset + assert_equal("xxxHello", string.trim_right("xxxHelloxxx", "x")) + assert_equal("", string.trim_right("xxxx", "x")) end) -test("String Contains and Position", function() - assert_equal(true, str.contains(test_string, "World")) - assert_equal(false, str.contains(test_string, "world")) - assert_equal(true, str.starts_with(test_string, "Hello")) - assert_equal(false, str.starts_with(test_string, "hello")) - assert_equal(true, str.ends_with(test_string, "!")) - assert_equal(false, str.ends_with(test_string, "?")) +test("string.upper", function() + assert_equal("HELLO", string.upper("hello")) + assert_equal("HELLO123!", string.upper("Hello123!")) + assert_equal("", string.upper("")) + assert_equal("ABC", string.upper("abc")) end) -test("String Replace", function() - assert_equal("hi world hi", str.replace("hello world hello", "hello", "hi")) - assert_equal("hi world hello", str.replace_n("hello world hello", "hello", "hi", 1)) - - -- Test with no matches - assert_equal("hello", str.replace("hello", "xyz", "abc")) +test("string.lower", function() + assert_equal("hello", string.lower("HELLO")) + assert_equal("hello123!", string.lower("HELLO123!")) + assert_equal("", string.lower("")) + assert_equal("abc", string.lower("ABC")) end) -test("String Index Operations", function() - assert_equal(7, str.index("hello world", "world")) - assert_equal(nil, str.index("hello world", "xyz")) - assert_equal(7, str.last_index("hello hello", "hello")) - assert_equal(3, str.count("hello hello hello", "hello")) +test("string.title", function() + assert_equal("Hello World", string.title("hello world")) + assert_equal("Hello World", string.title("HELLO WORLD")) + assert_equal("", string.title("")) + assert_equal("A", string.title("a")) + assert_equal("Test_Case", string.title("test_case")) end) -test("String Repeat and Reverse", function() - assert_equal("abcabcabc", str.repeat_("abc", 3)) - assert_equal("", str.repeat_("x", 0)) - assert_equal("olleh", str.reverse("hello")) - assert_equal("", str.reverse("")) +test("string.contains", function() + assert_equal(true, string.contains(test_string, "World")) + assert_equal(false, string.contains(test_string, "world")) + assert_equal(true, string.contains(test_string, "")) + assert_equal(false, string.contains("", "a")) + assert_equal(true, string.contains("abc", "b")) end) -test("String Length Operations", function() - assert_equal(5, str.length("hello")) - assert_equal(5, str.byte_length("hello")) - assert_equal(0, str.length("")) - - -- Test Unicode - local unicode_str = "héllo" - assert_equal(5, str.length(unicode_str)) - assert_equal(6, str.byte_length(unicode_str)) -- é takes 2 bytes in UTF-8 +test("string.starts_with", function() + assert_equal(true, string.starts_with(test_string, "Hello")) + assert_equal(false, string.starts_with(test_string, "hello")) + assert_equal(true, string.starts_with(test_string, "")) + assert_equal(false, string.starts_with("", "a")) + assert_equal(true, string.starts_with("hello", "h")) end) -test("String Lines and Words", function() - local lines = str.lines(multi_line) +test("string.ends_with", function() + assert_equal(true, string.ends_with(test_string, "!")) + assert_equal(false, string.ends_with(test_string, "?")) + assert_equal(true, string.ends_with(test_string, "")) + assert_equal(false, string.ends_with("", "a")) + assert_equal(true, string.ends_with("hello", "o")) +end) + +test("string.replace", function() + assert_equal("hi world hi", string.replace("hello world hello", "hello", "hi")) + assert_equal("hello", string.replace("hello", "xyz", "abc")) + assert_equal("", string.replace("hello", "hello", "")) + assert_equal("xyzxyz", string.replace("abcabc", "abc", "xyz")) + + -- Special characters + assert_equal("h*llo", string.replace("hello", "e", "*")) +end) + +test("string.replace_n", function() + assert_equal("hi world hello", string.replace_n("hello world hello", "hello", "hi", 1)) + assert_equal("hi world hi", string.replace_n("hello world hello", "hello", "hi", 2)) + assert_equal("hello world hello", string.replace_n("hello world hello", "hello", "hi", 0)) + assert_equal("hello world hello", string.replace_n("hello world hello", "xyz", "hi", 5)) +end) + +test("string.index", function() + assert_equal(7, string.index("hello world", "world")) + assert_equal(nil, string.index("hello world", "xyz")) + assert_equal(1, string.index("hello", "h")) + assert_equal(5, string.index("hello", "o")) + assert_equal(1, string.index("hello", "")) +end) + +test("string.last_index", function() + assert_equal(7, string.last_index("hello hello", "hello")) + assert_equal(nil, string.last_index("hello world", "xyz")) + assert_equal(1, string.last_index("hello", "h")) + assert_equal(9, string.last_index("hello o o", "o")) +end) + +test("string.count", function() + assert_equal(3, string.count("hello hello hello", "hello")) + assert_equal(0, string.count("hello world", "xyz")) + assert_equal(2, string.count("hello", "l")) + assert_equal(6, string.count("hello", "")) +end) + +test("string.repeat_", function() + assert_equal("abcabcabc", string.repeat_("abc", 3)) + assert_equal("", string.repeat_("x", 0)) + assert_equal("x", string.repeat_("x", 1)) + assert_equal("", string.repeat_("", 5)) +end) + +test("string.reverse", function() + assert_equal("olleh", string.reverse("hello")) + assert_equal("", string.reverse("")) + assert_equal("a", string.reverse("a")) + assert_equal("dcba", string.reverse("abcd")) + + -- Test with Go fallback for longer strings + local long_str = string.rep("abc", 50) + local reversed = string.reverse(long_str) + assert_equal(string.length(long_str), string.length(reversed)) +end) + +test("string.length", function() + assert_equal(5, string.length("hello")) + assert_equal(0, string.length("")) + assert_equal(1, string.length("a")) + + -- Unicode length + assert_equal(4, string.length("café")) + assert_equal(17, string.length(unicode_string)) +end) + +test("string.byte_length", function() + assert_equal(5, string.byte_length("hello")) + assert_equal(0, string.byte_length("")) + assert_equal(1, string.byte_length("a")) + + -- Unicode byte length (UTF-8) + assert_equal(5, string.byte_length("café")) -- é is 2 bytes + assert_equal(21, string.byte_length(unicode_string)) -- accented chars are 2 bytes each +end) + +test("string.lines", function() + local lines = string.lines(multi_line) assert_equal(3, #lines) assert_equal("Line 1", lines[1]) assert_equal("Line 3", lines[3]) - - local words = str.words("Hello world test") + + -- Empty string + assert_table_equal({""}, string.lines("")) + + -- Different line endings + assert_table_equal({"a", "b"}, string.lines("a\nb")) + assert_table_equal({"a", "b"}, string.lines("a\r\nb")) + assert_table_equal({"a", "b"}, string.lines("a\rb")) + + -- Trailing newline + assert_table_equal({"a", "b"}, string.lines("a\nb\n")) +end) + +test("string.words", function() + local words = string.words("Hello world test") assert_equal(3, #words) assert_equal("Hello", words[1]) assert_equal("test", words[3]) - - -- Test with extra whitespace - local words2 = str.words(" Hello world ") - assert_equal(2, #words2) + + -- Extra whitespace + assert_table_equal({"Hello", "world"}, string.words(" Hello world ")) + + -- Empty string + assert_table_equal({}, string.words("")) + assert_table_equal({}, string.words(" ")) + + -- Single word + assert_table_equal({"hello"}, string.words("hello")) end) -test("String Padding", function() - assert_equal(" hi", str.pad_left("hi", 5)) - assert_equal("hi ", str.pad_right("hi", 5)) - assert_equal("000hi", str.pad_left("hi", 5, "0")) - assert_equal("hi***", str.pad_right("hi", 5, "*")) - - -- Test when string is already long enough - assert_equal("hello", str.pad_left("hello", 3)) +test("string.pad_left", function() + assert_equal(" hi", string.pad_left("hi", 5)) + assert_equal("000hi", string.pad_left("hi", 5, "0")) + assert_equal("hello", string.pad_left("hello", 3)) + assert_equal("hi", string.pad_left("hi", 2)) + assert_equal("hi", string.pad_left("hi", 0)) + + -- Unicode padding + assert_equal(" café", string.pad_left("café", 6)) end) -test("String Slice", function() - assert_equal("ell", str.slice("hello", 2, 4)) - assert_equal("ello", str.slice("hello", 2)) - assert_equal("", str.slice("hello", 10)) - assert_equal("h", str.slice("hello", 1, 1)) +test("string.pad_right", function() + assert_equal("hi ", string.pad_right("hi", 5)) + assert_equal("hi***", string.pad_right("hi", 5, "*")) + assert_equal("hello", string.pad_right("hello", 3)) + assert_equal("hi", string.pad_right("hi", 2)) + assert_equal("hi", string.pad_right("hi", 0)) + + -- Unicode padding + assert_equal("café ", string.pad_right("café", 6)) +end) + +test("string.slice", function() + assert_equal("ell", string.slice("hello", 2, 4)) + assert_equal("ello", string.slice("hello", 2)) + assert_equal("", string.slice("hello", 10)) + assert_equal("h", string.slice("hello", 1, 1)) + assert_equal("", string.slice("hello", 3, 2)) + + -- Negative indices + assert_equal("lo", string.slice("hello", 4, -1)) + + -- Unicode slicing + assert_equal("afé", string.slice("café", 2, 4)) end) -- ====================================================================== -- REGULAR EXPRESSIONS -- ====================================================================== -test("Regex Match", function() - assert_equal(true, str.match("\\d+", "hello123")) - assert_equal(false, str.match("\\d+", "hello")) - assert_equal(true, str.match("^[a-z]+$", "hello")) - assert_equal(false, str.match("^[a-z]+$", "Hello")) +test("string.match", function() + assert_equal(true, string.match("hello123", "%d+") ~= nil) + assert_equal(false, string.match("hello", "%d+") ~= nil) + assert_equal(true, string.match("hello", "^[a-z]+$") ~= nil) + assert_equal(false, string.match("Hello", "^[a-z]+$") ~= nil) + assert_equal(true, string.match("testing", "test") ~= nil) end) -test("Regex Find", function() - assert_equal("123", str.find("\\d+", "hello123world")) - assert_equal(nil, str.find("\\d+", "hello")) - - local matches = str.find_all("\\d+", "123 and 456 and 789") +test("string.find", function() + assert_equal("123", string.find("hello123world", "%d+")) + assert_equal(nil, string.find("hello", "%d+")) + assert_equal("test", string.find("testing", "test")) + assert_equal("world", string.find("hello world", "world")) +end) + +test("string.find_all", function() + local matches = string.find_all("123 and 456 and 789", "%d+") assert_equal(3, #matches) assert_equal("123", matches[1]) assert_equal("789", matches[3]) + + -- No matches + assert_table_equal({}, string.find_all("hello", "%d+")) + + -- Multiple matches + local overlaps = string.find_all("test test test", "test") + assert_equal(3, #overlaps) end) -test("Regex Replace", function() - assert_equal("helloXXXworldXXX", str.gsub("\\d+", "hello123world456", "XXX")) - assert_equal("hello world", str.gsub("\\s+", "hello world", " ")) +test("string.gsub", function() + assert_equal("helloXXXworldXXX", (string.gsub("hello123world456", "%d+", "XXX"))) + assert_equal("hello world", (string.gsub("hello world", "%s+", " "))) + assert_equal("abc abc abc", (string.gsub("test abc test", "test", "abc"))) + + -- No matches + assert_equal("hello", (string.gsub("hello", "%d+", "XXX"))) end) -- ====================================================================== -- TYPE CONVERSION & VALIDATION -- ====================================================================== -test("String to Number", function() - assert_equal(123, str.to_number("123")) - assert_equal(123.45, str.to_number("123.45")) - assert_equal(-42, str.to_number("-42")) - assert_equal(nil, str.to_number("not_a_number")) +test("string.to_number", function() + assert_equal(123, string.to_number("123")) + assert_equal(123.45, string.to_number("123.45")) + assert_equal(-42, string.to_number("-42")) + assert_equal(nil, string.to_number("not_a_number")) + assert_equal(nil, string.to_number("")) + assert_equal(42, string.to_number(" 42 ")) end) -test("String Validation", function() - assert_equal(true, str.is_numeric("123")) - assert_equal(true, str.is_numeric("123.45")) - assert_equal(false, str.is_numeric("abc")) - - assert_equal(true, str.is_alpha("hello")) - assert_equal(false, str.is_alpha("hello123")) - assert_equal(false, str.is_alpha("")) - - assert_equal(true, str.is_alphanumeric("hello123")) - assert_equal(false, str.is_alphanumeric("hello!")) - assert_equal(false, str.is_alphanumeric("")) - - assert_equal(true, str.is_empty("")) - assert_equal(true, str.is_empty(nil)) - assert_equal(false, str.is_empty("hello")) - - assert_equal(true, str.is_blank("")) - assert_equal(true, str.is_blank(" ")) - assert_equal(false, str.is_blank("hello")) +test("string.is_numeric", function() + assert_equal(true, string.is_numeric("123")) + assert_equal(true, string.is_numeric("123.45")) + assert_equal(true, string.is_numeric("-42")) + assert_equal(false, string.is_numeric("abc")) + assert_equal(false, string.is_numeric("")) + assert_equal(true, string.is_numeric(" 42 ")) +end) + +test("string.is_alpha", function() + assert_equal(true, string.is_alpha("hello")) + assert_equal(false, string.is_alpha("hello123")) + assert_equal(false, string.is_alpha("")) + assert_equal(false, string.is_alpha("hello!")) + assert_equal(true, string.is_alpha("ABC")) +end) + +test("string.is_alphanumeric", function() + assert_equal(true, string.is_alphanumeric("hello123")) + assert_equal(false, string.is_alphanumeric("hello!")) + assert_equal(false, string.is_alphanumeric("")) + assert_equal(true, string.is_alphanumeric("ABC123")) + assert_equal(true, string.is_alphanumeric("hello")) +end) + +test("string.is_empty", function() + assert_equal(true, string.is_empty("")) + assert_equal(true, string.is_empty(nil)) + assert_equal(false, string.is_empty("hello")) + assert_equal(false, string.is_empty(" ")) +end) + +test("string.is_blank", function() + assert_equal(true, string.is_blank("")) + assert_equal(true, string.is_blank(" ")) + assert_equal(true, string.is_blank(nil)) + assert_equal(false, string.is_blank("hello")) + assert_equal(false, string.is_blank(" a ")) +end) + +test("string.is_utf8", function() + assert_equal(true, string.is_utf8("hello")) + assert_equal(true, string.is_utf8("café")) + assert_equal(true, string.is_utf8("")) + assert_equal(true, string.is_utf8(unicode_string)) end) -- ====================================================================== -- ADVANCED STRING OPERATIONS -- ====================================================================== -test("Case Conversion Functions", function() - assert_equal("Hello World", str.capitalize("hello world")) - assert_equal("helloWorld", str.camel_case("hello world")) - assert_equal("HelloWorld", str.pascal_case("hello world")) - assert_equal("hello_world", str.snake_case("Hello World")) - assert_equal("hello-world", str.kebab_case("Hello World")) - assert_equal("HELLO_WORLD", str.screaming_snake_case("hello world")) +test("string.capitalize", function() + assert_equal("Hello World", string.capitalize("hello world")) + assert_equal("Hello", string.capitalize("hello")) + assert_equal("", string.capitalize("")) + assert_equal("A", string.capitalize("a")) end) -test("String Center and Truncate", function() - assert_equal(" hi ", str.center("hi", 6)) - assert_equal("**hi***", str.center("hi", 7, "*")) - assert_equal("hello", str.center("hello", 3)) -- Already longer - - assert_equal("hello...", str.truncate("hello world", 8)) - assert_equal("hello>>", str.truncate("hello world", 8, ">>")) - assert_equal("hi", str.truncate("hi", 10)) -- Shorter than limit +test("string.camel_case", function() + assert_equal("helloWorld", string.camel_case("hello world")) + assert_equal("hello", string.camel_case("hello")) + assert_equal("", string.camel_case("")) + assert_equal("testCaseExample", string.camel_case("test case example")) end) -test("String Wrap", function() - local wrapped = str.wrap("The quick brown fox jumps over the lazy dog", 10) +test("string.pascal_case", function() + assert_equal("HelloWorld", string.pascal_case("hello world")) + assert_equal("Hello", string.pascal_case("hello")) + assert_equal("", string.pascal_case("")) + assert_equal("TestCaseExample", string.pascal_case("test case example")) +end) + +test("string.snake_case", function() + assert_equal("hello_world", string.snake_case("Hello World")) + assert_equal("hello", string.snake_case("hello")) + assert_equal("", string.snake_case("")) + assert_equal("test_case_example", string.snake_case("Test Case Example")) +end) + +test("string.kebab_case", function() + assert_equal("hello-world", string.kebab_case("Hello World")) + assert_equal("hello", string.kebab_case("hello")) + assert_equal("", string.kebab_case("")) + assert_equal("test-case-example", string.kebab_case("Test Case Example")) +end) + +test("string.screaming_snake_case", function() + assert_equal("HELLO_WORLD", string.screaming_snake_case("hello world")) + assert_equal("HELLO", string.screaming_snake_case("hello")) + assert_equal("", string.screaming_snake_case("")) + assert_equal("TEST_CASE", string.screaming_snake_case("test case")) +end) + +test("string.center", function() + assert_equal(" hi ", string.center("hi", 6)) + assert_equal("**hi***", string.center("hi", 7, "*")) + assert_equal("hello", string.center("hello", 3)) + assert_equal("hi", string.center("hi", 2)) + assert_equal(" hi ", string.center("hi", 4)) +end) + +test("string.truncate", function() + assert_equal("hello...", string.truncate("hello world", 8)) + assert_equal("hello>>", string.truncate("hello world", 8, ">>")) + assert_equal("hi", string.truncate("hi", 10)) + assert_equal("...", string.truncate("hello", 3)) + assert_equal("h>", string.truncate("hello", 2, ">")) +end) + +test("string.wrap", function() + local wrapped = string.wrap("The quick brown fox jumps over the lazy dog", 10) assert_equal("table", type(wrapped)) assert(#wrapped > 1, "should wrap into multiple lines") - + -- Each line should be within limit for _, line in ipairs(wrapped) do - assert(str.length(line) <= 10, "line should be within width limit") + assert(string.length(line) <= 10, "line should be within width limit: " .. line) end + + -- Empty string + assert_table_equal({""}, string.wrap("", 10)) + + -- Single word longer than width + local long_word = string.wrap("supercalifragilisticexpialidocious", 10) + assert_equal(1, #long_word) end) -test("String Dedent", function() +test("string.dedent", function() local indented = " line1\n line2\n line3" - local dedented = str.dedent(indented) - local lines = str.lines(dedented) - + local dedented = string.dedent(indented) + local lines = string.lines(dedented) + assert_equal("line1", lines[1]) assert_equal("line2", lines[2]) assert_equal("line3", lines[3]) + + -- Mixed indentation + local mixed = " a\n b\n c" + local mixed_result = string.dedent(mixed) + local mixed_lines = string.lines(mixed_result) + assert_equal("a", mixed_lines[1]) + assert_equal(" b", mixed_lines[2]) + assert_equal("c", mixed_lines[3]) end) -test("Escape and Quote Functions", function() - assert_equal("hello\\.world", str.escape_regex("hello.world")) - assert_equal("a\\+b\\*c\\?", str.escape_regex("a+b*c?")) - - assert_equal("'hello world'", str.shell_quote("hello world")) - assert_equal("'it'\"'\"'s great'", str.shell_quote("it's great")) +test("string.escape", function() + assert_equal("hello%.world", string.escape("hello.world")) + assert_equal("a%+b%*c%?", string.escape("a+b*c?")) + assert_equal("%[%]%(%)", string.escape("[]()")) + assert_equal("hello", string.escape("hello")) end) -test("URL Encoding", function() - assert_equal("hello%20world", str.url_encode("hello world")) - assert_equal("caf%C3%A9", str.url_encode("café")) - - local encoded = str.url_encode("hello world") - assert_equal("hello world", str.url_decode(encoded)) - - assert_equal("hello world", str.url_decode("hello+world")) +test("string.shell_quote", function() + assert_equal("'hello world'", string.shell_quote("hello world")) + assert_equal("'it'\"'\"'s great'", string.shell_quote("it's great")) + assert_equal("hello", string.shell_quote("hello")) + assert_equal("hello-world.txt", string.shell_quote("hello-world.txt")) end) --- ====================================================================== --- STRING COMPARISON --- ====================================================================== - -test("String Comparison", function() - assert_equal(true, str.iequals("Hello", "HELLO")) - assert_equal(false, str.iequals("Hello", "world")) - - -- Test distance and similarity - assert_equal(3, str.distance("kitten", "sitting")) - assert_equal(0, str.distance("hello", "hello")) - - local similarity = str.similarity("hello", "hallo") - assert(similarity > 0.5 and similarity < 1, "should be partial similarity") - assert_equal(1, str.similarity("hello", "hello")) +test("string.url_encode", function() + assert_equal("hello%20world", string.url_encode("hello world")) + assert_equal("hello", string.url_encode("hello")) + assert_equal("hello%21%40%23", string.url_encode("hello!@#")) end) --- ====================================================================== --- TEMPLATE FUNCTIONS --- ====================================================================== +test("string.url_decode", function() + assert_equal("hello world", string.url_decode("hello%20world")) + assert_equal("hello world", string.url_decode("hello+world")) + assert_equal("hello", string.url_decode("hello")) + assert_equal("hello!@#", string.url_decode("hello%21%40%23")) -test("Template Functions", function() - local simple_template = "Hello ${name}, you are ${age} years old" - local vars = {name = "John", age = 25} - - assert_equal("Hello John, you are 25 years old", str.template(simple_template, vars)) - - -- Test with missing variables - local incomplete = str.template("Hello ${name} and ${unknown}", {name = "John"}) - assert_equal("Hello John and ", incomplete) - - -- Advanced template + -- Round trip + local original = "hello world!@#" + local encoded = string.url_encode(original) + assert_equal(original, string.url_decode(encoded)) +end) + +test("string.template", function() local context = { user = {name = "Jane", role = "admin"}, count = 5 } - local advanced = str.template_advanced("User ${user.name} (${user.role}) has ${count} items", context) - assert_equal("User Jane (admin) has 5 items", advanced) + local template = "User ${user.name} (${user.role}) has ${count} items" + local result = string.template(template, context) + assert_equal("User Jane (admin) has 5 items", result) + + -- Missing variables + local incomplete = string.template("Hello ${name} and ${unknown}", {name = "John"}) + assert_equal("Hello John and ", incomplete) + + -- Missing nested property + local missing = string.template("${user.missing}", context) + assert_equal("", missing) + + -- No variables + assert_equal("Hello world", string.template("Hello world", {})) end) --- ====================================================================== --- UTILITY FUNCTIONS --- ====================================================================== +test("string.random", function() + local random1 = string.random(10) + local random2 = string.random(10) -test("Whitespace Functions", function() - assert_equal(true, str.is_whitespace(" ")) - assert_equal(true, str.is_whitespace("")) - assert_equal(false, str.is_whitespace("hello")) - - assert_equal("hello", str.strip_whitespace("h e l l o")) - assert_equal("hello world test", str.normalize_whitespace("hello world test")) + assert_equal(10, string.length(random1)) + assert_equal(10, string.length(random2)) + assert(random1 ~= random2, "random strings should be different") + + -- Custom charset + local custom = string.random(5, "abc") + assert_equal(5, string.length(custom)) + + -- Zero length + assert_equal("", string.random(0)) end) -test("Number Extraction", function() - local numbers = str.extract_numbers("The price is $123.45 and tax is 8.5%") +test("string.slug", function() + assert_equal("hello-world", string.slug("Hello World")) + assert_equal("cafe-restaurant", string.slug("Café & Restaurant")) + assert_equal("specialcharacters", string.slug("Special!@#$%Characters")) + assert_equal("", string.slug("")) + assert_equal("test", string.slug("test")) +end) + +test("string.iequals", function() + assert_equal(true, string.iequals("Hello", "HELLO")) + assert_equal(true, string.iequals("hello", "hello")) + assert_equal(false, string.iequals("Hello", "world")) + assert_equal(true, string.iequals("", "")) +end) + +test("string.is_whitespace", function() + assert_equal(true, string.is_whitespace(" ")) + assert_equal(true, string.is_whitespace("")) + assert_equal(true, string.is_whitespace("\t\n\r ")) + assert_equal(false, string.is_whitespace("hello")) + assert_equal(false, string.is_whitespace(" a ")) +end) + +test("string.strip_whitespace", function() + assert_equal("hello", string.strip_whitespace("h e l l o")) + assert_equal("helloworld", string.strip_whitespace("hello world")) + assert_equal("", string.strip_whitespace(" ")) + assert_equal("abc", string.strip_whitespace("a\tb\nc")) +end) + +test("string.normalize_whitespace", function() + assert_equal("hello world test", string.normalize_whitespace("hello world test")) + assert_equal("a b c", string.normalize_whitespace(" a b c ")) + assert_equal("", string.normalize_whitespace(" ")) + assert_equal("hello", string.normalize_whitespace("hello")) +end) + +test("string.extract_numbers", function() + local numbers = string.extract_numbers("The price is $123.45 and tax is 8.5%") assert_equal(2, #numbers) assert_close(123.45, numbers[1]) assert_close(8.5, numbers[2]) - - local negative_nums = str.extract_numbers("Temperature: -15.5 degrees") - assert_equal(1, #negative_nums) - assert_close(-15.5, negative_nums[1]) + + local negative = string.extract_numbers("Temperature: -15.5 degrees") + assert_equal(1, #negative) + assert_close(-15.5, negative[1]) + + -- No numbers + assert_table_equal({}, string.extract_numbers("hello world")) end) -test("Accent Removal", function() - assert_equal("cafe", str.remove_accents("café")) - assert_equal("resume", str.remove_accents("résumé")) - assert_equal("naive", str.remove_accents("naïve")) - assert_equal("hello", str.remove_accents("hello")) -end) +test("string.remove_accents", function() + assert_equal("cafe", string.remove_accents("café")) + assert_equal("resume", string.remove_accents("résumé")) + assert_equal("naive", string.remove_accents("naïve")) + assert_equal("hello", string.remove_accents("hello")) + assert_equal("", string.remove_accents("")) -test("Random String Generation", function() - local random1 = str.random(10) - local random2 = str.random(10) - - assert_equal(10, str.length(random1)) - assert_equal(10, str.length(random2)) - assert(random1 ~= random2, "random strings should be different") - - -- Custom charset - local custom = str.random(5, "abc") - assert_equal(5, str.length(custom)) - assert(str.match("^[abc]+$", custom), "should only contain specified characters") -end) - -test("UTF-8 Validation", function() - assert_equal(true, str.is_utf8("hello")) - assert_equal(true, str.is_utf8("café")) - assert_equal(true, str.is_utf8("")) - - -- Note: This test depends on the actual UTF-8 validation implementation - -- Some invalid UTF-8 sequences might still pass depending on the system -end) - -test("Slug Generation", function() - assert_equal("hello-world", str.slug("Hello World")) - assert_equal("cafe-restaurant", str.slug("Café & Restaurant")) - assert_equal("specialcharacters", str.slug("Special!@#$%Characters")) + -- Mixed case + assert_equal("Cafe", string.remove_accents("Café")) end) -- ====================================================================== @@ -347,89 +614,67 @@ end) -- ====================================================================== test("Empty String Handling", function() - assert_table_equal({""}, str.split("", ",")) - assert_equal("", str.join({}, ",")) - assert_equal("", str.trim("")) - assert_equal("", str.reverse("")) - assert_equal("", str.repeat_("", 5)) - assert_table_equal({""}, str.lines("")) - assert_table_equal({}, str.words("")) + assert_table_equal({""}, string.split("", ",")) + assert_equal("", string.join({}, ",")) + assert_equal("", string.trim("")) + assert_equal("", string.reverse("")) + assert_equal("", string.repeat_("", 5)) + assert_table_equal({""}, string.lines("")) + assert_table_equal({}, string.words("")) + assert_equal("", string.slice("", 1, 5)) + assert_equal("", string.slug("")) end) test("Large String Handling", function() local large_string = string.rep("test ", 1000) - - assert_equal(5000, str.length(large_string)) - assert_equal(1000, str.count(large_string, "test")) - - local words = str.words(large_string) + + assert_equal(5000, string.length(large_string)) + assert_equal(1000, string.count(large_string, "test")) + + local words = string.words(large_string) assert_equal(1000, #words) - - local trimmed = str.trim(large_string) - assert_equal(true, str.ends_with(trimmed, "test")) + + local trimmed = string.trim(large_string) + assert_equal(true, string.ends_with(trimmed, "test")) + + -- Should use Go for reverse on large strings + local reversed = string.reverse(large_string) + assert_equal(string.length(large_string), string.length(reversed)) end) test("Unicode Handling", function() - local unicode_string = "Hello 🌍 World 🚀" - - -- Basic operations should work with Unicode - assert_equal(true, str.contains(unicode_string, "🌍")) - assert_equal(str.upper(unicode_string), str.upper(unicode_string)) -- Should not crash - - local parts = str.split(unicode_string, " ") + local unicode_str = "Hello 🌍 World 🚀" + + assert_equal(true, string.contains(unicode_str, "🌍")) + assert_equal(true, string.starts_with(unicode_str, "Hello")) + assert_equal(true, string.ends_with(unicode_str, "🚀")) + + local parts = string.split(unicode_str, " ") assert_equal(4, #parts) assert_equal("🌍", parts[2]) + + -- Length should count Unicode characters, not bytes + assert_equal(15, string.length(unicode_str)) + assert(string.byte_length(unicode_str) > 15, "byte length should be larger") end) -test("Regex Error Handling", function() - -- Invalid regex pattern - check if it actually fails - local success, result = pcall(str.match, "\\", "test") - if success then - -- If it doesn't fail, just verify it works with valid patterns - assert_equal(true, str.match("test", "test")) - else - assert_equal(false, success) - end - - local success2, result2 = pcall(str.find, "\\", "test") - if success2 then - -- If it doesn't fail, just verify it works with valid patterns - assert(str.find("test", "test") ~= nil) - else - assert_equal(false, success2) - end -end) +test("Boundary Conditions", function() + -- Index boundary tests + assert_equal("", string.slice("hello", 6)) + assert_equal("", string.slice("hello", 1, 0)) + assert_equal("hello", string.slice("hello", 1, 100)) --- ====================================================================== --- PERFORMANCE TESTS --- ====================================================================== + -- Padding boundary tests + assert_equal("hi", string.pad_left("hi", 0)) + assert_equal("hi", string.pad_right("hi", 1)) -test("Performance Test", function() - local large_text = string.rep("The quick brown fox jumps over the lazy dog. ", 1000) - - local start = os.clock() - local words = str.words(large_text) - local words_time = os.clock() - start - - start = os.clock() - local lines = str.lines(large_text) - local lines_time = os.clock() - start - - start = os.clock() - local replaced = str.replace(large_text, "fox", "cat") - local replace_time = os.clock() - start - - start = os.clock() - local parts = str.split(large_text, " ") - local split_time = os.clock() - start - - print(string.format(" Extract %d words: %.3fs", #words, words_time)) - print(string.format(" Extract %d lines: %.3fs", #lines, lines_time)) - print(string.format(" Replace in %d chars: %.3fs", str.length(large_text), replace_time)) - print(string.format(" Split into %d parts: %.3fs", #parts, split_time)) - - assert(#words > 8000, "should extract many words") - assert(str.contains(replaced, "cat"), "replacement should work") + -- Count boundary tests + assert_equal(0, string.count("", "a")) + assert_equal(1, string.count("", "")) + + -- Replace boundary tests + assert_equal("", string.replace_n("hello", "hello", "", 1)) + assert_equal("hello", string.replace_n("hello", "x", "y", 0)) end) -- ====================================================================== @@ -438,49 +683,96 @@ end) test("String Processing Pipeline", function() local messy_input = " HELLO, world! How ARE you? " - + -- Clean and normalize - local cleaned = str.normalize_whitespace(str.trim(messy_input)) - local lowered = str.lower(cleaned) - local words = str.words(lowered) + local cleaned = string.normalize_whitespace(string.trim(messy_input)) + local lowered = string.lower(cleaned) + local words = string.words(lowered) local filtered = {} - + for _, word in ipairs(words) do - -- Remove punctuation from word before checking length - local clean_word = str.gsub("[[:punct:]]", word, "") - if str.length(clean_word) > 2 then + local clean_word = string.gsub(word, "[%p]", "") -- Remove punctuation using Lua pattern + if string.length(clean_word) > 2 then table.insert(filtered, clean_word) end end - - local result = str.join(filtered, "-") - + + local result = string.join(filtered, "-") assert_equal("hello-world-how-are-you", result) end) test("Text Analysis", function() local text = "The quick brown fox jumps over the lazy dog. The dog was sleeping." - - local word_count = #str.words(text) - local sentence_count = str.count(text, ".") - local the_count = str.count(str.lower(text), "the") - + + local word_count = #string.words(text) + local sentence_count = string.count(text, ".") + local the_count = string.count(string.lower(text), "the") + assert_equal(13, word_count) assert_equal(2, sentence_count) assert_equal(3, the_count) - - -- Extract all words starting with vowels - local words = str.words(str.lower(text)) - local vowel_words = {} - for _, word in ipairs(words) do - local clean_word = str.replace(word, "%p", "") -- Remove punctuation - if str.match("^[aeiou]", clean_word) then - table.insert(vowel_words, clean_word) - end - end - - assert(#vowel_words >= 1, "should find words starting with vowels") + + -- Template processing + local template = "Found ${word_count} words and ${the_count} instances of 'the'" + local vars = {word_count = word_count, the_count = the_count} + local summary = string.template(template, vars) + + assert_equal("Found 13 words and 3 instances of 'the'", summary) +end) + +test("Case Conversion Chain", function() + local original = "Hello World Test Case" + + -- Test conversion chain + local snake = string.snake_case(original) + local camel = string.camel_case(original) + local pascal = string.pascal_case(original) + local kebab = string.kebab_case(original) + local screaming = string.screaming_snake_case(original) + + assert_equal("hello_world_test_case", snake) + assert_equal("helloWorldTestCase", camel) + assert_equal("HelloWorldTestCase", pascal) + assert_equal("hello-world-test-case", kebab) + assert_equal("HELLO_WORLD_TEST_CASE", screaming) + + -- Convert back should be similar + local back_to_words = string.split(snake, "_") + local rejoined = string.join(back_to_words, " ") + local capitalized = string.title(rejoined) + + assert_equal("Hello World Test Case", capitalized) +end) + +-- ====================================================================== +-- PERFORMANCE TESTS +-- ====================================================================== + +test("Performance Characteristics", function() + local large_text = string.rep("The quick brown fox jumps over the lazy dog. ", 1000) + + -- Test that operations complete in reasonable time + local start = os.clock() + + local words = string.words(large_text) + local lines = string.lines(large_text) + local replaced = string.replace(large_text, "fox", "cat") + local parts = string.split(large_text, " ") + local reversed = string.reverse(large_text) + + local elapsed = os.clock() - start + + -- Verify results + assert(#words > 8000, "should extract many words") + assert(string.contains(replaced, "cat"), "replacement should work") + assert(#parts > 8000, "should split into many parts") + assert_equal(string.length(large_text), string.length(reversed)) + + print(string.format(" Processed %d characters in %.3fs", string.length(large_text), elapsed)) + + -- Performance should be reasonable (< 1 second for this test) + assert(elapsed < 1.0, "operations should complete quickly") end) summary() -test_exit() \ No newline at end of file +test_exit()