From 743fd0e835413877ae228592fabd07445f124ee9 Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Mon, 14 Jul 2025 23:53:53 -0500 Subject: [PATCH] crypto, fs, string libs --- .gitignore | 1 + functions/crypto.go | 352 +++++++++++++++++++++-- functions/fs.go | 529 ++++++++++++++++++++++++++++++++++- functions/registry.go | 31 +-- functions/string.go | 634 ++++++++++++++++++++++++++++++++++++++++-- go.mod | 5 + go.sum | 4 + modules/crypto.lua | 371 ++++++++++++++++++++++++ modules/fs.lua | 446 +++++++++++++++++++++++++++++ modules/string.lua | 464 +++++++++++++++++++++++++++++++ tests/crypto.lua | 332 ++++++++++++++++++++++ tests/fs.lua | 456 ++++++++++++++++++++++++++++++ tests/string.lua | 486 ++++++++++++++++++++++++++++++++ tests/tests.lua | 34 +-- 14 files changed, 4058 insertions(+), 87 deletions(-) create mode 100644 modules/crypto.lua create mode 100644 modules/fs.lua create mode 100644 modules/string.lua create mode 100644 tests/crypto.lua create mode 100644 tests/fs.lua create mode 100644 tests/string.lua diff --git a/.gitignore b/.gitignore index bf20b1c..7be2743 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ go.work # Test directories and files test.lua +test_fs_dir \ No newline at end of file diff --git a/functions/crypto.go b/functions/crypto.go index 8ae6698..b3b2af3 100644 --- a/functions/crypto.go +++ b/functions/crypto.go @@ -1,28 +1,35 @@ -// functions/crypto.go package functions import ( + "crypto/hmac" "crypto/md5" + "crypto/rand" + "crypto/sha1" "crypto/sha256" + "crypto/sha512" "encoding/base64" "encoding/hex" + "fmt" + "math/big" luajit "git.sharkk.net/Sky/LuaJIT-to-Go" + "github.com/google/uuid" ) -// GetCryptoFunctions returns all cryptographic Go functions func GetCryptoFunctions() map[string]luajit.GoFunction { return map[string]luajit.GoFunction{ "base64_encode": func(s *luajit.State) int { if err := s.CheckMinArgs(1); err != nil { - return s.PushError("base64_encode: %v", err) + s.PushNil() + s.PushString(fmt.Sprintf("base64_encode: %v", err)) + return 2 } - str, err := s.SafeToString(1) if err != nil { - return s.PushError("base64_encode: argument must be a string") + s.PushNil() + s.PushString("base64_encode: argument must be a string") + return 2 } - encoded := base64.StdEncoding.EncodeToString([]byte(str)) s.PushString(encoded) return 1 @@ -30,51 +37,358 @@ func GetCryptoFunctions() map[string]luajit.GoFunction { "base64_decode": func(s *luajit.State) int { if err := s.CheckMinArgs(1); err != nil { - return s.PushError("base64_decode: %v", err) + s.PushNil() + s.PushString(fmt.Sprintf("base64_decode: %v", err)) + return 2 } - str, err := s.SafeToString(1) if err != nil { - return s.PushError("base64_decode: argument must be a string") + s.PushNil() + s.PushString("base64_decode: argument must be a string") + return 2 } - decoded, err := base64.StdEncoding.DecodeString(str) if err != nil { - return s.PushError("base64_decode: %v", err) + s.PushNil() + s.PushString(fmt.Sprintf("base64_decode: %v", err)) + return 2 } + s.PushString(string(decoded)) + return 1 + }, + "base64_url_encode": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + s.PushNil() + s.PushString(fmt.Sprintf("base64_url_encode: %v", err)) + return 2 + } + str, err := s.SafeToString(1) + if err != nil { + s.PushNil() + s.PushString("base64_url_encode: argument must be a string") + return 2 + } + encoded := base64.URLEncoding.EncodeToString([]byte(str)) + s.PushString(encoded) + return 1 + }, + + "base64_url_decode": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + s.PushNil() + s.PushString(fmt.Sprintf("base64_url_decode: %v", err)) + return 2 + } + str, err := s.SafeToString(1) + if err != nil { + s.PushNil() + s.PushString("base64_url_decode: argument must be a string") + return 2 + } + decoded, err := base64.URLEncoding.DecodeString(str) + if err != nil { + s.PushNil() + s.PushString(fmt.Sprintf("base64_url_decode: %v", err)) + return 2 + } + s.PushString(string(decoded)) + return 1 + }, + + "hex_encode": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + s.PushNil() + s.PushString(fmt.Sprintf("hex_encode: %v", err)) + return 2 + } + str, err := s.SafeToString(1) + if err != nil { + s.PushNil() + s.PushString("hex_encode: argument must be a string") + return 2 + } + encoded := hex.EncodeToString([]byte(str)) + s.PushString(encoded) + return 1 + }, + + "hex_decode": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + s.PushNil() + s.PushString(fmt.Sprintf("hex_decode: %v", err)) + return 2 + } + str, err := s.SafeToString(1) + if err != nil { + s.PushNil() + s.PushString("hex_decode: argument must be a string") + return 2 + } + decoded, err := hex.DecodeString(str) + if err != nil { + s.PushNil() + s.PushString(fmt.Sprintf("hex_decode: %v", err)) + return 2 + } s.PushString(string(decoded)) return 1 }, "md5_hash": func(s *luajit.State) int { if err := s.CheckMinArgs(1); err != nil { - return s.PushError("md5_hash: %v", err) + s.PushNil() + s.PushString(fmt.Sprintf("md5_hash: %v", err)) + return 2 } - str, err := s.SafeToString(1) if err != nil { - return s.PushError("md5_hash: argument must be a string") + s.PushNil() + s.PushString("md5_hash: argument must be a string") + return 2 } - hash := md5.Sum([]byte(str)) s.PushString(hex.EncodeToString(hash[:])) return 1 }, + "sha1_hash": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + s.PushNil() + s.PushString(fmt.Sprintf("sha1_hash: %v", err)) + return 2 + } + str, err := s.SafeToString(1) + if err != nil { + s.PushNil() + s.PushString("sha1_hash: argument must be a string") + return 2 + } + hash := sha1.Sum([]byte(str)) + s.PushString(hex.EncodeToString(hash[:])) + return 1 + }, + "sha256_hash": func(s *luajit.State) int { if err := s.CheckMinArgs(1); err != nil { - return s.PushError("sha256_hash: %v", err) + s.PushNil() + s.PushString(fmt.Sprintf("sha256_hash: %v", err)) + return 2 } - str, err := s.SafeToString(1) if err != nil { - return s.PushError("sha256_hash: argument must be a string") + s.PushNil() + s.PushString("sha256_hash: argument must be a string") + return 2 } - hash := sha256.Sum256([]byte(str)) s.PushString(hex.EncodeToString(hash[:])) return 1 }, + + "sha512_hash": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + s.PushNil() + s.PushString(fmt.Sprintf("sha512_hash: %v", err)) + return 2 + } + str, err := s.SafeToString(1) + if err != nil { + s.PushNil() + s.PushString("sha512_hash: argument must be a string") + return 2 + } + hash := sha512.Sum512([]byte(str)) + s.PushString(hex.EncodeToString(hash[:])) + return 1 + }, + + "hmac_sha256": func(s *luajit.State) int { + if err := s.CheckExactArgs(2); err != nil { + s.PushNil() + s.PushString(fmt.Sprintf("hmac_sha256: %v", err)) + return 2 + } + message, err := s.SafeToString(1) + if err != nil { + s.PushNil() + s.PushString("hmac_sha256: first argument must be a string") + return 2 + } + key, err := s.SafeToString(2) + if err != nil { + s.PushNil() + s.PushString("hmac_sha256: second argument must be a string") + return 2 + } + h := hmac.New(sha256.New, []byte(key)) + h.Write([]byte(message)) + s.PushString(hex.EncodeToString(h.Sum(nil))) + return 1 + }, + + "hmac_sha1": func(s *luajit.State) int { + if err := s.CheckExactArgs(2); err != nil { + s.PushNil() + s.PushString(fmt.Sprintf("hmac_sha1: %v", err)) + return 2 + } + message, err := s.SafeToString(1) + if err != nil { + s.PushNil() + s.PushString("hmac_sha1: first argument must be a string") + return 2 + } + key, err := s.SafeToString(2) + if err != nil { + s.PushNil() + s.PushString("hmac_sha1: second argument must be a string") + return 2 + } + h := hmac.New(sha1.New, []byte(key)) + h.Write([]byte(message)) + s.PushString(hex.EncodeToString(h.Sum(nil))) + return 1 + }, + + "uuid_generate": func(s *luajit.State) int { + id := uuid.New() + s.PushString(id.String()) + return 1 + }, + + "uuid_generate_v4": func(s *luajit.State) int { + id := uuid.New() + s.PushString(id.String()) + return 1 + }, + + "uuid_validate": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + s.PushBoolean(false) + return 1 + } + str, err := s.SafeToString(1) + if err != nil { + s.PushBoolean(false) + return 1 + } + _, err = uuid.Parse(str) + s.PushBoolean(err == nil) + return 1 + }, + + "random_bytes": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + s.PushNil() + s.PushString(fmt.Sprintf("random_bytes: %v", err)) + return 2 + } + length, err := s.SafeToNumber(1) + if err != nil || length < 0 || length != float64(int(length)) { + s.PushNil() + s.PushString("random_bytes: argument must be a non-negative integer") + return 2 + } + if length > 65536 { + s.PushNil() + s.PushString("random_bytes: length too large (max 65536)") + return 2 + } + bytes := make([]byte, int(length)) + if _, err := rand.Read(bytes); err != nil { + s.PushNil() + s.PushString("random_bytes: failed to generate random bytes") + return 2 + } + s.PushString(string(bytes)) + return 1 + }, + + "random_hex": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + s.PushNil() + s.PushString(fmt.Sprintf("random_hex: %v", err)) + return 2 + } + length, err := s.SafeToNumber(1) + if err != nil || length < 0 || length != float64(int(length)) { + s.PushNil() + s.PushString("random_hex: argument must be a non-negative integer") + return 2 + } + if length > 32768 { + s.PushNil() + s.PushString("random_hex: length too large (max 32768)") + return 2 + } + bytes := make([]byte, int(length)) + if _, err := rand.Read(bytes); err != nil { + s.PushNil() + s.PushString("random_hex: failed to generate random bytes") + return 2 + } + s.PushString(hex.EncodeToString(bytes)) + return 1 + }, + + "random_string": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + s.PushNil() + s.PushString(fmt.Sprintf("random_string: %v", err)) + return 2 + } + length, err := s.SafeToNumber(1) + if err != nil || length < 0 || length != float64(int(length)) { + s.PushNil() + s.PushString("random_string: argument must be a non-negative integer") + return 2 + } + if length > 65536 { + s.PushNil() + s.PushString("random_string: length too large (max 65536)") + return 2 + } + + charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + if s.GetTop() >= 2 { + if customCharset, err := s.SafeToString(2); err == nil { + charset = customCharset + } + } + + result := make([]byte, int(length)) + charsetLen := big.NewInt(int64(len(charset))) + for i := range result { + n, err := rand.Int(rand.Reader, charsetLen) + if err != nil { + s.PushNil() + s.PushString("random_string: failed to generate random number") + return 2 + } + result[i] = charset[n.Int64()] + } + s.PushString(string(result)) + return 1 + }, + + "secure_compare": func(s *luajit.State) int { + if err := s.CheckExactArgs(2); err != nil { + s.PushBoolean(false) + return 1 + } + a, err := s.SafeToString(1) + if err != nil { + s.PushBoolean(false) + return 1 + } + b, err := s.SafeToString(2) + if err != nil { + s.PushBoolean(false) + return 1 + } + s.PushBoolean(hmac.Equal([]byte(a), []byte(b))) + return 1 + }, } } diff --git a/functions/fs.go b/functions/fs.go index 8227a4d..14929bf 100644 --- a/functions/fs.go +++ b/functions/fs.go @@ -1,24 +1,26 @@ package functions import ( + "io" + "io/fs" "os" + "path/filepath" + "strings" + "time" luajit "git.sharkk.net/Sky/LuaJIT-to-Go" ) -// GetFSFunctions returns all file system Go functions func GetFSFunctions() map[string]luajit.GoFunction { return map[string]luajit.GoFunction{ "file_exists": func(s *luajit.State) int { if err := s.CheckMinArgs(1); err != nil { return s.PushError("file_exists: %v", err) } - path, err := s.SafeToString(1) if err != nil { return s.PushError("file_exists: argument must be a string") } - _, err = os.Stat(path) s.PushBoolean(err == nil) return 1 @@ -28,18 +30,15 @@ func GetFSFunctions() map[string]luajit.GoFunction { if err := s.CheckMinArgs(1); err != nil { return s.PushError("file_size: %v", err) } - path, err := s.SafeToString(1) if err != nil { return s.PushError("file_size: argument must be a string") } - info, err := os.Stat(path) if err != nil { s.PushNumber(-1) return 1 } - s.PushNumber(float64(info.Size())) return 1 }, @@ -48,20 +47,532 @@ func GetFSFunctions() map[string]luajit.GoFunction { if err := s.CheckMinArgs(1); err != nil { return s.PushError("file_is_dir: %v", err) } - path, err := s.SafeToString(1) if err != nil { return s.PushError("file_is_dir: argument must be a string") } - info, err := os.Stat(path) if err != nil { s.PushBoolean(false) return 1 } - s.PushBoolean(info.IsDir()) return 1 }, + + "file_read": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("file_read: %v", err) + } + path, err := s.SafeToString(1) + if err != nil { + return s.PushError("file_read: argument must be a string") + } + data, err := os.ReadFile(path) + if err != nil { + s.PushNil() + s.PushString(err.Error()) + return 2 + } + s.PushString(string(data)) + return 1 + }, + + "file_write": func(s *luajit.State) int { + if err := s.CheckExactArgs(2); err != nil { + return s.PushError("file_write: %v", err) + } + path, err := s.SafeToString(1) + if err != nil { + return s.PushError("file_write: first argument must be a string") + } + content, err := s.SafeToString(2) + if err != nil { + return s.PushError("file_write: second argument must be a string") + } + err = os.WriteFile(path, []byte(content), 0644) + if err != nil { + s.PushBoolean(false) + s.PushString(err.Error()) + return 2 + } + s.PushBoolean(true) + return 1 + }, + + "file_append": func(s *luajit.State) int { + if err := s.CheckExactArgs(2); err != nil { + return s.PushError("file_append: %v", err) + } + path, err := s.SafeToString(1) + if err != nil { + return s.PushError("file_append: first argument must be a string") + } + content, err := s.SafeToString(2) + if err != nil { + return s.PushError("file_append: second argument must be a string") + } + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + s.PushBoolean(false) + s.PushString(err.Error()) + return 2 + } + defer file.Close() + _, err = file.WriteString(content) + if err != nil { + s.PushBoolean(false) + s.PushString(err.Error()) + return 2 + } + s.PushBoolean(true) + return 1 + }, + + "file_copy": func(s *luajit.State) int { + if err := s.CheckExactArgs(2); err != nil { + return s.PushError("file_copy: %v", err) + } + src, err := s.SafeToString(1) + if err != nil { + return s.PushError("file_copy: first argument must be a string") + } + dst, err := s.SafeToString(2) + if err != nil { + return s.PushError("file_copy: second argument must be a string") + } + + srcFile, err := os.Open(src) + if err != nil { + s.PushBoolean(false) + s.PushString(err.Error()) + return 2 + } + defer srcFile.Close() + + dstFile, err := os.Create(dst) + if err != nil { + s.PushBoolean(false) + s.PushString(err.Error()) + return 2 + } + defer dstFile.Close() + + _, err = io.Copy(dstFile, srcFile) + if err != nil { + s.PushBoolean(false) + s.PushString(err.Error()) + return 2 + } + s.PushBoolean(true) + return 1 + }, + + "file_move": func(s *luajit.State) int { + if err := s.CheckExactArgs(2); err != nil { + return s.PushError("file_move: %v", err) + } + src, err := s.SafeToString(1) + if err != nil { + return s.PushError("file_move: first argument must be a string") + } + dst, err := s.SafeToString(2) + if err != nil { + return s.PushError("file_move: second argument must be a string") + } + err = os.Rename(src, dst) + if err != nil { + s.PushBoolean(false) + s.PushString(err.Error()) + return 2 + } + s.PushBoolean(true) + return 1 + }, + + "file_delete": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("file_delete: %v", err) + } + path, err := s.SafeToString(1) + if err != nil { + return s.PushError("file_delete: argument must be a string") + } + err = os.Remove(path) + if err != nil { + s.PushBoolean(false) + s.PushString(err.Error()) + return 2 + } + s.PushBoolean(true) + return 1 + }, + + "file_mtime": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("file_mtime: %v", err) + } + path, err := s.SafeToString(1) + if err != nil { + return s.PushError("file_mtime: argument must be a string") + } + info, err := os.Stat(path) + if err != nil { + s.PushNumber(-1) + return 1 + } + s.PushNumber(float64(info.ModTime().Unix())) + return 1 + }, + + "dir_create": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("dir_create: %v", err) + } + path, err := s.SafeToString(1) + if err != nil { + return s.PushError("dir_create: argument must be a string") + } + err = os.MkdirAll(path, 0755) + if err != nil { + s.PushBoolean(false) + s.PushString(err.Error()) + return 2 + } + s.PushBoolean(true) + return 1 + }, + + "dir_remove": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("dir_remove: %v", err) + } + path, err := s.SafeToString(1) + if err != nil { + return s.PushError("dir_remove: argument must be a string") + } + err = os.RemoveAll(path) + if err != nil { + s.PushBoolean(false) + s.PushString(err.Error()) + return 2 + } + s.PushBoolean(true) + return 1 + }, + + "dir_list": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("dir_list: %v", err) + } + path, err := s.SafeToString(1) + if err != nil { + return s.PushError("dir_list: argument must be a string") + } + entries, err := os.ReadDir(path) + if err != nil { + s.PushNil() + s.PushString(err.Error()) + return 2 + } + + s.CreateTable(len(entries), 0) + for i, entry := range entries { + s.PushNumber(float64(i + 1)) + s.CreateTable(0, 4) + + s.PushString("name") + s.PushString(entry.Name()) + s.SetTable(-3) + + s.PushString("is_dir") + s.PushBoolean(entry.IsDir()) + s.SetTable(-3) + + if info, err := entry.Info(); err == nil { + s.PushString("size") + s.PushNumber(float64(info.Size())) + s.SetTable(-3) + + s.PushString("mtime") + s.PushNumber(float64(info.ModTime().Unix())) + s.SetTable(-3) + } + + s.SetTable(-3) + } + return 1 + }, + + "path_join": func(s *luajit.State) int { + var parts []string + for i := 1; i <= s.GetTop(); i++ { + part, err := s.SafeToString(i) + if err != nil { + return s.PushError("path_join: argument %d must be a string", i) + } + parts = append(parts, part) + } + s.PushString(filepath.Join(parts...)) + return 1 + }, + + "path_dir": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("path_dir: %v", err) + } + path, err := s.SafeToString(1) + if err != nil { + return s.PushError("path_dir: argument must be a string") + } + s.PushString(filepath.Dir(path)) + return 1 + }, + + "path_basename": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("path_basename: %v", err) + } + path, err := s.SafeToString(1) + if err != nil { + return s.PushError("path_basename: argument must be a string") + } + s.PushString(filepath.Base(path)) + return 1 + }, + + "path_ext": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("path_ext: %v", err) + } + path, err := s.SafeToString(1) + if err != nil { + return s.PushError("path_ext: argument must be a string") + } + s.PushString(filepath.Ext(path)) + return 1 + }, + + "path_abs": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("path_abs: %v", err) + } + path, err := s.SafeToString(1) + if err != nil { + return s.PushError("path_abs: argument must be a string") + } + abs, err := filepath.Abs(path) + if err != nil { + s.PushNil() + s.PushString(err.Error()) + return 2 + } + s.PushString(abs) + return 1 + }, + + "path_clean": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("path_clean: %v", err) + } + path, err := s.SafeToString(1) + if err != nil { + return s.PushError("path_clean: argument must be a string") + } + s.PushString(filepath.Clean(path)) + return 1 + }, + + "path_split": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("path_split: %v", err) + } + path, err := s.SafeToString(1) + if err != nil { + return s.PushError("path_split: argument must be a string") + } + dir, file := filepath.Split(path) + s.PushString(dir) + s.PushString(file) + return 2 + }, + + "temp_file": func(s *luajit.State) int { + var prefix string + if s.GetTop() >= 1 { + if p, err := s.SafeToString(1); err == nil { + prefix = p + } + } + + file, err := os.CreateTemp("", prefix) + if err != nil { + s.PushNil() + s.PushString(err.Error()) + return 2 + } + defer file.Close() + + s.PushString(file.Name()) + return 1 + }, + + "temp_dir": func(s *luajit.State) int { + var prefix string + if s.GetTop() >= 1 { + if p, err := s.SafeToString(1); err == nil { + prefix = p + } + } + + dir, err := os.MkdirTemp("", prefix) + if err != nil { + s.PushNil() + s.PushString(err.Error()) + return 2 + } + + s.PushString(dir) + return 1 + }, + + "glob": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("glob: %v", err) + } + pattern, err := s.SafeToString(1) + if err != nil { + return s.PushError("glob: argument must be a string") + } + + matches, err := filepath.Glob(pattern) + if err != nil { + s.PushNil() + s.PushString(err.Error()) + return 2 + } + + if err := s.PushValue(matches); err != nil { + return s.PushError("glob: failed to push result: %v", err) + } + return 1 + }, + + "walk": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("walk: %v", err) + } + root, err := s.SafeToString(1) + if err != nil { + return s.PushError("walk: argument must be a string") + } + + var files []string + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil // Skip errors + } + files = append(files, path) + return nil + }) + + if err != nil { + s.PushNil() + s.PushString(err.Error()) + return 2 + } + + if err := s.PushValue(files); err != nil { + return s.PushError("walk: failed to push result: %v", err) + } + return 1 + }, + + "getcwd": func(s *luajit.State) int { + cwd, err := os.Getwd() + if err != nil { + s.PushNil() + s.PushString(err.Error()) + return 2 + } + s.PushString(cwd) + return 1 + }, + + "chdir": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("chdir: %v", err) + } + path, err := s.SafeToString(1) + if err != nil { + return s.PushError("chdir: argument must be a string") + } + err = os.Chdir(path) + if err != nil { + s.PushBoolean(false) + s.PushString(err.Error()) + return 2 + } + s.PushBoolean(true) + return 1 + }, + + "file_lines": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("file_lines: %v", err) + } + path, err := s.SafeToString(1) + if err != nil { + return s.PushError("file_lines: argument must be a string") + } + + data, err := os.ReadFile(path) + if err != nil { + s.PushNil() + s.PushString(err.Error()) + return 2 + } + + lines := strings.Split(string(data), "\n") + // Remove empty last line if file ends with newline + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + + if err := s.PushValue(lines); err != nil { + return s.PushError("file_lines: failed to push result: %v", err) + } + return 1 + }, + + "touch": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("touch: %v", err) + } + path, err := s.SafeToString(1) + if err != nil { + return s.PushError("touch: argument must be a string") + } + + now := time.Now() + err = os.Chtimes(path, now, now) + if os.IsNotExist(err) { + // Create file if it doesn't exist + file, err := os.Create(path) + if err != nil { + s.PushBoolean(false) + s.PushString(err.Error()) + return 2 + } + file.Close() + } else if err != nil { + s.PushBoolean(false) + s.PushString(err.Error()) + return 2 + } + + s.PushBoolean(true) + return 1 + }, } } diff --git a/functions/registry.go b/functions/registry.go index c8d8ac2..a4e564f 100644 --- a/functions/registry.go +++ b/functions/registry.go @@ -1,6 +1,10 @@ package functions -import luajit "git.sharkk.net/Sky/LuaJIT-to-Go" +import ( + "maps" + + luajit "git.sharkk.net/Sky/LuaJIT-to-Go" +) // Registry holds all available Go functions for Lua modules type Registry map[string]luajit.GoFunction @@ -9,26 +13,11 @@ type Registry map[string]luajit.GoFunction func GetAll() Registry { registry := make(Registry) - // Register function groups - for name, fn := range GetJSONFunctions() { - registry[name] = fn - } - - for name, fn := range GetStringFunctions() { - registry[name] = fn - } - - for name, fn := range GetMathFunctions() { - registry[name] = fn - } - - for name, fn := range GetFSFunctions() { - registry[name] = fn - } - - for name, fn := range GetCryptoFunctions() { - registry[name] = fn - } + maps.Copy(registry, GetJSONFunctions()) + maps.Copy(registry, GetStringFunctions()) + maps.Copy(registry, GetMathFunctions()) + maps.Copy(registry, GetFSFunctions()) + maps.Copy(registry, GetCryptoFunctions()) return registry } diff --git a/functions/string.go b/functions/string.go index 44deb05..2f5752e 100644 --- a/functions/string.go +++ b/functions/string.go @@ -1,36 +1,38 @@ -// functions/string.go package functions import ( "fmt" + "math/rand" + "regexp" + "strconv" "strings" + "time" + "unicode" + "unicode/utf8" luajit "git.sharkk.net/Sky/LuaJIT-to-Go" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) -// GetStringFunctions returns all string manipulation Go functions func GetStringFunctions() map[string]luajit.GoFunction { return map[string]luajit.GoFunction{ "string_split": func(s *luajit.State) int { if err := s.CheckExactArgs(2); err != nil { return s.PushError("string_split: %v", err) } - str, err := s.SafeToString(1) if err != nil { return s.PushError("string_split: first argument must be a string") } - sep, err := s.SafeToString(2) if err != nil { return s.PushError("string_split: second argument must be a string") } - parts := strings.Split(str, sep) if err := s.PushValue(parts); err != nil { return s.PushError("string_split: failed to push result: %v", err) } - return 1 }, @@ -38,12 +40,10 @@ func GetStringFunctions() map[string]luajit.GoFunction { if err := s.CheckExactArgs(2); err != nil { return s.PushError("string_join: %v", err) } - arr, err := s.SafeToTable(1) if err != nil { return s.PushError("string_join: first argument must be a table") } - sep, err := s.SafeToString(2) if err != nil { return s.PushError("string_join: second argument must be a string") @@ -57,6 +57,13 @@ func GetStringFunctions() map[string]luajit.GoFunction { for i, v := range anySlice { parts[i] = fmt.Sprintf("%v", v) } + } else if anyMap, ok := arr.(map[string]interface{}); ok { + // Handle empty table case - check if it's meant to be an array + if len(anyMap) == 0 { + parts = []string{} // Empty array + } else { + return s.PushError("string_join: first argument must be an array") + } } else { return s.PushError("string_join: first argument must be an array") } @@ -70,26 +77,64 @@ func GetStringFunctions() map[string]luajit.GoFunction { if err := s.CheckMinArgs(1); err != nil { return s.PushError("string_trim: %v", err) } - str, err := s.SafeToString(1) if err != nil { return s.PushError("string_trim: argument must be a string") } - s.PushString(strings.TrimSpace(str)) return 1 }, + "string_trim_left": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("string_trim_left: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_trim_left: first argument must be a string") + } + + if s.GetTop() >= 2 && s.IsString(2) { + cutset, err := s.SafeToString(2) + if err != nil { + return s.PushError("string_trim_left: second argument must be a string") + } + s.PushString(strings.TrimLeft(str, cutset)) + } else { + s.PushString(strings.TrimLeftFunc(str, unicode.IsSpace)) + } + return 1 + }, + + "string_trim_right": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("string_trim_right: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_trim_right: first argument must be a string") + } + + if s.GetTop() >= 2 && s.IsString(2) { + cutset, err := s.SafeToString(2) + if err != nil { + return s.PushError("string_trim_right: second argument must be a string") + } + s.PushString(strings.TrimRight(str, cutset)) + } else { + s.PushString(strings.TrimRightFunc(str, unicode.IsSpace)) + } + return 1 + }, + "string_upper": func(s *luajit.State) int { if err := s.CheckMinArgs(1); err != nil { return s.PushError("string_upper: %v", err) } - str, err := s.SafeToString(1) if err != nil { return s.PushError("string_upper: argument must be a string") } - s.PushString(strings.ToUpper(str)) return 1 }, @@ -98,58 +143,605 @@ func GetStringFunctions() map[string]luajit.GoFunction { if err := s.CheckMinArgs(1); err != nil { return s.PushError("string_lower: %v", err) } - str, err := s.SafeToString(1) if err != nil { return s.PushError("string_lower: argument must be a string") } - s.PushString(strings.ToLower(str)) return 1 }, + "string_title": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("string_title: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_title: argument must be a string") + } + caser := cases.Title(language.English) + s.PushString(caser.String(str)) + return 1 + }, + "string_contains": func(s *luajit.State) int { if err := s.CheckExactArgs(2); err != nil { return s.PushError("string_contains: %v", err) } - str, err := s.SafeToString(1) if err != nil { return s.PushError("string_contains: first argument must be a string") } - substr, err := s.SafeToString(2) if err != nil { return s.PushError("string_contains: second argument must be a string") } - s.PushBoolean(strings.Contains(str, substr)) return 1 }, + "string_starts_with": func(s *luajit.State) int { + if err := s.CheckExactArgs(2); err != nil { + return s.PushError("string_starts_with: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_starts_with: first argument must be a string") + } + prefix, err := s.SafeToString(2) + if err != nil { + return s.PushError("string_starts_with: second argument must be a string") + } + s.PushBoolean(strings.HasPrefix(str, prefix)) + return 1 + }, + + "string_ends_with": func(s *luajit.State) int { + if err := s.CheckExactArgs(2); err != nil { + return s.PushError("string_ends_with: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_ends_with: first argument must be a string") + } + suffix, err := s.SafeToString(2) + if err != nil { + return s.PushError("string_ends_with: second argument must be a string") + } + s.PushBoolean(strings.HasSuffix(str, suffix)) + return 1 + }, + "string_replace": func(s *luajit.State) int { if err := s.CheckExactArgs(3); err != nil { return s.PushError("string_replace: %v", err) } - str, err := s.SafeToString(1) if err != nil { return s.PushError("string_replace: first argument must be a string") } - old, err := s.SafeToString(2) if err != nil { return s.PushError("string_replace: second argument must be a string") } - new, err := s.SafeToString(3) if err != nil { return s.PushError("string_replace: third argument must be a string") } - result := strings.ReplaceAll(str, old, new) s.PushString(result) return 1 }, + + "string_replace_n": func(s *luajit.State) int { + if err := s.CheckExactArgs(4); err != nil { + return s.PushError("string_replace_n: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_replace_n: first argument must be a string") + } + old, err := s.SafeToString(2) + if err != nil { + return s.PushError("string_replace_n: second argument must be a string") + } + new, err := s.SafeToString(3) + if err != nil { + return s.PushError("string_replace_n: third argument must be a string") + } + n, err := s.SafeToNumber(4) + if err != nil || n != float64(int(n)) { + return s.PushError("string_replace_n: fourth argument must be an integer") + } + result := strings.Replace(str, old, new, int(n)) + s.PushString(result) + return 1 + }, + + "string_index": func(s *luajit.State) int { + if err := s.CheckExactArgs(2); err != nil { + return s.PushError("string_index: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_index: first argument must be a string") + } + substr, err := s.SafeToString(2) + if err != nil { + return s.PushError("string_index: second argument must be a string") + } + index := strings.Index(str, substr) + s.PushNumber(float64(index + 1)) // Lua is 1-indexed + return 1 + }, + + "string_last_index": func(s *luajit.State) int { + if err := s.CheckExactArgs(2); err != nil { + return s.PushError("string_last_index: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_last_index: first argument must be a string") + } + substr, err := s.SafeToString(2) + if err != nil { + return s.PushError("string_last_index: second argument must be a string") + } + index := strings.LastIndex(str, substr) + if index == -1 { + s.PushNumber(0) + } else { + s.PushNumber(float64(index + 1)) // Lua is 1-indexed + } + return 1 + }, + + "string_count": func(s *luajit.State) int { + if err := s.CheckExactArgs(2); err != nil { + return s.PushError("string_count: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_count: first argument must be a string") + } + substr, err := s.SafeToString(2) + if err != nil { + return s.PushError("string_count: second argument must be a string") + } + count := strings.Count(str, substr) + s.PushNumber(float64(count)) + return 1 + }, + + "string_repeat": func(s *luajit.State) int { + if err := s.CheckExactArgs(2); err != nil { + return s.PushError("string_repeat: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_repeat: first argument must be a string") + } + count, err := s.SafeToNumber(2) + if err != nil || count < 0 || count != float64(int(count)) { + return s.PushError("string_repeat: second argument must be a non-negative integer") + } + if count > 1000000 { + return s.PushError("string_repeat: count too large (max 1000000)") + } + result := strings.Repeat(str, int(count)) + s.PushString(result) + return 1 + }, + + "string_reverse": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("string_reverse: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_reverse: argument must be a string") + } + runes := []rune(str) + for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { + runes[i], runes[j] = runes[j], runes[i] + } + s.PushString(string(runes)) + return 1 + }, + + "string_length": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("string_length: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_length: argument must be a string") + } + s.PushNumber(float64(utf8.RuneCountInString(str))) + return 1 + }, + + "string_byte_length": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("string_byte_length: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_byte_length: argument must be a string") + } + s.PushNumber(float64(len(str))) + return 1 + }, + + "string_lines": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("string_lines: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_lines: argument must be a string") + } + lines := strings.Split(str, "\n") + if err := s.PushValue(lines); err != nil { + return s.PushError("string_lines: failed to push result: %v", err) + } + return 1 + }, + + "string_words": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("string_words: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_words: argument must be a string") + } + words := strings.Fields(str) + if err := s.PushValue(words); err != nil { + return s.PushError("string_words: failed to push result: %v", err) + } + return 1 + }, + + "string_pad_left": func(s *luajit.State) int { + if err := s.CheckMinArgs(2); err != nil { + return s.PushError("string_pad_left: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_pad_left: first argument must be a string") + } + width, err := s.SafeToNumber(2) + if err != nil || width != float64(int(width)) { + return s.PushError("string_pad_left: second argument must be an integer") + } + + padChar := " " + if s.GetTop() >= 3 { + if p, err := s.SafeToString(3); err == nil && len(p) > 0 { + padChar = string([]rune(p)[0]) + } + } + + currentLen := utf8.RuneCountInString(str) + targetLen := int(width) + if currentLen >= targetLen { + s.PushString(str) + return 1 + } + + padding := strings.Repeat(padChar, targetLen-currentLen) + s.PushString(padding + str) + return 1 + }, + + "string_pad_right": func(s *luajit.State) int { + if err := s.CheckMinArgs(2); err != nil { + return s.PushError("string_pad_right: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_pad_right: first argument must be a string") + } + width, err := s.SafeToNumber(2) + if err != nil || width != float64(int(width)) { + return s.PushError("string_pad_right: second argument must be an integer") + } + + padChar := " " + if s.GetTop() >= 3 { + if p, err := s.SafeToString(3); err == nil && len(p) > 0 { + padChar = string([]rune(p)[0]) + } + } + + currentLen := utf8.RuneCountInString(str) + targetLen := int(width) + if currentLen >= targetLen { + s.PushString(str) + return 1 + } + + padding := strings.Repeat(padChar, targetLen-currentLen) + s.PushString(str + padding) + return 1 + }, + + "string_slice": func(s *luajit.State) int { + if err := s.CheckMinArgs(2); err != nil { + return s.PushError("string_slice: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_slice: first argument must be a string") + } + start, err := s.SafeToNumber(2) + if err != nil || start != float64(int(start)) { + return s.PushError("string_slice: second argument must be an integer") + } + + runes := []rune(str) + length := len(runes) + startIdx := int(start) - 1 // Convert from 1-indexed to 0-indexed + + if startIdx < 0 { + startIdx = 0 + } + if startIdx >= length { + s.PushString("") + return 1 + } + + endIdx := length + if s.GetTop() >= 3 { + end, err := s.SafeToNumber(3) + if err == nil && end == float64(int(end)) { + endIdx = int(end) + if endIdx < 0 { + endIdx = 0 + } + if endIdx > length { + endIdx = length + } + } + } + + if startIdx >= endIdx { + s.PushString("") + return 1 + } + + s.PushString(string(runes[startIdx:endIdx])) + return 1 + }, + + "regex_match": func(s *luajit.State) int { + if err := s.CheckExactArgs(2); err != nil { + return s.PushError("regex_match: %v", err) + } + pattern, err := s.SafeToString(1) + if err != nil { + return s.PushError("regex_match: first argument must be a string") + } + str, err := s.SafeToString(2) + if err != nil { + return s.PushError("regex_match: second argument must be a string") + } + + re, err := regexp.Compile(pattern) + if err != nil { + s.PushBoolean(false) + s.PushString(err.Error()) + return 2 + } + + s.PushBoolean(re.MatchString(str)) + return 1 + }, + + "regex_find": func(s *luajit.State) int { + if err := s.CheckExactArgs(2); err != nil { + return s.PushError("regex_find: %v", err) + } + pattern, err := s.SafeToString(1) + if err != nil { + return s.PushError("regex_find: first argument must be a string") + } + str, err := s.SafeToString(2) + if err != nil { + return s.PushError("regex_find: second argument must be a string") + } + + re, err := regexp.Compile(pattern) + if err != nil { + s.PushNil() + s.PushString(err.Error()) + return 2 + } + + match := re.FindString(str) + if match == "" { + s.PushNil() + } else { + s.PushString(match) + } + return 1 + }, + + "regex_find_all": func(s *luajit.State) int { + if err := s.CheckExactArgs(2); err != nil { + return s.PushError("regex_find_all: %v", err) + } + pattern, err := s.SafeToString(1) + if err != nil { + return s.PushError("regex_find_all: first argument must be a string") + } + str, err := s.SafeToString(2) + if err != nil { + return s.PushError("regex_find_all: second argument must be a string") + } + + re, err := regexp.Compile(pattern) + if err != nil { + s.PushNil() + s.PushString(err.Error()) + return 2 + } + + matches := re.FindAllString(str, -1) + if err := s.PushValue(matches); err != nil { + return s.PushError("regex_find_all: failed to push result: %v", err) + } + return 1 + }, + + "regex_replace": func(s *luajit.State) int { + if err := s.CheckExactArgs(3); err != nil { + return s.PushError("regex_replace: %v", err) + } + pattern, err := s.SafeToString(1) + if err != nil { + return s.PushError("regex_replace: first argument must be a string") + } + str, err := s.SafeToString(2) + if err != nil { + return s.PushError("regex_replace: second argument must be a string") + } + replacement, err := s.SafeToString(3) + if err != nil { + return s.PushError("regex_replace: third argument must be a string") + } + + re, err := regexp.Compile(pattern) + if err != nil { + s.PushNil() + s.PushString(err.Error()) + return 2 + } + + result := re.ReplaceAllString(str, replacement) + s.PushString(result) + return 1 + }, + + "string_to_number": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("string_to_number: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_to_number: argument must be a string") + } + + if num, err := strconv.ParseFloat(str, 64); err == nil { + s.PushNumber(num) + return 1 + } + if num, err := strconv.ParseInt(str, 10, 64); err == nil { + s.PushNumber(float64(num)) + return 1 + } + + s.PushNil() + return 1 + }, + + "string_is_numeric": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("string_is_numeric: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_is_numeric: argument must be a string") + } + + _, err1 := strconv.ParseFloat(str, 64) + _, err2 := strconv.ParseInt(str, 10, 64) + s.PushBoolean(err1 == nil || err2 == nil) + return 1 + }, + + "string_is_alpha": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("string_is_alpha: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_is_alpha: argument must be a string") + } + + if len(str) == 0 { + s.PushBoolean(false) + return 1 + } + + for _, r := range str { + if !unicode.IsLetter(r) { + s.PushBoolean(false) + return 1 + } + } + s.PushBoolean(true) + return 1 + }, + + "string_is_alphanumeric": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("string_is_alphanumeric: %v", err) + } + str, err := s.SafeToString(1) + if err != nil { + return s.PushError("string_is_alphanumeric: argument must be a string") + } + + if len(str) == 0 { + s.PushBoolean(false) + return 1 + } + + for _, r := range str { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + s.PushBoolean(false) + return 1 + } + } + s.PushBoolean(true) + return 1 + }, + + "random_string": func(s *luajit.State) int { + if err := s.CheckMinArgs(1); err != nil { + return s.PushError("random_string: %v", err) + } + length, err := s.SafeToNumber(1) + if err != nil || length != float64(int(length)) || length < 0 { + return s.PushError("random_string: first argument must be a non-negative integer") + } + + charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + if s.GetTop() >= 2 { + if custom, err := s.SafeToString(2); err == nil && len(custom) > 0 { + charset = custom + } + } + + n := int(length) + if n == 0 { + s.PushString("") + return 1 + } + if n > 100000 { + return s.PushError("random_string: length too large (max 100000)") + } + + result := make([]byte, n) + rnd := rand.New(rand.NewSource(time.Now().UnixNano())) + for i := range result { + result[i] = charset[rnd.Intn(len(charset))] + } + + s.PushString(string(result)) + return 1 + }, } } diff --git a/go.mod b/go.mod index b9869e1..e2c088f 100644 --- a/go.mod +++ b/go.mod @@ -3,4 +3,9 @@ module Moonshark go 1.24.1 require git.sharkk.net/Sky/LuaJIT-to-Go v0.5.6 + require github.com/goccy/go-json v0.10.5 + +require github.com/google/uuid v1.6.0 + +require golang.org/x/text v0.27.0 diff --git a/go.sum b/go.sum index 0ea3bab..3ddde42 100644 --- a/go.sum +++ b/go.sum @@ -2,3 +2,7 @@ git.sharkk.net/Sky/LuaJIT-to-Go v0.5.6 h1:XytP9R2fWykv0MXIzxggPx5S/PmTkjyZVvUX2s git.sharkk.net/Sky/LuaJIT-to-Go v0.5.6/go.mod h1:HQz+D7AFxOfNbTIogjxP+shEBtz1KKrLlLucU+w07c8= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= diff --git a/modules/crypto.lua b/modules/crypto.lua new file mode 100644 index 0000000..35348c4 --- /dev/null +++ b/modules/crypto.lua @@ -0,0 +1,371 @@ +-- modules/crypto.lua - Comprehensive cryptographic utilities + +local crypto = {} + +-- ====================================================================== +-- ENCODING / DECODING +-- ====================================================================== + +function crypto.base64_encode(data) + local result, err = moonshark.base64_encode(data) + if not result then + error(err) + end + return result +end + +function crypto.base64_decode(data) + local result, err = moonshark.base64_decode(data) + if not result then + error(err) + end + return result +end + +function crypto.base64_url_encode(data) + local result, err = moonshark.base64_url_encode(data) + if not result then + error(err) + end + return result +end + +function crypto.base64_url_decode(data) + local result, err = moonshark.base64_url_decode(data) + if not result then + error(err) + end + return result +end + +function crypto.hex_encode(data) + local result, err = moonshark.hex_encode(data) + if not result then + error(err) + end + return result +end + +function crypto.hex_decode(data) + local result, err = moonshark.hex_decode(data) + if not result then + error(err) + end + return result +end + +-- ====================================================================== +-- HASHING FUNCTIONS +-- ====================================================================== + +function crypto.md5(data) + local result, err = moonshark.md5_hash(data) + if not result then + error(err) + end + return result +end + +function crypto.sha1(data) + local result, err = moonshark.sha1_hash(data) + if not result then + error(err) + end + return result +end + +function crypto.sha256(data) + local result, err = moonshark.sha256_hash(data) + if not result then + error(err) + end + return result +end + +function crypto.sha512(data) + local result, err = moonshark.sha512_hash(data) + if not result then + error(err) + end + return result +end + +-- Hash file contents +function crypto.hash_file(path, algorithm) + algorithm = algorithm or "sha256" + + if not moonshark.file_exists(path) then + error("File not found: " .. path) + end + + local content = moonshark.file_read(path) + if not content then + error("Failed to read file: " .. path) + end + + if algorithm == "md5" then + return crypto.md5(content) + elseif algorithm == "sha1" then + return crypto.sha1(content) + elseif algorithm == "sha256" then + return crypto.sha256(content) + elseif algorithm == "sha512" then + return crypto.sha512(content) + else + error("Unsupported hash algorithm: " .. algorithm) + end +end + +-- ====================================================================== +-- HMAC FUNCTIONS +-- ====================================================================== + +function crypto.hmac_sha1(message, key) + local result, err = moonshark.hmac_sha1(message, key) + if not result then + error(err) + end + return result +end + +function crypto.hmac_sha256(message, key) + local result, err = moonshark.hmac_sha256(message, key) + if not result then + error(err) + end + return result +end + +-- ====================================================================== +-- UUID FUNCTIONS +-- ====================================================================== + +function crypto.uuid() + return moonshark.uuid_generate() +end + +function crypto.uuid_v4() + return moonshark.uuid_generate_v4() +end + +function crypto.is_uuid(str) + return moonshark.uuid_validate(str) +end + +-- ====================================================================== +-- RANDOM GENERATORS +-- ====================================================================== + +function crypto.random_bytes(length) + local result, err = moonshark.random_bytes(length) + if not result then + error(err) + end + return result +end + +function crypto.random_hex(length) + local result, err = moonshark.random_hex(length) + if not result then + error(err) + end + return result +end + +function crypto.random_string(length, charset) + local result, err = moonshark.random_string(length, charset) + if not result then + error(err) + end + return result +end + +-- Generate random alphanumeric string +function crypto.random_alphanumeric(length) + return crypto.random_string(length, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") +end + +-- Generate random password with mixed characters +function crypto.random_password(length, include_symbols) + length = length or 12 + local charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + + if include_symbols then + charset = charset .. "!@#$%^&*()_+-=[]{}|;:,.<>?" + end + + return crypto.random_string(length, charset) +end + +-- Generate cryptographically secure token +function crypto.token(length) + length = length or 32 + return crypto.random_hex(length) +end + +-- ====================================================================== +-- UTILITY FUNCTIONS +-- ====================================================================== + +function crypto.secure_compare(a, b) + return moonshark.secure_compare(a, b) +end + +-- Generate checksum for data integrity +function crypto.checksum(data, algorithm) + algorithm = algorithm or "sha256" + return crypto.hash_file and crypto[algorithm] and crypto[algorithm](data) or error("Invalid algorithm") +end + +-- Verify data against checksum +function crypto.verify_checksum(data, expected, algorithm) + algorithm = algorithm or "sha256" + local actual = crypto[algorithm](data) + return crypto.secure_compare(actual, expected) +end + +-- Simple encryption using XOR (not cryptographically secure) +function crypto.xor_encrypt(data, key) + local result = {} + local key_len = #key + + for i = 1, #data do + local data_byte = string.byte(data, i) + local key_byte = string.byte(key, ((i - 1) % key_len) + 1) + table.insert(result, string.char(bit32 and bit32.bxor(data_byte, key_byte) or bit.bxor(data_byte, key_byte))) + end + + return table.concat(result) +end + +-- XOR decryption (same as encryption) +function crypto.xor_decrypt(data, key) + return crypto.xor_encrypt(data, key) +end + +-- Generate hash chain for proof of work +function crypto.hash_chain(data, iterations, algorithm) + iterations = iterations or 1000 + algorithm = algorithm or "sha256" + + local result = data + for i = 1, iterations do + result = crypto[algorithm](result) + end + return result +end + +-- Key derivation using PBKDF2-like approach (simplified) +function crypto.derive_key(password, salt, iterations, algorithm) + iterations = iterations or 10000 + algorithm = algorithm or "sha256" + salt = salt or crypto.random_hex(16) + + local derived = password .. salt + for i = 1, iterations do + derived = crypto[algorithm](derived) + end + + return derived, salt +end + +-- Generate nonce (number used once) +function crypto.nonce(length) + length = length or 16 + return crypto.random_hex(length) +end + +-- Create message authentication code +function crypto.mac(message, key, algorithm) + algorithm = algorithm or "sha256" + return crypto["hmac_" .. algorithm](message, key) +end + +-- Verify message authentication code +function crypto.verify_mac(message, key, mac, algorithm) + algorithm = algorithm or "sha256" + local expected = crypto.mac(message, key, algorithm) + return crypto.secure_compare(expected, mac) +end + +-- ====================================================================== +-- CONVENIENCE FUNCTIONS +-- ====================================================================== + +-- One-shot encoding chain +function crypto.encode_chain(data, formats) + formats = formats or {"base64"} + local result = data + + for _, format in ipairs(formats) do + if format == "base64" then + result = crypto.base64_encode(result) + elseif format == "base64url" then + result = crypto.base64_url_encode(result) + elseif format == "hex" then + result = crypto.hex_encode(result) + else + error("Unknown encoding format: " .. format) + end + end + + return result +end + +-- One-shot decoding chain (reverse order) +function crypto.decode_chain(data, formats) + formats = formats or {"base64"} + local result = data + + -- Reverse the formats for decoding + for i = #formats, 1, -1 do + local format = formats[i] + if format == "base64" then + result = crypto.base64_decode(result) + elseif format == "base64url" then + result = crypto.base64_url_decode(result) + elseif format == "hex" then + result = crypto.hex_decode(result) + else + error("Unknown decoding format: " .. format) + end + end + + return result +end + +-- Hash multiple inputs +function crypto.hash_multiple(inputs, algorithm) + algorithm = algorithm or "sha256" + local combined = table.concat(inputs, "") + return crypto[algorithm](combined) +end + +-- Create fingerprint from table data +function crypto.fingerprint(data, algorithm) + algorithm = algorithm or "sha256" + local json = moonshark.json_encode(data) + return crypto[algorithm](json) +end + +-- Simple data integrity check +function crypto.integrity_check(data) + return { + data = data, + hash = crypto.sha256(data), + timestamp = os.time(), + uuid = crypto.uuid() + } +end + +-- Verify integrity check +function crypto.verify_integrity(check) + if not check.data or not check.hash then + return false + end + + local expected = crypto.sha256(check.data) + return crypto.secure_compare(expected, check.hash) +end + +return crypto \ No newline at end of file diff --git a/modules/fs.lua b/modules/fs.lua new file mode 100644 index 0000000..6c4dc07 --- /dev/null +++ b/modules/fs.lua @@ -0,0 +1,446 @@ +-- modules/fs.lua - Comprehensive filesystem utilities + +local fs = {} + +-- ====================================================================== +-- FILE OPERATIONS +-- ====================================================================== + +function fs.exists(path) + return moonshark.file_exists(path) +end + +function fs.size(path) + local size = moonshark.file_size(path) + return size >= 0 and size or nil +end + +function fs.is_dir(path) + return moonshark.file_is_dir(path) +end + +function fs.is_file(path) + return fs.exists(path) and not fs.is_dir(path) +end + +function fs.read(path) + local content, err = moonshark.file_read(path) + if not content then + error("Failed to read file '" .. path .. "': " .. (err or "unknown error")) + end + return content +end + +function fs.write(path, content) + local success, err = moonshark.file_write(path, content) + if not success then + error("Failed to write file '" .. path .. "': " .. (err or "unknown error")) + end + return true +end + +function fs.append(path, content) + local success, err = moonshark.file_append(path, content) + if not success then + error("Failed to append to file '" .. path .. "': " .. (err or "unknown error")) + end + return true +end + +function fs.copy(src, dst) + local success, err = moonshark.file_copy(src, dst) + if not success then + error("Failed to copy '" .. src .. "' to '" .. dst .. "': " .. (err or "unknown error")) + end + return true +end + +function fs.move(src, dst) + local success, err = moonshark.file_move(src, dst) + if not success then + error("Failed to move '" .. src .. "' to '" .. dst .. "': " .. (err or "unknown error")) + end + return true +end + +function fs.remove(path) + local success, err = moonshark.file_delete(path) + if not success then + error("Failed to remove '" .. path .. "': " .. (err or "unknown error")) + end + return true +end + +function fs.mtime(path) + local timestamp = moonshark.file_mtime(path) + return timestamp >= 0 and timestamp or nil +end + +function fs.touch(path) + local success, err = moonshark.touch(path) + if not success then + error("Failed to touch '" .. path .. "': " .. (err or "unknown error")) + end + return true +end + +function fs.lines(path) + local lines, err = moonshark.file_lines(path) + if not lines then + error("Failed to read lines from '" .. path .. "': " .. (err or "unknown error")) + end + return lines +end + +-- ====================================================================== +-- DIRECTORY OPERATIONS +-- ====================================================================== + +function fs.mkdir(path) + local success, err = moonshark.dir_create(path) + if not success then + error("Failed to create directory '" .. path .. "': " .. (err or "unknown error")) + end + return true +end + +function fs.rmdir(path) + local success, err = moonshark.dir_remove(path) + if not success then + error("Failed to remove directory '" .. path .. "': " .. (err or "unknown error")) + end + return true +end + +function fs.list(path) + local entries, err = moonshark.dir_list(path) + if not entries then + error("Failed to list directory '" .. path .. "': " .. (err or "unknown error")) + end + return entries +end + +function fs.list_files(path) + local entries = fs.list(path) + local files = {} + for _, entry in ipairs(entries) do + if not entry.is_dir then + table.insert(files, entry) + end + end + return files +end + +function fs.list_dirs(path) + local entries = fs.list(path) + local dirs = {} + for _, entry in ipairs(entries) do + if entry.is_dir then + table.insert(dirs, entry) + end + end + return dirs +end + +function fs.list_names(path) + local entries = fs.list(path) + local names = {} + for _, entry in ipairs(entries) do + table.insert(names, entry.name) + end + return names +end + +-- ====================================================================== +-- PATH OPERATIONS +-- ====================================================================== + +function fs.join(...) + return moonshark.path_join(...) +end + +function fs.dirname(path) + return moonshark.path_dir(path) +end + +function fs.basename(path) + return moonshark.path_basename(path) +end + +function fs.ext(path) + return moonshark.path_ext(path) +end + +function fs.abs(path) + local abs_path, err = moonshark.path_abs(path) + if not abs_path then + error("Failed to get absolute path for '" .. path .. "': " .. (err or "unknown error")) + end + return abs_path +end + +function fs.clean(path) + return moonshark.path_clean(path) +end + +function fs.split(path) + return moonshark.path_split(path) +end + +-- Split path into directory, name, and extension +function fs.splitext(path) + local dir = fs.dirname(path) + local base = fs.basename(path) + local ext = fs.ext(path) + local name = base + + if ext ~= "" then + name = base:sub(1, -(#ext + 1)) + end + + return dir, name, ext +end + +-- ====================================================================== +-- WORKING DIRECTORY +-- ====================================================================== + +function fs.getcwd() + local cwd, err = moonshark.getcwd() + if not cwd then + error("Failed to get current directory: " .. (err or "unknown error")) + end + return cwd +end + +function fs.chdir(path) + local success, err = moonshark.chdir(path) + if not success then + error("Failed to change directory to '" .. path .. "': " .. (err or "unknown error")) + end + return true +end + +-- ====================================================================== +-- TEMPORARY FILES +-- ====================================================================== + +function fs.tempfile(prefix) + local path, err = moonshark.temp_file(prefix) + if not path then + error("Failed to create temporary file: " .. (err or "unknown error")) + end + return path +end + +function fs.tempdir(prefix) + local path, err = moonshark.temp_dir(prefix) + if not path then + error("Failed to create temporary directory: " .. (err or "unknown error")) + end + return path +end + +-- ====================================================================== +-- PATTERN MATCHING +-- ====================================================================== + +function fs.glob(pattern) + local matches, err = moonshark.glob(pattern) + if not matches then + error("Failed to glob pattern '" .. pattern .. "': " .. (err or "unknown error")) + end + return matches +end + +function fs.walk(root) + local files, err = moonshark.walk(root) + if not files then + error("Failed to walk directory '" .. root .. "': " .. (err or "unknown error")) + end + return files +end + +-- ====================================================================== +-- UTILITY FUNCTIONS +-- ====================================================================== + +-- Get file extension without dot +function fs.extension(path) + local ext = fs.ext(path) + return ext:sub(2) -- Remove leading dot +end + +-- Change file extension +function fs.change_ext(path, new_ext) + local dir, name, _ = fs.splitext(path) + if not new_ext:match("^%.") then + new_ext = "." .. new_ext + end + return fs.join(dir, name .. new_ext) +end + +-- Ensure directory exists +function fs.ensure_dir(path) + if not fs.exists(path) then + fs.mkdir(path) + elseif not fs.is_dir(path) then + error("Path exists but is not a directory: " .. path) + end + return true +end + +-- Get file size in human readable format +function fs.size_human(path) + local size = fs.size(path) + if not size then return nil end + + local units = {"B", "KB", "MB", "GB", "TB"} + local unit_index = 1 + local size_float = tonumber(size) + + while size_float >= 1024 and unit_index < #units do + size_float = size_float / 1024 + unit_index = unit_index + 1 + end + + if unit_index == 1 then + return string.format("%d %s", size_float, units[unit_index]) + else + return string.format("%.1f %s", size_float, units[unit_index]) + end +end + +-- Check if path is safe (doesn't contain .. or other dangerous patterns) +function fs.is_safe_path(path) + -- Normalize path + path = fs.clean(path) + + -- Check for dangerous patterns + if path:match("%.%.") then return false end + if path:match("^/") then return false end -- Absolute paths might be dangerous + if path:match("^~") then return false end -- Home directory references + + return true +end + +-- Create directory tree +function fs.makedirs(path) + return fs.mkdir(path) -- mkdir already creates parent directories +end + +-- Remove directory tree +function fs.removedirs(path) + return fs.rmdir(path) -- rmdir already removes recursively +end + +-- Copy directory tree +function fs.copytree(src, dst) + if not fs.exists(src) then + error("Source directory does not exist: " .. src) + end + + if not fs.is_dir(src) then + error("Source is not a directory: " .. src) + end + + fs.mkdir(dst) + + local entries = fs.list(src) + for _, entry in ipairs(entries) do + local src_path = fs.join(src, entry.name) + local dst_path = fs.join(dst, entry.name) + + if entry.is_dir then + fs.copytree(src_path, dst_path) + else + fs.copy(src_path, dst_path) + end + end + + return true +end + +-- Find files by pattern +function fs.find(root, pattern, recursive) + recursive = recursive ~= false + local results = {} + + local function search(dir) + local entries = fs.list(dir) + for _, entry in ipairs(entries) do + local full_path = fs.join(dir, entry.name) + + if not entry.is_dir and entry.name:match(pattern) then + table.insert(results, full_path) + elseif entry.is_dir and recursive then + search(full_path) + end + end + end + + search(root) + return results +end + +-- Get directory tree as nested table +function fs.tree(root, max_depth) + max_depth = max_depth or 10 + + local function build_tree(path, depth) + if depth > max_depth then return nil end + + if not fs.exists(path) then return nil end + + local node = { + name = fs.basename(path), + path = path, + is_dir = fs.is_dir(path) + } + + if node.is_dir then + node.children = {} + local entries = fs.list(path) + for _, entry in ipairs(entries) do + local child_path = fs.join(path, entry.name) + local child = build_tree(child_path, depth + 1) + if child then + table.insert(node.children, child) + end + end + else + node.size = fs.size(path) + node.mtime = fs.mtime(path) + end + + return node + end + + return build_tree(root, 1) +end + +-- Monitor file changes (simplified version) +function fs.watch(path, callback, interval) + interval = interval or 1 + + if not fs.exists(path) then + error("Path does not exist: " .. path) + end + + local last_mtime = fs.mtime(path) + + while true do + local current_mtime = fs.mtime(path) + if current_mtime and current_mtime ~= last_mtime then + callback(path, "modified") + last_mtime = current_mtime + end + + -- Sleep for interval (this would need a proper sleep function) + -- This is a placeholder - real implementation would need proper timing + local start = os.clock() + while os.clock() - start < interval do end + end +end + +return fs \ No newline at end of file diff --git a/modules/string.lua b/modules/string.lua new file mode 100644 index 0000000..01db1f7 --- /dev/null +++ b/modules/string.lua @@ -0,0 +1,464 @@ +-- modules/string.lua - Comprehensive string manipulation utilities + +local str = {} + +-- ====================================================================== +-- BASIC STRING OPERATIONS +-- ====================================================================== + +function str.split(s, delimiter) + return moonshark.string_split(s, delimiter) +end + +function str.join(arr, separator) + return moonshark.string_join(arr, separator) +end + +function str.trim(s) + return moonshark.string_trim(s) +end + +function str.trim_left(s, cutset) + return moonshark.string_trim_left(s, cutset) +end + +function str.trim_right(s, cutset) + return moonshark.string_trim_right(s, cutset) +end + +function str.upper(s) + return moonshark.string_upper(s) +end + +function str.lower(s) + return moonshark.string_lower(s) +end + +function str.title(s) + return moonshark.string_title(s) +end + +function str.contains(s, substr) + return moonshark.string_contains(s, substr) +end + +function str.starts_with(s, prefix) + return moonshark.string_starts_with(s, prefix) +end + +function str.ends_with(s, suffix) + return moonshark.string_ends_with(s, suffix) +end + +function str.replace(s, old, new) + return moonshark.string_replace(s, old, new) +end + +function str.replace_n(s, old, new, n) + return moonshark.string_replace_n(s, old, new, n) +end + +function str.index(s, substr) + local idx = moonshark.string_index(s, substr) + return idx > 0 and idx or nil +end + +function str.last_index(s, substr) + local idx = moonshark.string_last_index(s, substr) + return idx > 0 and idx or nil +end + +function str.count(s, substr) + return moonshark.string_count(s, substr) +end + +function str.repeat_(s, n) + return moonshark.string_repeat(s, n) +end + +function str.reverse(s) + return moonshark.string_reverse(s) +end + +function str.length(s) + return moonshark.string_length(s) +end + +function str.byte_length(s) + return moonshark.string_byte_length(s) +end + +function str.lines(s) + return moonshark.string_lines(s) +end + +function str.words(s) + return moonshark.string_words(s) +end + +function str.pad_left(s, width, pad_char) + return moonshark.string_pad_left(s, width, pad_char) +end + +function str.pad_right(s, width, pad_char) + return moonshark.string_pad_right(s, width, pad_char) +end + +function str.slice(s, start, end_pos) + return moonshark.string_slice(s, start, end_pos) +end + +-- ====================================================================== +-- REGULAR EXPRESSIONS +-- ====================================================================== + +function str.match(pattern, s) + return moonshark.regex_match(pattern, s) +end + +function str.find(pattern, s) + return moonshark.regex_find(pattern, s) +end + +function str.find_all(pattern, s) + return moonshark.regex_find_all(pattern, s) +end + +function str.gsub(pattern, s, replacement) + return moonshark.regex_replace(pattern, s, replacement) +end + +-- ====================================================================== +-- TYPE CONVERSION & VALIDATION +-- ====================================================================== + +function str.to_number(s) + return moonshark.string_to_number(s) +end + +function str.is_numeric(s) + return moonshark.string_is_numeric(s) +end + +function str.is_alpha(s) + return moonshark.string_is_alpha(s) +end + +function str.is_alphanumeric(s) + return moonshark.string_is_alphanumeric(s) +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 + +-- ====================================================================== +-- ADVANCED STRING OPERATIONS +-- ====================================================================== + +-- Capitalize first letter of each word +function str.capitalize(s) + return s:gsub("(%a)([%w_']*)", function(first, rest) + return str.upper(first) .. str.lower(rest) + end) +end + +-- Convert string to camelCase +function str.camel_case(s) + local words = str.words(str.lower(s)) + if #words == 0 then return s end + + local result = words[1] + for i = 2, #words do + result = result .. str.capitalize(words[i]) + end + return result +end + +-- Convert string to PascalCase +function str.pascal_case(s) + local words = str.words(str.lower(s)) + local result = "" + for _, word in ipairs(words) do + result = result .. str.capitalize(word) + end + return result +end + +-- Convert string to snake_case +function str.snake_case(s) + local words = str.words(str.lower(s)) + return str.join(words, "_") +end + +-- Convert string to kebab-case +function str.kebab_case(s) + local words = str.words(str.lower(s)) + return str.join(words, "-") +end + +-- Convert string to SCREAMING_SNAKE_CASE +function str.screaming_snake_case(s) + return str.upper(str.snake_case(s)) +end + +-- Center text within given width +function str.center(s, width, fill_char) + fill_char = fill_char or " " + 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 + +-- Truncate string to maximum length +function str.truncate(s, max_length, suffix) + suffix = suffix or "..." + if str.length(s) <= max_length then + return s + end + local main_part = str.slice(s, 1, max_length - str.length(suffix)) + main_part = str.trim_right(main_part) + return main_part .. suffix +end + +-- Wrap text to specified width +function str.wrap(s, width) + local words = str.words(s) + local lines = {} + local current_line = "" + + for _, word in ipairs(words) do + if str.length(current_line) + str.length(word) + 1 <= width then + if current_line == "" then + current_line = word + else + current_line = current_line .. " " .. word + end + else + if current_line ~= "" then + table.insert(lines, current_line) + end + current_line = word + end + end + + if current_line ~= "" then + table.insert(lines, current_line) + end + + return lines +end + +-- Remove common leading whitespace +function str.dedent(s) + local lines = str.split(s, "\n") + if #lines <= 1 then return s end + + -- Find minimum indentation (excluding empty lines) + local min_indent = math.huge + for _, line in ipairs(lines) do + if str.trim(line) ~= "" then + local indent = line:match("^%s*") + min_indent = math.min(min_indent, #indent) + end + end + + if min_indent == math.huge or min_indent == 0 then + return s + end + + -- Remove common indentation + for i, line in ipairs(lines) do + if str.trim(line) ~= "" then + lines[i] = line:sub(min_indent + 1) + end + end + + return str.join(lines, "\n") +end + +-- Escape special characters for regex +function str.escape_regex(s) + return s:gsub("([%.%+%*%?%[%]%^%$%(%)%{%}%|%\\])", "\\%1") +end + +-- Quote string for shell usage +function str.shell_quote(s) + return "'" .. s:gsub("'", "'\"'\"'") .. "'" +end + +-- URL encode string +function str.url_encode(s) + return s:gsub("([^%w%-%.%_%~])", function(c) + return string.format("%%%02X", string.byte(c)) + end) +end + +-- URL decode string +function str.url_decode(s) + return s:gsub("%%(%x%x)", function(hex) + return string.char(tonumber(hex, 16)) + end):gsub("+", " ") +end + +-- ====================================================================== +-- STRING COMPARISON +-- ====================================================================== + +-- Case-insensitive comparison +function str.iequals(a, b) + return str.lower(a) == str.lower(b) +end + +-- Levenshtein distance +function str.distance(a, b) + local len_a, len_b = str.length(a), str.length(b) + local matrix = {} + + -- Initialize matrix + for i = 0, len_a do + matrix[i] = {[0] = i} + end + for j = 0, len_b do + matrix[0][j] = j + end + + -- Fill matrix + 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, -- deletion + matrix[i][j-1] + 1, -- insertion + matrix[i-1][j-1] + cost -- substitution + ) + end + end + + return matrix[len_a][len_b] +end + +-- String similarity (0-1) +function str.similarity(a, b) + local max_len = math.max(str.length(a), str.length(b)) + if max_len == 0 then return 1 end + return 1 - (str.distance(a, b) / max_len) +end + +-- ====================================================================== +-- TEMPLATE FUNCTIONS +-- ====================================================================== + +-- Simple template substitution +function str.template(template, vars) + vars = vars or {} + return template:gsub("%${([%w_]+)}", function(var) + return tostring(vars[var] or "") + end) +end + +-- Advanced template with functions +function str.template_advanced(template, context) + context = context or {} + + return template:gsub("%${([^}]+)}", function(expr) + -- Simple variable substitution + if context[expr] then + return tostring(context[expr]) + end + + -- Handle simple expressions like ${var.prop} + local parts = str.split(expr, ".") + local value = context + for _, part in ipairs(parts) do + if type(value) == "table" and value[part] then + value = value[part] + else + return "" + end + end + + return tostring(value) + end) +end + +-- ====================================================================== +-- UTILITY FUNCTIONS +-- ====================================================================== + +-- Check if string contains only whitespace +function str.is_whitespace(s) + return s:match("^%s*$") ~= nil +end + +-- Remove all whitespace +function str.strip_whitespace(s) + return s:gsub("%s", "") +end + +-- Normalize whitespace (replace multiple spaces with single space) +function str.normalize_whitespace(s) + return str.trim(s:gsub("%s+", " ")) +end + +-- Extract numbers from string +function str.extract_numbers(s) + 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 + +-- Remove diacritics/accents +function str.remove_accents(s) + local accents = { + ["à"] = "a", ["á"] = "a", ["â"] = "a", ["ã"] = "a", ["ä"] = "a", + ["è"] = "e", ["é"] = "e", ["ê"] = "e", ["ë"] = "e", + ["ì"] = "i", ["í"] = "i", ["î"] = "i", ["ï"] = "i", + ["ò"] = "o", ["ó"] = "o", ["ô"] = "o", ["õ"] = "o", ["ö"] = "o", + ["ù"] = "u", ["ú"] = "u", ["û"] = "u", ["ü"] = "u", + ["ñ"] = "n", ["ç"] = "c" + } + + local result = s + for accented, plain in pairs(accents) do + result = result:gsub(accented, plain) + end + return result +end + +-- Generate random string +function str.random(length, charset) + return moonshark.random_string(length, charset) +end + +-- Check if string is valid UTF-8 +function str.is_utf8(s) + -- Simple check - if we can iterate through the string as UTF-8, it's valid + local success = pcall(function() + for p, c in utf8 and utf8.codes or string.gmatch(s, ".") do + -- Just iterate through + end + end) + return success +end + +-- Generate slug from string +function str.slug(s) + local kebab = str.kebab_case(str.remove_accents(s)) + local cleaned = (kebab:gsub("[^%w%-]", "")) + return (cleaned:gsub("%-+", "-")) +end + +return str \ No newline at end of file diff --git a/tests/crypto.lua b/tests/crypto.lua new file mode 100644 index 0000000..7fc3c48 --- /dev/null +++ b/tests/crypto.lua @@ -0,0 +1,332 @@ +require("tests") +local crypto = require("crypto") + +-- Test data +local test_data = "Hello, World!" +local test_key = "secret-key-123" + +-- ====================================================================== +-- ENCODING/DECODING TESTS +-- ====================================================================== + +test("Base64 Encoding/Decoding", function() + local encoded = crypto.base64_encode(test_data) + assert_equal(type(encoded), "string") + assert(#encoded > 0, "encoded string should not be empty") + + local decoded = crypto.base64_decode(encoded) + assert_equal(decoded, test_data) +end) + +test("Base64 URL Encoding/Decoding", function() + local encoded = crypto.base64_url_encode(test_data) + assert_equal(type(encoded), "string") + + local decoded = crypto.base64_url_decode(encoded) + assert_equal(decoded, test_data) +end) + +test("Hex Encoding/Decoding", function() + local encoded = crypto.hex_encode(test_data) + assert_equal(type(encoded), "string") + assert(encoded:match("^[0-9a-f]+$"), "hex should only contain hex characters") + + local decoded = crypto.hex_decode(encoded) + assert_equal(decoded, test_data) +end) + +test("Encoding Chain", function() + local chain_encoded = crypto.encode_chain(test_data, {"hex", "base64"}) + local chain_decoded = crypto.decode_chain(chain_encoded, {"hex", "base64"}) + assert_equal(chain_decoded, test_data) +end) + +-- ====================================================================== +-- HASHING TESTS +-- ====================================================================== + +test("MD5 Hash", function() + local hash = crypto.md5(test_data) + assert_equal(type(hash), "string") + assert_equal(#hash, 32) -- MD5 is 32 hex characters + assert(hash:match("^[0-9a-f]+$"), "hash should be hex") + + -- Same input should produce same hash + assert_equal(crypto.md5(test_data), hash) +end) + +test("SHA1 Hash", function() + local hash = crypto.sha1(test_data) + assert_equal(type(hash), "string") + assert_equal(#hash, 40) -- SHA1 is 40 hex characters + assert(hash:match("^[0-9a-f]+$"), "hash should be hex") +end) + +test("SHA256 Hash", function() + local hash = crypto.sha256(test_data) + assert_equal(type(hash), "string") + assert_equal(#hash, 64) -- SHA256 is 64 hex characters + assert(hash:match("^[0-9a-f]+$"), "hash should be hex") +end) + +test("SHA512 Hash", function() + local hash = crypto.sha512(test_data) + assert_equal(type(hash), "string") + assert_equal(#hash, 128) -- SHA512 is 128 hex characters + assert(hash:match("^[0-9a-f]+$"), "hash should be hex") +end) + +test("Hash Multiple Inputs", function() + local hash1 = crypto.hash_multiple({"hello", "world"}) + local hash2 = crypto.hash_multiple({"hello", "world"}) + local hash3 = crypto.hash_multiple({"world", "hello"}) + + assert_equal(hash1, hash2) + assert(hash1 ~= hash3, "different order should produce different hash") +end) + +-- ====================================================================== +-- HMAC TESTS +-- ====================================================================== + +test("HMAC SHA1", function() + local hmac = crypto.hmac_sha1(test_data, test_key) + assert_equal(type(hmac), "string") + assert_equal(#hmac, 40) -- SHA1 HMAC is 40 hex characters + assert(hmac:match("^[0-9a-f]+$"), "hmac should be hex") +end) + +test("HMAC SHA256", function() + local hmac = crypto.hmac_sha256(test_data, test_key) + assert_equal(type(hmac), "string") + assert_equal(#hmac, 64) -- SHA256 HMAC is 64 hex characters + assert(hmac:match("^[0-9a-f]+$"), "hmac should be hex") +end) + +test("MAC Functions", function() + local mac = crypto.mac(test_data, test_key) + assert_equal(type(mac), "string") + assert(#mac > 0, "mac should not be empty") + + local valid = crypto.verify_mac(test_data, test_key, mac) + assert_equal(valid, true) + + local invalid = crypto.verify_mac("different data", test_key, mac) + assert_equal(invalid, false) +end) + +-- ====================================================================== +-- UUID TESTS +-- ====================================================================== + +test("UUID Generation", function() + local uuid1 = crypto.uuid() + local uuid2 = crypto.uuid_v4() + + assert_equal(type(uuid1), "string") + assert_equal(type(uuid2), "string") + assert_equal(#uuid1, 36) -- Standard UUID length + assert_equal(#uuid2, 36) + assert(uuid1 ~= uuid2, "UUIDs should be unique") + + assert_equal(crypto.is_uuid(uuid1), true) + assert_equal(crypto.is_uuid(uuid2), true) + assert_equal(crypto.is_uuid("not-a-uuid"), false) +end) + +-- ====================================================================== +-- RANDOM GENERATION TESTS +-- ====================================================================== + +test("Random Bytes", function() + local bytes1 = crypto.random_bytes(16) + local bytes2 = crypto.random_bytes(16) + + assert_equal(type(bytes1), "string") + assert_equal(#bytes1, 16) + assert(bytes1 ~= bytes2, "random bytes should be different") +end) + +test("Random Hex", function() + local hex1 = crypto.random_hex(8) + local hex2 = crypto.random_hex(8) + + assert_equal(type(hex1), "string") + assert_equal(#hex1, 16) -- 8 bytes = 16 hex characters + assert(hex1:match("^[0-9a-f]+$"), "should be hex") + assert(hex1 ~= hex2, "random hex should be different") +end) + +test("Random String", function() + local str1 = crypto.random_string(10) + local str2 = crypto.random_string(10) + + assert_equal(type(str1), "string") + assert_equal(#str1, 10) + assert(str1 ~= str2, "random strings should be different") + + local custom = crypto.random_string(5, "abc") + assert_equal(#custom, 5) + assert(custom:match("^[abc]+$"), "should only contain specified characters") +end) + +test("Random Alphanumeric", function() + local str = crypto.random_alphanumeric(20) + assert_equal(#str, 20) + assert(str:match("^[a-zA-Z0-9]+$"), "should be alphanumeric") +end) + +test("Random Password", function() + local pass1 = crypto.random_password(12) + local pass2 = crypto.random_password(12, true) -- with symbols + + assert_equal(#pass1, 12) + assert_equal(#pass2, 12) + assert(pass1 ~= pass2, "passwords should be different") +end) + +test("Token Generation", function() + local token1 = crypto.token(32) + local token2 = crypto.token(32) + + assert_equal(#token1, 64) -- 32 bytes = 64 hex characters + assert(token1:match("^[0-9a-f]+$"), "token should be hex") + assert(token1 ~= token2, "tokens should be unique") +end) + +test("Nonce Generation", function() + local nonce1 = crypto.nonce() + local nonce2 = crypto.nonce(32) + + assert_equal(#nonce1, 32) -- default 16 bytes = 32 hex + assert_equal(#nonce2, 64) -- 32 bytes = 64 hex + assert(nonce1 ~= nonce2, "nonces should be unique") +end) + +-- ====================================================================== +-- UTILITY TESTS +-- ====================================================================== + +test("Secure Compare", function() + local str1 = "hello" + local str2 = "hello" + local str3 = "world" + + assert_equal(crypto.secure_compare(str1, str2), true) + assert_equal(crypto.secure_compare(str1, str3), false) +end) + +test("Checksum Functions", function() + local checksum = crypto.checksum(test_data) + assert_equal(type(checksum), "string") + + local valid = crypto.verify_checksum(test_data, checksum) + assert_equal(valid, true) + + local invalid = crypto.verify_checksum("different", checksum) + assert_equal(invalid, false) +end) + +test("XOR Encryption", function() + local key = "mykey" + local encrypted = crypto.xor_encrypt(test_data, key) + local decrypted = crypto.xor_decrypt(encrypted, key) + + assert_equal(decrypted, test_data) + assert(encrypted ~= test_data, "encrypted should be different") +end) + +test("Hash Chain", function() + local chain1 = crypto.hash_chain(test_data, 100) + local chain2 = crypto.hash_chain(test_data, 100) + local chain3 = crypto.hash_chain(test_data, 101) + + assert_equal(chain1, chain2) + assert(chain1 ~= chain3, "different iterations should produce different results") +end) + +test("Key Derivation", function() + local derived1, salt1 = crypto.derive_key("password", "salt", 1000) + local derived2, salt2 = crypto.derive_key("password", salt1, 1000) + + assert_equal(derived1, derived2) + assert_equal(salt1, salt2) + assert_equal(type(derived1), "string") + assert(#derived1 > 0, "derived key should not be empty") +end) + +test("Fingerprint", function() + local data = {name = "test", value = 42} + local fp1 = crypto.fingerprint(data) + local fp2 = crypto.fingerprint(data) + + assert_equal(fp1, fp2) + assert_equal(type(fp1), "string") + assert(#fp1 > 0, "fingerprint should not be empty") +end) + +test("Integrity Check", function() + local check = crypto.integrity_check(test_data) + + assert_equal(check.data, test_data) + assert_equal(type(check.hash), "string") + assert_equal(type(check.timestamp), "number") + assert_equal(type(check.uuid), "string") + + local valid = crypto.verify_integrity(check) + assert_equal(valid, true) + + -- Tamper with data + check.data = "tampered" + local invalid = crypto.verify_integrity(check) + assert_equal(invalid, false) +end) + +-- ====================================================================== +-- ERROR HANDLING TESTS +-- ====================================================================== + +test("Error Handling", function() + -- Invalid base64 + local success, err = pcall(crypto.base64_decode, "invalid===base64") + assert_equal(success, false) + + -- Invalid hex + local success2, err2 = pcall(crypto.hex_decode, "invalid_hex") + assert_equal(success2, false) + + -- Invalid UUID validation (returns boolean, doesn't throw) + assert_equal(crypto.is_uuid("not-a-uuid"), false) + assert_equal(crypto.is_uuid(""), false) + assert_equal(crypto.is_uuid("12345"), false) +end) + +-- ====================================================================== +-- PERFORMANCE TESTS +-- ====================================================================== + +test("Performance Test", function() + local large_data = string.rep("test data for performance ", 1000) + + local start = os.clock() + local hash = crypto.sha256(large_data) + local hash_time = os.clock() - start + + start = os.clock() + local encoded = crypto.base64_encode(large_data) + local encode_time = os.clock() - start + + start = os.clock() + local decoded = crypto.base64_decode(encoded) + local decode_time = os.clock() - start + + print(string.format(" SHA256 of %d bytes: %.3fs", #large_data, hash_time)) + print(string.format(" Base64 encode: %.3fs", encode_time)) + print(string.format(" Base64 decode: %.3fs", decode_time)) + + assert_equal(decoded, large_data) + assert_equal(type(hash), "string") +end) + +summary() +test_exit() \ No newline at end of file diff --git a/tests/fs.lua b/tests/fs.lua new file mode 100644 index 0000000..0fa552c --- /dev/null +++ b/tests/fs.lua @@ -0,0 +1,456 @@ +require("tests") +local fs = require("fs") + +-- Test data +local test_content = "Hello, filesystem!\nThis is a test file.\n" +local test_dir = "test_fs_dir" +local test_file = fs.join(test_dir, "test.txt") + +-- Clean up function +local function cleanup() + if fs.exists(test_file) then fs.remove(test_file) end + if fs.exists(test_dir) then fs.rmdir(test_dir) end +end + +-- ====================================================================== +-- SETUP AND CLEANUP +-- ====================================================================== + +-- Clean up before tests +cleanup() + +-- ====================================================================== +-- BASIC FILE OPERATIONS +-- ====================================================================== + +test("File Write and Read", function() + fs.mkdir(test_dir) + + fs.write(test_file, test_content) + assert_equal(fs.exists(test_file), true) + assert_equal(fs.is_file(test_file), true) + assert_equal(fs.is_dir(test_file), false) + + local content = fs.read(test_file) + assert_equal(content, test_content) +end) + +test("File Size", function() + local size = fs.size(test_file) + assert_equal(size, #test_content) + assert_equal(fs.size("nonexistent.txt"), nil) +end) + +test("File Append", function() + local additional = "Appended content.\n" + fs.append(test_file, additional) + + local content = fs.read(test_file) + assert_equal(content, test_content .. additional) +end) + +test("File Copy", function() + local copy_file = fs.join(test_dir, "copy.txt") + fs.copy(test_file, copy_file) + + assert_equal(fs.exists(copy_file), true) + assert_equal(fs.read(copy_file), fs.read(test_file)) + + fs.remove(copy_file) +end) + +test("File Move", function() + local move_file = fs.join(test_dir, "moved.txt") + local original_content = fs.read(test_file) + + fs.move(test_file, move_file) + + assert_equal(fs.exists(test_file), false) + assert_equal(fs.exists(move_file), true) + assert_equal(fs.read(move_file), original_content) + + -- Move back for other tests + fs.move(move_file, test_file) +end) + +test("File Lines", function() + local lines = fs.lines(test_file) + assert_equal(type(lines), "table") + assert(#lines >= 2, "should have multiple lines") + assert(string.find(lines[1], "Hello"), "first line should contain 'Hello'") +end) + +test("File Touch", function() + local touch_file = fs.join(test_dir, "touched.txt") + + fs.touch(touch_file) + assert_equal(fs.exists(touch_file), true) + assert_equal(fs.size(touch_file), 0) + + fs.remove(touch_file) +end) + +test("File Modification Time", function() + local mtime = fs.mtime(test_file) + assert_equal(type(mtime), "number") + assert(mtime > 0, "mtime should be positive") + + -- Touch should update mtime + local old_mtime = mtime + fs.touch(test_file) + local new_mtime = fs.mtime(test_file) + assert(new_mtime >= old_mtime, "mtime should be updated") +end) + +-- ====================================================================== +-- DIRECTORY OPERATIONS +-- ====================================================================== + +test("Directory Creation and Removal", function() + local nested_dir = fs.join(test_dir, "nested", "deep") + + fs.mkdir(nested_dir) + assert_equal(fs.exists(nested_dir), true) + assert_equal(fs.is_dir(nested_dir), true) + + -- Clean up nested directories + fs.rmdir(fs.join(test_dir, "nested")) +end) + +test("Directory Listing", function() + -- Create some test files + fs.write(fs.join(test_dir, "file1.txt"), "content1") + fs.write(fs.join(test_dir, "file2.log"), "content2") + fs.mkdir(fs.join(test_dir, "subdir")) + + local entries = fs.list(test_dir) + assert_equal(type(entries), "table") + assert(#entries >= 3, "should have at least 3 entries") + + -- Check entry structure + local found_file = false + for _, entry in ipairs(entries) do + assert_equal(type(entry.name), "string") + assert_equal(type(entry.is_dir), "boolean") + if entry.name == "file1.txt" then + found_file = true + assert_equal(entry.is_dir, false) + assert_equal(type(entry.size), "number") + end + end + assert_equal(found_file, true) + + -- Test filtered listings + local files = fs.list_files(test_dir) + local dirs = fs.list_dirs(test_dir) + local names = fs.list_names(test_dir) + + assert_equal(type(files), "table") + assert_equal(type(dirs), "table") + assert_equal(type(names), "table") + assert(#files > 0, "should have files") + assert(#dirs > 0, "should have directories") + + -- Clean up + fs.remove(fs.join(test_dir, "file1.txt")) + fs.remove(fs.join(test_dir, "file2.log")) + fs.rmdir(fs.join(test_dir, "subdir")) +end) + +-- ====================================================================== +-- PATH OPERATIONS +-- ====================================================================== + +test("Path Join", function() + local path = fs.join("a", "b", "c", "file.txt") + assert(string.find(path, "file.txt"), "should contain filename") + + local empty_path = fs.join() + assert_equal(type(empty_path), "string") +end) + +test("Path Components", function() + local test_path = fs.join("home", "user", "documents", "file.txt") + + local dir = fs.dirname(test_path) + local base = fs.basename(test_path) + local ext = fs.ext(test_path) + + assert_equal(base, "file.txt") + assert_equal(ext, ".txt") + assert(string.find(dir, "documents"), "dirname should contain 'documents'") +end) + +test("Path Split Extension", function() + local test_path = fs.join("home", "user", "file.tar.gz") + local dir, name, ext = fs.splitext(test_path) + + assert_equal(name, "file.tar") + assert_equal(ext, ".gz") + assert(string.find(dir, "user"), "dir should contain 'user'") +end) + +test("Path Absolute", function() + local abs_path = fs.abs(".") + assert_equal(type(abs_path), "string") + assert(#abs_path > 1, "absolute path should not be empty") +end) + +test("Path Clean", function() + local messy_path = "./test/../test/./file.txt" + local clean_path = fs.clean(messy_path) + assert_equal(type(clean_path), "string") + assert(not string.find(clean_path, "%.%."), "should not contain '..'") +end) + +test("Path Split", function() + local test_path = fs.join("home", "user", "file.txt") + local dir, file = fs.split(test_path) + + assert_equal(file, "file.txt") + assert(string.find(dir, "user"), "dir should contain 'user'") +end) + +-- ====================================================================== +-- WORKING DIRECTORY +-- ====================================================================== + +test("Working Directory", function() + local original_cwd = fs.getcwd() + assert_equal(type(original_cwd), "string") + assert(#original_cwd > 0, "cwd should not be empty") + + -- Test directory change + fs.chdir(test_dir) + local new_cwd = fs.getcwd() + assert(string.find(new_cwd, test_dir), "cwd should contain test_dir") + + -- Change back + fs.chdir(original_cwd) + assert_equal(fs.getcwd(), original_cwd) +end) + +-- ====================================================================== +-- TEMPORARY FILES +-- ====================================================================== + +test("Temporary Files", function() + local temp_file = fs.tempfile("test_") + local temp_dir = fs.tempdir("test_") + + assert_equal(type(temp_file), "string") + assert_equal(type(temp_dir), "string") + assert_equal(fs.exists(temp_file), true) + assert_equal(fs.exists(temp_dir), true) + assert_equal(fs.is_dir(temp_dir), true) + + -- Clean up + fs.remove(temp_file) + fs.rmdir(temp_dir) +end) + +-- ====================================================================== +-- PATTERN MATCHING +-- ====================================================================== + +test("Glob Patterns", function() + -- Create test files for globbing + fs.write(fs.join(test_dir, "test1.txt"), "content") + fs.write(fs.join(test_dir, "test2.txt"), "content") + fs.write(fs.join(test_dir, "other.log"), "content") + + local pattern = fs.join(test_dir, "*.txt") + local matches = fs.glob(pattern) + + assert_equal(type(matches), "table") + assert(#matches >= 2, "should match txt files") + + -- Clean up + fs.remove(fs.join(test_dir, "test1.txt")) + fs.remove(fs.join(test_dir, "test2.txt")) + fs.remove(fs.join(test_dir, "other.log")) +end) + +test("Walk Directory", function() + -- Create nested structure + fs.mkdir(fs.join(test_dir, "sub1")) + fs.mkdir(fs.join(test_dir, "sub2")) + fs.write(fs.join(test_dir, "root.txt"), "content") + fs.write(fs.join(test_dir, "sub1", "nested.txt"), "content") + + local files = fs.walk(test_dir) + assert_equal(type(files), "table") + assert(#files > 3, "should find multiple files and directories") + + -- Clean up + fs.remove(fs.join(test_dir, "root.txt")) + fs.remove(fs.join(test_dir, "sub1", "nested.txt")) + fs.rmdir(fs.join(test_dir, "sub1")) + fs.rmdir(fs.join(test_dir, "sub2")) +end) + +-- ====================================================================== +-- UTILITY FUNCTIONS +-- ====================================================================== + +test("File Extension Functions", function() + local path = "document.pdf" + assert_equal(fs.extension(path), "pdf") + + local new_path = fs.change_ext(path, "txt") + assert_equal(new_path, "document.txt") + + local new_path2 = fs.change_ext(path, ".docx") + assert_equal(new_path2, "document.docx") +end) + +test("Ensure Directory", function() + local ensure_dir = fs.join(test_dir, "ensure_test") + + fs.ensure_dir(ensure_dir) + assert_equal(fs.exists(ensure_dir), true) + assert_equal(fs.is_dir(ensure_dir), true) + + -- Should not error if already exists + fs.ensure_dir(ensure_dir) + + fs.rmdir(ensure_dir) +end) + +test("Human Readable Size", function() + local small_file = fs.join(test_dir, "small.txt") + fs.write(small_file, "test") + + local size_str = fs.size_human(small_file) + assert_equal(type(size_str), "string") + assert(string.find(size_str, "B"), "should contain byte unit") + + fs.remove(small_file) +end) + +test("Safe Path Check", function() + assert_equal(fs.is_safe_path("safe/path.txt"), true) + assert_equal(fs.is_safe_path("../dangerous"), false) + assert_equal(fs.is_safe_path("/absolute/path"), false) + assert_equal(fs.is_safe_path("~/home/path"), false) +end) + +test("Copy Tree", function() + -- Create source structure + local src_dir = fs.join(test_dir, "src") + local dst_dir = fs.join(test_dir, "dst") + + fs.mkdir(src_dir) + fs.mkdir(fs.join(src_dir, "subdir")) + fs.write(fs.join(src_dir, "file1.txt"), "content1") + fs.write(fs.join(src_dir, "subdir", "file2.txt"), "content2") + + fs.copytree(src_dir, dst_dir) + + assert_equal(fs.exists(dst_dir), true) + assert_equal(fs.exists(fs.join(dst_dir, "file1.txt")), true) + assert_equal(fs.exists(fs.join(dst_dir, "subdir", "file2.txt")), true) + assert_equal(fs.read(fs.join(dst_dir, "file1.txt")), "content1") + + -- Clean up + fs.rmdir(src_dir) + fs.rmdir(dst_dir) +end) + +test("Find Files", function() + -- Create test files + fs.write(fs.join(test_dir, "find1.txt"), "content") + fs.write(fs.join(test_dir, "find2.txt"), "content") + fs.write(fs.join(test_dir, "other.log"), "content") + fs.mkdir(fs.join(test_dir, "subdir")) + fs.write(fs.join(test_dir, "subdir", "find3.txt"), "content") + + local txt_files = fs.find(test_dir, "%.txt$", true) + assert_equal(type(txt_files), "table") + assert(#txt_files >= 3, "should find txt files recursively") + + local txt_files_flat = fs.find(test_dir, "%.txt$", false) + assert(#txt_files_flat < #txt_files, "non-recursive should find fewer files") + + -- Clean up + fs.remove(fs.join(test_dir, "find1.txt")) + fs.remove(fs.join(test_dir, "find2.txt")) + fs.remove(fs.join(test_dir, "other.log")) + fs.remove(fs.join(test_dir, "subdir", "find3.txt")) + fs.rmdir(fs.join(test_dir, "subdir")) +end) + +test("Directory Tree", function() + -- Create test structure + fs.mkdir(fs.join(test_dir, "tree_test")) + fs.write(fs.join(test_dir, "tree_test", "file.txt"), "content") + fs.mkdir(fs.join(test_dir, "tree_test", "subdir")) + + local tree = fs.tree(test_dir) + assert_equal(type(tree), "table") + assert_equal(tree.is_dir, true) + assert_equal(type(tree.children), "table") + assert(#tree.children > 0, "should have children") + + -- Clean up + fs.remove(fs.join(test_dir, "tree_test", "file.txt")) + fs.rmdir(fs.join(test_dir, "tree_test", "subdir")) + fs.rmdir(fs.join(test_dir, "tree_test")) +end) + +-- ====================================================================== +-- ERROR HANDLING +-- ====================================================================== + +test("Error Handling", function() + -- Reading non-existent file + local success, err = pcall(fs.read, "nonexistent.txt") + assert_equal(success, false) + + -- Writing to invalid path + local success2, err2 = pcall(fs.write, "/invalid/path/file.txt", "content") + assert_equal(success2, false) + + -- Listing non-existent directory + local success3, err3 = pcall(fs.list, "nonexistent_dir") + assert_equal(success3, false) +end) + +-- ====================================================================== +-- PERFORMANCE TESTS +-- ====================================================================== + +test("Performance Test", function() + local large_content = string.rep("performance test data\n", 1000) + local perf_file = fs.join(test_dir, "performance.txt") + + local start = os.clock() + fs.write(perf_file, large_content) + local write_time = os.clock() - start + + start = os.clock() + local read_content = fs.read(perf_file) + local read_time = os.clock() - start + + start = os.clock() + local lines = fs.lines(perf_file) + local lines_time = os.clock() - start + + print(string.format(" Write %d bytes: %.3fs", #large_content, write_time)) + print(string.format(" Read %d bytes: %.3fs", #read_content, read_time)) + print(string.format(" Parse %d lines: %.3fs", #lines, lines_time)) + + assert_equal(read_content, large_content) + assert_equal(#lines, 1000) + + fs.remove(perf_file) +end) + +-- ====================================================================== +-- CLEANUP +-- ====================================================================== + +cleanup() + +summary() +test_exit() \ No newline at end of file diff --git a/tests/string.lua b/tests/string.lua new file mode 100644 index 0000000..7ebe1a5 --- /dev/null +++ b/tests/string.lua @@ -0,0 +1,486 @@ +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 " + +-- ====================================================================== +-- BASIC STRING OPERATIONS +-- ====================================================================== + +test("String Split and Join", function() + local parts = str.split("a,b,c,d", ",") + assert_equal("table", type(parts)) + 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("", ",") + assert_equal(1, #empty_parts) + assert_equal("", empty_parts[1]) +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)) + + -- Custom cutset + assert_equal("Helloxxx", str.trim_left("xxxHelloxxx", "x")) + assert_equal("xxxHello", str.trim_right("xxxHelloxxx", "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!")) +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, "?")) +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")) +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")) +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("")) +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 +end) + +test("String Lines and Words", function() + local lines = str.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") + 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) +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)) +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)) +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")) +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") + assert_equal(3, #matches) + assert_equal("123", matches[1]) + assert_equal("789", matches[3]) +end) + +test("Regex Replace", function() + assert_equal("helloXXXworldXXX", str.gsub("\\d+", "hello123world456", "XXX")) + assert_equal("hello world", str.gsub("\\s+", "hello world", " ")) +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")) +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")) +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")) +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 +end) + +test("String Wrap", function() + local wrapped = str.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") + end +end) + +test("String Dedent", function() + local indented = " line1\n line2\n line3" + local dedented = str.dedent(indented) + local lines = str.lines(dedented) + + assert_equal("line1", lines[1]) + assert_equal("line2", lines[2]) + assert_equal("line3", 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")) +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")) +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")) +end) + +-- ====================================================================== +-- TEMPLATE FUNCTIONS +-- ====================================================================== + +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 + 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) +end) + +-- ====================================================================== +-- UTILITY FUNCTIONS +-- ====================================================================== + +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")) +end) + +test("Number Extraction", function() + local numbers = str.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]) +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("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")) +end) + +-- ====================================================================== +-- EDGE CASES AND ERROR HANDLING +-- ====================================================================== + +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("")) +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(1000, #words) + + local trimmed = str.trim(large_string) + assert_equal(true, str.ends_with(trimmed, "test")) +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, " ") + assert_equal(4, #parts) + assert_equal("🌍", parts[2]) +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) + +-- ====================================================================== +-- PERFORMANCE TESTS +-- ====================================================================== + +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") +end) + +-- ====================================================================== +-- INTEGRATION TESTS +-- ====================================================================== + +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 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 + table.insert(filtered, clean_word) + end + end + + local result = str.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") + + 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") +end) + +summary() +test_exit() \ No newline at end of file diff --git a/tests/tests.lua b/tests/tests.lua index 03b6274..3652369 100644 --- a/tests/tests.lua +++ b/tests/tests.lua @@ -23,57 +23,57 @@ function assert(condition, message, level) end -- Assert with tolerance for floating point comparisons -function assert_close(a, b, tolerance, message) +function assert_close(expected, actual, tolerance, message) tolerance = tolerance or 1e-10 - local diff = math.abs(a - b) + local diff = math.abs(expected - actual) if diff <= tolerance then return true end - local msg = message or string.format("Expected %g, got %g (diff: %g, tolerance: %g)", a, b, diff, tolerance) + local msg = message or string.format("Expected %g, got %g (diff: %g, tolerance: %g)", expected, actual, diff, tolerance) assert(false, msg, 3) end -- Assert equality with better error messages -function assert_equal(a, b, message) - if a == b then +function assert_equal(expected, actual, message) + if expected == actual then return true end - local msg = message or string.format("Expected %s, got %s", tostring(a), tostring(b)) + local msg = message or string.format("Expected %s, got %s", tostring(expected), tostring(actual)) assert(false, msg, 3) end -- Assert table equality (deep comparison) -function assert_table_equal(a, b, message, path) +function assert_table_equal(expected, actual, message, path) path = path or "root" - if type(a) ~= type(b) then - local msg = message or string.format("Type mismatch at %s: expected %s, got %s", path, type(a), type(b)) + if type(expected) ~= type(actual) then + local msg = message or string.format("Type mismatch at %s: expected %s, got %s", path, type(expected), type(actual)) assert(false, msg, 3) end - if type(a) ~= "table" then - if a ~= b then - local msg = message or string.format("Value mismatch at %s: expected %s, got %s", path, tostring(a), tostring(b)) + if type(expected) ~= "table" then + if expected ~= actual then + local msg = message or string.format("Value mismatch at %s: expected %s, got %s", path, tostring(expected), tostring(actual)) assert(false, msg, 3) end return true end -- Check all keys in a exist in b with same values - for k, v in pairs(a) do + for k, v in pairs(expected) do local new_path = path .. "." .. tostring(k) - if b[k] == nil then + if actual[k] == nil then local msg = message or string.format("Missing key at %s", new_path) assert(false, msg, 3) end - assert_table_equal(v, b[k], message, new_path) + assert_table_equal(v, actual[k], message, new_path) end -- Check all keys in b exist in a - for k, v in pairs(b) do - if a[k] == nil then + for k, v in pairs(actual) do + if expected[k] == nil then local new_path = path .. "." .. tostring(k) local msg = message or string.format("Extra key at %s", new_path) assert(false, msg, 3)